From 8c782333330c76340c3be7bba9bdf119cc222919 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:34 +0000 Subject: [PATCH 01/30] [gdpatchagent] fix(superfluid-campaign-web): follow AppKit active chain, multi-chain claims, network-settings label (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- apps/superfluid-campaign-web/src/App.tsx | 40 +++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index 9cc3799..7fafd72 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -1,10 +1,12 @@ -import React, { useRef } from 'react' +import React, { useMemo, useRef } from 'react' import { SuperfluidCampaignWidget } from '@goodwidget/superfluid-campaign-widget' import type { EIP1193Provider } from '@goodwidget/core' import { + DEFAULT_APPKIT_NETWORKS, DefaultAppKitProvider, useAppKit, useAppKitAccount, + useAppKitNetwork, useAppKitProvider, } from '@goodwidget/embed/appkit-provider' import { TamaguiProvider } from '@tamagui/core' @@ -12,18 +14,54 @@ import { YStack, defaultConfig } from '@goodwidget/ui' const DESKTOP_WIDGET_MAX_WIDTH = 960 +/** + * Pressing the disconnect-menu item under AppKit opens AppKit's own Account + * view (see disconnectOverride below) rather than ending the session + * directly, so the button is labeled to match what it actually does. + */ +const APPKIT_DISCONNECT_LABEL = 'Network settings' + function AppKitSuperfluidCampaignWidget() { const { open } = useAppKit() const { address } = useAppKitAccount() const { walletProvider } = useAppKitProvider('eip155') + const { chainId, switchNetwork } = useAppKitNetwork() const addressRef = useRef(address) addressRef.current = address + // AppKit's switchNetwork takes the network descriptor object, not a chain id, + // so this looks up the descriptor for whichever chain the widget wants to + // switch to. AppKit's own active-network state (chainId here) is always the + // source of truth for what's "current" — the widget never tracks it separately. + const appKitNetworksByChainId = useMemo( + () => new Map(DEFAULT_APPKIT_NETWORKS.map((network) => [Number(network.id), network])), + [], + ) + return ( { + const targetNetwork = appKitNetworksByChainId.get(targetChainId) + // Falling through to AppKit's own network-selection modal is the + // documented recovery path when a target chain has no direct + // programmatic descriptor here, or when switchNetwork itself fails + // (e.g. the connected wallet rejects the automatic switch request). + if (!targetNetwork) { + await open({ view: 'Networks' }) + return + } + try { + await switchNetwork(targetNetwork) + } catch { + await open({ view: 'Networks' }) + } + }} + disconnectLabel={APPKIT_DISCONNECT_LABEL} connectOverride={async () => { await open({ view: 'Connect' }) if (!addressRef.current) throw new Error('wallet_connect_cancelled') From 8dd95c0b2324fa31aba464c06bccb9d753c8b465 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:36 +0000 Subject: [PATCH 02/30] [gdpatchagent] fix(citizen-claim-widget): declarative per-chain claim copy and live active chain (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/CitizenClaimWidget.tsx | 127 +++++++++++++----- 1 file changed, 94 insertions(+), 33 deletions(-) diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 25722ec..632f392 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { GoodWidgetProvider } from '@goodwidget/core' +import { GoodWidgetProvider, useWallet } from '@goodwidget/core' import type { EIP1193Provider } from '@goodwidget/core' import { createComponent, @@ -348,8 +348,10 @@ function CitizenClaimInner({ await actions.refresh() break case 'switch_chain': - // Default to Celo (42220) as the first preferred supported chain - await actions.switchChain?.(42220) + // The wallet is connected but on a chain citizen-sdk doesn't support at + // all, so there is no known claimable chain to target yet — fall back + // to Celo, the first preferred supported chain. + await actions.switchChain?.(claimablesByChain[0]?.chainId ?? SupportedChains.CELO) break } } catch (err: unknown) { @@ -414,7 +416,19 @@ function CitizenClaimInner({ {(status === 'eligible' || status === 'claiming' || claimablesByChain.length > 0) && ( <> - Ready to claim + {/* + Declarative to claim status: an already_claimed wallet with + other chains still available must not read as "no claims + left" (the "Just a little longer" copy below is reserved for + when every chain has actually been claimed for the day). + */} + + {status === 'already_claimed' && claimablesByChain.length === 1 + ? `G$ Claim is still available on ${getChainName(claimablesByChain[0].chainId)}` + : status === 'already_claimed' && claimablesByChain.length > 1 + ? 'G$ Claim is still available on other chains' + : 'Ready to claim'} + {displayAmount && } )} @@ -534,44 +548,36 @@ function CitizenClaimInner({ } // --------------------------------------------------------------------------- -// Public component +// Shell — rendered inside GoodWidgetProvider so the network pill reflects the +// live wallet chain id from useWallet() (itself driven by chainIdOverride when +// the integrator supplies one) rather than a static prop fixed at mount. // --------------------------------------------------------------------------- -/** - * CitizenClaimWidget — real SDK-backed GoodDollar UBI claim flow. - * - * Aligned to GoodWalletV2 claim behavior and the claim-widget-theme-demo visual baseline. - * - * Usage as a React component: - * - * - * Also available as a Web Component via the `element` or `register` entry points. - * - * Provider-first runtime path: - * host provider → GoodWidgetProvider → citizen-claim adapter → citizen-sdk - */ -export function CitizenClaimWidget({ - provider, - environment = 'production', - chainId, +interface CitizenClaimShellProps { + /** Used only until the live wallet chain id resolves, or while disconnected. */ + fallbackChainId?: number + environment: CitizenClaimWidgetEnvironment + clientFactory?: CitizenClaimWidgetClientFactory + claimExecution?: CitizenClaimWidgetCustodialExecution + onClaimSuccess?: (detail: CitizenClaimWidgetSuccessDetail) => void + onClaimError?: (detail: CitizenClaimWidgetErrorDetail) => void + initialTab?: CitizenClaimTab +} + +function CitizenClaimShell({ + fallbackChainId, + environment, clientFactory, claimExecution, - themeOverrides, - config, - defaultTheme = 'dark', onClaimSuccess, onClaimError, initialTab, -}: CitizenClaimWidgetProps) { +}: CitizenClaimShellProps) { + const { chainId } = useWallet() // Initial tab only — not synced after mount, matching existing internal-state pattern. const [activeTab, setActiveTab] = useState(initialTab ?? 'claim') return ( - + <> setActiveTab(tabId as CitizenClaimTab)} - chainId={chainId ?? 42220} + chainId={chainId ?? fallbackChainId ?? 42220} /> {activeTab === 'claim' ? ( <> @@ -600,6 +606,61 @@ export function CitizenClaimWidget({ )} + + ) +} + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- +/** + * CitizenClaimWidget — real SDK-backed GoodDollar UBI claim flow. + * + * Aligned to GoodWalletV2 claim behavior and the claim-widget-theme-demo visual baseline. + * + * Usage as a React component: + * + * + * Also available as a Web Component via the `element` or `register` entry points. + * + * Provider-first runtime path: + * host provider → GoodWidgetProvider → citizen-claim adapter → citizen-sdk + */ +export function CitizenClaimWidget({ + provider, + environment = 'production', + chainId, + clientFactory, + claimExecution, + themeOverrides, + config, + defaultTheme = 'dark', + onClaimSuccess, + onClaimError, + initialTab, + addressOverride, + chainIdOverride, + switchChainOverride, +}: CitizenClaimWidgetProps) { + return ( + + ) } From 99876d10ccc7adf2718153fa7c6dceb6c869dfa9 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:37 +0000 Subject: [PATCH 03/30] [gdpatchagent] fix(citizen-claim-widget): load claimables for every supported chain, not just the active one (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/adapter.ts | 130 +++++++++---------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 2159208..d11469d 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -180,7 +180,7 @@ type CitizenEnvironment = 'production' | 'staging' | 'development' export function useCitizenClaimAdapter( options: UseCitizenClaimAdapterOptions = {}, ): CitizenClaimWidgetAdapterResult { - const { address, chainId, isConnected, provider, connect } = useWallet() + const { address, chainId, isConnected, provider, connect, switchChain } = useWallet() const clientFactory = options.clientFactory const claimExecution = options.claimExecution @@ -358,53 +358,84 @@ export function useCitizenClaimAdapter( /** * Collects claimable UBI amounts for all citizen-sdk supported chains. * This mirrors GoodWalletV2's claim breakdown model (eligible amounts per chain). + * + * A wallet's injected/bridged EIP-1193 provider only ever answers RPC calls + * for its own currently-active chain, regardless of the viem Chain + * descriptor attached to a client built on top of it — so per-chain reads + * cannot each get their own wallet-provider-transported client. Instead, + * one SDK instance is created for the active chain, and every other + * chain's entitlement is read through that same instance's + * checkEntitlement({ chainOverride, publicClient }), passing an RPC-backed + * publicClient (never the wallet's own provider) for the non-active chain — + * this is the SDK's own supported multi-chain read path. */ const loadClaimablesByChain = useCallback(async (): Promise => { const eligible: Array<{ chainId: number; amount: string }> = [] - await Promise.all( - SUPPORTED_CHAINS.map(async (supportedChainId) => { - try { - let entitlement: bigint - if (isConnected && address) { - const sdk = await createSdkInstancesForChain(supportedChainId) - if (!sdk) return - // The no-argument generic entitlement is the chain pool amount, - // not this wallet's entitlement. Use the SDK's account-scoped - // check when the wallet is connected so claimAll only targets - // chains this account can actually claim on. - entitlement = (await sdk.claimSDK.checkEntitlement()).amount - } else { + if (isConnected && address && onSupportedChain) { + const primarySdk = await createSdkInstancesForChain(chainId as number) + if (primarySdk) { + await Promise.all( + SUPPORTED_CHAINS.map(async (supportedChainId) => { + try { + const isActiveChain = supportedChainId === chainId + const result = await primarySdk.claimSDK.checkEntitlement( + isActiveChain + ? undefined + : { + chainOverride: supportedChainId, + publicClient: getPublicClientForChain(supportedChainId) ?? undefined, + }, + ) + if (result.amount <= 0n) return + + const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 + eligible.push({ + chainId: supportedChainId, + amount: formatUnits(result.amount, decimals), + }) + } catch { + // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. + } + }), + ) + } + } else if (!isConnected) { + await Promise.all( + SUPPORTED_CHAINS.map(async (supportedChainId) => { + try { const publicClient = getPublicClientForChain(supportedChainId) if (!publicClient) return - entitlement = await checkGenericEntitlement({ + const entitlement = await checkGenericEntitlement({ publicClient, chainId: supportedChainId, env, }) - } - if (entitlement <= 0n) return + if (entitlement <= 0n) return - const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 - eligible.push({ - chainId: supportedChainId, - amount: formatUnits(entitlement, decimals), - }) - } catch { - // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. - } - }), - ) + const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 + eligible.push({ + chainId: supportedChainId, + amount: formatUnits(entitlement, decimals), + }) + } catch { + // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. + } + }), + ) + } if (!mountedRef.current) return eligible.sort((a, b) => b.chainId - a.chainId) setClaimablesByChain(eligible) }, [ address, + chainId, createSdkInstancesForChain, env, getPublicClientForChain, isConnected, + onSupportedChain, ]) const loadDailyStats = useCallback(async (): Promise => { @@ -530,15 +561,11 @@ export function useCitizenClaimAdapter( // A single EIP-1193 provider has one active chain. Custodial clients are // already chain-bound, so switching would introduce a race between claims. + // switchChain tries the raw wallet_switchEthereumChain request first and + // falls back to the integrator's own switch/network-modal flow (e.g. + // AppKit) when the active connector rejects or ignores it. if (!isCustodialExecution) { - await ( - provider as { - request: (args: { method: string; params: unknown[] }) => Promise - } - ).request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: `0x${targetChainId.toString(16)}` }], - }) + await switchChain(targetChainId) } const sdk = await createSdkInstancesForChain(targetChainId) @@ -546,7 +573,7 @@ export function useCitizenClaimAdapter( return sdk.claimSDK.claim() }, - [provider, address, createSdkInstancesForChain, isCustodialExecution], + [address, createSdkInstancesForChain, isCustodialExecution, provider, switchChain], ) const claimAll = useCallback( @@ -647,25 +674,6 @@ export function useCitizenClaimAdapter( } }, [connect, loadClaimStatus]) - // --------------------------------------------------------------------------- - // handleSwitchChain — requests the wallet to switch to a supported chain. - // Uses the EIP-3326 wallet_switchEthereumChain method. - // --------------------------------------------------------------------------- - const handleSwitchChain = useCallback( - async (targetChainId: number): Promise => { - if (!provider) throw new Error('No wallet provider available') - await ( - provider as { - request: (args: { method: string; params: unknown[] }) => Promise - } - ).request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: `0x${targetChainId.toString(16)}` }], - }) - }, - [provider], - ) - // --------------------------------------------------------------------------- // Derived state: primaryAction and primaryLabel // --------------------------------------------------------------------------- @@ -754,17 +762,9 @@ export function useCitizenClaimAdapter( claim: handleClaim, claimOnChain, claimAll, - switchChain: handleSwitchChain, + switchChain, }), - [ - handleConnect, - loadClaimStatus, - handleVerify, - handleClaim, - claimOnChain, - claimAll, - handleSwitchChain, - ], + [handleConnect, loadClaimStatus, handleVerify, handleClaim, claimOnChain, claimAll, switchChain], ) return { state, actions } From 949bdc3ef83d64e6aa23e7673e7798db1818e1ad Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:39 +0000 Subject: [PATCH 04/30] [gdpatchagent] feat(citizen-claim-widget): accept address/chain/switch-chain overrides (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/widgetRuntimeContract.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts index 923a54d..4c3423f 100644 --- a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts +++ b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts @@ -132,6 +132,21 @@ export interface CitizenClaimWidgetProps { claimExecution?: CitizenClaimWidgetCustodialExecution onClaimSuccess?: (detail: CitizenClaimWidgetSuccessDetail) => void onClaimError?: (detail: CitizenClaimWidgetErrorDetail) => void + /** + * Integrator-owned live address (e.g. from a wallet-connection SDK's own + * reactive account hook). See `GoodWidgetProviderProps.addressOverride`. + */ + addressOverride?: string | null + /** + * Integrator-owned live chain id, mirroring `addressOverride`. See + * `GoodWidgetProviderProps.chainIdOverride`. + */ + chainIdOverride?: number | null + /** + * Integrator-owned chain-switch fallback. See + * `GoodWidgetProviderProps.switchChainOverride`. + */ + switchChainOverride?: (chainId: number) => Promise // ---- Theming (optional, passed through to GoodWidgetProvider) ---- /** Token and theme overrides applied at the widget boundary. */ themeOverrides?: GoodWidgetThemeOverrides From a048f1ecc5fa83ccc0eeceedf765991eef9848f1 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:40 +0000 Subject: [PATCH 05/30] [gdpatchagent] feat(core): thread override props through WalletContextValue and switchChain (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/core/src/provider.tsx | 104 ++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index 4cb32dc..500e751 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -23,6 +23,9 @@ const DEFAULT_CAPABILITIES: HostCapabilities = { export interface WalletContextValue extends WalletState { connect: () => Promise disconnect: () => Promise + /** Label for the disconnect action; see `GoodWidgetProviderProps.disconnectLabel`. */ + disconnectLabel: string + switchChain: (chainId: number) => Promise } export type HostContextValue = HostState @@ -30,6 +33,12 @@ export type HostContextValue = HostState export interface GoodWidgetContextValue extends GoodWidgetState { connect: () => Promise disconnect: () => Promise + disconnectLabel: string + switchChain: (chainId: number) => Promise +} + +const noopSwitchChain = async () => { + throw new Error('No wallet provider available to switch chains') } export const WalletContext = React.createContext({ @@ -39,6 +48,8 @@ export const WalletContext = React.createContext({ provider: null, connect: async () => {}, disconnect: async () => {}, + disconnectLabel: 'Disconnect', + switchChain: noopSwitchChain, }) export const HostContext = React.createContext({ @@ -55,12 +66,18 @@ export const GoodWidgetContext = React.createContext({ capabilities: DEFAULT_CAPABILITIES, connect: async () => {}, disconnect: async () => {}, + disconnectLabel: 'Disconnect', + switchChain: noopSwitchChain, }) export function GoodWidgetProvider({ provider: explicitProvider, connectOverride, disconnectOverride, + addressOverride, + chainIdOverride, + switchChainOverride, + disconnectLabel = 'Disconnect', config: authorConfig, themeOverrides, defaultTheme = 'dark', @@ -72,8 +89,8 @@ export function GoodWidgetProvider({ ) const [host, setHost] = useState('injected') const [capabilities, setCapabilities] = useState(DEFAULT_CAPABILITIES) - const [address, setAddress] = useState(null) - const [chainId, setChainId] = useState(null) + const [trackedAddress, setTrackedAddress] = useState(null) + const [trackedChainId, setTrackedChainId] = useState(null) useEffect(() => { let cancelled = false @@ -88,37 +105,54 @@ export function GoodWidgetProvider({ } }, [explicitProvider]) + // When the integrator supplies a live address/chain id (e.g. from a wallet + // connection SDK's own reactive hooks), that becomes the single source of + // truth and the raw EIP-1193 event listeners below are skipped entirely — + // some connectors (WalletConnect sessions bridged through AppKit in + // particular) do not reliably emit accountsChanged/chainChanged, which + // otherwise leaves this state stale after a connect/disconnect/switch. + const hasAddressOverride = addressOverride !== undefined + const hasChainIdOverride = chainIdOverride !== undefined + useEffect(() => { + if (hasAddressOverride && hasChainIdOverride) return if (!resolvedProvider) return const handleAccountsChanged = (accounts: string[]) => { - setAddress(accounts[0] ?? null) + if (!hasAddressOverride) setTrackedAddress(accounts[0] ?? null) } const handleChainChanged = (newChainId: string) => { - setChainId(parseInt(newChainId, 16)) + if (!hasChainIdOverride) setTrackedChainId(parseInt(newChainId, 16)) } resolvedProvider.on('accountsChanged', handleAccountsChanged) resolvedProvider.on('chainChanged', handleChainChanged) - resolvedProvider - .request({ method: 'eth_accounts' }) - .then((accounts) => { - const accs = accounts as string[] - if (accs.length > 0) setAddress(accs[0]) - }) - .catch(() => {}) + if (!hasAddressOverride) { + resolvedProvider + .request({ method: 'eth_accounts' }) + .then((accounts) => { + const accs = accounts as string[] + if (accs.length > 0) setTrackedAddress(accs[0]) + }) + .catch(() => {}) + } - resolvedProvider - .request({ method: 'eth_chainId' }) - .then((id) => setChainId(parseInt(id as string, 16))) - .catch(() => {}) + if (!hasChainIdOverride) { + resolvedProvider + .request({ method: 'eth_chainId' }) + .then((id) => setTrackedChainId(parseInt(id as string, 16))) + .catch(() => {}) + } return () => { resolvedProvider.removeListener('accountsChanged', handleAccountsChanged) resolvedProvider.removeListener('chainChanged', handleChainChanged) } - }, [resolvedProvider]) + }, [resolvedProvider, hasAddressOverride, hasChainIdOverride]) + + const address = hasAddressOverride ? (addressOverride ?? null) : trackedAddress + const chainId = hasChainIdOverride ? (chainIdOverride ?? null) : trackedChainId const connect = useCallback(async () => { if (connectOverride) { @@ -130,18 +164,44 @@ export function GoodWidgetProvider({ const accounts = (await resolvedProvider.request({ method: 'eth_requestAccounts', })) as string[] - if (accounts.length > 0) { - setAddress(accounts[0]) + if (accounts.length > 0 && !hasAddressOverride) { + setTrackedAddress(accounts[0]) } - }, [connectOverride, resolvedProvider]) + }, [connectOverride, resolvedProvider, hasAddressOverride]) // Wallet session ownership stays with the integrator. Provider/account - // updates after the override resolves flow back through the normal EIP-1193 + // updates after the override resolves flow back through addressOverride/ + // chainIdOverride when supplied, otherwise through the normal EIP-1193 // accountsChanged event or a changed provider prop. const disconnect = useCallback(async () => { await disconnectOverride?.() }, [disconnectOverride]) + // Tries the standard EIP-3326 request first; falls back to the + // integrator's own switch/network-modal flow (e.g. AppKit) when the + // active connector rejects or does not support it — some WalletConnect + // sessions never resolve wallet_switchEthereumChain at all. + const switchChain = useCallback( + async (targetChainId: number) => { + if (resolvedProvider) { + try { + await resolvedProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }) + return + } catch (err) { + if (!switchChainOverride) throw err + } + } + if (!switchChainOverride) { + throw new Error('No wallet provider available to switch chains') + } + await switchChainOverride(targetChainId) + }, + [resolvedProvider, switchChainOverride], + ) + const mergedConfig = useMemo(() => { const finalConfig = mergeThemeOverrides(authorConfig, themeOverrides) return createGoodWidgetConfig(finalConfig ?? undefined) @@ -155,8 +215,10 @@ export function GoodWidgetProvider({ provider: resolvedProvider, connect, disconnect, + disconnectLabel, + switchChain, }), - [address, chainId, resolvedProvider, connect, disconnect], + [address, chainId, resolvedProvider, connect, disconnect, disconnectLabel, switchChain], ) const hostValue = useMemo(() => ({ host, capabilities }), [host, capabilities]) From 00f0fabe4e0e56772af67ffb38455507b20e4c66 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:41 +0000 Subject: [PATCH 06/30] [gdpatchagent] feat(core): add address/chain/switch-chain override props to GoodWidgetProviderProps (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/core/src/types.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d3c1c75..f5dfd29 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,6 +39,34 @@ export interface GoodWidgetProviderProps { provider?: EIP1193Provider connectOverride?: () => Promise disconnectOverride?: () => Promise + /** + * Integrator-owned live address (e.g. from a wallet-connection SDK's own + * reactive account hook). When set, including `null`, this replaces the + * address normally derived from the raw provider's `accountsChanged` + * event/`eth_accounts` call, which some connectors (e.g. a WalletConnect + * session bridged through AppKit) do not reliably emit. + */ + addressOverride?: string | null + /** + * Integrator-owned live chain id, mirroring `addressOverride`. When set, + * including `null`, this replaces the chain id normally derived from the + * raw provider's `chainChanged` event/`eth_chainId` call. + */ + chainIdOverride?: number | null + /** + * Integrator-owned chain-switch fallback, invoked when a raw + * `wallet_switchEthereumChain` request fails or the provider does not + * support it (e.g. to open a connection SDK's own network modal). + */ + switchChainOverride?: (chainId: number) => Promise + /** + * Label for the disconnect action in the wallet chip menu. Defaults to + * "Disconnect". Integrators whose `disconnectOverride` opens a + * connection-management modal rather than disconnecting directly (e.g. + * AppKit's Account view) should override this to something like "Network + * settings" so the action's label matches what it actually does. + */ + disconnectLabel?: string config?: GoodWidgetConfig themeOverrides?: GoodWidgetThemeOverrides defaultTheme?: 'light' | 'dark' From c658b49dc46e8a8bbfaa0ecaba5a8d0780e82867 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:42 +0000 Subject: [PATCH 07/30] [gdpatchagent] refactor(embed): export DEFAULT_APPKIT_NETWORKS for chain-id lookups (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/embed/src/DefaultAppKitProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/embed/src/DefaultAppKitProvider.tsx b/packages/embed/src/DefaultAppKitProvider.tsx index eff2883..703f13d 100644 --- a/packages/embed/src/DefaultAppKitProvider.tsx +++ b/packages/embed/src/DefaultAppKitProvider.tsx @@ -3,7 +3,7 @@ import { AppKitProvider } from '@reown/appkit/react' import { base, celo, fuse, mainnet, xdc, type AppKitNetwork } from '@reown/appkit/networks' import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' -const DEFAULT_APPKIT_NETWORKS = [mainnet, base, xdc, fuse, celo] as [ +export const DEFAULT_APPKIT_NETWORKS = [mainnet, base, xdc, fuse, celo] as [ AppKitNetwork, ...AppKitNetwork[], ] From 3adebac8a9f93c67e13610d85c9bb4121f203ee4 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:44 +0000 Subject: [PATCH 08/30] [gdpatchagent] refactor(embed): re-export useAppKitNetwork and DEFAULT_APPKIT_NETWORKS (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/embed/src/appkitProvider.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/embed/src/appkitProvider.ts b/packages/embed/src/appkitProvider.ts index e812c87..287df22 100644 --- a/packages/embed/src/appkitProvider.ts +++ b/packages/embed/src/appkitProvider.ts @@ -1,2 +1,9 @@ export { DefaultAppKitProvider } from './DefaultAppKitProvider' -export { useAppKit, useAppKitAccount, useAppKitProvider, modal } from '@reown/appkit/react' +export { + useAppKit, + useAppKitAccount, + useAppKitNetwork, + useAppKitProvider, + modal, +} from '@reown/appkit/react' +export { DEFAULT_APPKIT_NETWORKS } from './DefaultAppKitProvider' From 11750ff8e267fef223e1bd25c0102a19083927d7 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:45 +0000 Subject: [PATCH 09/30] [gdpatchagent] fix(superfluid-campaign-widget): wire override props into runtime and embedded claim widget (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/SuperfluidCampaignWidget.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx index f95b881..de014df 100644 --- a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx +++ b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx @@ -43,6 +43,10 @@ interface SuperfluidCampaignRuntimeProps { config?: SuperfluidCampaignWidgetProps['config'] themeOverrides?: SuperfluidCampaignWidgetProps['themeOverrides'] defaultTheme?: SuperfluidCampaignWidgetProps['defaultTheme'] + /** Forwarded to the embedded CitizenClaimWidget's own GoodWidgetProvider. */ + addressOverride?: SuperfluidCampaignWidgetProps['addressOverride'] + chainIdOverride?: SuperfluidCampaignWidgetProps['chainIdOverride'] + switchChainOverride?: SuperfluidCampaignWidgetProps['switchChainOverride'] hasDisconnectOverride: boolean } @@ -117,9 +121,12 @@ function SuperfluidCampaignRuntime({ config, themeOverrides, defaultTheme, + addressOverride, + chainIdOverride, + switchChainOverride, hasDisconnectOverride, }: SuperfluidCampaignRuntimeProps) { - const { isConnected, connect, disconnect, address } = useWallet() + const { isConnected, connect, disconnect, disconnectLabel, address } = useWallet() const [view, setView] = useState(initialView) const [embeddedClaimTab, setEmbeddedClaimTab] = useState(null) const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0) @@ -146,6 +153,7 @@ function SuperfluidCampaignRuntime({ isConnected={isConnected} onConnect={connect} onDisconnect={hasDisconnectOverride ? disconnect : undefined} + disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} onClose={() => setEmbeddedClaimTab(null)} /> setView('content')} leaderboardRefreshKey={leaderboardRefreshKey} userPointsAdapter={isMockRuntime ? dataClient.userPoints : undefined} @@ -188,6 +200,7 @@ function SuperfluidCampaignRuntime({ isConnected={isConnected} onConnect={connect} onDisconnect={hasDisconnectOverride ? disconnect : undefined} + disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} /> From 7279ad8e0108a269712f0172b302951755f47364 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:46 +0000 Subject: [PATCH 10/30] [gdpatchagent] feat(superfluid-campaign-widget): pass disconnectLabel through CampaignHeader (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/components/CampaignHeader.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx index 5e7deca..ba6eb0e 100644 --- a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx +++ b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx @@ -17,6 +17,8 @@ interface CampaignHeaderProps { isConnected: boolean onConnect: () => void onDisconnect?: () => Promise + /** Label for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectLabel?: string /** Present when the campaign shell is showing an in-place child widget. */ onClose?: () => void } @@ -36,6 +38,7 @@ export function CampaignHeader({ isConnected, onConnect, onDisconnect, + disconnectLabel, onClose, }: CampaignHeaderProps) { return ( @@ -60,7 +63,7 @@ export function CampaignHeader({ {isConnected ? ( - + ) : ( )} From a46f15a85275bef1a3592452ef1ec79549b41136 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:47 +0000 Subject: [PATCH 11/30] [gdpatchagent] feat(superfluid-campaign-widget): pass disconnectLabel through LeaderboardView (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/components/LeaderboardView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx index c78353d..eb301f9 100644 --- a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx +++ b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx @@ -42,6 +42,8 @@ interface LeaderboardViewProps { isConnected: boolean onConnect: () => void onDisconnect?: () => Promise + /** Label for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectLabel?: string onClose: () => void leaderboardRefreshKey: number userPointsAdapter?: CampaignUserPointsAdapter @@ -85,6 +87,7 @@ export function LeaderboardView({ isConnected, onConnect, onDisconnect, + disconnectLabel, onClose, leaderboardRefreshKey, userPointsAdapter, @@ -177,7 +180,7 @@ export function LeaderboardView({ {isConnected ? ( - + ) : ( {disconnectMessage && ( From 88aa28afe755be96f4550b9b587aed95955a44ed Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:50 +0000 Subject: [PATCH 13/30] [gdpatchagent] feat(superfluid-campaign-widget): accept address/chain/switch-chain/disconnect-label overrides (#164) On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/widgetRuntimeContract.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts index 59cbf93..df400c2 100644 --- a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts +++ b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts @@ -194,6 +194,26 @@ export interface SuperfluidCampaignWidgetProps { connectOverride?: () => Promise /** Integrator-owned wallet disconnect flow. */ disconnectOverride?: () => Promise + /** + * Integrator-owned live address (e.g. from a wallet-connection SDK's own + * reactive account hook). See `GoodWidgetProviderProps.addressOverride`. + */ + addressOverride?: string | null + /** + * Integrator-owned live chain id, mirroring `addressOverride`. See + * `GoodWidgetProviderProps.chainIdOverride`. + */ + chainIdOverride?: number | null + /** + * Integrator-owned chain-switch fallback. See + * `GoodWidgetProviderProps.switchChainOverride`. + */ + switchChainOverride?: (chainId: number) => Promise + /** + * Label for the wallet chip's disconnect action. See + * `GoodWidgetProviderProps.disconnectLabel`. + */ + disconnectLabel?: string environment?: SuperfluidCampaignWidgetEnvironment themeOverrides?: GoodWidgetThemeOverrides config?: GoodWidgetConfig From 8de277c4a7b9784dbdaaca585ecfd7bc1439dfde Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:34:20 +0000 Subject: [PATCH 14/30] [gdpatchagent] fix(core,citizen-claim-widget,superfluid-campaign-widget): read-only balance reads, chain-scoped execute gating, PR #165 cleanup - Balance/entitlement reads (loadClaimablesByChain, loadClaimStatus) now use address-only, read-only clients built locally, instead of requiring a connected account or the passed-down provider. - On-chain claim execution is now gated to the passed-down provider's available/active chains via a new availableChainIdsOverride prop threaded through GoodWidgetProvider, CitizenClaimWidget and SuperfluidCampaignWidget, and populated from AppKit's approvedCaipNetworkIds in the demo app. - Errors now name the specific chain an action cannot run on (CitizenClaimAdapterError), surfaced verbatim by humanReadableError. - Cleanup: removed the unsafe chainId cast in loadClaimStatus (replaced by isSupportedChain narrowing), fixed the getPublicClientForChain(...) ?? undefined bug, deduped the switch-chain error string, corrected a stale comment in provider.tsx, and replaced WalletChip's hardcoded log-out icon with a disconnectIcon override mirroring disconnectLabel. Addresses PR #165 review feedback. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- apps/superfluid-campaign-web/src/App.tsx | 18 +- .../src/CitizenClaimWidget.tsx | 2 + packages/citizen-claim-widget/src/adapter.ts | 180 +++++++++++------- .../src/widgetRuntimeContract.ts | 6 + packages/core/src/provider.tsx | 46 ++++- packages/core/src/types.ts | 25 ++- .../src/SuperfluidCampaignWidget.tsx | 13 +- .../src/components/CampaignHeader.tsx | 11 +- .../src/components/LeaderboardView.tsx | 11 +- .../src/components/shared/WalletChip.tsx | 12 +- .../src/widgetRuntimeContract.ts | 13 +- 11 files changed, 253 insertions(+), 84 deletions(-) diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index 7fafd72..932af89 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -25,7 +25,7 @@ function AppKitSuperfluidCampaignWidget() { const { open } = useAppKit() const { address } = useAppKitAccount() const { walletProvider } = useAppKitProvider('eip155') - const { chainId, switchNetwork } = useAppKitNetwork() + const { chainId, switchNetwork, approvedCaipNetworkIds } = useAppKitNetwork() const addressRef = useRef(address) addressRef.current = address @@ -38,6 +38,20 @@ function AppKitSuperfluidCampaignWidget() { [], ) + // CAIP network ids are formatted like "eip155:122" — the widget's execute + // gating needs plain numeric chain ids. `undefined` (AppKit hasn't reported + // approved networks yet) is kept as `null`, meaning "no restriction known" + // rather than "nothing is available". + const availableChainIds = useMemo( + () => + approvedCaipNetworkIds + ? approvedCaipNetworkIds + .map((caipId) => Number(caipId.split(':')[1])) + .filter((id) => Number.isFinite(id)) + : null, + [approvedCaipNetworkIds], + ) + return ( { const targetNetwork = appKitNetworksByChainId.get(targetChainId) // Falling through to AppKit's own network-selection modal is the @@ -62,6 +77,7 @@ function AppKitSuperfluidCampaignWidget() { } }} disconnectLabel={APPKIT_DISCONNECT_LABEL} + disconnectIcon="settings" connectOverride={async () => { await open({ view: 'Connect' }) if (!addressRef.current) throw new Error('wallet_connect_cancelled') diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 632f392..5dd3801 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -641,6 +641,7 @@ export function CitizenClaimWidget({ addressOverride, chainIdOverride, switchChainOverride, + availableChainIdsOverride, }: CitizenClaimWidgetProps) { return ( = { const SUPPORTED_CHAINS = citizenSdkCapabilities.chains const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments +/** Resolves a supported chain id to its display name, falling back to the raw id. */ +function getChainDisplayName(chainId: number): string { + return CHAIN_CONFIGS[chainId]?.name ?? `chain ${chainId}` +} + +/** + * Thrown for adapter-level failures whose message is already user-facing + * (e.g. naming the specific chain an action cannot run on). humanReadableError + * passes these through verbatim instead of remapping them to a generic string. + */ +class CitizenClaimAdapterError extends Error {} + // --------------------------------------------------------------------------- // humanReadableError — converts a raw SDK/viem error into a short, user-friendly // string. The full technical error is always logged to the console for debugging. @@ -80,6 +92,10 @@ const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments function humanReadableError(err: unknown): string { console.error('[CitizenClaimWidget]', err) + if (err instanceof CitizenClaimAdapterError) { + return err.message + } + if (!(err instanceof Error)) { // Log the raw value so non-Error throws are still traceable console.error('[CitizenClaimWidget] non-Error thrown:', typeof err, err) @@ -180,7 +196,8 @@ type CitizenEnvironment = 'production' | 'staging' | 'development' export function useCitizenClaimAdapter( options: UseCitizenClaimAdapterOptions = {}, ): CitizenClaimWidgetAdapterResult { - const { address, chainId, isConnected, provider, connect, switchChain } = useWallet() + const { address, chainId, isConnected, provider, availableChainIds, connect, switchChain } = + useWallet() const clientFactory = options.clientFactory const claimExecution = options.claimExecution @@ -355,52 +372,78 @@ export function useCitizenClaimAdapter( [createSdkInstances, resolveClientsForChain], ) + // --------------------------------------------------------------------------- + // Read-only client resolution — balance/entitlement reads never need a + // connected account or the passed-down provider, only the address to read + // for. Custodial mode reuses its own configured per-chain clients (already + // address-scoped); the default path builds an RPC-backed publicClient plus + // a signer-less walletClient carrying just `account`, since checkEntitlement/ + // getWalletClaimStatus only use walletClient.account to identify whose + // entitlement to read and never send a transaction through it. + // --------------------------------------------------------------------------- + const createReadOnlyClientsForChain = useCallback( + (targetChainId: number) => { + if (!address) return null + + if (isCustodialExecution) { + const configuredClients = claimExecution?.clientsByChain[targetChainId] + return configuredClients ? normalizeClientBundle(configuredClients) : null + } + + const chain = CHAIN_CONFIGS[targetChainId] + const rpcUrl = chain?.rpcUrls.default.http[0] + if (!chain || !rpcUrl) return null + + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + chain, + transport: http(rpcUrl), + }) + return { publicClient, walletClient } + }, + [address, claimExecution, isCustodialExecution, normalizeClientBundle], + ) + + const createReadOnlySdkForChain = useCallback( + (targetChainId: number) => createSdkInstances(createReadOnlyClientsForChain(targetChainId)), + [createReadOnlyClientsForChain, createSdkInstances], + ) + /** * Collects claimable UBI amounts for all citizen-sdk supported chains. * This mirrors GoodWalletV2's claim breakdown model (eligible amounts per chain). * - * A wallet's injected/bridged EIP-1193 provider only ever answers RPC calls - * for its own currently-active chain, regardless of the viem Chain - * descriptor attached to a client built on top of it — so per-chain reads - * cannot each get their own wallet-provider-transported client. Instead, - * one SDK instance is created for the active chain, and every other - * chain's entitlement is read through that same instance's - * checkEntitlement({ chainOverride, publicClient }), passing an RPC-backed - * publicClient (never the wallet's own provider) for the non-active chain — - * this is the SDK's own supported multi-chain read path. + * These are personalized reads once an address is known, but they never need + * a connected account or the passed-down provider — each supported chain gets + * its own independently-scoped, read-only SDK instance (RPC-backed publicClient + * + a signer-less walletClient carrying just the address), so no chain's read + * depends on any other chain being "active". */ const loadClaimablesByChain = useCallback(async (): Promise => { const eligible: Array<{ chainId: number; amount: string }> = [] - if (isConnected && address && onSupportedChain) { - const primarySdk = await createSdkInstancesForChain(chainId as number) - if (primarySdk) { - await Promise.all( - SUPPORTED_CHAINS.map(async (supportedChainId) => { - try { - const isActiveChain = supportedChainId === chainId - const result = await primarySdk.claimSDK.checkEntitlement( - isActiveChain - ? undefined - : { - chainOverride: supportedChainId, - publicClient: getPublicClientForChain(supportedChainId) ?? undefined, - }, - ) - if (result.amount <= 0n) return - - const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 - eligible.push({ - chainId: supportedChainId, - amount: formatUnits(result.amount, decimals), - }) - } catch { - // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. - } - }), - ) - } - } else if (!isConnected) { + if (address) { + await Promise.all( + SUPPORTED_CHAINS.map(async (supportedChainId) => { + try { + const sdk = createReadOnlySdkForChain(supportedChainId) + if (!sdk) return + const result = await sdk.claimSDK.checkEntitlement() + if (result.amount <= 0n) return + + const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 + eligible.push({ + chainId: supportedChainId, + amount: formatUnits(result.amount, decimals), + }) + } catch { + // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. + } + }), + ) + } else { + // No address at all: fall back to the non-personalized, chain-level entitlement reads. await Promise.all( SUPPORTED_CHAINS.map(async (supportedChainId) => { try { @@ -428,15 +471,7 @@ export function useCitizenClaimAdapter( if (!mountedRef.current) return eligible.sort((a, b) => b.chainId - a.chainId) setClaimablesByChain(eligible) - }, [ - address, - chainId, - createSdkInstancesForChain, - env, - getPublicClientForChain, - isConnected, - onSupportedChain, - ]) + }, [address, createReadOnlySdkForChain, env, getPublicClientForChain]) const loadDailyStats = useCallback(async (): Promise => { let maxClaimers = 0 @@ -481,15 +516,15 @@ export function useCitizenClaimAdapter( loadDailyStats(), ]) - if (!isConnected || !address) { + if (!address) { await auxiliaryReads setStatus('not_connected') return } - if (!onSupportedChain) { + if (chainId === null || !isSupportedChain(chainId)) { await auxiliaryReads - // Wallet connected but on an unsupported chain — surface switch_chain action + // Address known but the active chain is unsupported/unknown — surface switch_chain action setStatus('not_connected') return } @@ -497,7 +532,9 @@ export function useCitizenClaimAdapter( setStatus('loading') setError(null) - const sdk = await createSdkInstancesForChain(chainId) + // A personalized status read, but still address-only: no connected + // account or passed-down provider is required, only the address itself. + const sdk = createReadOnlySdkForChain(chainId) if (!sdk) { setStatus('not_connected') return @@ -514,7 +551,7 @@ export function useCitizenClaimAdapter( } else if (walletStatus.status === 'can_claim') { // User is whitelisted and has unclaimed UBI setStatus('eligible') - const decimals = CHAIN_DECIMALS[chainId as SupportedChains] ?? 18 + const decimals = CHAIN_DECIMALS[chainId] ?? 18 setAmount(formatUnits(walletStatus.entitlement, decimals)) } else { // User is whitelisted but has already claimed for this period @@ -527,15 +564,7 @@ export function useCitizenClaimAdapter( setStatus('error') setError(humanReadableError(err)) } - }, [ - isConnected, - address, - onSupportedChain, - chainId, - createSdkInstancesForChain, - loadClaimablesByChain, - loadDailyStats, - ]) + }, [address, chainId, createReadOnlySdkForChain, loadClaimablesByChain, loadDailyStats]) // Auto-refresh claim status whenever wallet connection or chain changes useEffect(() => { @@ -549,11 +578,24 @@ export function useCitizenClaimAdapter( // --------------------------------------------------------------------------- const claimOnChain = useCallback( async (targetChainId: number): Promise => { - if (!isCustodialExecution && !provider) throw new Error('No wallet provider available') - if (!address && !isCustodialExecution) throw new Error('Wallet not connected') + if (!isCustodialExecution && !provider) { + throw new CitizenClaimAdapterError('No wallet provider available') + } + if (!address && !isCustodialExecution) { + throw new CitizenClaimAdapterError('Wallet not connected') + } if (!isSupportedChain(targetChainId)) { - throw new Error(`Unsupported chain for citizen-sdk: ${targetChainId}`) + throw new CitizenClaimAdapterError(`Unsupported chain for citizen-sdk: ${targetChainId}`) + } + + // Execute actions must stay within the chains the passed-down provider + // can actually sign for right now. Custodial execution supplies its own + // pre-configured per-chain clients and is not subject to this restriction. + if (!isCustodialExecution && availableChainIds && !availableChainIds.includes(targetChainId)) { + throw new CitizenClaimAdapterError( + `Claim is not available on ${getChainDisplayName(targetChainId)} for this connection.`, + ) } setStatus('claiming') @@ -569,11 +611,15 @@ export function useCitizenClaimAdapter( } const sdk = await createSdkInstancesForChain(targetChainId) - if (!sdk) throw new Error('Unable to initialize SDK clients for target chain') + if (!sdk) { + throw new CitizenClaimAdapterError( + `Unable to initialize SDK clients for ${getChainDisplayName(targetChainId)}`, + ) + } return sdk.claimSDK.claim() }, - [address, createSdkInstancesForChain, isCustodialExecution, provider, switchChain], + [address, availableChainIds, createSdkInstancesForChain, isCustodialExecution, provider, switchChain], ) const claimAll = useCallback( diff --git a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts index 4c3423f..8851f05 100644 --- a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts +++ b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts @@ -147,6 +147,12 @@ export interface CitizenClaimWidgetProps { * `GoodWidgetProviderProps.switchChainOverride`. */ switchChainOverride?: (chainId: number) => Promise + /** + * Chain ids the passed-down provider can currently execute on. See + * `GoodWidgetProviderProps.availableChainIdsOverride`. Claim execution is + * scoped to this set; balance/entitlement reads are unaffected. + */ + availableChainIdsOverride?: number[] | null // ---- Theming (optional, passed through to GoodWidgetProvider) ---- /** Token and theme overrides applied at the widget boundary. */ themeOverrides?: GoodWidgetThemeOverrides diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index 500e751..a00fd22 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react' import { TamaguiProvider } from 'tamagui' import { createGoodWidgetConfig, mergeThemeOverrides, YStack, Stack } from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' import { detectHost } from './detect' import type { EIP1193Provider } from './eip1193' import type { @@ -25,6 +26,8 @@ export interface WalletContextValue extends WalletState { disconnect: () => Promise /** Label for the disconnect action; see `GoodWidgetProviderProps.disconnectLabel`. */ disconnectLabel: string + /** Icon for the disconnect action; see `GoodWidgetProviderProps.disconnectIcon`. */ + disconnectIcon: IconName switchChain: (chainId: number) => Promise } @@ -34,11 +37,14 @@ export interface GoodWidgetContextValue extends GoodWidgetState { connect: () => Promise disconnect: () => Promise disconnectLabel: string + disconnectIcon: IconName switchChain: (chainId: number) => Promise } +const SWITCH_CHAIN_UNAVAILABLE_ERROR = 'No wallet provider available to switch chains' + const noopSwitchChain = async () => { - throw new Error('No wallet provider available to switch chains') + throw new Error(SWITCH_CHAIN_UNAVAILABLE_ERROR) } export const WalletContext = React.createContext({ @@ -46,9 +52,11 @@ export const WalletContext = React.createContext({ chainId: null, isConnected: false, provider: null, + availableChainIds: null, connect: async () => {}, disconnect: async () => {}, disconnectLabel: 'Disconnect', + disconnectIcon: 'log-out', switchChain: noopSwitchChain, }) @@ -62,11 +70,13 @@ export const GoodWidgetContext = React.createContext({ chainId: null, isConnected: false, provider: null, + availableChainIds: null, host: 'injected', capabilities: DEFAULT_CAPABILITIES, connect: async () => {}, disconnect: async () => {}, disconnectLabel: 'Disconnect', + disconnectIcon: 'log-out', switchChain: noopSwitchChain, }) @@ -77,7 +87,9 @@ export function GoodWidgetProvider({ addressOverride, chainIdOverride, switchChainOverride, + availableChainIdsOverride, disconnectLabel = 'Disconnect', + disconnectIcon = 'log-out', config: authorConfig, themeOverrides, defaultTheme = 'dark', @@ -105,12 +117,14 @@ export function GoodWidgetProvider({ } }, [explicitProvider]) - // When the integrator supplies a live address/chain id (e.g. from a wallet - // connection SDK's own reactive hooks), that becomes the single source of - // truth and the raw EIP-1193 event listeners below are skipped entirely — - // some connectors (WalletConnect sessions bridged through AppKit in - // particular) do not reliably emit accountsChanged/chainChanged, which - // otherwise leaves this state stale after a connect/disconnect/switch. + // When the integrator supplies both a live address and chain id (e.g. from + // a wallet connection SDK's own reactive hooks), those become the single + // source of truth and the raw EIP-1193 event listeners below are skipped + // entirely. When only one of the two is supplied, the listeners still run + // so the other value keeps tracking — some connectors (WalletConnect + // sessions bridged through AppKit in particular) do not reliably emit + // accountsChanged/chainChanged, which otherwise leaves that value stale + // after a connect/disconnect/switch. const hasAddressOverride = addressOverride !== undefined const hasChainIdOverride = chainIdOverride !== undefined @@ -195,13 +209,15 @@ export function GoodWidgetProvider({ } } if (!switchChainOverride) { - throw new Error('No wallet provider available to switch chains') + throw new Error(SWITCH_CHAIN_UNAVAILABLE_ERROR) } await switchChainOverride(targetChainId) }, [resolvedProvider, switchChainOverride], ) + const availableChainIds = availableChainIdsOverride ?? null + const mergedConfig = useMemo(() => { const finalConfig = mergeThemeOverrides(authorConfig, themeOverrides) return createGoodWidgetConfig(finalConfig ?? undefined) @@ -213,12 +229,24 @@ export function GoodWidgetProvider({ chainId, isConnected: address !== null, provider: resolvedProvider, + availableChainIds, connect, disconnect, disconnectLabel, + disconnectIcon, switchChain, }), - [address, chainId, resolvedProvider, connect, disconnect, disconnectLabel, switchChain], + [ + address, + chainId, + resolvedProvider, + availableChainIds, + connect, + disconnect, + disconnectLabel, + disconnectIcon, + switchChain, + ], ) const hostValue = useMemo(() => ({ host, capabilities }), [host, capabilities]) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f5dfd29..93f6819 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,6 +1,6 @@ import type { EIP1193Provider } from './eip1193' import type { ReactNode } from 'react' -import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' +import type { GoodWidgetConfig, GoodWidgetThemeOverrides, IconName } from '@goodwidget/ui' export type HostEnvironment = 'farcaster' | 'minipay' | 'worldapp' | 'injected' | 'custom' @@ -23,6 +23,13 @@ export interface WalletState { chainId: number | null isConnected: boolean provider: EIP1193Provider | null + /** + * Chain ids the passed-down provider can actually execute on right now + * (e.g. an AppKit connection's approved networks). `null` means no + * restriction is known, so on-chain execute actions should not be gated + * by chain availability. + */ + availableChainIds: number[] | null } export interface HostState { @@ -59,6 +66,14 @@ export interface GoodWidgetProviderProps { * support it (e.g. to open a connection SDK's own network modal). */ switchChainOverride?: (chainId: number) => Promise + /** + * Integrator-owned set of chain ids the passed-down provider can currently + * execute on (e.g. AppKit's approved networks). `null`/omitted means no + * restriction is known. On-chain execute actions must be scoped to this + * set; balance/entitlement reads are unaffected since those use read-only + * clients keyed on `address` alone. + */ + availableChainIdsOverride?: number[] | null /** * Label for the disconnect action in the wallet chip menu. Defaults to * "Disconnect". Integrators whose `disconnectOverride` opens a @@ -67,6 +82,14 @@ export interface GoodWidgetProviderProps { * settings" so the action's label matches what it actually does. */ disconnectLabel?: string + /** + * Icon for the disconnect action in the wallet chip menu, mirroring + * `disconnectLabel`. Defaults to 'log-out'. Integrators overriding + * `disconnectLabel` to describe a settings/management action (e.g. + * AppKit's "Network settings") should pair it with a matching icon (e.g. + * 'settings') instead of leaving the literal log-out glyph. + */ + disconnectIcon?: IconName config?: GoodWidgetConfig themeOverrides?: GoodWidgetThemeOverrides defaultTheme?: 'light' | 'dark' diff --git a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx index de014df..8851d29 100644 --- a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx +++ b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx @@ -47,6 +47,7 @@ interface SuperfluidCampaignRuntimeProps { addressOverride?: SuperfluidCampaignWidgetProps['addressOverride'] chainIdOverride?: SuperfluidCampaignWidgetProps['chainIdOverride'] switchChainOverride?: SuperfluidCampaignWidgetProps['switchChainOverride'] + availableChainIdsOverride?: SuperfluidCampaignWidgetProps['availableChainIdsOverride'] hasDisconnectOverride: boolean } @@ -124,9 +125,10 @@ function SuperfluidCampaignRuntime({ addressOverride, chainIdOverride, switchChainOverride, + availableChainIdsOverride, hasDisconnectOverride, }: SuperfluidCampaignRuntimeProps) { - const { isConnected, connect, disconnect, disconnectLabel, address } = useWallet() + const { isConnected, connect, disconnect, disconnectLabel, disconnectIcon, address } = useWallet() const [view, setView] = useState(initialView) const [embeddedClaimTab, setEmbeddedClaimTab] = useState(null) const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0) @@ -154,6 +156,7 @@ function SuperfluidCampaignRuntime({ onConnect={connect} onDisconnect={hasDisconnectOverride ? disconnect : undefined} disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} + disconnectIcon={hasDisconnectOverride ? disconnectIcon : undefined} onClose={() => setEmbeddedClaimTab(null)} /> setView('content')} leaderboardRefreshKey={leaderboardRefreshKey} userPointsAdapter={isMockRuntime ? dataClient.userPoints : undefined} @@ -201,6 +206,7 @@ function SuperfluidCampaignRuntime({ onConnect={connect} onDisconnect={hasDisconnectOverride ? disconnect : undefined} disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} + disconnectIcon={hasDisconnectOverride ? disconnectIcon : undefined} /> diff --git a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx index ba6eb0e..a6e6c5b 100644 --- a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx +++ b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Badge, BadgeText, Button, ButtonText, Heading, Icon, Text, XStack, YStack } from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' import type { CampaignDefinition } from '../widgetRuntimeContract' import { ConnectWalletPrompt } from './ConnectWalletPrompt' import { compactButtonProps } from './shared/styles' @@ -19,6 +20,8 @@ interface CampaignHeaderProps { onDisconnect?: () => Promise /** Label for the WalletChip's disconnect action. See WalletChip's own prop for details. */ disconnectLabel?: string + /** Icon for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectIcon?: IconName /** Present when the campaign shell is showing an in-place child widget. */ onClose?: () => void } @@ -39,6 +42,7 @@ export function CampaignHeader({ onConnect, onDisconnect, disconnectLabel, + disconnectIcon, onClose, }: CampaignHeaderProps) { return ( @@ -63,7 +67,12 @@ export function CampaignHeader({ {isConnected ? ( - + ) : ( )} diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx index eb301f9..3cd7466 100644 --- a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx +++ b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx @@ -12,6 +12,7 @@ import { XStack, YStack, } from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' import type { CampaignLeaderboardAdapter, CampaignPointsAccount, @@ -44,6 +45,8 @@ interface LeaderboardViewProps { onDisconnect?: () => Promise /** Label for the WalletChip's disconnect action. See WalletChip's own prop for details. */ disconnectLabel?: string + /** Icon for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectIcon?: IconName onClose: () => void leaderboardRefreshKey: number userPointsAdapter?: CampaignUserPointsAdapter @@ -88,6 +91,7 @@ export function LeaderboardView({ onConnect, onDisconnect, disconnectLabel, + disconnectIcon, onClose, leaderboardRefreshKey, userPointsAdapter, @@ -180,7 +184,12 @@ export function LeaderboardView({ {isConnected ? ( - + ) : ( {disconnectMessage && ( diff --git a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts index df400c2..c01594c 100644 --- a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts +++ b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts @@ -1,4 +1,4 @@ -import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' +import type { GoodWidgetConfig, GoodWidgetThemeOverrides, IconName } from '@goodwidget/ui' import type { CitizenClaimWidgetCustodialExecution, CitizenClaimWidgetEnvironment, @@ -209,11 +209,22 @@ export interface SuperfluidCampaignWidgetProps { * `GoodWidgetProviderProps.switchChainOverride`. */ switchChainOverride?: (chainId: number) => Promise + /** + * Chain ids the passed-down provider can currently execute on. See + * `GoodWidgetProviderProps.availableChainIdsOverride`. Claim execution is + * scoped to this set; balance/entitlement reads are unaffected. + */ + availableChainIdsOverride?: number[] | null /** * Label for the wallet chip's disconnect action. See * `GoodWidgetProviderProps.disconnectLabel`. */ disconnectLabel?: string + /** + * Icon for the wallet chip's disconnect action, mirroring `disconnectLabel`. + * See `GoodWidgetProviderProps.disconnectIcon`. + */ + disconnectIcon?: IconName environment?: SuperfluidCampaignWidgetEnvironment themeOverrides?: GoodWidgetThemeOverrides config?: GoodWidgetConfig From e248ebec6050e9835bbede8c3d3ceba0e86d2799 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:44:46 +0000 Subject: [PATCH 15/30] [gdpatchagent] fix(citizen-claim-widget,superfluid-campaign-widget): address Copilot re-review nits - Renamed WalletChip's menuActionIcon prop to disconnectIcon so it matches the upstream prop name it's fed from (CampaignHeader/LeaderboardView no longer need to remap it at the call site). - Capitalized the chain-id fallback string in getChainDisplayName ("Chain ") to match this repo's other user-facing chain fallbacks. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/adapter.ts | 2 +- .../src/components/CampaignHeader.tsx | 2 +- .../src/components/LeaderboardView.tsx | 2 +- .../src/components/shared/WalletChip.tsx | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 0af6b34..d690ada 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -65,7 +65,7 @@ const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments /** Resolves a supported chain id to its display name, falling back to the raw id. */ function getChainDisplayName(chainId: number): string { - return CHAIN_CONFIGS[chainId]?.name ?? `chain ${chainId}` + return CHAIN_CONFIGS[chainId]?.name ?? `Chain ${chainId}` } /** diff --git a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx index a6e6c5b..876c39c 100644 --- a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx +++ b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx @@ -71,7 +71,7 @@ export function CampaignHeader({ address={address} onDisconnect={onDisconnect} disconnectLabel={disconnectLabel} - menuActionIcon={disconnectIcon} + disconnectIcon={disconnectIcon} /> ) : ( diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx index 3cd7466..c99b5c7 100644 --- a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx +++ b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx @@ -188,7 +188,7 @@ export function LeaderboardView({ address={address} onDisconnect={onDisconnect} disconnectLabel={disconnectLabel} - menuActionIcon={disconnectIcon} + disconnectIcon={disconnectIcon} /> ) : ( {disconnectMessage && ( From 07dec0f5c4090dbebc358bbcc4e2b1d4ee34c6d9 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:20:24 +0000 Subject: [PATCH 16/30] [gdpatchagent] fix(citizen-claim-widget,superfluid-campaign-web): clear stale claim data on disconnect, humanize switch-chain errors, fix AppKit init race - adapter.ts: loadClaimStatus now resets amount/nextClaimTime when the wallet disconnects or moves to an unsupported chain, so no personalized entitlement from a prior session survives a disconnect. - adapter.ts: the switchChain action exposed to widget UI is now wrapped (handleSwitchChain) so a rejected/failing chain switch always surfaces a humanized message via humanReadableError instead of a raw RPC error or silent no-op. - App.tsx: addressOverride/chainIdOverride now stay undefined while AppKit hasn't yet resolved its account status, instead of collapsing to null (== "known disconnected"). This lets the core provider's own EIP-1193 fallback tracking cover AppKit's init/reconnect window instead of a premature disconnected override. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- apps/superfluid-campaign-web/src/App.tsx | 14 +++++-- packages/citizen-claim-widget/src/adapter.ts | 43 ++++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index 932af89..eb7964f 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -23,12 +23,20 @@ const APPKIT_DISCONNECT_LABEL = 'Network settings' function AppKitSuperfluidCampaignWidget() { const { open } = useAppKit() - const { address } = useAppKitAccount() + const { address, status: accountStatus } = useAppKitAccount() const { walletProvider } = useAppKitProvider('eip155') const { chainId, switchNetwork, approvedCaipNetworkIds } = useAppKitNetwork() const addressRef = useRef(address) addressRef.current = address + // AppKit reports `address: undefined` both while it's still restoring a prior + // session ('connecting'/'reconnecting'/not yet reported) and once it has + // definitively resolved to "no wallet". Only the latter is a real override — + // during the unresolved window this stays `undefined` so the core provider's + // own EIP-1193 fallback tracking (rather than a premature "disconnected" + // override) covers the gap until AppKit reports a final status. + const isAccountResolved = accountStatus === 'connected' || accountStatus === 'disconnected' + // AppKit's switchNetwork takes the network descriptor object, not a chain id, // so this looks up the descriptor for whichever chain the widget wants to // switch to. AppKit's own active-network state (chainId here) is always the @@ -57,8 +65,8 @@ function AppKitSuperfluidCampaignWidget() { provider={walletProvider} defaultTheme="dark" contentMaxWidth={DESKTOP_WIDGET_MAX_WIDTH} - addressOverride={address ?? null} - chainIdOverride={chainId === undefined ? null : Number(chainId)} + addressOverride={isAccountResolved ? (address ?? null) : undefined} + chainIdOverride={chainId === undefined ? undefined : Number(chainId)} availableChainIdsOverride={availableChainIds} switchChainOverride={async (targetChainId) => { const targetNetwork = appKitNetworksByChainId.get(targetChainId) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index d690ada..98a1885 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -518,13 +518,20 @@ export function useCitizenClaimAdapter( if (!address) { await auxiliaryReads + // No wallet address: clear any personalized entitlement left over from a + // prior connected session so a disconnected user never sees stale amounts. + setAmount(null) + setNextClaimTime(null) setStatus('not_connected') return } if (chainId === null || !isSupportedChain(chainId)) { await auxiliaryReads - // Address known but the active chain is unsupported/unknown — surface switch_chain action + // Address known but the active chain is unsupported/unknown — surface switch_chain action. + // Clear personalized entitlement from whatever chain was previously active. + setAmount(null) + setNextClaimTime(null) setStatus('not_connected') return } @@ -622,6 +629,28 @@ export function useCitizenClaimAdapter( [address, availableChainIds, createSdkInstancesForChain, isCustodialExecution, provider, switchChain], ) + // --------------------------------------------------------------------------- + // handleSwitchChain — the switchChain action exposed to widget UI (the + // standalone "switch to a supported chain" prompt, as opposed to the + // claim-flow's internal switchChain call inside claimOnChain). Wraps the raw + // useWallet() switchChain so a wallet rejection or RPC failure always reaches + // the widget as a humanized message in state.error, never as a raw error. + // --------------------------------------------------------------------------- + const handleSwitchChain = useCallback( + async (targetChainId: number): Promise => { + setError(null) + try { + await switchChain(targetChainId) + } catch (err: unknown) { + if (!mountedRef.current) throw err + setStatus('error') + setError(humanReadableError(err)) + throw err + } + }, + [switchChain], + ) + const claimAll = useCallback( async (targetChainIds: number[]): Promise => { const chainIdsToClaim = [...new Set(targetChainIds)] @@ -808,9 +837,17 @@ export function useCitizenClaimAdapter( claim: handleClaim, claimOnChain, claimAll, - switchChain, + switchChain: handleSwitchChain, }), - [handleConnect, loadClaimStatus, handleVerify, handleClaim, claimOnChain, claimAll, switchChain], + [ + handleConnect, + loadClaimStatus, + handleVerify, + handleClaim, + claimOnChain, + claimAll, + handleSwitchChain, + ], ) return { state, actions } From 0e7221abe11ad727e962cd3fc27e7aed1f0e95c0 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:25:40 +0000 Subject: [PATCH 17/30] [gdpatchagent] docs(citizen-claim-widget,superfluid-campaign-widget): clarify fallback-chain and customizable-menu doc comments - CitizenClaimWidgetProps.chainId: document that it's a fallback shown only until the live wallet chain resolves, mirroring the internal CitizenClaimShellProps.fallbackChainId comment. - WalletChip: clarify the menu's default "Disconnect" label/icon are customizable via disconnectLabel/disconnectIcon, per Copilot review nit. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/widgetRuntimeContract.ts | 5 +++++ .../src/components/shared/WalletChip.tsx | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts index 8851f05..bc39d19 100644 --- a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts +++ b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts @@ -127,6 +127,11 @@ export interface CitizenClaimWidgetChainClaimResult { export interface CitizenClaimWidgetProps { provider?: unknown environment?: CitizenClaimWidgetEnvironment + /** + * Fallback chain id shown only until the live wallet chain resolves via + * `provider`/`chainIdOverride`, or while disconnected. Once a live chain is + * known it always takes precedence over this value. + */ chainId?: number clientFactory?: CitizenClaimWidgetClientFactory claimExecution?: CitizenClaimWidgetCustodialExecution diff --git a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx index 24805b0..02bfe1a 100644 --- a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx +++ b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx @@ -21,8 +21,9 @@ interface WalletChipProps { /** * Connected-wallet chip (status dot + truncated address + chevron) shared by * CampaignHeader and LeaderboardView so both headers stay identical instead - * of duplicating the markup. Pressing the chip opens a single-action - * dropdown, following the same relative/absolute positioning + * of duplicating the markup. Pressing the chip opens a single-action menu + * (default "Disconnect", customizable via disconnectLabel/disconnectIcon — + * see their doc comments), following the same relative/absolute positioning * pattern as InfoTooltip in ai-credits-widget rather than pulling in the * heavier Drawer/ActionSheet primitives for one menu item. */ From baed5d1e9de864fbd663ae184c9a544814717d6b Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:35:31 +0000 Subject: [PATCH 18/30] [gdpatchagent] fix(citizen-claim-widget): stop rethrowing handled switch_chain errors handleSwitchChain already reports failures via state.error/state.status; the switch_chain caller in CitizenClaimWidget's outer catch does nothing with the rethrown value, so the rethrow served no purpose and risked surfacing as an unhandled rejection. Also: name CitizenClaimAdapterError instances for clearer logs, and guard chainIdOverride against a null chainId with == null instead of === undefined. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- apps/superfluid-campaign-web/src/App.tsx | 2 +- packages/citizen-claim-widget/src/adapter.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index eb7964f..97cf2d6 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -66,7 +66,7 @@ function AppKitSuperfluidCampaignWidget() { defaultTheme="dark" contentMaxWidth={DESKTOP_WIDGET_MAX_WIDTH} addressOverride={isAccountResolved ? (address ?? null) : undefined} - chainIdOverride={chainId === undefined ? undefined : Number(chainId)} + chainIdOverride={chainId == null ? undefined : Number(chainId)} availableChainIdsOverride={availableChainIds} switchChainOverride={async (targetChainId) => { const targetNetwork = appKitNetworksByChainId.get(targetChainId) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 98a1885..9279bd2 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -73,7 +73,12 @@ function getChainDisplayName(chainId: number): string { * (e.g. naming the specific chain an action cannot run on). humanReadableError * passes these through verbatim instead of remapping them to a generic string. */ -class CitizenClaimAdapterError extends Error {} +class CitizenClaimAdapterError extends Error { + constructor(message: string) { + super(message) + this.name = 'CitizenClaimAdapterError' + } +} // --------------------------------------------------------------------------- // humanReadableError — converts a raw SDK/viem error into a short, user-friendly @@ -642,10 +647,13 @@ export function useCitizenClaimAdapter( try { await switchChain(targetChainId) } catch (err: unknown) { - if (!mountedRef.current) throw err + if (!mountedRef.current) return + // This action owns its own error reporting via state.error — unlike + // handleClaim's caller, the switch_chain caller has no use for a + // rethrown value, so swallow it here rather than let it surface as + // an unused rejection. setStatus('error') setError(humanReadableError(err)) - throw err } }, [switchChain], From 79eed9089f075d78e21b5143c01d27ac6b6a53a7 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:41:37 +0000 Subject: [PATCH 19/30] [gdpatchagent] fix(citizen-claim-widget): add unsupported_chain status distinct from not_connected An address-known-but-unsupported-chain wallet reused the not_connected status, so CitizenClaimWidget rendered "Connect your wallet..." copy even though a wallet was already connected. Adds a dedicated unsupported_chain status: loadClaimStatus sets it instead of not_connected when the active chain isn't supported, primaryAction derives switch_chain/none from it directly (dropping the now-redundant onSupportedChain check), and the widget shows chain-specific "Claiming isn't available on . Switch network to continue." copy, naming the chain the action can't run on. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/CitizenClaimWidget.tsx | 10 +++++++++ packages/citizen-claim-widget/src/adapter.ts | 21 +++++++++---------- .../src/widgetRuntimeContract.ts | 2 ++ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 5dd3801..f24a192 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -414,6 +414,16 @@ function CitizenClaimInner({ )} + {status === 'unsupported_chain' && ( + <> + + {chainId + ? `Claiming isn't available on ${getChainName(chainId)}. Switch network to continue.` + : 'Switch to a supported network to claim daily G$'} + + + )} + {(status === 'eligible' || status === 'claiming' || claimablesByChain.length > 0) && ( <> {/* diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 9279bd2..16a5be0 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -193,7 +193,8 @@ type CitizenEnvironment = 'production' | 'staging' | 'development' * * State transitions (mirrors GoodWalletV2 ClaimView.tsx logic): * not_connected → [connect] → loading - * loading → not_whitelisted | eligible | already_claimed | error + * loading → not_whitelisted | eligible | already_claimed | unsupported_chain | error + * unsupported_chain → [switch_chain] → loading * not_whitelisted → [verify] → (external FV flow) → loading after return * eligible → [claim] → claiming → success | error * error → [refresh] → loading @@ -215,9 +216,6 @@ export function useCitizenClaimAdapter( : 'production' ) as CitizenEnvironment - // Whether the connected wallet is on a chain supported by citizen-sdk - const onSupportedChain = chainId !== null && isSupportedChain(chainId) - const [status, setStatus] = useState( isConnected ? 'loading' : 'not_connected', ) @@ -533,11 +531,13 @@ export function useCitizenClaimAdapter( if (chainId === null || !isSupportedChain(chainId)) { await auxiliaryReads - // Address known but the active chain is unsupported/unknown — surface switch_chain action. + // Address known but the active chain is unsupported/unknown — a distinct + // status from not_connected so the UI can show "switch chain" copy + // instead of misleadingly asking an already-connected wallet to connect. // Clear personalized entitlement from whatever chain was previously active. setAmount(null) setNextClaimTime(null) - setStatus('not_connected') + setStatus('unsupported_chain') return } @@ -765,13 +765,13 @@ export function useCitizenClaimAdapter( // Custodial execution is multi-chain. An account-scoped entitlement on any // configured chain must take precedence over the active chain's status. if (isConnected && address && claimablesByChain.length > 0) return 'claim' - if (status === 'not_connected') { + if (status === 'unsupported_chain') { // Custodial clients are already configured per chain, so they never need // the active wallet chain to be switched. Native wallet integrations keep // the existing switch-chain behavior. - if (isCustodialExecution) return 'none' - return isConnected && !onSupportedChain ? 'switch_chain' : 'connect' + return isCustodialExecution ? 'none' : 'switch_chain' } + if (status === 'not_connected') return 'connect' if (status === 'not_whitelisted') return 'verify' // Keep the claim button mounted while a claim is in-flight so UI copy can // switch to "Claiming..." without hiding the action surface. @@ -783,9 +783,8 @@ export function useCitizenClaimAdapter( status, address, isConnected, - onSupportedChain, - claimablesByChain, isCustodialExecution, + claimablesByChain, ]) const primaryLabel: string = useMemo(() => { diff --git a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts index bc39d19..ba698b4 100644 --- a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts +++ b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts @@ -7,6 +7,8 @@ export type CitizenClaimWidgetStatus = | 'loading' | 'connecting' | 'not_connected' + /** Wallet is connected but its active chain isn't one citizen-sdk supports. */ + | 'unsupported_chain' | 'not_whitelisted' | 'eligible' | 'already_claimed' From 8715ebd402c92bf04089319da92288cfddf41107 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:50:33 +0000 Subject: [PATCH 20/30] [gdpatchagent] fix(superfluid-campaign-widget): fix gardens-funding link, add disableWalletButton - App.tsx: fix malformed gardens-funding URL (was missing a '/' after 'https:'). - Add disableWalletButton to SuperfluidCampaignWidgetProps, threaded through CampaignHeader and LeaderboardView, so the header's connect-wallet CTA / connected wallet-status chip can be disabled by the integrating host page. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- apps/superfluid-campaign-web/src/App.tsx | 2 +- .../src/SuperfluidCampaignWidget.tsx | 7 +++++++ .../src/components/CampaignHeader.tsx | 6 +++++- .../src/components/ConnectWalletPrompt.tsx | 6 ++++-- .../src/components/LeaderboardView.tsx | 11 ++++++++++- .../src/components/shared/WalletChip.tsx | 9 +++++++-- .../src/widgetRuntimeContract.ts | 5 +++++ 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index 97cf2d6..b7beaff 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -97,7 +97,7 @@ function AppKitSuperfluidCampaignWidget() { 'flow-state-vote': 'https://ubi.gd/4fEqvrS', 'flow-state-funding': 'https://ubi.gd/4z2tH8y', 'gardens-donation': 'https://ubi.gd/4xhk4kv', - 'gardens-funding': 'https:/ubi.gd/3TABl9O', + 'gardens-funding': 'https://ubi.gd/3TABl9O', 'invite-users': 'https://ubi.gd/4xhYTyH', 'claim-ubi': 'https://ubi.gd/3RNtzJd', }} diff --git a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx index 8851d29..def49df 100644 --- a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx +++ b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx @@ -36,6 +36,7 @@ interface SuperfluidCampaignRuntimeProps { citizenClaimEnvironment: SuperfluidCampaignWidgetProps['citizenClaimEnvironment'] citizenClaimExecution: SuperfluidCampaignWidgetProps['citizenClaimExecution'] disableClaim: boolean + disableWalletButton?: SuperfluidCampaignWidgetProps['disableWalletButton'] initialView: SuperfluidCampaignView poolAddresses?: SuperfluidCampaignWidgetProps['poolAddresses'] /** Forwarded to the embedded CitizenClaimWidget so it shares the same provider/config/theme context. */ @@ -116,6 +117,7 @@ function SuperfluidCampaignRuntime({ citizenClaimEnvironment, citizenClaimExecution, disableClaim, + disableWalletButton, initialView, poolAddresses, provider, @@ -158,6 +160,7 @@ function SuperfluidCampaignRuntime({ disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} disconnectIcon={hasDisconnectOverride ? disconnectIcon : undefined} onClose={() => setEmbeddedClaimTab(null)} + disableWalletButton={disableWalletButton} /> setView('content')} leaderboardRefreshKey={leaderboardRefreshKey} userPointsAdapter={isMockRuntime ? dataClient.userPoints : undefined} + disableWalletButton={disableWalletButton} /> ) } @@ -207,6 +211,7 @@ function SuperfluidCampaignRuntime({ onDisconnect={hasDisconnectOverride ? disconnect : undefined} disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} disconnectIcon={hasDisconnectOverride ? disconnectIcon : undefined} + disableWalletButton={disableWalletButton} /> void + /** Disables the connect/wallet-status button. See `SuperfluidCampaignWidgetProps.disableWalletButton`. */ + disableWalletButton?: boolean } /** @@ -44,6 +46,7 @@ export function CampaignHeader({ disconnectLabel, disconnectIcon, onClose, + disableWalletButton = false, }: CampaignHeaderProps) { return ( @@ -72,9 +75,10 @@ export function CampaignHeader({ onDisconnect={onDisconnect} disconnectLabel={disconnectLabel} disconnectIcon={disconnectIcon} + disabled={disableWalletButton} /> ) : ( - + )} {onClose && ( ) diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx index c99b5c7..d256085 100644 --- a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx +++ b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx @@ -50,6 +50,8 @@ interface LeaderboardViewProps { onClose: () => void leaderboardRefreshKey: number userPointsAdapter?: CampaignUserPointsAdapter + /** Disables the connect/wallet-status button. See `SuperfluidCampaignWidgetProps.disableWalletButton`. */ + disableWalletButton?: boolean } /** @@ -95,6 +97,7 @@ export function LeaderboardView({ onClose, leaderboardRefreshKey, userPointsAdapter, + disableWalletButton = false, }: LeaderboardViewProps) { const [searchQuery, setSearchQuery] = useState('') const [activeCampaignTab, setActiveCampaignTab] = useState(pools[0]?.id ?? '') @@ -189,9 +192,15 @@ export function LeaderboardView({ onDisconnect={onDisconnect} disconnectLabel={disconnectLabel} disconnectIcon={disconnectIcon} + disabled={disableWalletButton} /> ) : ( - )} diff --git a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx index 02bfe1a..3d98254 100644 --- a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx +++ b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx @@ -16,6 +16,8 @@ interface WalletChipProps { disconnectLabel?: string /** Icon for the menu action, mirroring `disconnectLabel`. Defaults to 'log-out'. */ disconnectIcon?: IconName + /** When true, the chip no longer opens its menu and renders at reduced opacity. */ + disabled?: boolean } /** @@ -32,12 +34,13 @@ export function WalletChip({ onDisconnect, disconnectLabel = 'Disconnect', disconnectIcon = 'log-out', + disabled = false, }: WalletChipProps) { const [isMenuOpen, setIsMenuOpen] = useState(false) const [disconnectMessage, setDisconnectMessage] = useState(null) return ( - + { + if (disabled) return setDisconnectMessage(null) setIsMenuOpen((open) => !open) }} aria-label="Wallet options" + aria-disabled={disabled} > {address ? truncateAddress(address) : ''} diff --git a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts index c01594c..c33c3e6 100644 --- a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts +++ b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts @@ -250,6 +250,11 @@ export interface SuperfluidCampaignWidgetProps { citizenClaimExecution?: CitizenClaimWidgetCustodialExecution /** Redirect the Claim CTA to GoodWallet instead of embedding CitizenClaimWidget. */ disableClaim?: boolean + /** + * Disables the header's connect-wallet CTA and connected-wallet status chip, + * e.g. while the host page isn't ready to accept a wallet connection yet. + */ + disableWalletButton?: boolean /** * View shown on first render. Defaults to 'content'. Lets Storybook fixtures * and deep links land directly on the leaderboard without a click. From f05f87803f61e2fb3c5b39027020b74dc9272182 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:54:37 +0000 Subject: [PATCH 21/30] [gdpatchagent] fix(citizen-claim-widget,core): fix custodial status-read gating, avoid double switch-chain prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adapter.ts: custodial execution has no active wallet chain to gate on (it submits through its own configured per-chain clients), so loadClaimStatus no longer forces 'unsupported_chain' for custodial mode based on the (often null) active chainId. It instead reads personalized status from whichever configured custodial chain comes first, so custodial integrations can still resolve not_whitelisted/ eligible/already_claimed instead of getting stuck on a generic blocked state whenever there are no claimables left. - provider.tsx: switchChain no longer falls back to the integrator's switch-chain override when the wallet's own prompt was explicitly rejected by the user (EIP-1193 4001 / ethers ACTION_REJECTED) — only falls back for requests that are unsupported or never resolve, so a user who declines the wallet prompt doesn't immediately see a second network-selection modal. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/adapter.ts | 35 +++++++++++++++----- packages/core/src/provider.tsx | 15 ++++++++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 16a5be0..a67fe94 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -529,12 +529,23 @@ export function useCitizenClaimAdapter( return } - if (chainId === null || !isSupportedChain(chainId)) { + // Custodial execution submits claims through its own configured per-chain + // clients, never the active wallet chain, so it has no dependency on + // `chainId` at all — read the personalized status from whichever + // configured chain comes first, rather than gating on an "active chain" + // that may never resolve to a supported one (or may not exist). + const statusChainId = isCustodialExecution + ? SUPPORTED_CHAINS.find((supportedChainId) => claimExecution?.clientsByChain[supportedChainId]) ?? + null + : chainId + + if (statusChainId === null || !isSupportedChain(statusChainId)) { await auxiliaryReads - // Address known but the active chain is unsupported/unknown — a distinct - // status from not_connected so the UI can show "switch chain" copy - // instead of misleadingly asking an already-connected wallet to connect. - // Clear personalized entitlement from whatever chain was previously active. + // Address known but no supported chain is available to read personalized + // status from — a distinct status from not_connected so the UI can show + // "switch chain" copy instead of misleadingly asking an already-connected + // wallet to connect. Clear personalized entitlement from whatever chain + // was previously active. setAmount(null) setNextClaimTime(null) setStatus('unsupported_chain') @@ -546,7 +557,7 @@ export function useCitizenClaimAdapter( // A personalized status read, but still address-only: no connected // account or passed-down provider is required, only the address itself. - const sdk = createReadOnlySdkForChain(chainId) + const sdk = createReadOnlySdkForChain(statusChainId) if (!sdk) { setStatus('not_connected') return @@ -563,7 +574,7 @@ export function useCitizenClaimAdapter( } else if (walletStatus.status === 'can_claim') { // User is whitelisted and has unclaimed UBI setStatus('eligible') - const decimals = CHAIN_DECIMALS[chainId] ?? 18 + const decimals = CHAIN_DECIMALS[statusChainId] ?? 18 setAmount(formatUnits(walletStatus.entitlement, decimals)) } else { // User is whitelisted but has already claimed for this period @@ -576,7 +587,15 @@ export function useCitizenClaimAdapter( setStatus('error') setError(humanReadableError(err)) } - }, [address, chainId, createReadOnlySdkForChain, loadClaimablesByChain, loadDailyStats]) + }, [ + address, + chainId, + claimExecution, + isCustodialExecution, + createReadOnlySdkForChain, + loadClaimablesByChain, + loadDailyStats, + ]) // Auto-refresh claim status whenever wallet connection or chain changes useEffect(() => { diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index a00fd22..0cbbd43 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -47,6 +47,19 @@ const noopSwitchChain = async () => { throw new Error(SWITCH_CHAIN_UNAVAILABLE_ERROR) } +/** + * EIP-1193's standard user-rejection code (4001), plus ethers v6's + * ACTION_REJECTED — both mean the wallet's own switch-chain prompt was + * shown and the user explicitly declined it, as opposed to the request + * being unsupported or never resolving. Falling back to the integrator's + * switch-chain override on a rejection would re-prompt via a second modal + * right after the user just said no to the first one. + */ +function isUserRejectedSwitchChain(err: unknown): boolean { + const code = (err as { code?: number | string } | undefined)?.code + return code === 4001 || code === 'ACTION_REJECTED' +} + export const WalletContext = React.createContext({ address: null, chainId: null, @@ -205,7 +218,7 @@ export function GoodWidgetProvider({ }) return } catch (err) { - if (!switchChainOverride) throw err + if (!switchChainOverride || isUserRejectedSwitchChain(err)) throw err } } if (!switchChainOverride) { From 00d029c400e135fcb78281c276f586c0ff1b1b3b Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:03:42 +0000 Subject: [PATCH 22/30] [gdpatchagent] fix(citizen-claim-widget,superfluid-campaign-widget): fix chain-resolving flicker and stale disabled menu loadClaimStatus was mapping a non-custodial connection whose active chain hasn't reported yet (chainId still null right after connect) into unsupported_chain instead of a transient loading state, causing a brief "switch network" flash before the real chain resolves. WalletChip kept its menu open and its disconnect action pressable if disableWalletButton flipped true while the menu was already open. Co-Authored-By: Claude On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/adapter.ts | 15 +++++++++++++-- .../src/components/shared/WalletChip.tsx | 6 +++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index a67fe94..32e5c33 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -529,6 +529,17 @@ export function useCitizenClaimAdapter( return } + if (!isCustodialExecution && chainId === null) { + // Wallet is connected but hasn't reported an active chain yet (common + // right after connecting) — treat this as still resolving rather than + // unsupported, since it's unknown whether the eventual chain will be + // supported. The chainId-keyed effect below reruns this once it + // resolves, so this never gets stuck. + await auxiliaryReads + setStatus('loading') + return + } + // Custodial execution submits claims through its own configured per-chain // clients, never the active wallet chain, so it has no dependency on // `chainId` at all — read the personalized status from whichever @@ -541,8 +552,8 @@ export function useCitizenClaimAdapter( if (statusChainId === null || !isSupportedChain(statusChainId)) { await auxiliaryReads - // Address known but no supported chain is available to read personalized - // status from — a distinct status from not_connected so the UI can show + // Chain is known but unsupported (or custodial has no chain configured + // at all) — a distinct status from not_connected so the UI can show // "switch chain" copy instead of misleadingly asking an already-connected // wallet to connect. Clear personalized entitlement from whatever chain // was previously active. diff --git a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx index 3d98254..16dd9e3 100644 --- a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx +++ b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx @@ -63,7 +63,11 @@ export function WalletChip({ - {isMenuOpen && ( + {/* Also gated on !disabled: if disabled flips true while the menu is + already open, this stops rendering it (and its disconnect action) + immediately, rather than leaving a stale open menu the disabled + chip can no longer be pressed to close. */} + {isMenuOpen && !disabled && ( <> {/* Invisible full-viewport layer so any outside press closes the menu, same dismiss approach as ActionSheet's overlay. Sits below the menu From e14d1a19ed9cb2d19ba1d0a506505222a2482492 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:10:21 +0000 Subject: [PATCH 23/30] [gdpatchagent] fix(superfluid-campaign-web,superfluid-campaign-widget,core): fix stale chainIdOverride, stale wallet-menu reopen, string switch-chain rejection code chainIdOverride was not gated on isAccountResolved like addressOverride, so a disconnected account could keep showing a stale previously-tracked chain instead of falling back correctly. WalletChip left isMenuOpen true when disabled turned on, so re-enabling the chip later reopened the menu without a click. isUserRejectedSwitchChain only matched numeric code 4001, missing connectors that surface it as the string '4001'. Co-Authored-By: Claude On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- apps/superfluid-campaign-web/src/App.tsx | 6 ++++-- packages/core/src/provider.tsx | 2 +- .../src/components/shared/WalletChip.tsx | 9 ++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index b7beaff..bb07464 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -34,7 +34,9 @@ function AppKitSuperfluidCampaignWidget() { // definitively resolved to "no wallet". Only the latter is a real override — // during the unresolved window this stays `undefined` so the core provider's // own EIP-1193 fallback tracking (rather than a premature "disconnected" - // override) covers the gap until AppKit reports a final status. + // override) covers the gap until AppKit reports a final status. `chainId` + // below is gated on the same flag so it doesn't fall back to a stale + // previously-tracked chain once the account is known to be disconnected. const isAccountResolved = accountStatus === 'connected' || accountStatus === 'disconnected' // AppKit's switchNetwork takes the network descriptor object, not a chain id, @@ -66,7 +68,7 @@ function AppKitSuperfluidCampaignWidget() { defaultTheme="dark" contentMaxWidth={DESKTOP_WIDGET_MAX_WIDTH} addressOverride={isAccountResolved ? (address ?? null) : undefined} - chainIdOverride={chainId == null ? undefined : Number(chainId)} + chainIdOverride={isAccountResolved ? (chainId == null ? null : Number(chainId)) : undefined} availableChainIdsOverride={availableChainIds} switchChainOverride={async (targetChainId) => { const targetNetwork = appKitNetworksByChainId.get(targetChainId) diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index 0cbbd43..3edc257 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -57,7 +57,7 @@ const noopSwitchChain = async () => { */ function isUserRejectedSwitchChain(err: unknown): boolean { const code = (err as { code?: number | string } | undefined)?.code - return code === 4001 || code === 'ACTION_REJECTED' + return Number(code) === 4001 || code === 'ACTION_REJECTED' } export const WalletContext = React.createContext({ diff --git a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx index 16dd9e3..8045a78 100644 --- a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx +++ b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Button, ButtonText, Icon, Text, XStack, YStack } from '@goodwidget/ui' import type { IconName } from '@goodwidget/ui' import { truncateAddress } from './styles' @@ -39,6 +39,13 @@ export function WalletChip({ const [isMenuOpen, setIsMenuOpen] = useState(false) const [disconnectMessage, setDisconnectMessage] = useState(null) + // Resets the open state itself (not just its render) when disabled turns + // true, so re-enabling later starts from closed instead of the menu + // silently reappearing from whatever state it was left in. + useEffect(() => { + if (disabled) setIsMenuOpen(false) + }, [disabled]) + return ( Date: Thu, 13 Aug 2026 11:16:21 +0000 Subject: [PATCH 24/30] [gdpatchagent] fix(citizen-claim-widget): fix conflicting unsupported-chain UI, misleading sdk-init status, stale comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CitizenClaimWidget rendered the unsupported_chain message alongside the eligible/claiming/Ready-to-claim block whenever claimablesByChain wasn't empty, e.g. an unsupported active chain with claimable UBI still sitting on another chain — showing two contradictory status lines at once. adapter.ts's loadClaimStatus set status to not_connected when the read-only sdk failed to initialize for a supported chain, which is misleading once an address is already known and connected. Also corrected a switch_chain comment that no longer matched the code. Co-Authored-By: Claude On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/CitizenClaimWidget.tsx | 9 +++++---- packages/citizen-claim-widget/src/adapter.ts | 8 +++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index f24a192..190b8c9 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -348,9 +348,9 @@ function CitizenClaimInner({ await actions.refresh() break case 'switch_chain': - // The wallet is connected but on a chain citizen-sdk doesn't support at - // all, so there is no known claimable chain to target yet — fall back - // to Celo, the first preferred supported chain. + // Prefer switching to a chain the wallet already has a claimable + // balance on; when none is known yet, fall back to Celo, the + // first preferred supported chain. await actions.switchChain?.(claimablesByChain[0]?.chainId ?? SupportedChains.CELO) break } @@ -424,7 +424,8 @@ function CitizenClaimInner({ )} - {(status === 'eligible' || status === 'claiming' || claimablesByChain.length > 0) && ( + {status !== 'unsupported_chain' && + (status === 'eligible' || status === 'claiming' || claimablesByChain.length > 0) && ( <> {/* Declarative to claim status: an already_claimed wallet with diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 32e5c33..5b19bf1 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -570,7 +570,13 @@ export function useCitizenClaimAdapter( // account or passed-down provider is required, only the address itself. const sdk = createReadOnlySdkForChain(statusChainId) if (!sdk) { - setStatus('not_connected') + // The address is known and statusChainId passed the supported-chain + // check above, so a null sdk here means client/RPC setup itself + // failed (e.g. a misconfigured chain) rather than no wallet being + // connected — 'not_connected' would tell an already-connected user + // to do something they've already done. + setStatus('error') + setError(humanReadableError(new CitizenClaimAdapterError('Unable to load claim status for this chain right now.'))) return } From 17faecfdb44d53258234e9916fcd219c673bcd44 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:26:24 +0000 Subject: [PATCH 25/30] [gdpatchagent] fix(citizen-claim-widget): surface switch-chain failures instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outer catch in handlePrimaryAction only called onClaimError for primaryAction === 'claim', so a failed switch_chain (wallet rejects, chain not added, raw provider error with no integrator override to fall back to, etc.) produced no toast and no callback — the user saw nothing happen at all. Now it surfaces an error toast + onClaimError naming the specific chain that was targeted, per the "name the chain an action can't run on" requirement, without leaking the raw wallet/provider error text (still logged via console.error for debugging). On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/CitizenClaimWidget.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 190b8c9..23fd775 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -242,6 +242,10 @@ function CitizenClaimInner({ /** Dispatch the primary action and surface callbacks for claim outcomes. */ const handlePrimaryAction = useCallback(async () => { + // Tracks which chain a 'switch_chain' attempt targeted, so a failure can + // name that chain in the catch block below (the switch/case scope it's + // set in doesn't otherwise survive into the shared catch). + let switchChainTargetId: number | null = null try { switch (primaryAction) { case 'connect': @@ -351,7 +355,8 @@ function CitizenClaimInner({ // Prefer switching to a chain the wallet already has a claimable // balance on; when none is known yet, fall back to Celo, the // first preferred supported chain. - await actions.switchChain?.(claimablesByChain[0]?.chainId ?? SupportedChains.CELO) + switchChainTargetId = claimablesByChain[0]?.chainId ?? SupportedChains.CELO + await actions.switchChain?.(switchChainTargetId) break } } catch (err: unknown) { @@ -361,6 +366,25 @@ function CitizenClaimInner({ chainId: chainId ?? null, message: err instanceof Error ? err.message : 'Claim failed', }) + } else if (primaryAction === 'switch_chain') { + // Previously swallowed entirely: a failed switch (unsupported chain + // not yet added to the wallet, a raw provider error with no + // integrator override to fall back to, etc.) produced no toast and + // no onClaimError call, leaving the user with zero feedback that + // nothing happened. Log the underlying error for debugging, but + // surface a message naming the specific chain rather than the raw + // wallet/provider error text. + console.error('[CitizenClaimWidget] switchChain failed', err) + const targetChainName = switchChainTargetId + ? getChainName(switchChainTargetId) + : 'the requested chain' + const message = `Couldn't switch to ${targetChainName}. Please try again from your wallet.` + createToast({ message, status: 'error', duration: 0 }) + onClaimError?.({ + address: address ?? null, + chainId: switchChainTargetId, + message, + }) } } }, [ From 0ed69d547f30c30f72e409e1472dd8f13e8bb200 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:32:27 +0000 Subject: [PATCH 26/30] [gdpatchagent] fix(citizen-claim-widget,superfluid-campaign-widget): rethrow switch-chain error, fix grammar nit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSwitchChain caught and swallowed its error entirely (only setting the adapter's own status/error), so CitizenClaimWidget's new switch_chain catch branch (added in 17faecf to name the failing chain in a toast/onClaimError) was unreachable dead code — the caller had no error to catch. Now rethrows after setting status/error, mirroring the existing handleClaim pattern, so both the inline banner and the toast/callback fire consistently. Also fixes a grammar typo in WalletChip's disconnect message ("wallets session" -> "wallet's session"). On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/adapter.ts | 9 +++++---- .../src/components/shared/WalletChip.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 5b19bf1..099dc8a 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -684,12 +684,13 @@ export function useCitizenClaimAdapter( await switchChain(targetChainId) } catch (err: unknown) { if (!mountedRef.current) return - // This action owns its own error reporting via state.error — unlike - // handleClaim's caller, the switch_chain caller has no use for a - // rethrown value, so swallow it here rather than let it surface as - // an unused rejection. + // Sets state.error so the inline banner shows a humanized message + // immediately, then rethrows — mirroring handleClaim above — so the + // widget's own catch can also name the specific chain that failed + // in a toast/onClaimError, which needs the raw error to reach it. setStatus('error') setError(humanReadableError(err)) + throw err } }, [switchChain], diff --git a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx index 8045a78..845050a 100644 --- a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx +++ b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx @@ -106,7 +106,7 @@ export function WalletChip({ variant="list" onPress={async () => { if (!onDisconnect) { - setDisconnectMessage('Disconnect should be done in your wallets session') + setDisconnectMessage("Disconnect should be done in your wallet's session") return } setIsMenuOpen(false) From e77f7882077bcb44e8f2b3e652ddff4101d2a749 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:39:28 +0000 Subject: [PATCH 27/30] [gdpatchagent] fix(core,citizen-claim-widget): add switch-chain timeout, fix custodial misconfig status, use shared chain constant - provider.tsx: some WalletConnect sessions never resolve or reject wallet_switchEthereumChain, so the request could hang switchChain forever and never reach the integrator override fallback. Races it against a 10s timeout, treated like any other rejection. - adapter.ts: when custodial execution has no configured client for any supported chain, this was reported as 'unsupported_chain', driving a "switch network" narrative the user has no way to act on (custodial mode has no wallet chain to switch). Now reports 'error' with a clear message instead, reserving 'unsupported_chain' for the real switchable case. - CitizenClaimWidget.tsx: replaced a hard-coded 42220 fallback chain id with the existing SupportedChains.CELO constant already used elsewhere. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/CitizenClaimWidget.tsx | 2 +- packages/citizen-claim-widget/src/adapter.ts | 26 +++++++++--- packages/core/src/provider.tsx | 42 ++++++++++++++++--- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 23fd775..99ab225 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -621,7 +621,7 @@ function CitizenClaimShell({ ]} activeTab={activeTab} onTabChange={(tabId: string) => setActiveTab(tabId as CitizenClaimTab)} - chainId={chainId ?? fallbackChainId ?? 42220} + chainId={chainId ?? fallbackChainId ?? SupportedChains.CELO} /> {activeTab === 'claim' ? ( <> diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 099dc8a..11cce6b 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -550,13 +550,29 @@ export function useCitizenClaimAdapter( null : chainId + if (isCustodialExecution && statusChainId === null) { + await auxiliaryReads + // Custodial execution has no wallet chain to switch, so a missing + // client for every supported chain is an integrator configuration + // problem rather than something the "switch network" narrative below + // could ever resolve — route it to a plain error instead. + setAmount(null) + setNextClaimTime(null) + setStatus('error') + setError( + humanReadableError( + new CitizenClaimAdapterError('Claim execution is not configured for any supported chain.'), + ), + ) + return + } + if (statusChainId === null || !isSupportedChain(statusChainId)) { await auxiliaryReads - // Chain is known but unsupported (or custodial has no chain configured - // at all) — a distinct status from not_connected so the UI can show - // "switch chain" copy instead of misleadingly asking an already-connected - // wallet to connect. Clear personalized entitlement from whatever chain - // was previously active. + // Chain is known but unsupported — a distinct status from not_connected + // so the UI can show "switch chain" copy instead of misleadingly asking + // an already-connected wallet to connect. Clear personalized entitlement + // from whatever chain was previously active. setAmount(null) setNextClaimTime(null) setStatus('unsupported_chain') diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index 3edc257..1cf8c10 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -42,11 +42,36 @@ export interface GoodWidgetContextValue extends GoodWidgetState { } const SWITCH_CHAIN_UNAVAILABLE_ERROR = 'No wallet provider available to switch chains' +const SWITCH_CHAIN_TIMEOUT_ERROR = 'Timed out waiting for the wallet to respond to the network switch request' +const SWITCH_CHAIN_REQUEST_TIMEOUT_MS = 10_000 const noopSwitchChain = async () => { throw new Error(SWITCH_CHAIN_UNAVAILABLE_ERROR) } +/** + * Races a promise against a timeout so a request the wallet never settles + * (some WalletConnect sessions never resolve or reject + * wallet_switchEthereumChain at all) doesn't hang the caller forever — a + * timeout is treated the same as any other rejection, so switchChain's + * existing override fallback below still applies. + */ +function raceWithTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (err) => { + clearTimeout(timer) + reject(err) + }, + ) + }) +} + /** * EIP-1193's standard user-rejection code (4001), plus ethers v6's * ACTION_REJECTED — both mean the wallet's own switch-chain prompt was @@ -206,16 +231,21 @@ export function GoodWidgetProvider({ // Tries the standard EIP-3326 request first; falls back to the // integrator's own switch/network-modal flow (e.g. AppKit) when the - // active connector rejects or does not support it — some WalletConnect - // sessions never resolve wallet_switchEthereumChain at all. + // active connector rejects, does not support it, or never settles the + // request at all (some WalletConnect sessions do this, hence the timeout + // race below rather than a bare await). const switchChain = useCallback( async (targetChainId: number) => { if (resolvedProvider) { try { - await resolvedProvider.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: `0x${targetChainId.toString(16)}` }], - }) + await raceWithTimeout( + resolvedProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }), + SWITCH_CHAIN_REQUEST_TIMEOUT_MS, + SWITCH_CHAIN_TIMEOUT_ERROR, + ) return } catch (err) { if (!switchChainOverride || isUserRejectedSwitchChain(err)) throw err From b512ce0eac818814d4828a517cc1c45b45c0c60b Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:47:32 +0000 Subject: [PATCH 28/30] [gdpatchagent] fix(superfluid-campaign-web): throw from switchChainOverride when falling back to AppKit modal Resolving instead of throwing let callers treat an opened network-selection modal as an already-completed chain switch, since the modal never confirms the switch synchronously. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- apps/superfluid-campaign-web/src/App.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index bb07464..c6f0b0d 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -21,6 +21,8 @@ const DESKTOP_WIDGET_MAX_WIDTH = 960 */ const APPKIT_DISCONNECT_LABEL = 'Network settings' +const SWITCH_CHAIN_MANUAL_SELECTION_ERROR = 'Select the network in the wallet dialog, then try again.' + function AppKitSuperfluidCampaignWidget() { const { open } = useAppKit() const { address, status: accountStatus } = useAppKitAccount() @@ -76,14 +78,20 @@ function AppKitSuperfluidCampaignWidget() { // documented recovery path when a target chain has no direct // programmatic descriptor here, or when switchNetwork itself fails // (e.g. the connected wallet rejects the automatic switch request). + // The modal only lets the user attempt the switch themselves — it + // never confirms synchronously that one actually happened — so this + // throws rather than resolves, otherwise the caller (core's + // switchChain, and the claim widget's error handling built on top of + // it) would treat an opened modal as an already-finished switch. if (!targetNetwork) { await open({ view: 'Networks' }) - return + throw new Error(SWITCH_CHAIN_MANUAL_SELECTION_ERROR) } try { await switchNetwork(targetNetwork) } catch { await open({ view: 'Networks' }) + throw new Error(SWITCH_CHAIN_MANUAL_SELECTION_ERROR) } }} disconnectLabel={APPKIT_DISCONNECT_LABEL} From a00127209047622e97a8adce1a4d12d68fd9d875 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:54:55 +0000 Subject: [PATCH 29/30] [gdpatchagent] fix(citizen-claim-widget): prioritize unsupported_chain over cross-chain claimables for non-custodial wallets primaryAction returned 'claim' whenever any supported chain had a claimable balance, even when the active wallet chain was unsupported and claimOnChain targets only the active chain for non-custodial execution. The button now correctly offers 'switch_chain' first in that case; custodial execution (which has no single active chain) keeps its existing precedence. On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- packages/citizen-claim-widget/src/adapter.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 11cce6b..c322dba 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -815,15 +815,18 @@ export function useCitizenClaimAdapter( // --------------------------------------------------------------------------- const primaryAction: CitizenClaimWidgetAdapterState['primaryAction'] = useMemo(() => { if (status === 'connecting') return 'connect' - // Custodial execution is multi-chain. An account-scoped entitlement on any - // configured chain must take precedence over the active chain's status. + // A non-custodial claim always executes on the active wallet chain (see + // claimOnChain/handleClaim below) — a claimable balance on some other + // supported chain is irrelevant until that chain itself is switched to, + // so this must be checked before the claimablesByChain precedence below, + // or the button would offer "Claim" and immediately fail against the + // active unsupported chain. + if (status === 'unsupported_chain' && !isCustodialExecution) return 'switch_chain' + // Custodial execution is multi-chain and has no "active" wallet chain, so + // an account-scoped entitlement on any configured chain takes precedence + // over the unsupported_chain status entirely. if (isConnected && address && claimablesByChain.length > 0) return 'claim' - if (status === 'unsupported_chain') { - // Custodial clients are already configured per chain, so they never need - // the active wallet chain to be switched. Native wallet integrations keep - // the existing switch-chain behavior. - return isCustodialExecution ? 'none' : 'switch_chain' - } + if (status === 'unsupported_chain') return 'none' if (status === 'not_connected') return 'connect' if (status === 'not_whitelisted') return 'verify' // Keep the claim button mounted while a claim is in-flight so UI copy can From b27abc2a258a803419de1358d645842ed856a5c1 Mon Sep 17 00:00:00 2001 From: "goodbounties-nanoclaw-agent[bot]" <307944451+goodbounties-nanoclaw-agent[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:52:49 +0000 Subject: [PATCH 30/30] [gdpatchagent] fix(citizen-claim-widget,embed,superfluid-campaign-widget): connect via AppKit override, fix whitelist/default-chain precedence, resolve chain display names - Thread connectOverride through SuperfluidCampaignWidget -> CitizenClaimWidget so the internal claim button opens the AppKit modal when a connectOverride is supplied, instead of always falling back to the raw injected provider. - Reorder primaryAction precedence so not_whitelisted takes priority over any cross-chain claimables, and gate the widget's own whitelist card the same way, so an unverified wallet only ever gets the sign-message/face-verify redirect instead of also attempting claims on other chains. - Make XDC the default chain (DEFAULT_APPKIT_NETWORKS order and the switch-chain/fallback chain id defaults), replacing CELO. - Broaden and export getChainDisplayName from adapter.ts to resolve mainnet and base in addition to FUSE/CELO/XDC, and reuse it from CitizenClaimWidget.tsx instead of a separate, narrower local duplicate, so the unsupported-chain message shows a network name instead of a raw chain id. Co-Authored-By: Claude On-Behalf-Of: gdpatchagent[onecli] (yaskkeryodtdijpv) --- .../src/CitizenClaimWidget.tsx | 45 +++++++------------ packages/citizen-claim-widget/src/adapter.ts | 29 +++++++++--- .../src/widgetRuntimeContract.ts | 6 +++ packages/embed/src/DefaultAppKitProvider.tsx | 4 +- .../src/SuperfluidCampaignWidget.tsx | 4 ++ 5 files changed, 54 insertions(+), 34 deletions(-) diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 99ab225..c2b5e3a 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -21,7 +21,7 @@ import { WidgetTabs, } from '@goodwidget/ui' import { SupportedChains } from '@goodsdks/citizen-sdk' -import { useCitizenClaimAdapter } from './adapter' +import { getChainDisplayName, useCitizenClaimAdapter } from './adapter' import type { CitizenClaimWidgetProps, CitizenClaimWidgetSuccessDetail, @@ -143,19 +143,6 @@ const ClaimDailyStatsRow = createComponent(Text, { display: 'flex' as const, }) -function getChainName(chainId: number): string { - switch (chainId) { - case SupportedChains.FUSE: - return 'Fuse' - case SupportedChains.CELO: - return 'Celo' - case SupportedChains.XDC: - return 'XDC' - default: - return `Chain ${chainId}` - } -} - function formatCompactNumber(value: number): string { return new Intl.NumberFormat('en-US', { style: 'decimal', @@ -235,7 +222,7 @@ function CitizenClaimInner({ const chainNameById = useMemo(() => { const map = new Map() for (const entry of claimablesByChain) { - map.set(entry.chainId, getChainName(entry.chainId)) + map.set(entry.chainId, getChainDisplayName(entry.chainId)) } return map }, [claimablesByChain]) @@ -257,7 +244,7 @@ function CitizenClaimInner({ case 'claim': { const claimPlan = [...claimablesByChain] if (claimPlan.length === 0) { - const singleChainName = chainId ? getChainName(chainId) : 'active chain' + const singleChainName = chainId ? getChainDisplayName(chainId) : 'active chain' const toastId = createToast({ message: `Claim initiated on ${singleChainName}`, status: 'pending', @@ -296,7 +283,7 @@ function CitizenClaimInner({ const toastByChain = new Map() for (const claimEntry of claimPlan) { const entryChainName = - chainNameById.get(claimEntry.chainId) ?? getChainName(claimEntry.chainId) + chainNameById.get(claimEntry.chainId) ?? getChainDisplayName(claimEntry.chainId) toastByChain.set( claimEntry.chainId, createToast({ @@ -311,7 +298,7 @@ function CitizenClaimInner({ for (const claimResult of claimResults) { const entryChainName = - chainNameById.get(claimResult.chainId) ?? getChainName(claimResult.chainId) + chainNameById.get(claimResult.chainId) ?? getChainDisplayName(claimResult.chainId) const toastId = toastByChain.get(claimResult.chainId) if (!toastId) continue @@ -353,9 +340,9 @@ function CitizenClaimInner({ break case 'switch_chain': // Prefer switching to a chain the wallet already has a claimable - // balance on; when none is known yet, fall back to Celo, the - // first preferred supported chain. - switchChainTargetId = claimablesByChain[0]?.chainId ?? SupportedChains.CELO + // balance on; when none is known yet, fall back to XDC, the + // default supported chain. + switchChainTargetId = claimablesByChain[0]?.chainId ?? SupportedChains.XDC await actions.switchChain?.(switchChainTargetId) break } @@ -376,7 +363,7 @@ function CitizenClaimInner({ // wallet/provider error text. console.error('[CitizenClaimWidget] switchChain failed', err) const targetChainName = switchChainTargetId - ? getChainName(switchChainTargetId) + ? getChainDisplayName(switchChainTargetId) : 'the requested chain' const message = `Couldn't switch to ${targetChainName}. Please try again from your wallet.` createToast({ message, status: 'error', duration: 0 }) @@ -402,7 +389,7 @@ function CitizenClaimInner({ return ( - {status === 'not_whitelisted' && claimablesByChain.length === 0 && ( + {status === 'not_whitelisted' && ( @@ -425,7 +412,7 @@ function CitizenClaimInner({ {/* ------------------------------------------------------------------ */} {/* Main claim card */} {/* ------------------------------------------------------------------ */} - {(status !== 'not_whitelisted' || claimablesByChain.length > 0) && ( + {status !== 'not_whitelisted' && ( {/* Status content */} @@ -442,7 +429,7 @@ function CitizenClaimInner({ <> {chainId - ? `Claiming isn't available on ${getChainName(chainId)}. Switch network to continue.` + ? `Claiming isn't available on ${getChainDisplayName(chainId)}. Switch network to continue.` : 'Switch to a supported network to claim daily G$'} @@ -459,7 +446,7 @@ function CitizenClaimInner({ */} {status === 'already_claimed' && claimablesByChain.length === 1 - ? `G$ Claim is still available on ${getChainName(claimablesByChain[0].chainId)}` + ? `G$ Claim is still available on ${getChainDisplayName(claimablesByChain[0].chainId)}` : status === 'already_claimed' && claimablesByChain.length > 1 ? 'G$ Claim is still available on other chains' : 'Ready to claim'} @@ -494,7 +481,7 @@ function CitizenClaimInner({ {claimablesByChain.map((entry, index) => ( - {getChainName(entry.chainId)} + {getChainDisplayName(entry.chainId)} {index < claimablesByChain.length - 1 && ( · @@ -621,7 +608,7 @@ function CitizenClaimShell({ ]} activeTab={activeTab} onTabChange={(tabId: string) => setActiveTab(tabId as CitizenClaimTab)} - chainId={chainId ?? fallbackChainId ?? SupportedChains.CELO} + chainId={chainId ?? fallbackChainId ?? SupportedChains.XDC} /> {activeTab === 'claim' ? ( <> @@ -675,6 +662,7 @@ export function CitizenClaimWidget({ initialTab, addressOverride, chainIdOverride, + connectOverride, switchChainOverride, availableChainIdsOverride, }: CitizenClaimWidgetProps) { @@ -686,6 +674,7 @@ export function CitizenClaimWidget({ defaultTheme={defaultTheme} addressOverride={addressOverride} chainIdOverride={chainIdOverride} + connectOverride={connectOverride} switchChainOverride={switchChainOverride} availableChainIdsOverride={availableChainIdsOverride} > diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index c322dba..ba13684 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -63,9 +63,20 @@ const CHAIN_CONFIGS: Record = { const SUPPORTED_CHAINS = citizenSdkCapabilities.chains const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments -/** Resolves a supported chain id to its display name, falling back to the raw id. */ -function getChainDisplayName(chainId: number): string { - return CHAIN_CONFIGS[chainId]?.name ?? `Chain ${chainId}` +// Display names for chains a connected wallet can land on outside the 3 +// citizen-sdk supports (e.g. the other networks this app's own wallet-connect +// modal offers) — "unsupported chain" messaging only ever needs to name a +// chain outside CHAIN_CONFIGS, so these are looked up separately from the +// viem Chain descriptors above. +const KNOWN_CHAIN_NAMES: Record = { + ...Object.fromEntries(Object.entries(CHAIN_CONFIGS).map(([id, chain]) => [id, chain.name])), + 1: 'Ethereum', + 8453: 'Base', +} + +/** Resolves a chain id to its display name, falling back to the raw id only when truly unknown. */ +export function getChainDisplayName(chainId: number): string { + return KNOWN_CHAIN_NAMES[chainId] ?? `Chain ${chainId}` } /** @@ -650,7 +661,9 @@ export function useCitizenClaimAdapter( } if (!isSupportedChain(targetChainId)) { - throw new CitizenClaimAdapterError(`Unsupported chain for citizen-sdk: ${targetChainId}`) + throw new CitizenClaimAdapterError( + `Unsupported chain for citizen-sdk: ${getChainDisplayName(targetChainId)}`, + ) } // Execute actions must stay within the chains the passed-down provider @@ -822,13 +835,19 @@ export function useCitizenClaimAdapter( // or the button would offer "Claim" and immediately fail against the // active unsupported chain. if (status === 'unsupported_chain' && !isCustodialExecution) return 'switch_chain' + // Whitelisting is required on the chain getWalletClaimStatus checked + // (the active/default chain), and identity is account-scoped rather than + // per-chain — a claimable balance surfaced on some other chain must not + // offer "Claim" ahead of resolving that, since claimAll would otherwise + // attempt (and fail) real claims on every other chain before the wallet + // has even completed face verification. + if (status === 'not_whitelisted') return 'verify' // Custodial execution is multi-chain and has no "active" wallet chain, so // an account-scoped entitlement on any configured chain takes precedence // over the unsupported_chain status entirely. if (isConnected && address && claimablesByChain.length > 0) return 'claim' if (status === 'unsupported_chain') return 'none' if (status === 'not_connected') return 'connect' - if (status === 'not_whitelisted') return 'verify' // Keep the claim button mounted while a claim is in-flight so UI copy can // switch to "Claiming..." without hiding the action surface. if (status === 'claiming') return 'claim' diff --git a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts index ba698b4..61e7c01 100644 --- a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts +++ b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts @@ -149,6 +149,12 @@ export interface CitizenClaimWidgetProps { * `GoodWidgetProviderProps.chainIdOverride`. */ chainIdOverride?: number | null + /** + * Integrator-owned connect fallback (e.g. opening a wallet-connect modal + * instead of requesting the injected provider directly). See + * `GoodWidgetProviderProps.connectOverride`. + */ + connectOverride?: () => Promise /** * Integrator-owned chain-switch fallback. See * `GoodWidgetProviderProps.switchChainOverride`. diff --git a/packages/embed/src/DefaultAppKitProvider.tsx b/packages/embed/src/DefaultAppKitProvider.tsx index 703f13d..e1fddb4 100644 --- a/packages/embed/src/DefaultAppKitProvider.tsx +++ b/packages/embed/src/DefaultAppKitProvider.tsx @@ -3,7 +3,9 @@ import { AppKitProvider } from '@reown/appkit/react' import { base, celo, fuse, mainnet, xdc, type AppKitNetwork } from '@reown/appkit/networks' import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' -export const DEFAULT_APPKIT_NETWORKS = [mainnet, base, xdc, fuse, celo] as [ +// xdc leads the list: AppKit treats the first network as the default chain +// a freshly connecting wallet lands on. +export const DEFAULT_APPKIT_NETWORKS = [xdc, mainnet, base, fuse, celo] as [ AppKitNetwork, ...AppKitNetwork[], ] diff --git a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx index def49df..c7f70eb 100644 --- a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx +++ b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx @@ -47,6 +47,7 @@ interface SuperfluidCampaignRuntimeProps { /** Forwarded to the embedded CitizenClaimWidget's own GoodWidgetProvider. */ addressOverride?: SuperfluidCampaignWidgetProps['addressOverride'] chainIdOverride?: SuperfluidCampaignWidgetProps['chainIdOverride'] + connectOverride?: SuperfluidCampaignWidgetProps['connectOverride'] switchChainOverride?: SuperfluidCampaignWidgetProps['switchChainOverride'] availableChainIdsOverride?: SuperfluidCampaignWidgetProps['availableChainIdsOverride'] hasDisconnectOverride: boolean @@ -126,6 +127,7 @@ function SuperfluidCampaignRuntime({ defaultTheme, addressOverride, chainIdOverride, + connectOverride, switchChainOverride, availableChainIdsOverride, hasDisconnectOverride, @@ -169,6 +171,7 @@ function SuperfluidCampaignRuntime({ defaultTheme={defaultTheme} addressOverride={addressOverride} chainIdOverride={chainIdOverride} + connectOverride={connectOverride} switchChainOverride={switchChainOverride} availableChainIdsOverride={availableChainIdsOverride} environment={citizenClaimEnvironment} @@ -312,6 +315,7 @@ export function SuperfluidCampaignWidgetWithClient({ defaultTheme={defaultTheme} addressOverride={addressOverride} chainIdOverride={chainIdOverride} + connectOverride={connectOverride} switchChainOverride={switchChainOverride} availableChainIdsOverride={availableChainIdsOverride} hasDisconnectOverride={Boolean(disconnectOverride)}