From 7a045315ef303a0fb154fc1f110320ed0b3a67f0 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 16:21:55 +0100 Subject: [PATCH 01/21] fix(account): isolate activity history, token balances, and sender addresses - Key activity history cache by active account and purge on account change/logout - Guard activity history and token balances against late async responses - Preserve dApp-supplied from address in WalletWrapper and validate against connected account --- .../useTokenBalances/stores/amountsStore.ts | 14 +- .../WalletConnect/utils/WalletWrapper.test.ts | 36 ++++++ .../WalletConnect/utils/WalletWrapper.ts | 8 ++ src/stores/transactionHistoryStore.test.ts | 120 ++++++++++++++++++ src/stores/transactionHistoryStore.ts | 48 ++++++- 5 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 src/stores/transactionHistoryStore.test.ts diff --git a/src/hooks/useTokenBalances/stores/amountsStore.ts b/src/hooks/useTokenBalances/stores/amountsStore.ts index 30f3594..8c86718 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.ts @@ -44,11 +44,16 @@ async function getAmountsForChain( return provider.getAmounts(chainId, tokensOfChain, address) } +// Guard token bumped on every refresh or account change so late async responses +// from a previous account cannot overwrite the current account's balances. +let amountsToken = 0 + export const refreshAmounts = async ( tokens: Tokens | undefined, addresses: Addresses | undefined, chainId: number | "all", ) => { + const token = ++amountsToken if (!tokens || !addresses) { amountsStore.amounts.clear() amountsStore.isLoading = true @@ -67,6 +72,8 @@ export const refreshAmounts = async ( addresses, chainId, ) + // Drop late response if the account or query changed while in flight. + if (token !== amountsToken) return amountsStore.amounts.set(chainId, amounts) }, ) @@ -83,12 +90,15 @@ export const refreshAmounts = async ( addresses, chainId, ) + if (token !== amountsToken) return amountsStore.amounts.set(chainId, amountsForChain) } } } finally { - amountsStore.isLoading = false - amountsStore.isValidating = false + if (token === amountsToken) { + amountsStore.isLoading = false + amountsStore.isValidating = false + } } } refreshAmounts(tokensStore.tokens, sessionState.addresses, "all") diff --git a/src/sections/WalletConnect/utils/WalletWrapper.test.ts b/src/sections/WalletConnect/utils/WalletWrapper.test.ts index 553457f..630576c 100644 --- a/src/sections/WalletConnect/utils/WalletWrapper.test.ts +++ b/src/sections/WalletConnect/utils/WalletWrapper.test.ts @@ -25,6 +25,42 @@ describe("WalletWrapper.sendTransactionHash", () => { expect(signer.signTransaction).not.toHaveBeenCalled() }) + it("defaults from to the connected address when the dApp omits it", async () => { + const submitTransaction = vi.fn(async () => "0xwallethash") + const signer = { ...createSigner(), submitTransaction } + // tx has no `from` field. + const txNoFrom = { to: address, value: BigInt(1), chainId: 1 } + + await new WalletWrapper(signer).sendTransactionHash(txNoFrom) + + expect(submitTransaction).toHaveBeenCalledWith({ + ...txNoFrom, + from: address, + }) + }) + + it("rejects when the dApp supplies a from that differs from the connected account", async () => { + const submitTransaction = vi.fn(async () => "0xwallethash") + const signer = { ...createSigner(), submitTransaction } + const differentAddress = "0x2222222222222222222222222222222222222222" + const txWithWrongFrom = { ...tx, from: differentAddress } + + await expect( + new WalletWrapper(signer).sendTransactionHash(txWithWrongFrom), + ).rejects.toThrow(/does not match connected account/) + expect(submitTransaction).not.toHaveBeenCalled() + }) + + it("accepts when the dApp supplies a from equal to the connected address", async () => { + const submitTransaction = vi.fn(async () => "0xwallethash") + const signer = { ...createSigner(), submitTransaction } + const txWithMatchingFrom = { ...tx, from: address } + + await expect( + new WalletWrapper(signer).sendTransactionHash(txWithMatchingFrom), + ).resolves.toBe("0xwallethash") + }) + it("signs and broadcasts locally for local-key signers", async () => { const signer = createSigner() const wrapper = new WalletWrapper(signer, {} as Provider) diff --git a/src/sections/WalletConnect/utils/WalletWrapper.ts b/src/sections/WalletConnect/utils/WalletWrapper.ts index 39bf8d2..4312f84 100644 --- a/src/sections/WalletConnect/utils/WalletWrapper.ts +++ b/src/sections/WalletConnect/utils/WalletWrapper.ts @@ -37,6 +37,14 @@ export class WalletWrapper extends AbstractSigner { // External wallets broadcast themselves; local keys sign and broadcast here. async sendTransactionHash(tx: TransactionRequest): Promise { if (this.#signer.submitTransaction) { + // Preserve a `from` supplied by the dApp so the signer can verify it + // matches the connected account; default to the current address only + // when `from` is absent. + if (tx.from && tx.from !== this.address) { + throw new Error( + `Transaction sender ${String(tx.from)} does not match connected account ${this.address}`, + ) + } return await this.#signer.submitTransaction({ ...tx, from: this.address }) } return (await this.sendTransaction(tx)).hash diff --git a/src/stores/transactionHistoryStore.test.ts b/src/stores/transactionHistoryStore.test.ts new file mode 100644 index 0000000..17e476d --- /dev/null +++ b/src/stores/transactionHistoryStore.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { type Tx, TxDirection } from "@/hooks/useLedger/types" +import type { Addresses, ISigner } from "@/login" + +const mockGetTransactions = vi.fn() + +vi.mock("@/chain/chains", () => ({ + getAvailableChainIds: vi.fn(() => [42220]), +})) + +vi.mock("@/chain/provider/provider", () => ({ + getChainProvider: vi.fn(() => ({ + family: "EVM", + getTransactions: mockGetTransactions, + })), +})) + +import { + activityHistoryStore, + clearActivityHistory, +} from "./transactionHistoryStore" + +const makeAddresses = (evmAddress: string): Addresses => { + const map = new Map() + map.set("EVM", evmAddress) + return map +} + +const mockTx = (hash: string, timestamp: number): Tx => ({ + hash, + chainId: 42220, + timestamp, + txDirection: TxDirection.OUTGOING, + amount: "1.0", + tokenAddress: "0x0000000000000000000000000000000000000000", + to: "0xrecipient", + from: "0xsender", +}) + +describe("transactionHistoryStore", () => { + beforeEach(() => { + vi.resetAllMocks() + clearActivityHistory() + }) + + it("isolates history per account and refreshes when active account changes", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + const txA = mockTx("0xtxA", 1000) + mockGetTransactions.mockResolvedValueOnce([txA]) + + // Fetch history for Account A + await activityHistoryStore.refreshHistory(makeAddresses(accountA)) + expect(activityHistoryStore.perChain.get(42220)).toEqual([txA]) + + // Switch to Account B within 10-minute window + const txB = mockTx("0xtxB", 2000) + mockGetTransactions.mockResolvedValueOnce([txB]) + + await activityHistoryStore.refreshHistory(makeAddresses(accountB)) + + // Account B must see Account B's history, not Account A's + const historyB = activityHistoryStore.perChain.get(42220) + expect(historyB).toEqual([txB]) + expect(historyB?.some((tx) => tx.hash === "0xtxA")).toBe(false) + }) + + it("clears cached history and propagating transactions on clearActivityHistory", async () => { + const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const tx = mockTx("0xtx1", 1000) + mockGetTransactions.mockResolvedValueOnce([tx]) + + await activityHistoryStore.refreshHistory(makeAddresses(account)) + expect(activityHistoryStore.perChain.get(42220)?.length).toBe(1) + + // Add propagating tx + const propagatingTx = mockTx("0xpropagating", 1500) + activityHistoryStore.addPropagatingTx(propagatingTx) + expect(activityHistoryStore.perChain.get(42220)?.length).toBe(2) + + clearActivityHistory() + + expect(activityHistoryStore.perChain.get(42220)).toBeUndefined() + }) + + it("guards against late async responses from Account A writing into Account B UI", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + let resolveAccountA: (txs: Tx[]) => void = () => {} + const accountAPromise = new Promise((resolve) => { + resolveAccountA = resolve + }) + + // Account A triggers refresh with delayed response + mockGetTransactions.mockImplementationOnce(() => accountAPromise) + const refreshAPromise = activityHistoryStore.refreshHistory( + makeAddresses(accountA), + ) + + // While Account A's query is in flight, Account B logs in + const txB = mockTx("0xtxB", 2000) + mockGetTransactions.mockResolvedValueOnce([txB]) + await activityHistoryStore.refreshHistory(makeAddresses(accountB)) + + expect(activityHistoryStore.perChain.get(42220)).toEqual([txB]) + + // Now Account A's delayed response finally resolves + const txA = mockTx("0xtxA", 1000) + resolveAccountA([txA]) + await refreshAPromise + + // Account A's late response must NOT overwrite or contaminate Account B's store + const currentTxs = activityHistoryStore.perChain.get(42220) + expect(currentTxs).toEqual([txB]) + expect(currentTxs?.some((tx) => tx.hash === "0xtxA")).toBe(false) + }) +}) diff --git a/src/stores/transactionHistoryStore.ts b/src/stores/transactionHistoryStore.ts index be1de42..04abca4 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -7,9 +7,27 @@ import type { Tx } from "@/hooks/useLedger/types" import type { Addresses } from "@/login" const REFRESH_INTERVAL_MS = 1000 * 60 * 10 // 10 minutes + +// History is keyed by account address so a previous account's entries cannot +// leak across sessions. Both the internal (propagating) txs and the last-refresh +// timestamp are reset whenever the active account changes. +let activeAccount: string | undefined +let currentAccountToken = 0 const internalTxs = new Map() let lastRefreshAllTimestamp = 0 +/** + * Clears all cached history and resets the refresh timer. + * Must be called whenever the active account changes so stale data from a + * previous session cannot appear under a new account. + */ +export const clearActivityHistory = () => { + currentAccountToken++ + internalTxs.clear() + lastRefreshAllTimestamp = 0 + activityHistoryStore.perChain = proxyMap() +} + type ActivityHistoryStore = { perChain: Map isLoading: boolean @@ -44,25 +62,39 @@ const addPropagatingTx = (tx: Tx) => { const refreshHistory = async (addresses: Addresses | undefined) => { const now = Date.now() + + // Derive a stable account key from the EVM address (the primary signer key). + const account = addresses?.get("EVM") + + // Reset cached data when the account changes so a previous account's + // transactions cannot appear under the new one. + if (account !== activeAccount) { + activeAccount = account + clearActivityHistory() + } + if (!addresses || now - lastRefreshAllTimestamp < REFRESH_INTERVAL_MS) { return } + const token = currentAccountToken lastRefreshAllTimestamp = now activityHistoryStore.isLoading = true try { - await refreshAllChains(addresses) + await refreshAllChains(addresses, token) } catch (error) { console.error(error) } finally { - activityHistoryStore.isLoading = false + if (token === currentAccountToken) { + activityHistoryStore.isLoading = false + } } } -const refreshAllChains = async (addresses: Addresses) => { +const refreshAllChains = async (addresses: Addresses, token: number) => { const promises = getAvailableChainIds(addresses).map((chainId) => - refreshHistorySingleChain(addresses, chainId), + refreshHistorySingleChain(addresses, chainId, token), ) await Promise.allSettled(promises) } @@ -70,6 +102,7 @@ const refreshAllChains = async (addresses: Addresses) => { const refreshHistorySingleChain = async ( addresses: Addresses, chainId: number, + token: number, ) => { const provider = getChainProvider(chainId) const address = addresses.get(provider.family) @@ -77,6 +110,13 @@ const refreshHistorySingleChain = async ( throw new Error(`Address not found for provider: ${provider.family}`) } const externalTxsForChain = await provider.getTransactions(chainId, address) + + // Guard against late async writes: if the account changed or history was + // cleared while the query was in flight, discard the stale response. + if (token !== currentAccountToken || activeAccount !== addresses.get("EVM")) { + return + } + const internalTxsForChain = internalTxs.get(chainId) || [] const combinedTxsForChain = combineTxs( From 7ad0240d22eb86616e78b4528b3a423a722c521d Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 16:22:44 +0100 Subject: [PATCH 02/21] fix(wallet): coordinate network switching and separate account change from disconnect - Serialize ensureChain and transaction/typed-data signing via wallet-operation queue - Release operation lock on failed or rejected switch so subsequent actions are unblocked - Separate watchReownAccount into onAccountChanged and onDisconnected callbacks - Ensure all logout paths disconnect active dApp sessions and clear activity history --- src/login/context/SessionContext/storage.ts | 21 ++- src/login/reown.test.ts | 195 ++++++++++++++++++++ src/login/reown.ts | 81 +++++--- 3 files changed, 269 insertions(+), 28 deletions(-) create mode 100644 src/login/reown.test.ts diff --git a/src/login/context/SessionContext/storage.ts b/src/login/context/SessionContext/storage.ts index fa855b0..33f4f0c 100644 --- a/src/login/context/SessionContext/storage.ts +++ b/src/login/context/SessionContext/storage.ts @@ -9,6 +9,7 @@ import { } from "@/login/reown" import type { Addresses, ISigner, ISignerSession } from "@/login/types" import { resetWalletConnectDialogs } from "@/sections/WalletConnect/store/walletConnectDialogStore" +import { clearActivityHistory } from "@/stores/transactionHistoryStore" const SIGNER_SESSION_KEY = "SIGNER_SESSION" @@ -96,6 +97,8 @@ export const setSession = (session: ISignerSession | null) => { sessionState.reconnectReason = undefined } else { sessionState.addresses = undefined + // Clear cached history when a session ends so the next account starts fresh. + clearActivityHistory() } if (sessionState.session) { @@ -110,8 +113,12 @@ export const setSession = (session: ISignerSession | null) => { sessionState.isLoading = false if (session?.type === "REOWN") { - stopAccountWatch = watchReownAccount(session.address, () => - endSession("accountChanged"), + // Use distinct callbacks so an external wallet disconnect is not reported + // as an account change, keeping the reconnect message accurate. + stopAccountWatch = watchReownAccount( + session.address, + () => endSession("accountChanged"), + () => endSession(), ) } } @@ -129,10 +136,14 @@ const endSession = (reconnectReason?: ReconnectReason) => { setSession(null) sessionState.reconnectReason = reconnectReason + // Disconnect active dApp sessions on every logout path regardless of session + // type, so WalletConnect sessions bound to a local-key signer are also torn + // down and cannot silently rebind to a new signer. + disconnectDappSessions().catch((error) => { + console.error("Failed to disconnect WalletConnect sessions", error) + }) + if (previousSession?.type === "REOWN") { - disconnectDappSessions().catch((error) => { - console.error("Failed to disconnect WalletConnect sessions", error) - }) disconnectReown().catch((error) => { console.error("Failed to disconnect Reown session", error) }) diff --git a/src/login/reown.test.ts b/src/login/reown.test.ts new file mode 100644 index 0000000..999cad9 --- /dev/null +++ b/src/login/reown.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { + mockSubscribeAccount, + mockGetAddress, + mockGetChainId, + mockSwitchNetwork, + mockGetProvider, +} = vi.hoisted(() => ({ + mockSubscribeAccount: vi.fn(), + mockGetAddress: vi.fn(), + mockGetChainId: vi.fn(), + mockSwitchNetwork: vi.fn(), + mockGetProvider: vi.fn(), +})) + +vi.mock("@reown/appkit", () => ({ + createAppKit: vi.fn(() => ({ + ready: vi.fn().mockResolvedValue(undefined), + subscribeAccount: mockSubscribeAccount, + subscribeState: vi.fn(), + getAddress: mockGetAddress, + getChainId: mockGetChainId, + switchNetwork: mockSwitchNetwork, + getProvider: mockGetProvider, + })), +})) + +vi.mock("@reown/appkit-adapter-ethers", () => ({ + EthersAdapter: vi.fn(), +})) + +vi.mock("@/chain/chains", () => ({ + AVAILABLE_EVM_CHAINS: new Map([ + [ + 42220, + { + id: 42220, + name: "Celo", + nativeCurrency: { name: "CELO", symbol: "CELO", decimals: 18 }, + rpcUrls: { default: { http: ["https://rpc.celo.org"] } }, + }, + ], + [ + 122, + { + id: 122, + name: "Fuse", + nativeCurrency: { name: "FUSE", symbol: "FUSE", decimals: 18 }, + rpcUrls: { default: { http: ["https://rpc.fuse.io"] } }, + }, + ], + ]), +})) + +vi.mock("../../config", () => ({ + config: { + walletConnectEnabled: true, + walletConnectProjectId: "test-project-id", + }, +})) + +import { getReownSession, watchReownAccount } from "./reown" + +describe("reown - coordinated switching and account monitoring", () => { + const address = "0x1111111111111111111111111111111111111111" + + beforeEach(() => { + vi.resetAllMocks() + vi.stubGlobal("window", { + location: { origin: "http://localhost:3000" }, + }) + mockGetAddress.mockReturnValue(address) + mockGetProvider.mockReturnValue({ + request: vi.fn(), + }) + mockGetChainId.mockReturnValue(42220) + }) + + describe("watchReownAccount", () => { + type AccountState = { + isConnected: boolean + address?: string + status: string + } + + it("notifies onAccountChanged when account address changes", async () => { + let accountCallback: (state: AccountState) => void = () => {} + mockSubscribeAccount.mockImplementation( + (cb: (state: AccountState) => void) => { + accountCallback = cb + return vi.fn() + }, + ) + + // Initialize AppKit instance via getReownSession first + await getReownSession("direct", "existing_wallet", { + interactive: false, + expectedAddress: address, + }) + + const onAccountChanged = vi.fn() + const onDisconnected = vi.fn() + + watchReownAccount(address, onAccountChanged, onDisconnected) + + // Simulate account address change + accountCallback({ + isConnected: true, + address: "0x2222222222222222222222222222222222222222", + status: "connected", + }) + + expect(onAccountChanged).toHaveBeenCalledTimes(1) + expect(onDisconnected).not.toHaveBeenCalled() + }) + + it("notifies onDisconnected without triggering accountChanged when wallet disconnects", async () => { + let accountCallback: (state: AccountState) => void = () => {} + mockSubscribeAccount.mockImplementation( + (cb: (state: AccountState) => void) => { + accountCallback = cb + return vi.fn() + }, + ) + + // Initialize AppKit instance via getReownSession first + await getReownSession("direct", "existing_wallet", { + interactive: false, + expectedAddress: address, + }) + + const onAccountChanged = vi.fn() + const onDisconnected = vi.fn() + + watchReownAccount(address, onAccountChanged, onDisconnected) + + // Simulate external wallet disconnect + accountCallback({ + isConnected: false, + address: undefined, + status: "disconnected", + }) + + expect(onDisconnected).toHaveBeenCalledTimes(1) + expect(onAccountChanged).not.toHaveBeenCalled() + }) + }) + + describe("coordinated network switching", () => { + it("releases the queue when a chain switch fails so subsequent operations can proceed", async () => { + const session = await getReownSession("direct", "existing_wallet", { + interactive: false, + expectedAddress: address, + }) + + // First call: switch to Fuse (122), but user rejects switch + mockSwitchNetwork.mockRejectedValueOnce( + new Error("User rejected chain switch"), + ) + + expect(session.signer.EVM.submitTransaction).toBeDefined() + + await expect( + session.signer.EVM.submitTransaction?.({ + chainId: 122, + to: "0xrecipient", + value: BigInt(0), + }), + ).rejects.toThrow("User rejected chain switch") + + // Second call: now switch to Celo (42220) which succeeds + // If the queue was not released in finally, this would hang forever! + mockSwitchNetwork.mockResolvedValueOnce(undefined) + mockGetChainId.mockReturnValue(42220) + + // Mock signer sendUncheckedTransaction + const { BrowserProvider } = await import("ethers") + const mockSigner = { + sendUncheckedTransaction: vi.fn().mockResolvedValue("0xsuccesshash"), + } + vi.spyOn(BrowserProvider.prototype, "getSigner").mockResolvedValue( + mockSigner as never, + ) + + const hash = await session.signer.EVM.submitTransaction?.({ + chainId: 42220, + to: "0xrecipient", + value: BigInt(0), + }) + + expect(hash).toBe("0xsuccesshash") + }) + }) +}) diff --git a/src/login/reown.ts b/src/login/reown.ts index 4b1b887..19769d4 100644 --- a/src/login/reown.ts +++ b/src/login/reown.ts @@ -1,5 +1,6 @@ import { type CreateAppKit, createAppKit } from "@reown/appkit" import { EthersAdapter } from "@reown/appkit-adapter-ethers" +import { Lock } from "async-await-mutex-lock" import { BrowserProvider, type Eip1193Provider, @@ -10,8 +11,8 @@ import manifest from "@/app/manifest" import { CELO_CHAIN_ID } from "@/chain/chain-ids" import { AVAILABLE_EVM_CHAINS } from "@/chain/chains" import type { Chain } from "@/chain/types" -import { config } from "@/config" +import { config } from "../../config" import type { ISignerSession } from "./types" type GetReownSessionOptions = { @@ -31,6 +32,13 @@ export class ReownConnectionCancelledError extends Error { let appKitPromise: Promise | undefined let appKitInstance: AppKit | undefined +// A wallet-operation lock serialises the ensureChain → sign/submit sequence so +// concurrent operations (e.g. parallel claims) cannot interleave chain switches. +// The lock key is a constant string because there is only one connected wallet +// at a time and all signing shares a single EIP-1193 provider. +const CHAIN_OP_LOCK_KEY = "reown" +const chainOpLock = new Lock() + const getMetadata = () => { const { name, description, icons } = manifest() const url = @@ -242,11 +250,19 @@ const submitTransaction = async ( address: string, tx: TransactionRequest, ): Promise => { - await ensureChain(appKit, tx.chainId) - const signer = await getWalletSigner(appKit, address) - // The wallet owns nonce and fee selection for transactions it submits. - const { nonce: _nonce, ...request } = tx - return await signer.sendUncheckedTransaction(request) + // Hold the chain-operation lock for the entire switch → submit sequence to + // prevent a concurrent operation from switching chains between this check and + // the actual send. + await chainOpLock.acquire(CHAIN_OP_LOCK_KEY) + try { + await ensureChain(appKit, tx.chainId) + const signer = await getWalletSigner(appKit, address) + // The wallet owns nonce and fee selection for transactions it submits. + const { nonce: _nonce, ...request } = tx + return await signer.sendUncheckedTransaction(request) + } finally { + chainOpLock.release(CHAIN_OP_LOCK_KEY) + } } export const getReownSession = async ( @@ -284,10 +300,16 @@ export const getReownSession = async ( message, ), signTypedData: async (domain, types, value) => { - await ensureChain(appKit, domain.chainId) - return ( - await getWalletSigner(appKit, connectedAddress) - ).signTypedData(domain, types, value) + // Hold the lock for the entire switch → sign sequence. + await chainOpLock.acquire(CHAIN_OP_LOCK_KEY) + try { + await ensureChain(appKit, domain.chainId) + return ( + await getWalletSigner(appKit, connectedAddress) + ).signTypedData(domain, types, value) + } finally { + chainOpLock.release(CHAIN_OP_LOCK_KEY) + } }, signTransaction: async () => { throw new Error("Connected wallets cannot sign raw transactions") @@ -305,21 +327,31 @@ export const getReownEip1193Provider = async ( chainId?: number, ) => { const appKit = await getAppKit() - await ensureChain(appKit, chainId) - if (!isSameAddress(appKit.getAddress("eip155"), address)) { - throw new Error("Connected wallet account changed. Please reconnect.") - } - const provider = appKit.getProvider("eip155") - if (!provider) { - throw new Error("Connected provider not available") + // Hold the lock so a concurrent submitTransaction cannot switch chains while + // this call is resolving. + await chainOpLock.acquire(CHAIN_OP_LOCK_KEY) + try { + await ensureChain(appKit, chainId) + if (!isSameAddress(appKit.getAddress("eip155"), address)) { + throw new Error("Connected wallet account changed. Please reconnect.") + } + const provider = appKit.getProvider("eip155") + if (!provider) { + throw new Error("Connected provider not available") + } + return { provider, chainId: Number(appKit.getChainId()) } + } finally { + chainOpLock.release(CHAIN_OP_LOCK_KEY) } - return { provider, chainId: Number(appKit.getChainId()) } } // Calls onChange when the wallet switches account or disconnects. +// onAccountChanged is invoked when a different account is connected. +// onDisconnected is invoked when the wallet is explicitly disconnected. export const watchReownAccount = ( address: string, - onChange: () => void, + onAccountChanged: () => void, + onDisconnected: () => void, ): (() => void) => { if (!appKitInstance) { return () => undefined @@ -328,10 +360,13 @@ export const watchReownAccount = ( if (state.status === "connecting" || state.status === "reconnecting") { return } - const changed = state.isConnected - ? !isSameAddress(state.address, address) - : state.status === "disconnected" - if (changed) onChange() + if (state.isConnected) { + if (!isSameAddress(state.address, address)) { + onAccountChanged() + } + } else if (state.status === "disconnected") { + onDisconnected() + } }, "eip155") } From 080d6a62d47cdee49197bd4a931eddefc6061d6c Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 16:23:01 +0100 Subject: [PATCH 03/21] fix(ethers): track replaced and cancelled transactions from external wallets - Capture submission start block and enable ethers replacement tracking - Resolve replacement receipts for speedup and cancel without throwing unhandled errors - Preserve Divvi referral tracking on replacement transaction hash - Avoid duplicate transaction submissions on transient RPC getTransaction failures --- src/ethers-utils/utils.test.ts | 118 +++++++++++++++++++++++++++++++-- src/ethers-utils/utils.ts | 69 ++++++++++++++++--- 2 files changed, 173 insertions(+), 14 deletions(-) diff --git a/src/ethers-utils/utils.test.ts b/src/ethers-utils/utils.test.ts index 3f4225a..b06e543 100644 --- a/src/ethers-utils/utils.test.ts +++ b/src/ethers-utils/utils.test.ts @@ -10,6 +10,7 @@ const { mockBroadcastTransaction, mockGetTransaction, mockGetTransactionCount, + mockGetBlockNumber, mockSubmitReferral, mockSetDivviMarked, } = vi.hoisted(() => ({ @@ -18,6 +19,7 @@ const { mockBroadcastTransaction: vi.fn(), mockGetTransaction: vi.fn(), mockGetTransactionCount: vi.fn(), + mockGetBlockNumber: vi.fn(), mockSubmitReferral: vi.fn(), mockSetDivviMarked: vi.fn(), })) @@ -36,6 +38,7 @@ vi.mock("./ethersProviders", () => ({ broadcastTransaction: mockBroadcastTransaction, getTransaction: mockGetTransaction, getTransactionCount: mockGetTransactionCount, + getBlockNumber: mockGetBlockNumber, })), })) @@ -84,10 +87,17 @@ const createType2Options = ( maxPriorityFeePerGas, }) -const createTxResponse = (hash = TX_HASH) => ({ - hash, - wait: vi.fn().mockResolvedValue({ hash }), -}) +const createTxResponse = (hash = TX_HASH) => { + const response = { + hash, + wait: vi.fn().mockResolvedValue({ hash }), + replaceableTransaction: vi.fn(), + } + // By default replaceableTransaction returns a copy of itself so the tracked + // path exercises the same wait mock as the untracked path. + response.replaceableTransaction.mockReturnValue(response) + return response +} // ── Tests ─────────────────────────────────────────────────────────────────── @@ -107,6 +117,7 @@ describe("ethers-utils", () => { mockAcquire.mockResolvedValue(undefined) mockGetTransactionCount.mockResolvedValue(0) + mockGetBlockNumber.mockResolvedValue(100) mockBroadcastTransaction.mockResolvedValue(txResponse) mockSubmitReferral.mockResolvedValue({ json: vi.fn().mockResolvedValue({}), @@ -452,5 +463,104 @@ describe("ethers-utils", () => { ).rejects.toThrow("User rejected") expect(mockBroadcastTransaction).not.toHaveBeenCalled() }) + + it("resolves with the replacement receipt when the tx is sped up by the external wallet", async () => { + // Simulate the external wallet submitting a speed-up: the original hash + // is never mined; replaceableTransaction().wait() throws TRANSACTION_REPLACED. + const replacementReceipt = { hash: "0xreplacementhash" } + const replacedError = Object.assign( + new Error("transaction was replaced"), + { + code: "TRANSACTION_REPLACED", + reason: "speedup", + receipt: replacementReceipt, + replacement: { hash: "0xreplacementhash" }, + }, + ) + + txResponse.replaceableTransaction.mockReturnValue({ + ...txResponse, + wait: vi.fn().mockRejectedValue(replacedError), + }) + mockGetTransaction.mockResolvedValue(txResponse) + request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" }) + + const result = await sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + // The replacement receipt is surfaced, not an error. + expect(result.txReceipt).toEqual(replacementReceipt) + // Divvi tracking uses the replacement hash. + expect(mockSubmitReferral).toHaveBeenCalledWith({ + txHash: "0xreplacementhash", + chainId: request.chainId, + }) + }) + + it("resolves with the replacement receipt when the tx is cancelled by the external wallet", async () => { + const cancelReceipt = { hash: "0xcancelhash" } + const cancelError = Object.assign(new Error("transaction was replaced"), { + code: "TRANSACTION_REPLACED", + reason: "cancelled", + receipt: cancelReceipt, + replacement: { hash: "0xcancelhash" }, + }) + + txResponse.replaceableTransaction.mockReturnValue({ + ...txResponse, + wait: vi.fn().mockRejectedValue(cancelError), + }) + mockGetTransaction.mockResolvedValue(txResponse) + + const result = await sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + expect(result.txReceipt).toEqual(cancelReceipt) + }) + + it("does not trigger a resend when getTransaction temporarily fails to find the tx", async () => { + // Simulate RPC returning null for the first few polls then succeeding. + mockGetTransaction + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue(txResponse) + + const result = await sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + // No second submitTransaction call — no resend. + expect(walletSigner.submitTransaction).toHaveBeenCalledTimes(1) + expect(result.txResponse.hash).toBe(TX_HASH) + }) + + it("captures the start block before polling so replacement tracking covers the submission window", async () => { + const START_BLOCK = 42 + mockGetBlockNumber.mockResolvedValue(START_BLOCK) + mockGetTransaction.mockResolvedValue(txResponse) + + await sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + expect(mockGetBlockNumber).toHaveBeenCalled() + expect(txResponse.replaceableTransaction).toHaveBeenCalledWith( + START_BLOCK, + ) + }) }) }) diff --git a/src/ethers-utils/utils.ts b/src/ethers-utils/utils.ts index 10cad07..0bc2cca 100644 --- a/src/ethers-utils/utils.ts +++ b/src/ethers-utils/utils.ts @@ -6,7 +6,12 @@ import type { TransactionRequest, TransactionResponse, } from "ethers/providers" -import { type BigNumberish, FixedNumber, formatUnits } from "ethers/utils" +import { + type BigNumberish, + FixedNumber, + formatUnits, + isError, +} from "ethers/utils" import { GD_DATASUFFIX, @@ -54,10 +59,13 @@ const SUBMITTED_TX_MAX_ATTEMPTS = 40 const getSubmittedTransaction = async ( provider: AbstractProvider, hash: string, -): Promise => { +): Promise<{ txResponse: TransactionResponse; startBlock: number }> => { + // Capture the current block before polling so replacement tracking covers the + // window from submission to discovery. + const startBlock = await provider.getBlockNumber() for (let attempt = 0; attempt < SUBMITTED_TX_MAX_ATTEMPTS; attempt++) { const txResponse = await provider.getTransaction(hash) - if (txResponse) return txResponse + if (txResponse) return { txResponse, startBlock } await new Promise((resolve) => setTimeout(resolve, SUBMITTED_TX_POLL_MS)) } throw new Error(`Transaction ${hash} was not found on chain`) @@ -76,7 +84,10 @@ const submitWithWallet = async ( gasLimit, }) const provider = getEthersProvider(broadcastRequest.chainId) - return { hash, txResponse: getSubmittedTransaction(provider, hash) } + // getSubmittedTransaction resolves with both the response and the block at + // which we started watching; both are needed for replacement tracking. + const txInfo = getSubmittedTransaction(provider, hash) + return { hash, txInfo } } export const sendTx = async ( @@ -90,14 +101,14 @@ export const sendTx = async ( trxMinedPromise: Promise }> => { if (signer.submitTransaction) { - const { hash, txResponse } = await submitWithWallet( + const { hash, txInfo } = await submitWithWallet( signer.submitTransaction, signer, gasLimit, broadcastRequest, ) - const trxMinedPromise = txResponse.then((trx) => - waitForMinedStatus(trx, broadcastRequest, signer), + const trxMinedPromise = txInfo.then(({ txResponse, startBlock }) => + waitForMinedStatus(txResponse, startBlock, broadcastRequest, signer), ) return { hash, trxMinedPromise } } @@ -115,8 +126,15 @@ export const sendTx = async ( signer, broadcastRequest, ) + // broadcastTransaction returns a response with replacement tracking already + // set up by ethers; pass undefined for startBlock to use that tracking. const trx = await provider.broadcastTransaction(signedTx) - const trxMinedPromise = waitForMinedStatus(trx, broadcastRequest, signer) + const trxMinedPromise = waitForMinedStatus( + trx, + undefined, + broadcastRequest, + signer, + ) return { hash: trx.hash, trxMinedPromise } } finally { lock.release(broadcastRequest.chainId) @@ -136,9 +154,10 @@ export const sendTxWaitForMining = async ( gasLimit, broadcastRequest, ) - const txResponse = await submitted.txResponse + const { txResponse, startBlock } = await submitted.txInfo const txReceipt = await waitForMinedStatus( txResponse, + startBlock, broadcastRequest, signer, ) @@ -162,8 +181,10 @@ export const sendTxWaitForMining = async ( const txResponse = await provider.broadcastTransaction(signedTx) console.info("sendTx got response", { txResponse }) + // broadcastTransaction already attaches replacement tracking; pass undefined. const txReceipt = await waitForMinedStatus( txResponse, + undefined, broadcastRequest, signer, ) @@ -177,10 +198,38 @@ export const sendTxWaitForMining = async ( async function waitForMinedStatus( txResponse: TransactionResponse, + startBlock: number | undefined, broadcastRequest: BroadcastRequest, signer: EVMSigner, ) { - const txReceipt = await txResponse.wait() + // When startBlock is provided the response came from provider.getTransaction() + // and does not carry ethers' built-in replacement tracking. Enable it so that + // a speed-up or cancellation submitted by an external wallet resolves here + // rather than hanging until the original hash is eventually dropped. + const tracked = + startBlock !== undefined + ? txResponse.replaceableTransaction(startBlock) + : txResponse + + let txReceipt: TransactionReceipt | null + try { + txReceipt = await tracked.wait() + } catch (err) { + // TRANSACTION_REPLACED is thrown when a replacement tx mines with the same + // nonce. "cancelled" means a zero-value self-transfer; "speedup"/"repriced" + // means the original payload was re-sent at a higher fee. In all cases we + // treat the replacement receipt as the final outcome and continue with Divvi + // tracking so the user sees a completed state. + if (isError(err, "TRANSACTION_REPLACED")) { + console.info("waitForMinedStatus: transaction replaced", { + reason: err.reason, + hash: err.replacement?.hash, + }) + txReceipt = err.receipt ?? null + } else { + throw err + } + } // send marker to divvi and mark as done if (broadcastRequest.data?.includes(GD_DATASUFFIX) && txReceipt?.hash) { From 3a47d89370289ed2bba1fd080d8b79740d436811 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 16:23:24 +0100 Subject: [PATCH 04/21] fix(walletconnect): reject unsupported proposals and prune incompatible restored sessions - Reject session proposals for unsupported required namespaces with UNSUPPORTED_CHAINS - Disconnect restored sessions incompatible with the active signer before signer is usable - Prevent restored sessions from silently rebinding across accounts or unsupported namespaces --- .../requests/RequestHandler.test.ts | 151 ++++++++++++++++++ .../WalletConnect/requests/RequestHandler.ts | 13 ++ .../store/walletConnectStore.test.ts | 114 +++++++++++++ .../WalletConnect/store/walletConnectStore.ts | 73 ++++++++- 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/sections/WalletConnect/requests/RequestHandler.test.ts create mode 100644 src/sections/WalletConnect/store/walletConnectStore.test.ts diff --git a/src/sections/WalletConnect/requests/RequestHandler.test.ts b/src/sections/WalletConnect/requests/RequestHandler.test.ts new file mode 100644 index 0000000..75a890b --- /dev/null +++ b/src/sections/WalletConnect/requests/RequestHandler.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { + mockOpenDialog, + mockCaptureEvent, + mockRejectSession, + mockApproveSession, + mockRespondSessionRequest, +} = vi.hoisted(() => ({ + mockOpenDialog: vi.fn(), + mockCaptureEvent: vi.fn(), + mockRejectSession: vi.fn().mockResolvedValue(undefined), + mockApproveSession: vi.fn().mockResolvedValue(undefined), + mockRespondSessionRequest: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@/chain/chains", () => { + const celoChain = { id: 42220, name: "Celo", family: "EVM" } + const ethChain = { id: 1, name: "Ethereum", family: "EVM" } + return { + AVAILABLE_CHAINS_IDS: [42220, 1], + AVAILABLE_EVM_CHAINS: new Map([ + [42220, celoChain], + [1, ethChain], + ]), + AVAILABLE_CHAINS: new Map([ + [42220, celoChain], + [1, ethChain], + ]), + } +}) + +vi.mock("./SolanaRequestHandlerUtil", () => ({ + approveSolanaRequest: vi.fn(), +})) + +vi.mock("./BIP122RequestHandlerUtil", () => ({ + approveBIP122Request: vi.fn(), +})) + +vi.mock("./EIP155RequestHandlerUtil", () => ({ + approveEIP155Request: vi.fn(), +})) + +vi.mock("./EIP5792RequestHandlerUtil", () => ({ + approveEIP5792Request: vi.fn(), +})) + +vi.mock("@/chain/provider/provider", () => ({ + getChainProvider: vi.fn(), +})) + +vi.mock("@/analytics/useAnalytics", () => ({ + captureEvent: mockCaptureEvent, +})) + +vi.mock("../store/walletConnectDialogStore", () => ({ + openWalletConnectDialog: mockOpenDialog, +})) + +vi.mock("@/utils/reownAppKitWrapper", () => ({ + getCloseAfterWCAction: () => false, +})) + +import type { ISigner } from "@/login/types" + +import { setupListeners } from "./RequestHandler" + +describe("RequestHandler - Session Proposal", () => { + let listeners: Record Promise> + let mockWalletKit: unknown + let mockSigner: ISigner + + beforeEach(() => { + vi.resetAllMocks() + listeners = {} + + mockWalletKit = { + on: vi.fn((event: string, handler: (arg: unknown) => Promise) => { + listeners[event] = handler + }), + removeListener: vi.fn(), + rejectSession: mockRejectSession, + approveSession: mockApproveSession, + respondSessionRequest: mockRespondSessionRequest, + engine: { + signClient: { + events: { + on: vi.fn(), + removeListener: vi.fn(), + }, + }, + }, + } + + // External EVM wallet: has EVM with submitTransaction, but NO solana/BTC + mockSigner = { + EVM: { + address: "0x1111111111111111111111111111111111111111", + signMessage: vi.fn(), + signTypedData: vi.fn(), + signTransaction: vi.fn(), + submitTransaction: vi.fn(), + }, + } as unknown as ISigner + + setupListeners(() => mockSigner, mockWalletKit as never, vi.fn()) + }) + + it("rejects session proposal with UNSUPPORTED_CHAINS when required chains are not supported", async () => { + const onSessionProposal = listeners.session_proposal + expect(onSessionProposal).toBeDefined() + + // Proposal requesting an unsupported chain (e.g. Solana or Bitcoin for EVM-only wallet) + const unsupportedProposal = { + id: 12345, + params: { + requiredNamespaces: { + solana: { + chains: ["solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"], + methods: ["solana_signTransaction"], + events: [], + }, + }, + optionalNamespaces: {}, + }, + verifyContext: { + verified: { + origin: "https://solana-dapp.com", + }, + }, + } + + await onSessionProposal(unsupportedProposal) + + // Verify error dialog was shown + expect(mockOpenDialog).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + }), + ) + + // Verify rejectSession was called with UNSUPPORTED_CHAINS error so dApp does not hang + expect(mockRejectSession).toHaveBeenCalledWith({ + id: 12345, + reason: expect.objectContaining({ + message: expect.stringContaining("Unsupported"), + }), + }) + }) +}) diff --git a/src/sections/WalletConnect/requests/RequestHandler.ts b/src/sections/WalletConnect/requests/RequestHandler.ts index 3707213..ffbd479 100644 --- a/src/sections/WalletConnect/requests/RequestHandler.ts +++ b/src/sections/WalletConnect/requests/RequestHandler.ts @@ -154,6 +154,19 @@ export const setupListeners = ( methods, response: "error", }) + // Reject the proposal so the dApp is not left pending until expiry. + // Required namespaces that include unsupported chains are rejected with + // UNSUPPORTED_CHAINS; unsupported optional namespaces are silently omitted + // by buildApprovedNamespaces, so they never reach here. + try { + await walletConnectInstance.rejectSession({ + id: proposal.id, + reason: getSdkError("UNSUPPORTED_CHAINS"), + }) + } catch (error) { + console.error("Failed to reject session proposal", error) + } + closeWindowAfterWCRequestFullfilled() return } diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts new file mode 100644 index 0000000..83afdc0 --- /dev/null +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("../requests/RequestHandler", () => ({ + setupListeners: vi.fn(), +})) + +const { mockDisconnectSession } = vi.hoisted(() => ({ + mockDisconnectSession: vi.fn().mockResolvedValue(undefined), +})) + +import type WalletKit from "@reown/walletkit" +import type { SessionTypes } from "@walletconnect/types" + +import type { ISigner } from "@/login/types" + +import { + disconnectIncompatibleSessions, + isSessionCompatibleWithSigner, +} from "./walletConnectStore" + +describe("walletConnectStore - session compatibility and isolation", () => { + const evmAddressA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const evmAddressB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + const signerA = { + EVM: { + address: evmAddressA, + }, + } as unknown as ISigner + + const signerB = { + EVM: { + address: evmAddressB, + }, + } as unknown as ISigner + + beforeEach(() => { + vi.resetAllMocks() + }) + + it("considers an EVM session compatible only if its account matches the active signer", () => { + const sessionForA = { + topic: "topic-a", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressA}`, `eip155:42220:${evmAddressA}`], + methods: ["eth_sendTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + expect(isSessionCompatibleWithSigner(sessionForA, signerA)).toBe(true) + expect(isSessionCompatibleWithSigner(sessionForA, signerB)).toBe(false) + }) + + it("rejects non-EVM sessions for an EVM-only external signer", () => { + const solanaSession = { + topic: "topic-sol", + namespaces: { + solana: { + accounts: ["solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:SomeSolAddress"], + methods: ["solana_signTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + expect(isSessionCompatibleWithSigner(solanaSession, signerA)).toBe(false) + }) + + it("disconnects incompatible restored sessions so they do not silently rebind to a new signer", async () => { + const sessionForA = { + topic: "topic-a", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressA}`], + methods: ["eth_sendTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + const sessionForB = { + topic: "topic-b", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressB}`], + methods: ["eth_sendTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + const mockWallet = { + getActiveSessions: () => ({ + "topic-a": sessionForA, + "topic-b": sessionForB, + }), + disconnectSession: mockDisconnectSession, + } as unknown as WalletKit + + // When signer B logs in, session A must be disconnected; session B is retained + await disconnectIncompatibleSessions(mockWallet, signerB) + + expect(mockDisconnectSession).toHaveBeenCalledTimes(1) + expect(mockDisconnectSession).toHaveBeenCalledWith({ + topic: "topic-a", + reason: expect.objectContaining({ + message: expect.stringContaining("User disconnected"), + }), + }) + }) +}) diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index d5138f4..59655fe 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -5,10 +5,10 @@ import { getSdkError } from "@walletconnect/utils" import { proxy, ref } from "valtio" import manifest from "@/app/manifest" -import { config } from "@/config" import type { ISigner } from "@/login" import { getWCUri } from "@/utils/reownAppKitWrapper" +import { config } from "../../../../config" import { setupListeners } from "../requests/RequestHandler" export const INITIALIZE_WALLET_CONNECT_KEY = "INITIALIZE_WALLET_CONNECT" @@ -27,11 +27,72 @@ const getActiveSigner = () => { return activeSigner } +export const isSessionCompatibleWithSigner = ( + session: SessionTypes.Struct, + signer: ISigner, +): boolean => { + const namespaces = session.namespaces || {} + for (const [ns, data] of Object.entries(namespaces)) { + if (ns === "eip155") { + const evmAddress = signer.EVM?.address?.toLowerCase() + if (!evmAddress) return false + for (const account of data.accounts || []) { + const address = account.split(":").pop()?.toLowerCase() + if (address && address !== evmAddress) { + return false + } + } + } else if (ns === "solana") { + if (!signer.SOLANA && !signer.SOLANA_DEVNET) return false + const solAddress = signer.SOLANA?.address || signer.SOLANA_DEVNET?.address + for (const account of data.accounts || []) { + const address = account.split(":").pop() + if (address && address !== solAddress) { + return false + } + } + } else if (ns === "bip122") { + if (!signer.BTC && !signer.BTC_TESTNET) return false + const btcAddress = signer.BTC?.address || signer.BTC_TESTNET?.address + for (const account of data.accounts || []) { + const address = account.split(":").pop() + if (address && address !== btcAddress) { + return false + } + } + } else { + return false + } + } + return true +} + +export const disconnectIncompatibleSessions = async ( + wallet: WalletKit, + signer: ISigner, +) => { + const sessions = Object.values(wallet.getActiveSessions()) + const incompatible = sessions.filter( + (session) => !isSessionCompatibleWithSigner(session, signer), + ) + if (incompatible.length > 0) { + await Promise.allSettled( + incompatible.map((session) => + wallet.disconnectSession({ + topic: session.topic, + reason: getSdkError("USER_DISCONNECTED"), + }), + ), + ) + } +} + const initializeWalletConnect = async (signer: ISigner) => { if (!config.walletConnectEnabled) { return } activeSigner = signer + let wallet: WalletKit | undefined if (!walletConnectState.walletConnectInstance) { walletConnectState.walletConnectInstance = "initializing" if (typeof window !== "undefined") { @@ -55,7 +116,7 @@ const initializeWalletConnect = async (signer: ISigner) => { icons: iconUrls, url, } - const wallet = await WalletKit.init({ + wallet = await WalletKit.init({ core, metadata, }) @@ -66,6 +127,14 @@ const initializeWalletConnect = async (signer: ISigner) => { wallet.pair({ uri: wcUri }) } walletConnectState.walletConnectInstance = ref(wallet) + } else if (walletConnectState.walletConnectInstance !== "initializing") { + wallet = walletConnectState.walletConnectInstance + } + + // Restored sessions must not silently rebind to a new signer: + // disconnect incompatible sessions before the new signer is usable. + if (wallet) { + await disconnectIncompatibleSessions(wallet, signer) } refreshSessions() } From 4d319b6614ca9e0d2131ecb276611ef0b5e926ba Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 17:39:42 +0100 Subject: [PATCH 05/21] fix(walletconnect): use case-insensitive address comparison in WalletWrapper - Use isSameAddress helper to avoid rejecting dApp transactions with EIP-55 casing differences - Add regression test verifying case-insensitive sender acceptance --- .../WalletConnect/utils/WalletWrapper.test.ts | 27 +++++++++++++++++++ .../WalletConnect/utils/WalletWrapper.ts | 5 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/sections/WalletConnect/utils/WalletWrapper.test.ts b/src/sections/WalletConnect/utils/WalletWrapper.test.ts index 630576c..472a6dc 100644 --- a/src/sections/WalletConnect/utils/WalletWrapper.test.ts +++ b/src/sections/WalletConnect/utils/WalletWrapper.test.ts @@ -61,6 +61,33 @@ describe("WalletWrapper.sendTransactionHash", () => { ).resolves.toBe("0xwallethash") }) + it("accepts when the dApp supplies a from that differs only in EIP-55 casing", async () => { + const checksummedAddress = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1bEAeD" + const lowerCaseFrom = checksummedAddress.toLowerCase() + const submitTransaction = vi.fn(async () => "0xwallethash") + const signer = { + address: checksummedAddress, + signMessage: vi.fn(), + signTypedData: vi.fn(), + signTransaction: vi.fn(async () => "0xraw"), + submitTransaction, + } + const txWithLowerFrom = { + to: checksummedAddress, + from: lowerCaseFrom, + value: BigInt(1), + chainId: 1, + } + + await expect( + new WalletWrapper(signer).sendTransactionHash(txWithLowerFrom), + ).resolves.toBe("0xwallethash") + expect(submitTransaction).toHaveBeenCalledWith({ + ...txWithLowerFrom, + from: checksummedAddress, + }) + }) + it("signs and broadcasts locally for local-key signers", async () => { const signer = createSigner() const wrapper = new WalletWrapper(signer, {} as Provider) diff --git a/src/sections/WalletConnect/utils/WalletWrapper.ts b/src/sections/WalletConnect/utils/WalletWrapper.ts index 4312f84..64a0b8d 100644 --- a/src/sections/WalletConnect/utils/WalletWrapper.ts +++ b/src/sections/WalletConnect/utils/WalletWrapper.ts @@ -7,6 +7,7 @@ import { type TypedDataField, } from "ethers" +import { isSameAddress } from "@/ethers-utils/utils" import type { EVMSigner } from "@/login" export class WalletWrapper extends AbstractSigner { @@ -39,8 +40,8 @@ export class WalletWrapper extends AbstractSigner { if (this.#signer.submitTransaction) { // Preserve a `from` supplied by the dApp so the signer can verify it // matches the connected account; default to the current address only - // when `from` is absent. - if (tx.from && tx.from !== this.address) { + // when `from` is absent. Comparison is case-insensitive (EIP-55). + if (tx.from && !isSameAddress(String(tx.from), this.address)) { throw new Error( `Transaction sender ${String(tx.from)} does not match connected account ${this.address}`, ) From 411a07db4183cd7c3041bacedbedb86ef144a333 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 17:40:41 +0100 Subject: [PATCH 06/21] fix(reown): serialize signMessage through wallet-operation queue - Acquire chainOpLock in signMessage to prevent interleaving with switch-and-submit - Add regression test verifying signMessage waits for in-flight transaction submission --- src/login/reown.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++ src/login/reown.ts | 14 +++++++--- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/login/reown.test.ts b/src/login/reown.test.ts index 999cad9..a244320 100644 --- a/src/login/reown.test.ts +++ b/src/login/reown.test.ts @@ -191,5 +191,67 @@ describe("reown - coordinated switching and account monitoring", () => { expect(hash).toBe("0xsuccesshash") }) + + it("serializes signMessage so it cannot interleave with an in-progress submitTransaction", async () => { + const session = await getReownSession("direct", "existing_wallet", { + interactive: false, + expectedAddress: address, + }) + + const executionOrder: string[] = [] + let resolveSubmit: () => void = () => {} + const submitInFlight = new Promise((resolve) => { + resolveSubmit = resolve + }) + + const { BrowserProvider } = await import("ethers") + const mockSigner = { + sendUncheckedTransaction: vi.fn(async () => { + executionOrder.push("submit_started") + await submitInFlight + executionOrder.push("submit_finished") + return "0xhash" + }), + signMessage: vi.fn(async () => { + executionOrder.push("sign_message") + return "0xsignature" + }), + } + vi.spyOn(BrowserProvider.prototype, "getSigner").mockResolvedValue( + mockSigner as never, + ) + + mockSwitchNetwork.mockResolvedValue(undefined) + mockGetChainId.mockReturnValue(42220) + + // 1. Start submitTransaction (it acquires the lock and waits on submitInFlight) + const submitPromise = session.signer.EVM.submitTransaction?.({ + chainId: 42220, + to: "0xrecipient", + value: BigInt(0), + }) + + // 2. Concurrently call signMessage while submitTransaction holds the lock + const signMessagePromise = + session.signer.EVM.signMessage?.("test-payload") + + // Give event loop ticks to confirm signMessage cannot proceed while submit holds lock + await new Promise((r) => setTimeout(r, 50)) + expect(executionOrder).toEqual(["submit_started"]) + expect(mockSigner.signMessage).not.toHaveBeenCalled() + + // 3. Complete the in-flight submission + resolveSubmit() + await submitPromise + const signature = await signMessagePromise + + // 4. Verify signMessage executed strictly after submitTransaction finished + expect(executionOrder).toEqual([ + "submit_started", + "submit_finished", + "sign_message", + ]) + expect(signature).toBe("0xsignature") + }) }) }) diff --git a/src/login/reown.ts b/src/login/reown.ts index 19769d4..05257ec 100644 --- a/src/login/reown.ts +++ b/src/login/reown.ts @@ -295,10 +295,16 @@ export const getReownSession = async ( signer: { EVM: { address: connectedAddress, - signMessage: async (message) => - (await getWalletSigner(appKit, connectedAddress)).signMessage( - message, - ), + signMessage: async (message) => { + await chainOpLock.acquire(CHAIN_OP_LOCK_KEY) + try { + return await ( + await getWalletSigner(appKit, connectedAddress) + ).signMessage(message) + } finally { + chainOpLock.release(CHAIN_OP_LOCK_KEY) + } + }, signTypedData: async (domain, types, value) => { // Hold the lock for the entire switch → sign sequence. await chainOpLock.acquire(CHAIN_OP_LOCK_KEY) From 45a1fb574150e12ead4dae0cf1aa1bc06ce6b3ea Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 17:41:41 +0100 Subject: [PATCH 07/21] fix(ethers): capture startBlock before wallet submission - Query getBlockNumber prior to submitTransaction in submitWithWallet - Pass startBlock into getSubmittedTransaction and assert call ordering in test --- src/ethers-utils/utils.test.ts | 14 +++++++++++--- src/ethers-utils/utils.ts | 11 ++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/ethers-utils/utils.test.ts b/src/ethers-utils/utils.test.ts index b06e543..c079ae8 100644 --- a/src/ethers-utils/utils.test.ts +++ b/src/ethers-utils/utils.test.ts @@ -545,9 +545,17 @@ describe("ethers-utils", () => { expect(result.txResponse.hash).toBe(TX_HASH) }) - it("captures the start block before polling so replacement tracking covers the submission window", async () => { + it("captures the start block before wallet submission so replacement tracking covers the popup window", async () => { const START_BLOCK = 42 - mockGetBlockNumber.mockResolvedValue(START_BLOCK) + const callOrder: string[] = [] + mockGetBlockNumber.mockImplementation(async () => { + callOrder.push("getBlockNumber") + return START_BLOCK + }) + walletSigner.submitTransaction.mockImplementation(async () => { + callOrder.push("submitTransaction") + return TX_HASH + }) mockGetTransaction.mockResolvedValue(txResponse) await sendTxWaitForMining( @@ -557,7 +565,7 @@ describe("ethers-utils", () => { options, ) - expect(mockGetBlockNumber).toHaveBeenCalled() + expect(callOrder).toEqual(["getBlockNumber", "submitTransaction"]) expect(txResponse.replaceableTransaction).toHaveBeenCalledWith( START_BLOCK, ) diff --git a/src/ethers-utils/utils.ts b/src/ethers-utils/utils.ts index 0bc2cca..077ea25 100644 --- a/src/ethers-utils/utils.ts +++ b/src/ethers-utils/utils.ts @@ -59,10 +59,8 @@ const SUBMITTED_TX_MAX_ATTEMPTS = 40 const getSubmittedTransaction = async ( provider: AbstractProvider, hash: string, + startBlock: number, ): Promise<{ txResponse: TransactionResponse; startBlock: number }> => { - // Capture the current block before polling so replacement tracking covers the - // window from submission to discovery. - const startBlock = await provider.getBlockNumber() for (let attempt = 0; attempt < SUBMITTED_TX_MAX_ATTEMPTS; attempt++) { const txResponse = await provider.getTransaction(hash) if (txResponse) return { txResponse, startBlock } @@ -78,15 +76,18 @@ const submitWithWallet = async ( gasLimit: bigint, broadcastRequest: BroadcastRequest, ) => { + const provider = getEthersProvider(broadcastRequest.chainId) + // Capture startBlock before calling submit so replacement tracking covers the + // window while the user confirms the transaction in the wallet popup. + const startBlock = await provider.getBlockNumber() const hash = await submit({ ...broadcastRequest, from: signer.address, gasLimit, }) - const provider = getEthersProvider(broadcastRequest.chainId) // getSubmittedTransaction resolves with both the response and the block at // which we started watching; both are needed for replacement tracking. - const txInfo = getSubmittedTransaction(provider, hash) + const txInfo = getSubmittedTransaction(provider, hash, startBlock) return { hash, txInfo } } From 6a0e61fee5701bace952bf34239256f84d013a95 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 17:44:26 +0100 Subject: [PATCH 08/21] fix(config): restore canonical @/config path alias imports - Restore import { config } from "@/config" in reown.ts and walletConnectStore.ts matching base branch - Update reown.test.ts mock target to match @/config --- src/login/reown.ts | 2 +- src/sections/WalletConnect/store/walletConnectStore.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/login/reown.ts b/src/login/reown.ts index 05257ec..17b4434 100644 --- a/src/login/reown.ts +++ b/src/login/reown.ts @@ -11,8 +11,8 @@ import manifest from "@/app/manifest" import { CELO_CHAIN_ID } from "@/chain/chain-ids" import { AVAILABLE_EVM_CHAINS } from "@/chain/chains" import type { Chain } from "@/chain/types" +import { config } from "@/config" -import { config } from "../../config" import type { ISignerSession } from "./types" type GetReownSessionOptions = { diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 59655fe..44bcee1 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -5,10 +5,10 @@ import { getSdkError } from "@walletconnect/utils" import { proxy, ref } from "valtio" import manifest from "@/app/manifest" +import { config } from "@/config" import type { ISigner } from "@/login" import { getWCUri } from "@/utils/reownAppKitWrapper" -import { config } from "../../../../config" import { setupListeners } from "../requests/RequestHandler" export const INITIALIZE_WALLET_CONNECT_KEY = "INITIALIZE_WALLET_CONNECT" From 7facb2530b664f8e89130a56f176ea98fae2483f Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 18:50:38 +0100 Subject: [PATCH 09/21] test(config): mock @/config alias in reown and walletConnectStore tests --- src/config.ts | 2 ++ src/login/reown.test.ts | 2 +- .../WalletConnect/store/walletConnectStore.test.ts | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/config.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..dc9e1ea --- /dev/null +++ b/src/config.ts @@ -0,0 +1,2 @@ +export * from "../config" +export { config as default } from "../config" diff --git a/src/login/reown.test.ts b/src/login/reown.test.ts index a244320..99fba05 100644 --- a/src/login/reown.test.ts +++ b/src/login/reown.test.ts @@ -53,7 +53,7 @@ vi.mock("@/chain/chains", () => ({ ]), })) -vi.mock("../../config", () => ({ +vi.mock("@/config", () => ({ config: { walletConnectEnabled: true, walletConnectProjectId: "test-project-id", diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts index 83afdc0..3f91551 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.test.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -4,6 +4,13 @@ vi.mock("../requests/RequestHandler", () => ({ setupListeners: vi.fn(), })) +vi.mock("@/config", () => ({ + config: { + walletConnectEnabled: true, + walletConnectProjectId: "test-project-id", + }, +})) + const { mockDisconnectSession } = vi.hoisted(() => ({ mockDisconnectSession: vi.fn().mockResolvedValue(undefined), })) From 090b471205ccf1e075d01cf7a99bcb49ab636386 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 18:55:56 +0100 Subject: [PATCH 10/21] test(balances): verify amountsStore drops late async responses across accounts --- .../stores/amountsStore.test.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/hooks/useTokenBalances/stores/amountsStore.test.ts diff --git a/src/hooks/useTokenBalances/stores/amountsStore.test.ts b/src/hooks/useTokenBalances/stores/amountsStore.test.ts new file mode 100644 index 0000000..72167aa --- /dev/null +++ b/src/hooks/useTokenBalances/stores/amountsStore.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mockGetAmounts = vi.fn() + +vi.mock("@/config", () => ({ + config: { + env: "development", + walletConnectEnabled: true, + walletConnectProjectId: "test-project-id", + }, +})) + +vi.mock("@/chain/chains", () => ({ + isChainAvailableForAddresses: vi.fn(() => true), +})) + +vi.mock("@/chain/provider/provider", () => ({ + getChainProvider: vi.fn(() => ({ + family: "EVM", + getAmounts: mockGetAmounts, + })), + getChainProviderForFamily: vi.fn(() => ({ + family: "EVM", + isValidAddress: vi.fn(() => true), + })), +})) + +vi.mock("./focusStore", () => ({ + focusStore: { + isWalletInFocus: true, + }, +})) + +import { FixedNumber } from "ethers" + +import type { Addresses, ISigner } from "@/login" +import type { Tokens } from "@/tokens/types" + +import { NormalizedAddressMap } from "../NormalizedAddressMap" +import type { TokensOfChain } from "./amountsStore" +import { amountsStore, refreshAmounts } from "./amountsStore" + +const makeAddresses = (evmAddress: string): Addresses => { + const map = new Map() + map.set("EVM", evmAddress) + return map +} + +const makeTokens = (chainIds: number[]): Tokens => { + const map = new Map() + for (const chainId of chainIds) { + map.set(chainId, new Map() as unknown as TokensOfChain) + } + return map as unknown as Tokens +} + +describe("amountsStore - late async response guard", () => { + beforeEach(() => { + vi.resetAllMocks() + amountsStore.amounts.clear() + amountsStore.isLoading = true + amountsStore.isValidating = false + }) + + it("guards against late async responses from Account A overwriting Account B balances (all chains)", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + const tokens = makeTokens([42220]) + const addressesA = makeAddresses(accountA) + const addressesB = makeAddresses(accountB) + + const amountsA = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("100.0"), + ], + ]) + const amountsB = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("200.0"), + ], + ]) + + let resolveA: (val: unknown) => void = () => {} + const delayedPromiseA = new Promise((resolve) => { + resolveA = resolve + }) + + // Account A triggers refresh; its provider call hangs + mockGetAmounts.mockImplementationOnce(() => delayedPromiseA) + const refreshPromiseA = refreshAmounts(tokens, addressesA, "all") + + // Account B logs in and triggers refresh, resolving immediately + mockGetAmounts.mockResolvedValueOnce(amountsB) + await refreshAmounts(tokens, addressesB, "all") + + // Account B's balances are now present + expect(amountsStore.amounts.get(42220)).toBe(amountsB) + expect(amountsStore.isLoading).toBe(false) + expect(amountsStore.isValidating).toBe(false) + + // Now Account A's delayed response finally resolves + resolveA(amountsA) + await refreshPromiseA + + // Account A's late response must NOT overwrite Account B's balances + expect(amountsStore.amounts.get(42220)).toBe(amountsB) + expect(amountsStore.amounts.get(42220)).not.toBe(amountsA) + }) + + it("guards against late async responses for single chain refresh", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + const tokens = makeTokens([42220]) + const addressesA = makeAddresses(accountA) + const addressesB = makeAddresses(accountB) + + const amountsA = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("100.0"), + ], + ]) + const amountsB = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("500.0"), + ], + ]) + + let resolveA: (val: unknown) => void = () => {} + const delayedPromiseA = new Promise((resolve) => { + resolveA = resolve + }) + + mockGetAmounts.mockImplementationOnce(() => delayedPromiseA) + const refreshPromiseA = refreshAmounts(tokens, addressesA, 42220) + + mockGetAmounts.mockResolvedValueOnce(amountsB) + await refreshAmounts(tokens, addressesB, 42220) + + expect(amountsStore.amounts.get(42220)).toBe(amountsB) + + resolveA(amountsA) + await refreshPromiseA + + expect(amountsStore.amounts.get(42220)).toBe(amountsB) + expect(amountsStore.amounts.get(42220)).not.toBe(amountsA) + }) + + it("clears amounts when addresses are undefined", async () => { + amountsStore.amounts.set(42220, new NormalizedAddressMap("EVM")) + await refreshAmounts(undefined, undefined, "all") + expect(amountsStore.amounts.size).toBe(0) + expect(amountsStore.isLoading).toBe(true) + }) +}) From 6c74108f84ea96ee0e94490a0db7ef0c490c1291 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 18:57:00 +0100 Subject: [PATCH 11/21] fix(history): reset activeAccount inside clearActivityHistory --- src/stores/transactionHistoryStore.test.ts | 6 ++++++ src/stores/transactionHistoryStore.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/stores/transactionHistoryStore.test.ts b/src/stores/transactionHistoryStore.test.ts index 17e476d..3e69cf1 100644 --- a/src/stores/transactionHistoryStore.test.ts +++ b/src/stores/transactionHistoryStore.test.ts @@ -83,6 +83,12 @@ describe("transactionHistoryStore", () => { clearActivityHistory() expect(activityHistoryStore.perChain.get(42220)).toBeUndefined() + + // Re-authenticating with the same account must treat it as fresh + const tx2 = mockTx("0xtx2", 2000) + mockGetTransactions.mockResolvedValueOnce([tx2]) + await activityHistoryStore.refreshHistory(makeAddresses(account)) + expect(activityHistoryStore.perChain.get(42220)).toEqual([tx2]) }) it("guards against late async responses from Account A writing into Account B UI", async () => { diff --git a/src/stores/transactionHistoryStore.ts b/src/stores/transactionHistoryStore.ts index 04abca4..e63566a 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -22,6 +22,7 @@ let lastRefreshAllTimestamp = 0 * previous session cannot appear under a new account. */ export const clearActivityHistory = () => { + activeAccount = undefined currentAccountToken++ internalTxs.clear() lastRefreshAllTimestamp = 0 @@ -69,8 +70,8 @@ const refreshHistory = async (addresses: Addresses | undefined) => { // Reset cached data when the account changes so a previous account's // transactions cannot appear under the new one. if (account !== activeAccount) { - activeAccount = account clearActivityHistory() + activeAccount = account } if (!addresses || now - lastRefreshAllTimestamp < REFRESH_INTERVAL_MS) { From 477e4b1caf8eb6512700e35cbd0932f399f7c5f0 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 19:26:25 +0100 Subject: [PATCH 12/21] build(vitest): prioritize config aliases over src wildcard and remove shim --- src/config.ts | 2 -- vitest.config.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 src/config.ts diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index dc9e1ea..0000000 --- a/src/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "../config" -export { config as default } from "../config" diff --git a/vitest.config.ts b/vitest.config.ts index c43d5fd..6e7ff7d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,9 +14,9 @@ export default defineConfig({ }, resolve: { alias: { - "@": path.resolve(__dirname, "./src"), "@/config": path.resolve(__dirname, "./config"), "@/configServerless": path.resolve(__dirname, "./configServerless"), + "@": path.resolve(__dirname, "./src"), translations: path.resolve(__dirname, "./src/translations"), "ethers-utils": path.resolve(__dirname, "./src/ethers-utils"), "ethers-utils/*": path.resolve(__dirname, "./src/ethers-utils"), From b33c44f10dbf9099bd8b3b87d2e940331bca4a2c Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 19:28:57 +0100 Subject: [PATCH 13/21] test(walletconnect): add integration test for session pruning in initializeWalletConnect --- .../store/walletConnectStore.test.ts | 116 ++++++++++++------ 1 file changed, 81 insertions(+), 35 deletions(-) diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts index 3f91551..dc1cb49 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.test.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -11,10 +11,25 @@ vi.mock("@/config", () => ({ }, })) -const { mockDisconnectSession } = vi.hoisted(() => ({ +vi.mock("@walletconnect/core", () => ({ + Core: vi.fn(), +})) + +vi.mock("@/utils/reownAppKitWrapper", () => ({ + getWCUri: vi.fn(() => undefined), +})) + +const { mockWalletKitInit, mockDisconnectSession } = vi.hoisted(() => ({ + mockWalletKitInit: vi.fn(), mockDisconnectSession: vi.fn().mockResolvedValue(undefined), })) +vi.mock("@reown/walletkit", () => ({ + default: { + init: mockWalletKitInit, + }, +})) + import type WalletKit from "@reown/walletkit" import type { SessionTypes } from "@walletconnect/types" @@ -23,6 +38,7 @@ import type { ISigner } from "@/login/types" import { disconnectIncompatibleSessions, isSessionCompatibleWithSigner, + walletConnectState, } from "./walletConnectStore" describe("walletConnectStore - session compatibility and isolation", () => { @@ -41,22 +57,42 @@ describe("walletConnectStore - session compatibility and isolation", () => { }, } as unknown as ISigner + const sessionForA = { + topic: "topic-a", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressA}`, `eip155:42220:${evmAddressA}`], + methods: ["eth_sendTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + const sessionForB = { + topic: "topic-b", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressB}`, `eip155:42220:${evmAddressB}`], + methods: ["eth_sendTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + beforeEach(() => { vi.resetAllMocks() + vi.stubGlobal("window", { + location: { origin: "http://localhost:3000" }, + }) + vi.stubGlobal("sessionStorage", { + setItem: vi.fn(), + getItem: vi.fn(), + }) + walletConnectState.walletConnectInstance = undefined + walletConnectState.sessions = [] }) it("considers an EVM session compatible only if its account matches the active signer", () => { - const sessionForA = { - topic: "topic-a", - namespaces: { - eip155: { - accounts: [`eip155:1:${evmAddressA}`, `eip155:42220:${evmAddressA}`], - methods: ["eth_sendTransaction"], - events: [], - }, - }, - } as unknown as SessionTypes.Struct - expect(isSessionCompatibleWithSigner(sessionForA, signerA)).toBe(true) expect(isSessionCompatibleWithSigner(sessionForA, signerB)).toBe(false) }) @@ -76,29 +112,7 @@ describe("walletConnectStore - session compatibility and isolation", () => { expect(isSessionCompatibleWithSigner(solanaSession, signerA)).toBe(false) }) - it("disconnects incompatible restored sessions so they do not silently rebind to a new signer", async () => { - const sessionForA = { - topic: "topic-a", - namespaces: { - eip155: { - accounts: [`eip155:1:${evmAddressA}`], - methods: ["eth_sendTransaction"], - events: [], - }, - }, - } as unknown as SessionTypes.Struct - - const sessionForB = { - topic: "topic-b", - namespaces: { - eip155: { - accounts: [`eip155:1:${evmAddressB}`], - methods: ["eth_sendTransaction"], - events: [], - }, - }, - } as unknown as SessionTypes.Struct - + it("disconnects incompatible restored sessions so they do not silently rebind to a new signer (helper)", async () => { const mockWallet = { getActiveSessions: () => ({ "topic-a": sessionForA, @@ -118,4 +132,36 @@ describe("walletConnectStore - session compatibility and isolation", () => { }), }) }) + + it("disconnects incompatible restored sessions during initializeWalletConnect integration", async () => { + const activeSessions: Record = { + "topic-a": sessionForA, + "topic-b": sessionForB, + } + const mockWallet = { + getActiveSessions: vi.fn(() => ({ ...activeSessions })), + disconnectSession: vi.fn( + async (args: { topic: string; reason: unknown }) => { + delete activeSessions[args.topic] + await mockDisconnectSession(args) + }, + ), + pair: vi.fn(), + } + mockWalletKitInit.mockResolvedValueOnce(mockWallet) + + // Account B logs in and initializes WalletConnect + await walletConnectState.initializeWalletConnect(signerB) + + // Integration check: initializeWalletConnect must prune Account A's session + expect(mockDisconnectSession).toHaveBeenCalledTimes(1) + expect(mockDisconnectSession).toHaveBeenCalledWith({ + topic: "topic-a", + reason: expect.objectContaining({ + message: expect.stringContaining("User disconnected"), + }), + }) + // And retained sessions should only be Account B's + expect(walletConnectState.sessions).toEqual([sessionForB]) + }) }) From d2e4f70bfd7458a92676167a96d30628548e59f8 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 4 Nov 2026 20:10:14 +0100 Subject: [PATCH 14/21] fix(ethers): rethrow cancelled and replaced transactions instead of resolving as success Ethers TRANSACTION_REPLACED distinguishes three replacement reasons: - repriced: same calldata/to/value at higher gas; replacement carries the original intent so the receipt is used and Divvi tracking proceeds. - cancelled: zero-value self-transfer with empty calldata. - replaced: different calldata/to/value (different intent). Previously all replacement reasons resolved as success with the replacement receipt. This caused cancelled transactions (e.g. user cancels a claim in their wallet popup) to report success in ClaimView, fire false success analytics, and trigger Divvi referral tracking with a cancel tx hash. Branch on err.reason: rethrow for 'cancelled' and 'replaced' so caller error paths surface the failure correctly and Divvi tracking is not reached. Keep current behavior for 'repriced' speed-ups. --- src/ethers-utils/utils.test.ts | 52 +++++++++++++++++++++++++++++----- src/ethers-utils/utils.ts | 24 +++++++++++++--- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/ethers-utils/utils.test.ts b/src/ethers-utils/utils.test.ts index c079ae8..242b4a9 100644 --- a/src/ethers-utils/utils.test.ts +++ b/src/ethers-utils/utils.test.ts @@ -501,7 +501,9 @@ describe("ethers-utils", () => { }) }) - it("resolves with the replacement receipt when the tx is cancelled by the external wallet", async () => { + it("throws and does not submit a Divvi referral when the tx is cancelled by the external wallet", async () => { + // "cancelled" = zero-value self-transfer: the original intent was NOT + // executed; waitForMinedStatus must rethrow so callers surface a failure. const cancelReceipt = { hash: "0xcancelhash" } const cancelError = Object.assign(new Error("transaction was replaced"), { code: "TRANSACTION_REPLACED", @@ -515,15 +517,51 @@ describe("ethers-utils", () => { wait: vi.fn().mockRejectedValue(cancelError), }) mockGetTransaction.mockResolvedValue(txResponse) + request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" }) - const result = await sendTxWaitForMining( - walletSigner as never, - DEFAULT_GAS_LIMIT, - request, - options, + await expect( + sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ), + ).rejects.toThrow("transaction was replaced") + + expect(mockSubmitReferral).not.toHaveBeenCalled() + }) + + it("throws and does not submit a Divvi referral when the tx is replaced with different intent by the external wallet", async () => { + // "replaced" = different calldata/to/value: a different-intent transaction + // mined in place of the original; waitForMinedStatus must rethrow. + const replaceReceipt = { hash: "0xreplacehash" } + const replaceError = Object.assign( + new Error("transaction was replaced"), + { + code: "TRANSACTION_REPLACED", + reason: "replaced", + receipt: replaceReceipt, + replacement: { hash: "0xreplacehash" }, + }, ) - expect(result.txReceipt).toEqual(cancelReceipt) + txResponse.replaceableTransaction.mockReturnValue({ + ...txResponse, + wait: vi.fn().mockRejectedValue(replaceError), + }) + mockGetTransaction.mockResolvedValue(txResponse) + request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" }) + + await expect( + sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ), + ).rejects.toThrow("transaction was replaced") + + expect(mockSubmitReferral).not.toHaveBeenCalled() }) it("does not trigger a resend when getTransaction temporarily fails to find the tx", async () => { diff --git a/src/ethers-utils/utils.ts b/src/ethers-utils/utils.ts index 077ea25..124deb8 100644 --- a/src/ethers-utils/utils.ts +++ b/src/ethers-utils/utils.ts @@ -217,15 +217,31 @@ async function waitForMinedStatus( txReceipt = await tracked.wait() } catch (err) { // TRANSACTION_REPLACED is thrown when a replacement tx mines with the same - // nonce. "cancelled" means a zero-value self-transfer; "speedup"/"repriced" - // means the original payload was re-sent at a higher fee. In all cases we - // treat the replacement receipt as the final outcome and continue with Divvi - // tracking so the user sees a completed state. + // nonce. Ethers distinguishes three reasons: + // + // "repriced" — same calldata/to/value at a higher fee (speed-up). The + // replacement carries the original intent, so we use its + // receipt and continue with Divvi tracking. + // + // "cancelled" — zero-value self-transfer with empty calldata. The original + // transaction's intent was NOT executed; rethrowing lets + // callers surface a failure to the user. + // + // "replaced" — different calldata/to/value (different intent). Same as + // "cancelled": the original payload was not executed, so we + // rethrow rather than presenting the replacement as success. if (isError(err, "TRANSACTION_REPLACED")) { console.info("waitForMinedStatus: transaction replaced", { reason: err.reason, hash: err.replacement?.hash, }) + if (err.reason === "cancelled" || err.reason === "replaced") { + // "cancelled" (zero-value self-transfer) or "replaced" (different intent): + // the original transaction's intent was NOT executed. Rethrow so callers' + // error paths surface failure to the user; Divvi referral is not reached. + throw err + } + // "repriced" (speed-up): same intent executed at higher fee; use receipt. txReceipt = err.receipt ?? null } else { throw err From ff1c9201fe3f926828550b2057391cfe10fd943e Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Fri, 6 Nov 2026 19:00:11 +0100 Subject: [PATCH 15/21] fix: harden transaction tracking, lock concurrency, and session validation - Reject reverted speed-up replacements instead of treating as success (H1) - Await signTypedData inside chainOpLock to prevent premature lock release (H2) - Retry transient RPC failures in getSubmittedTransaction poll loop (M3) - Return replacement transaction and hash from sendTxWaitForMining (M4) - Scope balance refresh invalidation token per chain in amountsStore (M5) - Provide disconnected reconnect reason and notification for external disconnects (M6) - Reject WalletConnect proposals requiring unsupported methods (M7) - Validate restored WalletConnect sessions against signer capabilities (M8) - Match network CAIP-2 chain when selecting Bitcoin and Solana addresses (M9) --- src/ethers-utils/types.ts | 1 + src/ethers-utils/utils.test.ts | 53 +++++++ src/ethers-utils/utils.ts | 57 +++++-- .../stores/amountsStore.test.ts | 43 ++++++ .../useTokenBalances/stores/amountsStore.ts | 71 ++++++--- .../context/SessionContext/storage.test.ts | 74 +++++++++ src/login/context/SessionContext/storage.ts | 23 +-- src/login/reown.test.ts | 68 +++++++++ src/login/reown.ts | 2 +- src/sections/Login/components/Goodwallet.tsx | 4 +- .../requests/RequestHandler.test.ts | 51 +++++++ .../WalletConnect/requests/RequestHandler.ts | 120 +++++++-------- .../store/walletConnectStore.test.ts | 144 ++++++++++++++++++ .../WalletConnect/store/walletConnectStore.ts | 39 ++++- .../utils/supportedNamespaces.ts | 85 +++++++++++ src/translations/translations/da.ts | 2 + src/translations/translations/en.ts | 2 + src/translations/types.ts | 1 + 18 files changed, 722 insertions(+), 118 deletions(-) create mode 100644 src/login/context/SessionContext/storage.test.ts create mode 100644 src/sections/WalletConnect/utils/supportedNamespaces.ts diff --git a/src/ethers-utils/types.ts b/src/ethers-utils/types.ts index 961de7e..1535590 100644 --- a/src/ethers-utils/types.ts +++ b/src/ethers-utils/types.ts @@ -30,6 +30,7 @@ export type BroadcastOptions = export type BroadcastResult = { txResponse: TransactionResponse txReceipt: TransactionReceipt | null + originalTxResponse?: TransactionResponse } export type RpcUrls = Record diff --git a/src/ethers-utils/utils.test.ts b/src/ethers-utils/utils.test.ts index 242b4a9..e547f07 100644 --- a/src/ethers-utils/utils.test.ts +++ b/src/ethers-utils/utils.test.ts @@ -494,6 +494,9 @@ describe("ethers-utils", () => { // The replacement receipt is surfaced, not an error. expect(result.txReceipt).toEqual(replacementReceipt) + // The replacement response (with replacement hash) is returned to callers. + expect(result.txResponse.hash).toBe("0xreplacementhash") + expect(result.originalTxResponse?.hash).toBe(txResponse.hash) // Divvi tracking uses the replacement hash. expect(mockSubmitReferral).toHaveBeenCalledWith({ txHash: "0xreplacementhash", @@ -564,6 +567,37 @@ describe("ethers-utils", () => { expect(mockSubmitReferral).not.toHaveBeenCalled() }) + it("throws and does not submit a Divvi referral when a repriced replacement tx reverts on-chain (status 0)", async () => { + const revertedReceipt = { hash: "0xreplacementhash", status: 0 } + const revertedError = Object.assign( + new Error("transaction was replaced and reverted"), + { + code: "TRANSACTION_REPLACED", + reason: "repriced", + receipt: revertedReceipt, + replacement: { hash: "0xreplacementhash" }, + }, + ) + + txResponse.replaceableTransaction.mockReturnValue({ + ...txResponse, + wait: vi.fn().mockRejectedValue(revertedError), + }) + mockGetTransaction.mockResolvedValue(txResponse) + request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" }) + + await expect( + sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ), + ).rejects.toThrow("transaction was replaced and reverted") + + expect(mockSubmitReferral).not.toHaveBeenCalled() + }) + it("does not trigger a resend when getTransaction temporarily fails to find the tx", async () => { // Simulate RPC returning null for the first few polls then succeeding. mockGetTransaction @@ -583,6 +617,25 @@ describe("ethers-utils", () => { expect(result.txResponse.hash).toBe(TX_HASH) }) + it("retries when getTransaction encounters transient RPC errors and resolves when it succeeds", async () => { + // Simulate RPC throwing transient errors before returning the transaction. + mockGetTransaction + .mockRejectedValueOnce(new Error("RPC rate limited: 429")) + .mockRejectedValueOnce(new Error("Network timeout")) + .mockResolvedValue(txResponse) + + const result = await sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + expect(result.txResponse.hash).toBe(TX_HASH) + expect(mockGetTransaction).toHaveBeenCalledTimes(3) + expect(walletSigner.submitTransaction).toHaveBeenCalledTimes(1) + }) + it("captures the start block before wallet submission so replacement tracking covers the popup window", async () => { const START_BLOCK = 42 const callOrder: string[] = [] diff --git a/src/ethers-utils/utils.ts b/src/ethers-utils/utils.ts index 124deb8..3d9a889 100644 --- a/src/ethers-utils/utils.ts +++ b/src/ethers-utils/utils.ts @@ -62,8 +62,14 @@ const getSubmittedTransaction = async ( startBlock: number, ): Promise<{ txResponse: TransactionResponse; startBlock: number }> => { for (let attempt = 0; attempt < SUBMITTED_TX_MAX_ATTEMPTS; attempt++) { - const txResponse = await provider.getTransaction(hash) - if (txResponse) return { txResponse, startBlock } + try { + const txResponse = await provider.getTransaction(hash) + if (txResponse) return { txResponse, startBlock } + } catch (err) { + if (attempt === SUBMITTED_TX_MAX_ATTEMPTS - 1) { + throw err + } + } await new Promise((resolve) => setTimeout(resolve, SUBMITTED_TX_POLL_MS)) } throw new Error(`Transaction ${hash} was not found on chain`) @@ -108,9 +114,15 @@ export const sendTx = async ( gasLimit, broadcastRequest, ) - const trxMinedPromise = txInfo.then(({ txResponse, startBlock }) => - waitForMinedStatus(txResponse, startBlock, broadcastRequest, signer), - ) + const trxMinedPromise = txInfo.then(async ({ txResponse, startBlock }) => { + const { txReceipt } = await waitForMinedStatus( + txResponse, + startBlock, + broadcastRequest, + signer, + ) + return txReceipt + }) return { hash, trxMinedPromise } } @@ -135,7 +147,7 @@ export const sendTx = async ( undefined, broadcastRequest, signer, - ) + ).then((res) => res.txReceipt) return { hash: trx.hash, trxMinedPromise } } finally { lock.release(broadcastRequest.chainId) @@ -156,13 +168,18 @@ export const sendTxWaitForMining = async ( broadcastRequest, ) const { txResponse, startBlock } = await submitted.txInfo - const txReceipt = await waitForMinedStatus( + const { txReceipt, replacementTxResponse } = await waitForMinedStatus( txResponse, startBlock, broadcastRequest, signer, ) - return { txResponse, txReceipt } + const effectiveResponse = replacementTxResponse ?? txResponse + return { + txResponse: effectiveResponse, + txReceipt, + ...(replacementTxResponse && { originalTxResponse: txResponse }), + } } const from = signer.address @@ -183,7 +200,7 @@ export const sendTxWaitForMining = async ( console.info("sendTx got response", { txResponse }) // broadcastTransaction already attaches replacement tracking; pass undefined. - const txReceipt = await waitForMinedStatus( + const { txReceipt, replacementTxResponse } = await waitForMinedStatus( txResponse, undefined, broadcastRequest, @@ -191,7 +208,12 @@ export const sendTxWaitForMining = async ( ) console.info("sendTx got receipt", { txReceipt }) - return { txResponse, txReceipt } + const effectiveResponse = replacementTxResponse ?? txResponse + return { + txResponse: effectiveResponse, + txReceipt, + ...(replacementTxResponse && { originalTxResponse: txResponse }), + } } finally { lock.release(broadcastRequest.chainId) } @@ -202,7 +224,10 @@ async function waitForMinedStatus( startBlock: number | undefined, broadcastRequest: BroadcastRequest, signer: EVMSigner, -) { +): Promise<{ + txReceipt: TransactionReceipt | null + replacementTxResponse?: TransactionResponse +}> { // When startBlock is provided the response came from provider.getTransaction() // and does not carry ethers' built-in replacement tracking. Enable it so that // a speed-up or cancellation submitted by an external wallet resolves here @@ -213,6 +238,7 @@ async function waitForMinedStatus( : txResponse let txReceipt: TransactionReceipt | null + let replacementTxResponse: TransactionResponse | undefined try { txReceipt = await tracked.wait() } catch (err) { @@ -241,8 +267,15 @@ async function waitForMinedStatus( // error paths surface failure to the user; Divvi referral is not reached. throw err } + if (err.receipt?.status === 0) { + // The replacement transaction reverted on-chain (status 0). Treat as + // a reverted transaction: rethrow so caller surfaces failure and Divvi + // tracking is not reached. + throw err + } // "repriced" (speed-up): same intent executed at higher fee; use receipt. txReceipt = err.receipt ?? null + replacementTxResponse = err.replacement } else { throw err } @@ -262,7 +295,7 @@ async function waitForMinedStatus( console.error("divvi failed", { error }) }) } - return txReceipt + return { txReceipt, replacementTxResponse } } async function prepareTx( diff --git a/src/hooks/useTokenBalances/stores/amountsStore.test.ts b/src/hooks/useTokenBalances/stores/amountsStore.test.ts index 72167aa..bb80974 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.test.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.test.ts @@ -151,6 +151,49 @@ describe("amountsStore - late async response guard", () => { expect(amountsStore.amounts.get(42220)).not.toBe(amountsA) }) + it("allows concurrent refreshes on different chains to both land without dropping each other", async () => { + const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const addresses = makeAddresses(account) + const tokens = makeTokens([42220, 122]) + + const amountsCelo = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("100.0"), + ], + ]) + const amountsFuse = new NormalizedAddressMap("EVM", [ + [ + "0x2222222222222222222222222222222222222222", + FixedNumber.fromString("200.0"), + ], + ]) + + let resolveCelo!: (val: unknown) => void + const delayedCelo = new Promise((resolve) => { + resolveCelo = resolve + }) + + // Chain 42220 refresh starts and hangs + mockGetAmounts.mockImplementationOnce(() => delayedCelo) + const refreshCelo = refreshAmounts(tokens, addresses, 42220) + + // Chain 122 refresh starts and finishes immediately + mockGetAmounts.mockResolvedValueOnce(amountsFuse) + await refreshAmounts(tokens, addresses, 122) + + // Chain 122 should have landed + expect(amountsStore.amounts.get(122)).toBe(amountsFuse) + + // Now chain 42220 completes + resolveCelo(amountsCelo) + await refreshCelo + + // Both chains must be present; chain 42220 must not have been dropped by chain 122's refresh + expect(amountsStore.amounts.get(42220)).toBe(amountsCelo) + expect(amountsStore.amounts.get(122)).toBe(amountsFuse) + }) + it("clears amounts when addresses are undefined", async () => { amountsStore.amounts.set(42220, new NormalizedAddressMap("EVM")) await refreshAmounts(undefined, undefined, "all") diff --git a/src/hooks/useTokenBalances/stores/amountsStore.ts b/src/hooks/useTokenBalances/stores/amountsStore.ts index 8c86718..c5b98ad 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.ts @@ -44,39 +44,57 @@ async function getAmountsForChain( return provider.getAmounts(chainId, tokensOfChain, address) } -// Guard token bumped on every refresh or account change so late async responses -// from a previous account cannot overwrite the current account's balances. -let amountsToken = 0 +// Session epoch bumped on account/address change to drop late async responses +// from previous accounts. Per-chain versions ensure concurrent refreshes on +// different chains do not invalidate each other. +let sessionEpoch = 0 +let lastAddresses: Addresses | undefined +const chainVersions = new Map() +let inFlightRequests = 0 export const refreshAmounts = async ( tokens: Tokens | undefined, addresses: Addresses | undefined, chainId: number | "all", ) => { - const token = ++amountsToken if (!tokens || !addresses) { + sessionEpoch++ + chainVersions.clear() + lastAddresses = undefined amountsStore.amounts.clear() amountsStore.isLoading = true + amountsStore.isValidating = false return } + if (lastAddresses !== addresses) { + sessionEpoch++ + chainVersions.clear() + lastAddresses = addresses + inFlightRequests = 0 + } + + const currentEpoch = sessionEpoch + inFlightRequests++ + amountsStore.isValidating = true + try { - amountsStore.isValidating = true if (chainId === "all") { - const promises = imap( - tokens.entries(), - async ([chainId, tokensOfChain]) => { - if (!isChainAvailableForAddresses(chainId, addresses)) return - const amounts = await getAmountsForChain( - tokensOfChain, - addresses, - chainId, - ) - // Drop late response if the account or query changed while in flight. - if (token !== amountsToken) return - amountsStore.amounts.set(chainId, amounts) - }, - ) + const promises = imap(tokens.entries(), async ([cId, tokensOfChain]) => { + if (!isChainAvailableForAddresses(cId, addresses)) return + const version = (chainVersions.get(cId) ?? 0) + 1 + chainVersions.set(cId, version) + + const amounts = await getAmountsForChain(tokensOfChain, addresses, cId) + // Drop late response if account changed or this chain was refreshed again. + if ( + currentEpoch !== sessionEpoch || + version !== chainVersions.get(cId) + ) { + return + } + amountsStore.amounts.set(cId, amounts) + }) const r = await Promise.allSettled(promises) //Log errors, if any r.filter((p) => p.status === "rejected").forEach((p) => @@ -85,17 +103,28 @@ export const refreshAmounts = async ( } else { const tokensOfChain = tokens.get(chainId) if (tokensOfChain && isChainAvailableForAddresses(chainId, addresses)) { + const version = (chainVersions.get(chainId) ?? 0) + 1 + chainVersions.set(chainId, version) + const amountsForChain = await getAmountsForChain( tokensOfChain, addresses, chainId, ) - if (token !== amountsToken) return + // Drop late response if account changed or this chain was refreshed again. + if ( + currentEpoch !== sessionEpoch || + version !== chainVersions.get(chainId) + ) { + return + } amountsStore.amounts.set(chainId, amountsForChain) } } } finally { - if (token === amountsToken) { + inFlightRequests-- + if (inFlightRequests <= 0) { + inFlightRequests = 0 amountsStore.isLoading = false amountsStore.isValidating = false } diff --git a/src/login/context/SessionContext/storage.test.ts b/src/login/context/SessionContext/storage.test.ts new file mode 100644 index 0000000..86fb057 --- /dev/null +++ b/src/login/context/SessionContext/storage.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { ISigner, ISignerSession } from "@/login/types" + +let capturedOnAccountChanged: () => void = () => {} +let capturedOnDisconnected: () => void = () => {} + +vi.mock("@/login/reown", () => ({ + watchReownAccount: vi.fn((_address, onAccountChanged, onDisconnected) => { + capturedOnAccountChanged = onAccountChanged + capturedOnDisconnected = onDisconnected + return vi.fn() + }), + disconnectReown: vi.fn().mockResolvedValue(undefined), + getReownSession: vi.fn(), +})) + +vi.mock("@/sections/WalletConnect/store/walletConnectStore", () => ({ + disconnectAllWalletConnectSessions: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@/stores/transactionHistoryStore", () => ({ + clearActivityHistory: vi.fn(), +})) + +import { sessionState, setSession } from "./storage" + +describe("SessionContext storage", () => { + const dummySigner = { + CELO: { address: "0x1111111111111111111111111111111111111111" }, + } as unknown as ISigner + + const reownSession: ISignerSession = { + type: "REOWN", + address: "0x1111111111111111111111111111111111111111", + signer: dummySigner, + sessionOrigin: "direct", + } + + beforeEach(() => { + const store = new Map() + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, val: string) => store.set(key, val)), + removeItem: vi.fn((key: string) => store.delete(key)), + clear: vi.fn(() => store.clear()), + }) + setSession(null) + }) + + it("sets reconnectReason to disconnected when external wallet disconnects", () => { + setSession(reownSession) + expect(sessionState.session).toBeTruthy() + expect(sessionState.reconnectReason).toBeUndefined() + + // Trigger disconnect callback + capturedOnDisconnected() + + expect(sessionState.session).toBeNull() + expect(sessionState.reconnectReason).toBe("disconnected") + }) + + it("sets reconnectReason to accountChanged when external wallet account changes", () => { + setSession(reownSession) + expect(sessionState.session).toBeTruthy() + expect(sessionState.reconnectReason).toBeUndefined() + + // Trigger account changed callback + capturedOnAccountChanged() + + expect(sessionState.session).toBeNull() + expect(sessionState.reconnectReason).toBe("accountChanged") + }) +}) diff --git a/src/login/context/SessionContext/storage.ts b/src/login/context/SessionContext/storage.ts index 33f4f0c..2a753ac 100644 --- a/src/login/context/SessionContext/storage.ts +++ b/src/login/context/SessionContext/storage.ts @@ -62,7 +62,10 @@ const fromJSON = async ( return null } -export type ReconnectReason = "accountChanged" | "restoreFailed" +export type ReconnectReason = + | "accountChanged" + | "restoreFailed" + | "disconnected" type SessionState = { session: null | ISignerSession @@ -101,13 +104,15 @@ export const setSession = (session: ISignerSession | null) => { clearActivityHistory() } - if (sessionState.session) { - localStorage.setItem( - SIGNER_SESSION_KEY, - JSON.stringify(sessionState.session), - ) - } else { - localStorage.removeItem(SIGNER_SESSION_KEY) + if (typeof localStorage !== "undefined") { + if (sessionState.session) { + localStorage.setItem( + SIGNER_SESSION_KEY, + JSON.stringify(sessionState.session), + ) + } else { + localStorage.removeItem(SIGNER_SESSION_KEY) + } } sessionState.isLoading = false @@ -118,7 +123,7 @@ export const setSession = (session: ISignerSession | null) => { stopAccountWatch = watchReownAccount( session.address, () => endSession("accountChanged"), - () => endSession(), + () => endSession("disconnected"), ) } } diff --git a/src/login/reown.test.ts b/src/login/reown.test.ts index 99fba05..72d395a 100644 --- a/src/login/reown.test.ts +++ b/src/login/reown.test.ts @@ -253,5 +253,73 @@ describe("reown - coordinated switching and account monitoring", () => { ]) expect(signature).toBe("0xsignature") }) + + it("holds chainOpLock until signTypedData finishes and does not allow a concurrent operation to proceed while signing is pending", async () => { + const address = "0x1111111111111111111111111111111111111111" + mockGetAddress.mockReturnValue(address) + mockGetProvider.mockReturnValue({ request: vi.fn() }) + + const session = await getReownSession("http://localhost", "reown", { + interactive: false, + }) + + let resolveSigning!: () => void + const signingInFlight = new Promise((resolve) => { + resolveSigning = resolve + }) + + const executionOrder: string[] = [] + const mockSigner = { + signTypedData: vi.fn(async () => { + executionOrder.push("typed_data_started") + await signingInFlight + executionOrder.push("typed_data_finished") + return "0xtypedsig" + }), + sendUncheckedTransaction: vi.fn(async () => { + executionOrder.push("submit_executed") + return "0xhash" + }), + } + const { BrowserProvider } = await import("ethers") + vi.spyOn(BrowserProvider.prototype, "getSigner").mockResolvedValue( + mockSigner as never, + ) + + mockSwitchNetwork.mockResolvedValue(undefined) + mockGetChainId.mockReturnValue(42220) + + // 1. Start signTypedData (must hold lock while waiting for wallet user) + const signPromise = session.signer.EVM.signTypedData?.( + { chainId: 42220 }, + {}, + {}, + ) + + await new Promise((r) => setTimeout(r, 20)) + + // 2. Concurrently attempt submitTransaction while signTypedData is pending + const submitPromise = session.signer.EVM.submitTransaction?.({ + chainId: 42220, + to: "0xrecipient", + value: BigInt(0), + }) + + await new Promise((r) => setTimeout(r, 50)) + expect(executionOrder).toEqual(["typed_data_started"]) + expect(mockSigner.sendUncheckedTransaction).not.toHaveBeenCalled() + + // 3. Complete the in-flight signing + resolveSigning() + const signature = await signPromise + await submitPromise + + expect(executionOrder).toEqual([ + "typed_data_started", + "typed_data_finished", + "submit_executed", + ]) + expect(signature).toBe("0xtypedsig") + }) }) }) diff --git a/src/login/reown.ts b/src/login/reown.ts index 17b4434..0838032 100644 --- a/src/login/reown.ts +++ b/src/login/reown.ts @@ -310,7 +310,7 @@ export const getReownSession = async ( await chainOpLock.acquire(CHAIN_OP_LOCK_KEY) try { await ensureChain(appKit, domain.chainId) - return ( + return await ( await getWalletSigner(appKit, connectedAddress) ).signTypedData(domain, types, value) } finally { diff --git a/src/sections/Login/components/Goodwallet.tsx b/src/sections/Login/components/Goodwallet.tsx index cd8d5da..1793d3d 100644 --- a/src/sections/Login/components/Goodwallet.tsx +++ b/src/sections/Login/components/Goodwallet.tsx @@ -131,7 +131,9 @@ export default function LoginDefault() {

{reconnectReason === "accountChanged" ? loginTranslations.existingWalletAccountChanged - : loginTranslations.existingWalletRestoreFailed} + : reconnectReason === "disconnected" + ? loginTranslations.existingWalletDisconnected + : loginTranslations.existingWalletRestoreFailed}

)} diff --git a/src/sections/WalletConnect/requests/RequestHandler.test.ts b/src/sections/WalletConnect/requests/RequestHandler.test.ts index 75a890b..db706ef 100644 --- a/src/sections/WalletConnect/requests/RequestHandler.test.ts +++ b/src/sections/WalletConnect/requests/RequestHandler.test.ts @@ -148,4 +148,55 @@ describe("RequestHandler - Session Proposal", () => { }), }) }) + + it("rejects session proposal with UNSUPPORTED_METHODS when required methods are not supported", async () => { + const onSessionProposal = listeners.session_proposal + expect(onSessionProposal).toBeDefined() + + // Proposal requesting an unsupported method for external wallet (e.g. eth_signTransaction) + const unsupportedProposal = { + id: 54321, + params: { + requiredNamespaces: { + eip155: { + chains: ["eip155:42220"], + methods: ["eth_signTransaction"], + events: [], + }, + }, + optionalNamespaces: {}, + }, + verifyContext: { + verified: { + origin: "https://evm-dapp.com", + }, + }, + } + + await onSessionProposal(unsupportedProposal) + + // Verify error dialog was shown with unsupported methods + expect(mockOpenDialog).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + errorText: expect.stringContaining("eth_signTransaction"), + }), + ) + + // Verify rejectSession was called with UNSUPPORTED_METHODS error so dApp does not hang + expect(mockRejectSession).toHaveBeenCalledWith({ + id: 54321, + reason: expect.objectContaining({ + code: 5101, + message: expect.stringContaining("Unsupported methods"), + }), + }) + + // Dialog was not opened for approval + expect(mockOpenDialog).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "sessionProposal", + }), + ) + }) }) diff --git a/src/sections/WalletConnect/requests/RequestHandler.ts b/src/sections/WalletConnect/requests/RequestHandler.ts index ffbd479..4c5f57c 100644 --- a/src/sections/WalletConnect/requests/RequestHandler.ts +++ b/src/sections/WalletConnect/requests/RequestHandler.ts @@ -16,87 +16,27 @@ import type { ISigner } from "@/login/types" import { getCloseAfterWCAction } from "@/utils/reownAppKitWrapper" import { - BIP122_CHAINS, BIP122_MAINNET_CAIP2, BIP122_SIGNING_METHODS, BIP122_TESTNET_CAIP2, } from "../data/BIP122Data" -import { EIP155_CHAINS, EIP155_SIGNING_METHODS } from "../data/EIP155Data" +import { EIP155_CHAINS } from "../data/EIP155Data" import { EIP5792_METHODS } from "../data/EIP5792Data" -import { SOLANA_CHAINS, SOLANA_SIGNING_METHODS } from "../data/SolanaData" +import { SOLANA_SIGNING_METHODS } from "../data/SolanaData" import { openWalletConnectDialog } from "../store/walletConnectDialogStore" import { getInternalChainId } from "../utils/HelperUtils" +import { + getEip155Methods, + getSupportedNamespaces, + isExternalWallet, +} from "../utils/supportedNamespaces" import { WalletWrapper } from "../utils/WalletWrapper" import { approveBIP122Request } from "./BIP122RequestHandlerUtil" import { approveEIP155Request } from "./EIP155RequestHandlerUtil" import { approveEIP5792Request } from "./EIP5792RequestHandlerUtil" import { approveSolanaRequest } from "./SolanaRequestHandlerUtil" -// External wallets broadcast themselves, so raw-signing methods are not offered. -const RAW_SIGNING_METHODS = new Set([ - EIP155_SIGNING_METHODS.ETH_SIGN_TRANSACTION, - EIP155_SIGNING_METHODS.ETH_SEND_RAW_TRANSACTION, -]) - -const isExternalWallet = (signer: ISigner) => !!signer.EVM.submitTransaction - -const getEip155Methods = (signer: ISigner) => - Object.values(EIP155_SIGNING_METHODS).filter( - (method) => !isExternalWallet(signer) || !RAW_SIGNING_METHODS.has(method), - ) - -type SupportedNamespace = { - chains: string[] - methods: string[] - events: string[] - accounts: string[] -} - -const getSupportedNamespaces = (signer: ISigner) => { - const namespaces: Record = { - eip155: { - chains: EIP155_CHAINS, - methods: getEip155Methods(signer), - events: ["accountsChanged", "chainChanged"], - accounts: EIP155_CHAINS.map((chain) => `${chain}:${signer.EVM.address}`), - }, - } - - const solanaChains: string[] = [] - const solanaAccounts: string[] = [] - for (const [internalChainId, walletConnectChainId] of SOLANA_CHAINS) { - const chain = AVAILABLE_CHAINS.get(internalChainId) - const address = chain && signer[chain.family]?.address - if (!address) continue - solanaChains.push(walletConnectChainId) - solanaAccounts.push(`${walletConnectChainId}:${address}`) - } - if (solanaAccounts.length > 0) { - namespaces.solana = { - chains: solanaChains, - methods: Object.values(SOLANA_SIGNING_METHODS), - events: [], - accounts: solanaAccounts, - } - } - - if (signer.BTC) { - const bitcoinAccounts = [`${BIP122_MAINNET_CAIP2}:${signer.BTC.address}`] - if (signer.BTC_TESTNET) { - bitcoinAccounts.push( - `${BIP122_TESTNET_CAIP2}:${signer.BTC_TESTNET.address}`, - ) - } - namespaces.bip122 = { - chains: Array.from(BIP122_CHAINS.values()), - methods: Object.values(BIP122_SIGNING_METHODS), - events: [], - accounts: bitcoinAccounts, - } - } - - return namespaces -} +export { getEip155Methods, getSupportedNamespaces, isExternalWallet } export const setupListeners = ( getSigner: () => ISigner, @@ -170,6 +110,50 @@ export const setupListeners = ( return } + const supportedNamespaces = getSupportedNamespaces(signer) + const nonSupportedMethods: string[] = [] + + for (const [nsKey, nsConfig] of Object.entries( + proposal.params.requiredNamespaces ?? {}, + )) { + const ns = nsKey.includes(":") ? nsKey.split(":")[0] : nsKey + const supportedNs = supportedNamespaces[nsKey] ?? supportedNamespaces[ns] + if (!supportedNs) { + nonSupportedMethods.push(...(nsConfig.methods ?? [])) + } else { + for (const method of nsConfig.methods ?? []) { + if (!supportedNs.methods.includes(method)) { + nonSupportedMethods.push(method) + } + } + } + } + + if (nonSupportedMethods.length > 0) { + openWalletConnectDialog({ + type: "error", + errorText: `Methods not supported: ${nonSupportedMethods.join(", ")}`, + acceptBtnText: "Ok", + }) + captureEvent({ + type: AnalyticsEventTypes.WalletConnectSessionProposal, + origin: proposal.verifyContext.verified.origin, + chainIds, + methods, + response: "error", + }) + try { + await walletConnectInstance.rejectSession({ + id: proposal.id, + reason: getSdkError("UNSUPPORTED_METHODS"), + }) + } catch (error) { + console.error("Failed to reject session proposal", error) + } + closeWindowAfterWCRequestFullfilled() + return + } + const status = await openWalletConnectDialog({ type: "sessionProposal", sessionProposal: proposal, diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts index dc1cb49..a9326d2 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.test.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -35,6 +35,8 @@ import type { SessionTypes } from "@walletconnect/types" import type { ISigner } from "@/login/types" +import { BIP122_MAINNET_CAIP2, BIP122_TESTNET_CAIP2 } from "../data/BIP122Data" +import { SOLANA_DEVNET, SOLANA_MAINNET } from "../data/SolanaData" import { disconnectIncompatibleSessions, isSessionCompatibleWithSigner, @@ -164,4 +166,146 @@ describe("walletConnectStore - session compatibility and isolation", () => { // And retained sessions should only be Account B's expect(walletConnectState.sessions).toEqual([sessionForB]) }) + + it("rejects restored sessions with methods unsupported by the signer (M8)", async () => { + const externalSigner = { + EVM: { + address: evmAddressA, + submitTransaction: vi.fn(), + }, + } as unknown as ISigner + + const sessionWithRawSign = { + topic: "topic-raw-sign", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressA}`], + methods: ["eth_signTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + const sessionWithSupportedMethods = { + topic: "topic-supported-methods", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressA}`], + methods: ["eth_sendTransaction", "personal_sign"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + expect( + isSessionCompatibleWithSigner(sessionWithRawSign, externalSigner), + ).toBe(false) + expect( + isSessionCompatibleWithSigner( + sessionWithSupportedMethods, + externalSigner, + ), + ).toBe(true) + + const mockWallet = { + getActiveSessions: () => ({ + "topic-raw-sign": sessionWithRawSign, + "topic-supported-methods": sessionWithSupportedMethods, + }), + disconnectSession: mockDisconnectSession, + } as unknown as WalletKit + + await disconnectIncompatibleSessions(mockWallet, externalSigner) + + expect(mockDisconnectSession).toHaveBeenCalledWith({ + topic: "topic-raw-sign", + reason: expect.objectContaining({ + message: expect.stringContaining("User disconnected"), + }), + }) + expect(mockDisconnectSession).not.toHaveBeenCalledWith( + expect.objectContaining({ topic: "topic-supported-methods" }), + ) + }) + + it("prefers the matching-network address for Bitcoin and Solana testnets (M9)", () => { + const multiChainSigner = { + EVM: { address: evmAddressA }, + BTC: { address: "1MainnetBtcAddress" }, + BTC_TESTNET: { address: "tb1qTestnetBtcAddress" }, + SOLANA: { address: "MainnetSolAddress" }, + SOLANA_DEVNET: { address: "DevnetSolAddress" }, + } as unknown as ISigner + + const btcTestnetSession = { + topic: "topic-btc-testnet", + namespaces: { + bip122: { + accounts: [`${BIP122_TESTNET_CAIP2}:tb1qTestnetBtcAddress`], + methods: ["signPsbt"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + const btcMainnetSession = { + topic: "topic-btc-mainnet", + namespaces: { + bip122: { + accounts: [`${BIP122_MAINNET_CAIP2}:1MainnetBtcAddress`], + methods: ["signPsbt"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + const solDevnetSession = { + topic: "topic-sol-devnet", + namespaces: { + solana: { + accounts: [`${SOLANA_DEVNET}:DevnetSolAddress`], + methods: ["solana_signTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + const solMainnetSession = { + topic: "topic-sol-mainnet", + namespaces: { + solana: { + accounts: [`${SOLANA_MAINNET}:MainnetSolAddress`], + methods: ["solana_signTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + expect( + isSessionCompatibleWithSigner(btcTestnetSession, multiChainSigner), + ).toBe(true) + expect( + isSessionCompatibleWithSigner(btcMainnetSession, multiChainSigner), + ).toBe(true) + expect( + isSessionCompatibleWithSigner(solDevnetSession, multiChainSigner), + ).toBe(true) + expect( + isSessionCompatibleWithSigner(solMainnetSession, multiChainSigner), + ).toBe(true) + + const btcWrongTestnetSession = { + topic: "topic-btc-wrong-testnet", + namespaces: { + bip122: { + accounts: [`${BIP122_TESTNET_CAIP2}:wrongAddress`], + methods: ["signPsbt"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + expect( + isSessionCompatibleWithSigner(btcWrongTestnetSession, multiChainSigner), + ).toBe(false) + }) }) diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 44bcee1..412b929 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -9,7 +9,10 @@ import { config } from "@/config" import type { ISigner } from "@/login" import { getWCUri } from "@/utils/reownAppKitWrapper" +import { BIP122_TESTNET_CAIP2 } from "../data/BIP122Data" +import { SOLANA_DEVNET } from "../data/SolanaData" import { setupListeners } from "../requests/RequestHandler" +import { getSupportedNamespaces } from "../utils/supportedNamespaces" export const INITIALIZE_WALLET_CONNECT_KEY = "INITIALIZE_WALLET_CONNECT" @@ -32,7 +35,21 @@ export const isSessionCompatibleWithSigner = ( signer: ISigner, ): boolean => { const namespaces = session.namespaces || {} + const supported = getSupportedNamespaces(signer) + for (const [ns, data] of Object.entries(namespaces)) { + const supportedNs = supported[ns] + if (!supportedNs) { + return false + } + + const supportedMethods = new Set(supportedNs.methods) + for (const method of data.methods || []) { + if (!supportedMethods.has(method)) { + return false + } + } + if (ns === "eip155") { const evmAddress = signer.EVM?.address?.toLowerCase() if (!evmAddress) return false @@ -44,19 +61,29 @@ export const isSessionCompatibleWithSigner = ( } } else if (ns === "solana") { if (!signer.SOLANA && !signer.SOLANA_DEVNET) return false - const solAddress = signer.SOLANA?.address || signer.SOLANA_DEVNET?.address for (const account of data.accounts || []) { - const address = account.split(":").pop() - if (address && address !== solAddress) { + const parts = account.split(":") + const address = parts.pop() + const chainId = parts.join(":") + const expectedAddress = + chainId === SOLANA_DEVNET + ? (signer.SOLANA_DEVNET?.address ?? signer.SOLANA?.address) + : (signer.SOLANA?.address ?? signer.SOLANA_DEVNET?.address) + if (!expectedAddress || address !== expectedAddress) { return false } } } else if (ns === "bip122") { if (!signer.BTC && !signer.BTC_TESTNET) return false - const btcAddress = signer.BTC?.address || signer.BTC_TESTNET?.address for (const account of data.accounts || []) { - const address = account.split(":").pop() - if (address && address !== btcAddress) { + const parts = account.split(":") + const address = parts.pop() + const chainId = parts.join(":") + const expectedAddress = + chainId === BIP122_TESTNET_CAIP2 + ? (signer.BTC_TESTNET?.address ?? signer.BTC?.address) + : (signer.BTC?.address ?? signer.BTC_TESTNET?.address) + if (!expectedAddress || address !== expectedAddress) { return false } } diff --git a/src/sections/WalletConnect/utils/supportedNamespaces.ts b/src/sections/WalletConnect/utils/supportedNamespaces.ts new file mode 100644 index 0000000..76f1c4b --- /dev/null +++ b/src/sections/WalletConnect/utils/supportedNamespaces.ts @@ -0,0 +1,85 @@ +import { AVAILABLE_CHAINS } from "@/chain/chains" +import type { ISigner } from "@/login/types" + +import { + BIP122_CHAINS, + BIP122_MAINNET_CAIP2, + BIP122_SIGNING_METHODS, + BIP122_TESTNET_CAIP2, +} from "../data/BIP122Data" +import { EIP155_CHAINS, EIP155_SIGNING_METHODS } from "../data/EIP155Data" +import { SOLANA_CHAINS, SOLANA_SIGNING_METHODS } from "../data/SolanaData" + +// External wallets broadcast themselves, so raw-signing methods are not offered. +export const RAW_SIGNING_METHODS = new Set([ + EIP155_SIGNING_METHODS.ETH_SIGN_TRANSACTION, + EIP155_SIGNING_METHODS.ETH_SEND_RAW_TRANSACTION, +]) + +export const isExternalWallet = (signer: ISigner) => + !!signer.EVM?.submitTransaction + +export const getEip155Methods = (signer: ISigner) => + Object.values(EIP155_SIGNING_METHODS).filter( + (method) => !isExternalWallet(signer) || !RAW_SIGNING_METHODS.has(method), + ) + +export type SupportedNamespace = { + chains: string[] + methods: string[] + events: string[] + accounts: string[] +} + +export const getSupportedNamespaces = ( + signer: ISigner, +): Record => { + const namespaces: Record = { + eip155: { + chains: EIP155_CHAINS, + methods: getEip155Methods(signer), + events: ["accountsChanged", "chainChanged"], + accounts: EIP155_CHAINS.map( + (chain) => `${chain}:${signer.EVM?.address ?? ""}`, + ), + }, + } + + const solanaChains: string[] = [] + const solanaAccounts: string[] = [] + for (const [internalChainId, walletConnectChainId] of SOLANA_CHAINS) { + const chain = AVAILABLE_CHAINS.get(internalChainId) + const address = chain && signer[chain.family]?.address + if (!address) continue + solanaChains.push(walletConnectChainId) + solanaAccounts.push(`${walletConnectChainId}:${address}`) + } + if (solanaAccounts.length > 0) { + namespaces.solana = { + chains: solanaChains, + methods: Object.values(SOLANA_SIGNING_METHODS), + events: [], + accounts: solanaAccounts, + } + } + + if (signer.BTC || signer.BTC_TESTNET) { + const bitcoinAccounts: string[] = [] + if (signer.BTC) { + bitcoinAccounts.push(`${BIP122_MAINNET_CAIP2}:${signer.BTC.address}`) + } + if (signer.BTC_TESTNET) { + bitcoinAccounts.push( + `${BIP122_TESTNET_CAIP2}:${signer.BTC_TESTNET.address}`, + ) + } + namespaces.bip122 = { + chains: Array.from(BIP122_CHAINS.values()), + methods: Object.values(BIP122_SIGNING_METHODS), + events: [], + accounts: bitcoinAccounts, + } + } + + return namespaces +} diff --git a/src/translations/translations/da.ts b/src/translations/translations/da.ts index fb15986..dc9981e 100644 --- a/src/translations/translations/da.ts +++ b/src/translations/translations/da.ts @@ -37,6 +37,8 @@ const da: TypedTranslations = { existingWalletFailed: "Kunne ikke forbinde din wallet. Prøv igen", existingWalletAccountChanged: "Din wallet-konto er ændret. Forbind din wallet igen", + existingWalletDisconnected: + "Din wallet blev afbrudt. Forbind venligst din wallet igen", existingWalletRestoreFailed: "Vi kunne ikke genoprette forbindelsen til din wallet. Forbind din wallet igen", evmOnlyNotice: diff --git a/src/translations/translations/en.ts b/src/translations/translations/en.ts index 7dba58c..7cc0e7a 100644 --- a/src/translations/translations/en.ts +++ b/src/translations/translations/en.ts @@ -38,6 +38,8 @@ const en: TypedTranslations = { existingWalletFailed: "Could not connect your wallet. Please try again", existingWalletAccountChanged: "Your wallet account changed. Please connect your wallet again", + existingWalletDisconnected: + "Your wallet disconnected. Please connect your wallet again", existingWalletRestoreFailed: "We couldn't reconnect your wallet. Please connect your wallet again", evmOnlyNotice: diff --git a/src/translations/types.ts b/src/translations/types.ts index 00da2b3..6c7b1c9 100644 --- a/src/translations/types.ts +++ b/src/translations/types.ts @@ -10,6 +10,7 @@ export type LoginTranslations = { existingWalletCancelled: string existingWalletFailed: string existingWalletAccountChanged: string + existingWalletDisconnected: string existingWalletRestoreFailed: string evmOnlyNotice: string legalText: { From 25313ad990eb1b5dca53e2bc2def523e7a96dcf5 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Fri, 6 Nov 2026 20:21:53 +0100 Subject: [PATCH 16/21] fix: guard late balance and transaction writes across sessions - Guard refreshAmounts against stale callbacks adopting inactive session addresses (B7) - Add session token guard to addPropagatingTx to prevent cross-account history leak (B8) - Defer exposing activeSigner in WalletConnect until incompatible sessions are pruned (B9) - Guard inFlightRequests counter decrement in amountsStore against stale requests (B10) - Normalize chain-scoped namespace keys in WalletConnect session compatibility (B11) - Clear cached amounts on account change in amountsStore (B12) - Validate EVM account chain against supported chains in restored sessions (B13) --- .../stores/amountsStore.test.ts | 127 +++++++++++++++++- .../useTokenBalances/stores/amountsStore.ts | 19 ++- .../GoodDollar/components/Claim/ClaimView.tsx | 30 +++-- src/sections/Send/hooks/evmTransaction.ts | 30 +++-- .../components/RouteSlider/RouteSlider.tsx | 3 +- .../store/walletConnectStore.test.ts | 73 ++++++++++ .../WalletConnect/store/walletConnectStore.ts | 25 ++-- src/stores/transactionHistoryStore.test.ts | 53 ++++++++ src/stores/transactionHistoryStore.ts | 11 +- 9 files changed, 328 insertions(+), 43 deletions(-) diff --git a/src/hooks/useTokenBalances/stores/amountsStore.test.ts b/src/hooks/useTokenBalances/stores/amountsStore.test.ts index bb80974..7d298f0 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.test.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.test.ts @@ -34,6 +34,7 @@ vi.mock("./focusStore", () => ({ import { FixedNumber } from "ethers" import type { Addresses, ISigner } from "@/login" +import { sessionState } from "@/login/context/SessionContext/storage" import type { Tokens } from "@/tokens/types" import { NormalizedAddressMap } from "../NormalizedAddressMap" @@ -55,8 +56,10 @@ const makeTokens = (chainIds: number[]): Tokens => { } describe("amountsStore - late async response guard", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetAllMocks() + sessionState.addresses = undefined + await refreshAmounts(undefined, undefined, "all") amountsStore.amounts.clear() amountsStore.isLoading = true amountsStore.isValidating = false @@ -200,4 +203,126 @@ describe("amountsStore - late async response guard", () => { expect(amountsStore.amounts.size).toBe(0) expect(amountsStore.isLoading).toBe(true) }) + + it("ignores late single-chain mutate callbacks from Account A after switching to Account B (B7)", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + const tokens = makeTokens([42220]) + const addressesA = makeAddresses(accountA) + const addressesB = makeAddresses(accountB) + + const amountsA = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("100.0"), + ], + ]) + const amountsB = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("200.0"), + ], + ]) + + // Account A is active initially + sessionState.addresses = addressesA + await new Promise((r) => setTimeout(r, 10)) + mockGetAmounts.mockResolvedValueOnce(amountsA) + await refreshAmounts(tokens, addressesA, "all") + expect(amountsStore.amounts.get(42220)).toBe(amountsA) + + // User switches to Account B + sessionState.addresses = addressesB + await new Promise((r) => setTimeout(r, 10)) + mockGetAmounts.mockResolvedValueOnce(amountsB) + await refreshAmounts(tokens, addressesB, "all") + expect(amountsStore.amounts.get(42220)).toBe(amountsB) + + // Account A's late mutate callback fires for chain 42220 with stale addressesA + await refreshAmounts(tokens, addressesA, 42220) + + // Account B's balances must NOT be overwritten by Account A's late callback + expect(amountsStore.amounts.get(42220)).toBe(amountsB) + expect(amountsStore.amounts.get(42220)).not.toBe(amountsA) + + // Cleanup sessionState for subsequent tests + sessionState.addresses = undefined + }) + + it("does not prematurely clear loading state when stale in-flight request resolves (B10)", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + const tokens = makeTokens([42220]) + const addressesA = makeAddresses(accountA) + const addressesB = makeAddresses(accountB) + + let resolveA!: (val: unknown) => void + const delayedA = new Promise((resolve) => { + resolveA = resolve + }) + let resolveB!: (val: unknown) => void + const delayedB = new Promise((resolve) => { + resolveB = resolve + }) + + // Account A starts refresh and hangs + mockGetAmounts.mockImplementationOnce(() => delayedA) + const refreshA = refreshAmounts(tokens, addressesA, "all") + + // Account B logs in while A is in flight; B's refresh also starts and hangs + mockGetAmounts.mockImplementationOnce(() => delayedB) + const refreshB = refreshAmounts(tokens, addressesB, "all") + + expect(amountsStore.isValidating).toBe(true) + + // Account A's stale fetch resolves + resolveA(new NormalizedAddressMap("EVM")) + await refreshA + + // Account B is still in flight, so isValidating must remain true + expect(amountsStore.isValidating).toBe(true) + + // Account B resolves + resolveB(new NormalizedAddressMap("EVM")) + await refreshB + + expect(amountsStore.isValidating).toBe(false) + }) + + it("clears cached amounts on account change (B12)", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + const tokens = makeTokens([42220]) + const addressesA = makeAddresses(accountA) + const addressesB = makeAddresses(accountB) + + const amountsA = new NormalizedAddressMap("EVM", [ + [ + "0x1111111111111111111111111111111111111111", + FixedNumber.fromString("100.0"), + ], + ]) + + mockGetAmounts.mockResolvedValueOnce(amountsA) + await refreshAmounts(tokens, addressesA, "all") + expect(amountsStore.amounts.get(42220)).toBe(amountsA) + + // When switching to Account B, cached amounts must be cleared immediately + let resolveB!: (val: unknown) => void + const delayedB = new Promise((resolve) => { + resolveB = resolve + }) + mockGetAmounts.mockImplementationOnce(() => delayedB) + + const refreshB = refreshAmounts(tokens, addressesB, "all") + + // Before B resolves, old amounts must already be cleared + expect(amountsStore.amounts.get(42220)).toBeUndefined() + + resolveB(new NormalizedAddressMap("EVM")) + await refreshB + }) }) diff --git a/src/hooks/useTokenBalances/stores/amountsStore.ts b/src/hooks/useTokenBalances/stores/amountsStore.ts index c5b98ad..1429555 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.ts @@ -62,15 +62,22 @@ export const refreshAmounts = async ( chainVersions.clear() lastAddresses = undefined amountsStore.amounts.clear() + inFlightRequests = 0 amountsStore.isLoading = true amountsStore.isValidating = false return } + // Drop stale callbacks closing over a previous account's addresses. + if (sessionState.addresses && addresses !== sessionState.addresses) { + return + } + if (lastAddresses !== addresses) { sessionEpoch++ chainVersions.clear() lastAddresses = addresses + amountsStore.amounts.clear() inFlightRequests = 0 } @@ -122,11 +129,13 @@ export const refreshAmounts = async ( } } } finally { - inFlightRequests-- - if (inFlightRequests <= 0) { - inFlightRequests = 0 - amountsStore.isLoading = false - amountsStore.isValidating = false + if (currentEpoch === sessionEpoch) { + inFlightRequests-- + if (inFlightRequests <= 0) { + inFlightRequests = 0 + amountsStore.isLoading = false + amountsStore.isValidating = false + } } } } diff --git a/src/sections/GoodDollar/components/Claim/ClaimView.tsx b/src/sections/GoodDollar/components/Claim/ClaimView.tsx index f9825f4..fdf5e70 100644 --- a/src/sections/GoodDollar/components/Claim/ClaimView.tsx +++ b/src/sections/GoodDollar/components/Claim/ClaimView.tsx @@ -412,6 +412,7 @@ function ClaimViewContent() { .filter((claim) => claim.sufficientGas) .forEach(async (claim) => { const { amount, chainId, type } = claim + const accountToken = activityHistoryStore.getCurrentAccountToken() const toastId = createToast({ message: @@ -467,19 +468,22 @@ function ClaimViewContent() { ? formatUnits(gasUsed, nativeToken.decimals) : undefined - addPropagatingTx({ - hash: txResponse.hash, - chainId: chainId, - method: TYPE_TO_METHOD_MAP[type], - //The tx-response received, is for the wallet -> contract invocation : - from: txResponse.to ?? "0x0", - to: txResponse.from ?? "0x0", - tokenAddress, - amount: amount.toString(), - timestamp: new Date().getTime() / 1000, - txDirection: TxDirection.INCOMING, - gas, - }) + addPropagatingTx( + { + hash: txResponse.hash, + chainId: chainId, + method: TYPE_TO_METHOD_MAP[type], + //The tx-response received, is for the wallet -> contract invocation : + from: txResponse.to ?? "0x0", + to: txResponse.from ?? "0x0", + tokenAddress, + amount: amount.toString(), + timestamp: new Date().getTime() / 1000, + txDirection: TxDirection.INCOMING, + gas, + }, + accountToken, + ) switch (type) { case "ubi": diff --git a/src/sections/Send/hooks/evmTransaction.ts b/src/sections/Send/hooks/evmTransaction.ts index 25598b4..97b1860 100644 --- a/src/sections/Send/hooks/evmTransaction.ts +++ b/src/sections/Send/hooks/evmTransaction.ts @@ -283,25 +283,29 @@ export const useSubmitTransaction = () => { } const { address, chainId } = selectedToken + const accountToken = activityHistoryStore.getCurrentAccountToken() return sendTx( signer.EVM, gasLimit, broadcastRequest, broadcastOptions, ).then(async ({ hash, trxMinedPromise }) => { - addPropagatingTx({ - hash, - chainId: chainId, - method: "Sent", - txDirection: TxDirection.OUTGOING, - from: signer.EVM.address, - to: broadcastRequest.to - ? broadcastRequest.to.toString().toLowerCase() - : "0x0", - tokenAddress: address, - amount: amountInCrypto, - timestamp: new Date().getTime() / 1000, - }) + addPropagatingTx( + { + hash, + chainId: chainId, + method: "Sent", + txDirection: TxDirection.OUTGOING, + from: signer.EVM.address, + to: broadcastRequest.to + ? broadcastRequest.to.toString().toLowerCase() + : "0x0", + tokenAddress: address, + amount: amountInCrypto, + timestamp: new Date().getTime() / 1000, + }, + accountToken, + ) return { hash, trxMinedPromise } }) }, [ diff --git a/src/sections/Swap/components/RouteSlider/RouteSlider.tsx b/src/sections/Swap/components/RouteSlider/RouteSlider.tsx index 54b181f..94edf13 100644 --- a/src/sections/Swap/components/RouteSlider/RouteSlider.tsx +++ b/src/sections/Swap/components/RouteSlider/RouteSlider.tsx @@ -421,6 +421,7 @@ export default function RouteSlider( }, }) + const accountToken = activityHistoryStore.getCurrentAccountToken() const routeExtended = await routeExtendedPromise const allStepsSucceeded = routeExtended.steps.every( (step) => step.execution?.status === "DONE", @@ -429,7 +430,7 @@ export default function RouteSlider( if (allStepsSucceeded) { const propagatingTxs = getPropagatingTxs(routeExtended) for (const tx of propagatingTxs) { - addPropagatingTx(tx) + addPropagatingTx(tx, accountToken) } captureEvent({ diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts index a9326d2..b2d9c9f 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.test.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -39,6 +39,7 @@ import { BIP122_MAINNET_CAIP2, BIP122_TESTNET_CAIP2 } from "../data/BIP122Data" import { SOLANA_DEVNET, SOLANA_MAINNET } from "../data/SolanaData" import { disconnectIncompatibleSessions, + getActiveSigner, isSessionCompatibleWithSigner, walletConnectState, } from "./walletConnectStore" @@ -308,4 +309,76 @@ describe("walletConnectStore - session compatibility and isolation", () => { isSessionCompatibleWithSigner(btcWrongTestnetSession, multiChainSigner), ).toBe(false) }) + + it("does not expose the new signer before incompatible sessions are pruned (B9)", async () => { + let resolvePruning!: () => void + const pruningPromise = new Promise((resolve) => { + resolvePruning = resolve + }) + + const mockWallet = { + getActiveSessions: () => ({ + "topic-a": sessionForA, + }), + disconnectSession: vi.fn(async () => { + await pruningPromise + }), + pair: vi.fn(), + } + mockWalletKitInit.mockResolvedValueOnce(mockWallet) + + // Start initialization for signerB while signerA sessions are present + const initPromise = walletConnectState.initializeWalletConnect(signerB) + + // Yield to let initialization proceed up to the pruning step + await new Promise((r) => setTimeout(r, 0)) + + // During pruning, getActiveSigner must NOT expose signerB and must throw + expect(() => getActiveSigner()).toThrow("No active wallet session") + + // Once pruning completes, getActiveSigner returns signerB + resolvePruning() + await initPromise + + expect(getActiveSigner()).toBe(signerB) + }) + + it("normalizes chain-scoped namespace keys in restored session compatibility (B11)", () => { + // Session uses chain-scoped namespace key "eip155:1" + const chainScopedSession = { + topic: "topic-scoped", + namespaces: { + "eip155:1": { + accounts: [`eip155:1:${evmAddressA}`], + methods: ["eth_sendTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + expect(isSessionCompatibleWithSigner(chainScopedSession, signerA)).toBe( + true, + ) + expect(isSessionCompatibleWithSigner(chainScopedSession, signerB)).toBe( + false, + ) + }) + + it("rejects restored EVM sessions requesting unsupported chains (B13)", () => { + // Session uses an unsupported EVM chain ID 999 + const unsupportedChainSession = { + topic: "topic-unsupported-chain", + namespaces: { + eip155: { + accounts: [`eip155:999:${evmAddressA}`], + methods: ["eth_sendTransaction"], + events: [], + }, + }, + } as unknown as SessionTypes.Struct + + expect( + isSessionCompatibleWithSigner(unsupportedChainSession, signerA), + ).toBe(false) + }) }) diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 412b929..3220a19 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -25,7 +25,7 @@ type WalletConnectState = { // Listeners resolve the signer per request so a reconnected session is used. let activeSigner: ISigner | undefined -const getActiveSigner = () => { +export const getActiveSigner = () => { if (!activeSigner) throw new Error("No active wallet session") return activeSigner } @@ -37,8 +37,9 @@ export const isSessionCompatibleWithSigner = ( const namespaces = session.namespaces || {} const supported = getSupportedNamespaces(signer) - for (const [ns, data] of Object.entries(namespaces)) { - const supportedNs = supported[ns] + for (const [nsKey, data] of Object.entries(namespaces)) { + const baseNs = nsKey.includes(":") ? nsKey.split(":")[0] : nsKey + const supportedNs = supported[nsKey] ?? supported[baseNs] if (!supportedNs) { return false } @@ -50,16 +51,22 @@ export const isSessionCompatibleWithSigner = ( } } - if (ns === "eip155") { + if (baseNs === "eip155") { const evmAddress = signer.EVM?.address?.toLowerCase() if (!evmAddress) return false + const supportedChains = new Set(supportedNs.chains) for (const account of data.accounts || []) { - const address = account.split(":").pop()?.toLowerCase() + const parts = account.split(":") + const address = parts.pop()?.toLowerCase() + const chainId = parts.join(":") if (address && address !== evmAddress) { return false } + if (chainId && !supportedChains.has(chainId)) { + return false + } } - } else if (ns === "solana") { + } else if (baseNs === "solana") { if (!signer.SOLANA && !signer.SOLANA_DEVNET) return false for (const account of data.accounts || []) { const parts = account.split(":") @@ -73,7 +80,7 @@ export const isSessionCompatibleWithSigner = ( return false } } - } else if (ns === "bip122") { + } else if (baseNs === "bip122") { if (!signer.BTC && !signer.BTC_TESTNET) return false for (const account of data.accounts || []) { const parts = account.split(":") @@ -118,7 +125,7 @@ const initializeWalletConnect = async (signer: ISigner) => { if (!config.walletConnectEnabled) { return } - activeSigner = signer + activeSigner = undefined let wallet: WalletKit | undefined if (!walletConnectState.walletConnectInstance) { walletConnectState.walletConnectInstance = "initializing" @@ -163,6 +170,7 @@ const initializeWalletConnect = async (signer: ISigner) => { if (wallet) { await disconnectIncompatibleSessions(wallet, signer) } + activeSigner = signer refreshSessions() } @@ -185,6 +193,7 @@ export const walletConnectState = proxy({ }) export const disconnectAllWalletConnectSessions = async () => { + activeSigner = undefined const wallet = walletConnectState.walletConnectInstance if (!wallet || wallet === "initializing") return await Promise.allSettled( diff --git a/src/stores/transactionHistoryStore.test.ts b/src/stores/transactionHistoryStore.test.ts index 3e69cf1..63e9c7c 100644 --- a/src/stores/transactionHistoryStore.test.ts +++ b/src/stores/transactionHistoryStore.test.ts @@ -41,6 +41,7 @@ const mockTx = (hash: string, timestamp: number): Tx => ({ describe("transactionHistoryStore", () => { beforeEach(() => { vi.resetAllMocks() + mockGetTransactions.mockResolvedValue([]) clearActivityHistory() }) @@ -123,4 +124,56 @@ describe("transactionHistoryStore", () => { expect(currentTxs).toEqual([txB]) expect(currentTxs?.some((tx) => tx.hash === "0xtxA")).toBe(false) }) + + it("guards addPropagatingTx against late writes after account switch (B8)", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const accountB = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + // Account A is active + await activityHistoryStore.refreshHistory(makeAddresses(accountA)) + const tokenA = activityHistoryStore.getCurrentAccountToken() + + // Switch active account to Account B + const txB = mockTx("0xtxB", 2000) + mockGetTransactions.mockResolvedValueOnce([txB]) + await activityHistoryStore.refreshHistory(makeAddresses(accountB)) + + // Account A's claim/operation finishes late and attempts to insert propagating tx + const claimTxA = mockTx("0xclaimA", 1500) + activityHistoryStore.addPropagatingTx(claimTxA, tokenA) + + // Account A's transaction must NOT be added to Account B's history + const historyB = activityHistoryStore.perChain.get(42220) + expect(historyB).toEqual([txB]) + expect(historyB?.some((tx) => tx.hash === "0xclaimA")).toBe(false) + }) + + it("allows addPropagatingTx when account has not changed (same session)", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + // Account A is active + await activityHistoryStore.refreshHistory(makeAddresses(accountA)) + const tokenA = activityHistoryStore.getCurrentAccountToken() + + // Same session: propagating tx is added + const claimTxA = mockTx("0xclaimA", 1500) + activityHistoryStore.addPropagatingTx(claimTxA, tokenA) + + const historyA = activityHistoryStore.perChain.get(42220) + expect(historyA?.some((tx) => tx.hash === "0xclaimA")).toBe(true) + }) + + it("records replacement hash in propagating transaction (R1)", async () => { + const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + await activityHistoryStore.refreshHistory(makeAddresses(account)) + const token = activityHistoryStore.getCurrentAccountToken() + + // Propagating tx with replacement hash from speed-up + const replacementTx = mockTx("0xreplacementhash", 1600) + activityHistoryStore.addPropagatingTx(replacementTx, token) + + const history = activityHistoryStore.perChain.get(42220) + expect(history?.some((tx) => tx.hash === "0xreplacementhash")).toBe(true) + }) }) diff --git a/src/stores/transactionHistoryStore.ts b/src/stores/transactionHistoryStore.ts index e63566a..5daf09e 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -16,6 +16,8 @@ let currentAccountToken = 0 const internalTxs = new Map() let lastRefreshAllTimestamp = 0 +export const getCurrentAccountToken = () => currentAccountToken + /** * Clears all cached history and resets the refresh timer. * Must be called whenever the active account changes so stale data from a @@ -33,7 +35,8 @@ type ActivityHistoryStore = { perChain: Map isLoading: boolean refreshHistory: (addresses: Addresses | undefined) => Promise - addPropagatingTx: (tx: Tx) => void + addPropagatingTx: (tx: Tx, token?: number) => void + getCurrentAccountToken: () => number } const combineTxs = (a: Tx[], b: Tx[]) => { @@ -44,7 +47,10 @@ const combineTxs = (a: Tx[], b: Tx[]) => { return combined.sort((tx1, tx2) => tx2.timestamp - tx1.timestamp) } -const addPropagatingTx = (tx: Tx) => { +const addPropagatingTx = (tx: Tx, token?: number) => { + if (token !== undefined && token !== currentAccountToken) { + return + } const internalTxsForChain = internalTxs.get(tx.chainId) || [] const combinedInternalTxsForChain = combineTxs(internalTxsForChain, [tx]) //If same length, no new internal txs added @@ -132,4 +138,5 @@ export const activityHistoryStore = proxy({ isLoading: false, refreshHistory: refreshHistory, addPropagatingTx: addPropagatingTx, + getCurrentAccountToken: getCurrentAccountToken, }) From 1ed4a0d34e5d886c3f47dd1b76d28254e5d06994 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Fri, 6 Nov 2026 21:07:35 +0100 Subject: [PATCH 17/21] fix: harden walletconnect lifecycle, cross-session tokens, and activity reconciliation - Guard WalletKit initialization and signer publication with shared init promise and generation counter - Validate session topic compatibility before opening request dialog and before signing - Extract chains from scoped namespace keys in session proposals - Exclude unsupported eth_sendRawTransaction from EIP155 supported methods for all signers - Initialize activity account token on session start to avoid invalidating same-session operations - Capture account token before awaiting SDK import in swap route execution - Require session tokens for all asynchronous transaction insertions across all chain dialogs - Reconcile propagating transactions with mined outcomes (hash replacement on speed-up, removal on failure) - Ignore balance refresh callbacks from ended or non-matching sessions --- .../stores/amountsStore.test.ts | 25 +++ .../useTokenBalances/stores/amountsStore.ts | 8 +- .../context/SessionContext/storage.test.ts | 1 + src/login/context/SessionContext/storage.ts | 6 +- .../SendConfirmDialog/BTCDialogDetails.tsx | 26 +-- .../SendConfirmDialog/SVMDialogDetails.tsx | 26 +-- .../SendConfirmDialog/XRPDialogDetails.tsx | 26 +-- src/sections/Send/hooks/evmTransaction.ts | 19 ++ .../components/RouteSlider/RouteSlider.tsx | 9 +- .../requests/RequestHandler.test.ts | 166 +++++++++++++++- .../WalletConnect/requests/RequestHandler.ts | 50 +++-- .../store/walletConnectStore.test.ts | 83 ++++++++ .../WalletConnect/store/walletConnectStore.ts | 184 +++++++----------- .../WalletConnect/utils/sessionValidation.ts | 78 ++++++++ .../utils/supportedNamespaces.ts | 14 +- src/stores/transactionHistoryStore.test.ts | 80 +++++++- src/stores/transactionHistoryStore.ts | 77 +++++++- 17 files changed, 708 insertions(+), 170 deletions(-) create mode 100644 src/sections/WalletConnect/utils/sessionValidation.ts diff --git a/src/hooks/useTokenBalances/stores/amountsStore.test.ts b/src/hooks/useTokenBalances/stores/amountsStore.test.ts index 7d298f0..1af2f78 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.test.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.test.ts @@ -92,10 +92,12 @@ describe("amountsStore - late async response guard", () => { }) // Account A triggers refresh; its provider call hangs + sessionState.addresses = addressesA mockGetAmounts.mockImplementationOnce(() => delayedPromiseA) const refreshPromiseA = refreshAmounts(tokens, addressesA, "all") // Account B logs in and triggers refresh, resolving immediately + sessionState.addresses = addressesB mockGetAmounts.mockResolvedValueOnce(amountsB) await refreshAmounts(tokens, addressesB, "all") @@ -139,9 +141,11 @@ describe("amountsStore - late async response guard", () => { resolveA = resolve }) + sessionState.addresses = addressesA mockGetAmounts.mockImplementationOnce(() => delayedPromiseA) const refreshPromiseA = refreshAmounts(tokens, addressesA, 42220) + sessionState.addresses = addressesB mockGetAmounts.mockResolvedValueOnce(amountsB) await refreshAmounts(tokens, addressesB, 42220) @@ -157,6 +161,7 @@ describe("amountsStore - late async response guard", () => { it("allows concurrent refreshes on different chains to both land without dropping each other", async () => { const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" const addresses = makeAddresses(account) + sessionState.addresses = addresses const tokens = makeTokens([42220, 122]) const amountsCelo = new NormalizedAddressMap("EVM", [ @@ -268,10 +273,12 @@ describe("amountsStore - late async response guard", () => { }) // Account A starts refresh and hangs + sessionState.addresses = addressesA mockGetAmounts.mockImplementationOnce(() => delayedA) const refreshA = refreshAmounts(tokens, addressesA, "all") // Account B logs in while A is in flight; B's refresh also starts and hangs + sessionState.addresses = addressesB mockGetAmounts.mockImplementationOnce(() => delayedB) const refreshB = refreshAmounts(tokens, addressesB, "all") @@ -306,11 +313,13 @@ describe("amountsStore - late async response guard", () => { ], ]) + sessionState.addresses = addressesA mockGetAmounts.mockResolvedValueOnce(amountsA) await refreshAmounts(tokens, addressesA, "all") expect(amountsStore.amounts.get(42220)).toBe(amountsA) // When switching to Account B, cached amounts must be cleared immediately + sessionState.addresses = addressesB let resolveB!: (val: unknown) => void const delayedB = new Promise((resolve) => { resolveB = resolve @@ -325,4 +334,20 @@ describe("amountsStore - late async response guard", () => { resolveB(new NormalizedAddressMap("EVM")) await refreshB }) + + it("ignores refresh callbacks from ended sessions (when session has ended / logged out)", async () => { + const accountA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const tokens = makeTokens([42220]) + const addressesA = makeAddresses(accountA) + + // User is logged out (ended session) + sessionState.addresses = undefined + + // A late claim or mutation callback fires with addressesA + await refreshAmounts(tokens, addressesA, 42220) + + // No RPC call should be made and no amounts stored + expect(mockGetAmounts).not.toHaveBeenCalled() + expect(amountsStore.amounts.get(42220)).toBeUndefined() + }) }) diff --git a/src/hooks/useTokenBalances/stores/amountsStore.ts b/src/hooks/useTokenBalances/stores/amountsStore.ts index 1429555..a34bc26 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.ts @@ -68,8 +68,8 @@ export const refreshAmounts = async ( return } - // Drop stale callbacks closing over a previous account's addresses. - if (sessionState.addresses && addresses !== sessionState.addresses) { + // Drop stale callbacks closing over a previous account's addresses or an ended session. + if (!sessionState.addresses || addresses !== sessionState.addresses) { return } @@ -145,7 +145,9 @@ subscribeKey(tokensStore, "tokens", (tokens) => { }) subscribeKey(sessionState, "addresses", (addresses) => { - refreshAmounts(tokensStore.tokens, addresses, "all") + if (tokensStore.tokens || !addresses) { + refreshAmounts(tokensStore.tokens, addresses, "all") + } }) setInterval(() => { diff --git a/src/login/context/SessionContext/storage.test.ts b/src/login/context/SessionContext/storage.test.ts index 86fb057..d0da53b 100644 --- a/src/login/context/SessionContext/storage.test.ts +++ b/src/login/context/SessionContext/storage.test.ts @@ -21,6 +21,7 @@ vi.mock("@/sections/WalletConnect/store/walletConnectStore", () => ({ vi.mock("@/stores/transactionHistoryStore", () => ({ clearActivityHistory: vi.fn(), + initActivityAccount: vi.fn(), })) import { sessionState, setSession } from "./storage" diff --git a/src/login/context/SessionContext/storage.ts b/src/login/context/SessionContext/storage.ts index 2a753ac..1ff5a32 100644 --- a/src/login/context/SessionContext/storage.ts +++ b/src/login/context/SessionContext/storage.ts @@ -9,7 +9,10 @@ import { } from "@/login/reown" import type { Addresses, ISigner, ISignerSession } from "@/login/types" import { resetWalletConnectDialogs } from "@/sections/WalletConnect/store/walletConnectDialogStore" -import { clearActivityHistory } from "@/stores/transactionHistoryStore" +import { + clearActivityHistory, + initActivityAccount, +} from "@/stores/transactionHistoryStore" const SIGNER_SESSION_KEY = "SIGNER_SESSION" @@ -98,6 +101,7 @@ export const setSession = (session: ISignerSession | null) => { } sessionState.addresses = addressMap sessionState.reconnectReason = undefined + initActivityAccount(session.signer.EVM?.address) } else { sessionState.addresses = undefined // Clear cached history when a session ends so the next account starts fresh. diff --git a/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx b/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx index fe9c4fb..d30f905 100644 --- a/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx +++ b/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx @@ -92,22 +92,26 @@ export const BTCDialogDetails = ({ ) setTotalFee(bytesRequired * feePerByteSats) + const accountToken = activityHistoryStore.getCurrentAccountToken() const signAndBroadcast = () => signer .signPsbt(psbt) .then(chainProvider.broadcastTransaction) .then((txId) => - addPropagatingTx({ - hash: txId, - chainId: selectedToken.chainId, - method: "Sent", - from: signer.address, - to: toAddress, - tokenAddress: selectedToken.address, - amount: amountInCrypto, - timestamp: new Date().getTime() / 1000, - txDirection: TxDirection.OUTGOING, - }), + addPropagatingTx( + { + hash: txId, + chainId: selectedToken.chainId, + method: "Sent", + from: signer.address, + to: toAddress, + tokenAddress: selectedToken.address, + amount: amountInCrypto, + timestamp: new Date().getTime() / 1000, + txDirection: TxDirection.OUTGOING, + }, + accountToken, + ), ) setSubmitTransaction(signAndBroadcast) setIsValid(true) diff --git a/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx b/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx index 8c3ab1f..e506fc9 100644 --- a/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx +++ b/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx @@ -112,6 +112,7 @@ export const SVMDialogDetails = ({ const computeUnits = await chainProvider.getComputeUnits(tx) setComputeUnits(computeUnits) + const accountToken = activityHistoryStore.getCurrentAccountToken() const signAndBroadcastTransaction = async () => { const [signatures] = await signer.signTransactions([tx]) const signedTx = Object.freeze({ @@ -122,17 +123,20 @@ export const SVMDialogDetails = ({ const signature = await chainProvider.broadcastTransaction(signedTx) - addPropagatingTx({ - hash: signature, - chainId: selectedToken.chainId, - method: "Sent", - from: signer.address, - to: toAddress, - tokenAddress: selectedToken.address, - amount: amountInCrypto, - timestamp: Date.now() / 1000, - txDirection: TxDirection.OUTGOING, - }) + addPropagatingTx( + { + hash: signature, + chainId: selectedToken.chainId, + method: "Sent", + from: signer.address, + to: toAddress, + tokenAddress: selectedToken.address, + amount: amountInCrypto, + timestamp: Date.now() / 1000, + txDirection: TxDirection.OUTGOING, + }, + accountToken, + ) } setIsValid(true) setSubmitTransaction(signAndBroadcastTransaction) diff --git a/src/sections/Send/components/SendConfirmDialog/XRPDialogDetails.tsx b/src/sections/Send/components/SendConfirmDialog/XRPDialogDetails.tsx index 6d5784f..3c0096d 100644 --- a/src/sections/Send/components/SendConfirmDialog/XRPDialogDetails.tsx +++ b/src/sections/Send/components/SendConfirmDialog/XRPDialogDetails.tsx @@ -100,6 +100,7 @@ export const XRPDialogDetails = ({ return } + const accountToken = activityHistoryStore.getCurrentAccountToken() const submitTransactionCallback = async () => { const signed = await signer.sign(trx) const res = await chainProvider.broadcastTransaction(signed) @@ -115,17 +116,20 @@ export const XRPDialogDetails = ({ ) } - addPropagatingTx({ - hash: signed.hash, - chainId: selectedToken.chainId, - method: "Sent", - from: signer.address, - to: toAddress, - tokenAddress: selectedToken.address, - amount: amountInCrypto, - timestamp: Date.now() / 1000, - txDirection: TxDirection.OUTGOING, - }) + addPropagatingTx( + { + hash: signed.hash, + chainId: selectedToken.chainId, + method: "Sent", + from: signer.address, + to: toAddress, + tokenAddress: selectedToken.address, + amount: amountInCrypto, + timestamp: Date.now() / 1000, + txDirection: TxDirection.OUTGOING, + }, + accountToken, + ) } setIsValid(true) diff --git a/src/sections/Send/hooks/evmTransaction.ts b/src/sections/Send/hooks/evmTransaction.ts index 97b1860..2047bfc 100644 --- a/src/sections/Send/hooks/evmTransaction.ts +++ b/src/sections/Send/hooks/evmTransaction.ts @@ -306,6 +306,25 @@ export const useSubmitTransaction = () => { }, accountToken, ) + + trxMinedPromise + .then((receipt) => { + if ( + receipt?.hash && + receipt.hash.toLowerCase() !== hash.toLowerCase() + ) { + activityHistoryStore.replacePropagatingTx( + chainId, + hash, + receipt.hash, + accountToken, + ) + } + }) + .catch(() => { + activityHistoryStore.removePropagatingTx(chainId, hash, accountToken) + }) + return { hash, trxMinedPromise } }) }, [ diff --git a/src/sections/Swap/components/RouteSlider/RouteSlider.tsx b/src/sections/Swap/components/RouteSlider/RouteSlider.tsx index 94edf13..795dfa2 100644 --- a/src/sections/Swap/components/RouteSlider/RouteSlider.tsx +++ b/src/sections/Swap/components/RouteSlider/RouteSlider.tsx @@ -305,6 +305,7 @@ export default function RouteSlider( ]) const executeSelectedRoute = async (selectedRoute: Route) => { + const initialAccountToken = activityHistoryStore.getCurrentAccountToken() const formattedAmount = formatTokenAmount( formatUnits(selectedRoute.fromAmount, selectedRoute.fromToken.decimals), selectedRoute.fromToken.symbol, @@ -333,6 +334,11 @@ export default function RouteSlider( throw new Error(`LiFi not initialised`) } const { executeRoute } = await import("@lifi/sdk") + if ( + activityHistoryStore.getCurrentAccountToken() !== initialAccountToken + ) { + throw new Error("Session changed before route execution") + } const routeExtendedPromise = executeRoute(lifiClient, selectedRoute, { //This is always called, before signing the transactions updateTransactionRequestHook: async (tx) => { @@ -421,7 +427,6 @@ export default function RouteSlider( }, }) - const accountToken = activityHistoryStore.getCurrentAccountToken() const routeExtended = await routeExtendedPromise const allStepsSucceeded = routeExtended.steps.every( (step) => step.execution?.status === "DONE", @@ -430,7 +435,7 @@ export default function RouteSlider( if (allStepsSucceeded) { const propagatingTxs = getPropagatingTxs(routeExtended) for (const tx of propagatingTxs) { - addPropagatingTx(tx, accountToken) + addPropagatingTx(tx, initialAccountToken) } captureEvent({ diff --git a/src/sections/WalletConnect/requests/RequestHandler.test.ts b/src/sections/WalletConnect/requests/RequestHandler.test.ts index db706ef..7073272 100644 --- a/src/sections/WalletConnect/requests/RequestHandler.test.ts +++ b/src/sections/WalletConnect/requests/RequestHandler.test.ts @@ -66,9 +66,26 @@ import type { ISigner } from "@/login/types" import { setupListeners } from "./RequestHandler" +type MockWalletKit = { + on: ReturnType + removeListener: ReturnType + rejectSession: ReturnType + approveSession: ReturnType + respondSessionRequest: ReturnType + getActiveSessions: ReturnType + engine: { + signClient: { + events: { + on: ReturnType + removeListener: ReturnType + } + } + } +} + describe("RequestHandler - Session Proposal", () => { let listeners: Record Promise> - let mockWalletKit: unknown + let mockWalletKit: MockWalletKit let mockSigner: ISigner beforeEach(() => { @@ -83,6 +100,7 @@ describe("RequestHandler - Session Proposal", () => { rejectSession: mockRejectSession, approveSession: mockApproveSession, respondSessionRequest: mockRespondSessionRequest, + getActiveSessions: vi.fn(() => ({})), engine: { signClient: { events: { @@ -199,4 +217,150 @@ describe("RequestHandler - Session Proposal", () => { }), ) }) + + it("accepts session proposal with chain-scoped namespace keys (eip155:1)", async () => { + const onSessionProposal = listeners.session_proposal + expect(onSessionProposal).toBeDefined() + + mockOpenDialog.mockResolvedValueOnce("accepted") + + const scopedProposal = { + id: 99999, + params: { + requiredNamespaces: { + "eip155:1": { + methods: ["personal_sign"], + events: [], + }, + }, + optionalNamespaces: {}, + }, + verifyContext: { + verified: { + origin: "https://ethereum-dapp.com", + }, + }, + } + + await onSessionProposal(scopedProposal) + + // Should NOT reject as UNSUPPORTED_CHAINS + expect(mockRejectSession).not.toHaveBeenCalled() + expect(mockOpenDialog).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sessionProposal", + }), + ) + }) + + it("rejects session request if session topic is not compatible with active signer", async () => { + const onSessionRequest = listeners.session_request + expect(onSessionRequest).toBeDefined() + + // Session belongs to another address + const otherAccountSession = { + topic: "other-topic", + namespaces: { + eip155: { + accounts: ["eip155:1:0x9999999999999999999999999999999999999999"], + methods: ["personal_sign"], + events: [], + }, + }, + } + + mockWalletKit.getActiveSessions = vi.fn(() => ({ + "other-topic": otherAccountSession, + })) + + const requestEvent = { + id: 111, + topic: "other-topic", + params: { + request: { + method: "personal_sign", + params: ["0xhello", "0x1111111111111111111111111111111111111111"], + }, + chainId: "eip155:1", + }, + } + + await onSessionRequest(requestEvent) + + // Should open error dialog and reject request with USER_DISCONNECTED + expect(mockOpenDialog).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + }), + ) + expect(mockRespondSessionRequest).toHaveBeenCalledWith({ + topic: "other-topic", + response: expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringContaining("disconnected"), + }), + }), + }) + }) + + it("rejects session request if signer switches while approval dialog is open", async () => { + let activeSigner = mockSigner + listeners = {} + setupListeners(() => activeSigner, mockWalletKit as never, vi.fn()) + const onSessionRequest = listeners.session_request + expect(onSessionRequest).toBeDefined() + + const compatibleSession = { + topic: "compatible-topic", + namespaces: { + eip155: { + accounts: ["eip155:1:0x1111111111111111111111111111111111111111"], + methods: ["personal_sign"], + events: [], + }, + }, + } + + mockWalletKit.getActiveSessions = vi.fn(() => ({ + "compatible-topic": compatibleSession, + })) + + // Dialog opens, and while it is open, user switches accounts + mockOpenDialog.mockImplementationOnce(async () => { + activeSigner = { + EVM: { + address: "0x2222222222222222222222222222222222222222", + signMessage: vi.fn(), + signTypedData: vi.fn(), + signTransaction: vi.fn(), + submitTransaction: vi.fn(), + }, + } as unknown as ISigner + return "accepted" + }) + + const requestEvent = { + id: 222, + topic: "compatible-topic", + params: { + request: { + method: "personal_sign", + params: ["0xhello", "0x1111111111111111111111111111111111111111"], + }, + chainId: "eip155:1", + }, + } + + await onSessionRequest(requestEvent) + + // Since active signer changed to 0x2222... during approval, post-dialog check fails + expect(mockRespondSessionRequest).toHaveBeenCalledWith({ + topic: "compatible-topic", + response: expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringContaining("disconnected"), + }), + }), + }) + }) }) diff --git a/src/sections/WalletConnect/requests/RequestHandler.ts b/src/sections/WalletConnect/requests/RequestHandler.ts index 4c5f57c..40e40d4 100644 --- a/src/sections/WalletConnect/requests/RequestHandler.ts +++ b/src/sections/WalletConnect/requests/RequestHandler.ts @@ -25,6 +25,7 @@ import { EIP5792_METHODS } from "../data/EIP5792Data" import { SOLANA_SIGNING_METHODS } from "../data/SolanaData" import { openWalletConnectDialog } from "../store/walletConnectDialogStore" import { getInternalChainId } from "../utils/HelperUtils" +import { isSessionCompatibleWithSigner } from "../utils/sessionValidation" import { getEip155Methods, getSupportedNamespaces, @@ -50,14 +51,26 @@ export const setupListeners = ( const requiredChainIds: string[] = [] const optionalChainIds: string[] = [] const methods: string[] = [] - Object.values(proposal.params.requiredNamespaces).map((namespaces) => { - requiredChainIds.push(...(namespaces.chains ?? [])) - methods.push(...namespaces.methods) - }) - Object.values(proposal.params.optionalNamespaces).map((namespaces) => { - optionalChainIds.push(...(namespaces.chains ?? [])) - methods.push(...namespaces.methods) - }) + Object.entries(proposal.params.requiredNamespaces ?? {}).forEach( + ([nsKey, namespaces]) => { + const chains = namespaces.chains ? [...namespaces.chains] : [] + if (nsKey.includes(":") && !chains.includes(nsKey)) { + chains.push(nsKey) + } + requiredChainIds.push(...chains) + methods.push(...namespaces.methods) + }, + ) + Object.entries(proposal.params.optionalNamespaces ?? {}).forEach( + ([nsKey, namespaces]) => { + const chains = namespaces.chains ? [...namespaces.chains] : [] + if (nsKey.includes(":") && !chains.includes(nsKey)) { + chains.push(nsKey) + } + optionalChainIds.push(...chains) + methods.push(...namespaces.methods) + }, + ) const safeIsChainSupported = (chainId: string) => { try { @@ -238,8 +251,12 @@ export const setupListeners = ( let responseStatus: "accepted" | "rejected" | "error" = "accepted" try { - const signer = getSigner() - const type = getSupportedMethods(signer).get(request.method) + const initialSigner = getSigner() + const session = walletConnectInstance.getActiveSessions()[topic] + if (!session || !isSessionCompatibleWithSigner(session, initialSigner)) { + throw new Error(getSdkError("USER_DISCONNECTED").message) + } + const type = getSupportedMethods(initialSigner).get(request.method) if (!type) { throw new Error(`The method ${request.method} is not supported`) } @@ -257,6 +274,13 @@ export const setupListeners = ( ) return } + + // Re-validate session compatibility after user approval in case the session changed + const currentSigner = getSigner() + if (!isSessionCompatibleWithSigner(session, currentSigner)) { + throw new Error(getSdkError("USER_DISCONNECTED").message) + } + responseStatus = "accepted" switch (type) { case "eip5792": @@ -264,7 +288,7 @@ export const setupListeners = ( break case "eip155": response = await approveEIP155Request( - new WalletWrapper(signer.EVM), + new WalletWrapper(currentSigner.EVM), requestEvent, ) break @@ -275,7 +299,7 @@ export const setupListeners = ( chainProvider.family === "SOLANA" || chainProvider.family === "SOLANA_DEVNET" ) { - const svmSigner = signer[chainProvider.family] + const svmSigner = currentSigner[chainProvider.family] if (svmSigner) { response = await approveSolanaRequest(svmSigner, requestEvent) break @@ -285,7 +309,7 @@ export const setupListeners = ( } case "bip122": { response = await approveBIP122Request( - getSignerForBip122ChainId(signer, chainId), + getSignerForBip122ChainId(currentSigner, chainId), requestEvent, ) break diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts index b2d9c9f..c585e58 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.test.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -37,7 +37,10 @@ import type { ISigner } from "@/login/types" import { BIP122_MAINNET_CAIP2, BIP122_TESTNET_CAIP2 } from "../data/BIP122Data" import { SOLANA_DEVNET, SOLANA_MAINNET } from "../data/SolanaData" +import { getSupportedNamespaces } from "../utils/supportedNamespaces" import { + _resetWalletConnectForTesting, + disconnectAllWalletConnectSessions, disconnectIncompatibleSessions, getActiveSigner, isSessionCompatibleWithSigner, @@ -91,6 +94,7 @@ describe("walletConnectStore - session compatibility and isolation", () => { setItem: vi.fn(), getItem: vi.fn(), }) + _resetWalletConnectForTesting() walletConnectState.walletConnectInstance = undefined walletConnectState.sessions = [] }) @@ -381,4 +385,83 @@ describe("walletConnectStore - session compatibility and isolation", () => { isSessionCompatibleWithSigner(unsupportedChainSession, signerA), ).toBe(false) }) + + it("excludes eth_sendRawTransaction from supported EIP155 methods for all signers", () => { + const socialSigner = { + EVM: { + address: evmAddressA, + }, + } as unknown as ISigner + + const externalSigner = { + EVM: { + address: evmAddressA, + submitTransaction: vi.fn(), + }, + } as unknown as ISigner + + const socialMethods = getSupportedNamespaces(socialSigner).eip155.methods + const externalMethods = + getSupportedNamespaces(externalSigner).eip155.methods + + expect(socialMethods).not.toContain("eth_sendRawTransaction") + expect(externalMethods).not.toContain("eth_sendRawTransaction") + expect(externalMethods).not.toContain("eth_signTransaction") + expect(socialMethods).toContain("eth_sendTransaction") + expect(externalMethods).toContain("eth_sendTransaction") + }) + + it("shares WalletKit initialization across overlapping initializeWalletConnect calls", async () => { + let resolveInit!: (wallet: unknown) => void + const initDelay = new Promise((resolve) => { + resolveInit = resolve + }) + + const mockWallet = { + getActiveSessions: () => ({}), + disconnectSession: mockDisconnectSession, + pair: vi.fn(), + } + mockWalletKitInit.mockImplementationOnce(() => initDelay) + + // Call 1 begins initializing + const p1 = walletConnectState.initializeWalletConnect(signerA) + // Call 2 arrives while instance is initializing + const p2 = walletConnectState.initializeWalletConnect(signerB) + + // WalletKit.init should only be called ONCE + expect(mockWalletKitInit).toHaveBeenCalledTimes(1) + + resolveInit(mockWallet) + await Promise.all([p1, p2]) + + // SignerB was the latest caller, so activeSigner should be signerB + expect(getActiveSigner()).toBe(signerB) + }) + + it("does not publish signer if initialization was superseded or logged out", async () => { + let resolveInit!: (wallet: unknown) => void + const initDelay = new Promise((resolve) => { + resolveInit = resolve + }) + + const mockWallet = { + getActiveSessions: () => ({}), + disconnectSession: mockDisconnectSession, + pair: vi.fn(), + } + mockWalletKitInit.mockImplementationOnce(() => initDelay) + + // User A starts initialization + const p1 = walletConnectState.initializeWalletConnect(signerA) + + // User logs out before initialization completes + await disconnectAllWalletConnectSessions() + + resolveInit(mockWallet) + await p1 + + // Stale initializer must NOT publish signerA after logout + expect(() => getActiveSigner()).toThrow("No active wallet session") + }) }) diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 3220a19..64d87b8 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -9,10 +9,10 @@ import { config } from "@/config" import type { ISigner } from "@/login" import { getWCUri } from "@/utils/reownAppKitWrapper" -import { BIP122_TESTNET_CAIP2 } from "../data/BIP122Data" -import { SOLANA_DEVNET } from "../data/SolanaData" import { setupListeners } from "../requests/RequestHandler" -import { getSupportedNamespaces } from "../utils/supportedNamespaces" +import { isSessionCompatibleWithSigner } from "../utils/sessionValidation" + +export { isSessionCompatibleWithSigner } export const INITIALIZE_WALLET_CONNECT_KEY = "INITIALIZE_WALLET_CONNECT" @@ -30,77 +30,6 @@ export const getActiveSigner = () => { return activeSigner } -export const isSessionCompatibleWithSigner = ( - session: SessionTypes.Struct, - signer: ISigner, -): boolean => { - const namespaces = session.namespaces || {} - const supported = getSupportedNamespaces(signer) - - for (const [nsKey, data] of Object.entries(namespaces)) { - const baseNs = nsKey.includes(":") ? nsKey.split(":")[0] : nsKey - const supportedNs = supported[nsKey] ?? supported[baseNs] - if (!supportedNs) { - return false - } - - const supportedMethods = new Set(supportedNs.methods) - for (const method of data.methods || []) { - if (!supportedMethods.has(method)) { - return false - } - } - - if (baseNs === "eip155") { - const evmAddress = signer.EVM?.address?.toLowerCase() - if (!evmAddress) return false - const supportedChains = new Set(supportedNs.chains) - for (const account of data.accounts || []) { - const parts = account.split(":") - const address = parts.pop()?.toLowerCase() - const chainId = parts.join(":") - if (address && address !== evmAddress) { - return false - } - if (chainId && !supportedChains.has(chainId)) { - return false - } - } - } else if (baseNs === "solana") { - if (!signer.SOLANA && !signer.SOLANA_DEVNET) return false - for (const account of data.accounts || []) { - const parts = account.split(":") - const address = parts.pop() - const chainId = parts.join(":") - const expectedAddress = - chainId === SOLANA_DEVNET - ? (signer.SOLANA_DEVNET?.address ?? signer.SOLANA?.address) - : (signer.SOLANA?.address ?? signer.SOLANA_DEVNET?.address) - if (!expectedAddress || address !== expectedAddress) { - return false - } - } - } else if (baseNs === "bip122") { - if (!signer.BTC && !signer.BTC_TESTNET) return false - for (const account of data.accounts || []) { - const parts = account.split(":") - const address = parts.pop() - const chainId = parts.join(":") - const expectedAddress = - chainId === BIP122_TESTNET_CAIP2 - ? (signer.BTC_TESTNET?.address ?? signer.BTC?.address) - : (signer.BTC?.address ?? signer.BTC_TESTNET?.address) - if (!expectedAddress || address !== expectedAddress) { - return false - } - } - } else { - return false - } - } - return true -} - export const disconnectIncompatibleSessions = async ( wallet: WalletKit, signer: ISigner, @@ -121,48 +50,78 @@ export const disconnectIncompatibleSessions = async ( } } +let walletKitInitPromise: Promise | undefined +let initGeneration = 0 + +export const _resetWalletConnectForTesting = () => { + walletKitInitPromise = undefined + initGeneration = 0 + activeSigner = undefined +} + const initializeWalletConnect = async (signer: ISigner) => { if (!config.walletConnectEnabled) { return } + const generation = ++initGeneration activeSigner = undefined - let wallet: WalletKit | undefined + if (!walletConnectState.walletConnectInstance) { - walletConnectState.walletConnectInstance = "initializing" - if (typeof window !== "undefined") { - sessionStorage.setItem(INITIALIZE_WALLET_CONNECT_KEY, "true") - } - const core = new Core({ - projectId: config.walletConnectProjectId, - }) - - const { name, description, icons } = manifest() - if (!name || !icons || !description) { - throw new Error( - "manifest is missing required fields for WalletConnect metadata", - ) - } - const url = window.location.origin - const iconUrls = icons.map((icon) => `${url}${icon.src}`) - const metadata = { - name, - description, - icons: iconUrls, - url, + walletKitInitPromise = undefined + } + + let wallet: WalletKit | undefined + if ( + walletConnectState.walletConnectInstance && + walletConnectState.walletConnectInstance !== "initializing" + ) { + wallet = walletConnectState.walletConnectInstance + } else { + if (!walletKitInitPromise) { + walletConnectState.walletConnectInstance = "initializing" + if (typeof window !== "undefined") { + sessionStorage.setItem(INITIALIZE_WALLET_CONNECT_KEY, "true") + } + walletKitInitPromise = (async () => { + const core = new Core({ + projectId: config.walletConnectProjectId, + }) + + const { name, description, icons } = manifest() + if (!name || !icons || !description) { + throw new Error( + "manifest is missing required fields for WalletConnect metadata", + ) + } + const url = window.location.origin + const iconUrls = icons.map((icon) => `${url}${icon.src}`) + const metadata = { + name, + description, + icons: iconUrls, + url, + } + const instance = await WalletKit.init({ + core, + metadata, + }) + setupListeners(getActiveSigner, instance, refreshSessions) + const wcUri = getWCUri() + + if (wcUri) { + instance.pair({ uri: wcUri }) + } + walletConnectState.walletConnectInstance = ref(instance) + return instance + })() } - wallet = await WalletKit.init({ - core, - metadata, - }) - setupListeners(getActiveSigner, wallet, refreshSessions) - const wcUri = getWCUri() - - if (wcUri) { - wallet.pair({ uri: wcUri }) + try { + wallet = await walletKitInitPromise + } catch (e) { + walletKitInitPromise = undefined + walletConnectState.walletConnectInstance = undefined + throw e } - walletConnectState.walletConnectInstance = ref(wallet) - } else if (walletConnectState.walletConnectInstance !== "initializing") { - wallet = walletConnectState.walletConnectInstance } // Restored sessions must not silently rebind to a new signer: @@ -170,8 +129,12 @@ const initializeWalletConnect = async (signer: ISigner) => { if (wallet) { await disconnectIncompatibleSessions(wallet, signer) } - activeSigner = signer - refreshSessions() + + // Guard signer publication: do not publish if superseded or disconnected. + if (generation === initGeneration) { + activeSigner = signer + refreshSessions() + } } const refreshSessions = () => { @@ -193,6 +156,7 @@ export const walletConnectState = proxy({ }) export const disconnectAllWalletConnectSessions = async () => { + initGeneration++ activeSigner = undefined const wallet = walletConnectState.walletConnectInstance if (!wallet || wallet === "initializing") return diff --git a/src/sections/WalletConnect/utils/sessionValidation.ts b/src/sections/WalletConnect/utils/sessionValidation.ts new file mode 100644 index 0000000..38afe77 --- /dev/null +++ b/src/sections/WalletConnect/utils/sessionValidation.ts @@ -0,0 +1,78 @@ +import type { SessionTypes } from "@walletconnect/types" + +import type { ISigner } from "@/login/types" + +import { BIP122_TESTNET_CAIP2 } from "../data/BIP122Data" +import { SOLANA_DEVNET } from "../data/SolanaData" +import { getSupportedNamespaces } from "./supportedNamespaces" + +export const isSessionCompatibleWithSigner = ( + session: SessionTypes.Struct, + signer: ISigner, +): boolean => { + const namespaces = session.namespaces || {} + const supported = getSupportedNamespaces(signer) + + for (const [nsKey, data] of Object.entries(namespaces)) { + const baseNs = nsKey.includes(":") ? nsKey.split(":")[0] : nsKey + const supportedNs = supported[nsKey] ?? supported[baseNs] + if (!supportedNs) { + return false + } + + const supportedMethods = new Set(supportedNs.methods) + for (const method of data.methods || []) { + if (!supportedMethods.has(method)) { + return false + } + } + + if (baseNs === "eip155") { + const evmAddress = signer.EVM?.address?.toLowerCase() + if (!evmAddress) return false + const supportedChains = new Set(supportedNs.chains) + for (const account of data.accounts || []) { + const parts = account.split(":") + const address = parts.pop()?.toLowerCase() + const chainId = parts.join(":") + if (address && address !== evmAddress) { + return false + } + if (chainId && !supportedChains.has(chainId)) { + return false + } + } + } else if (baseNs === "solana") { + if (!signer.SOLANA && !signer.SOLANA_DEVNET) return false + for (const account of data.accounts || []) { + const parts = account.split(":") + const address = parts.pop() + const chainId = parts.join(":") + const expectedAddress = + chainId === SOLANA_DEVNET + ? (signer.SOLANA_DEVNET?.address ?? signer.SOLANA?.address) + : (signer.SOLANA?.address ?? signer.SOLANA_DEVNET?.address) + if (!expectedAddress || address !== expectedAddress) { + return false + } + } + } else if (baseNs === "bip122") { + if (!signer.BTC && !signer.BTC_TESTNET) return false + for (const account of data.accounts || []) { + const parts = account.split(":") + const address = parts.pop() + const chainId = parts.join(":") + const expectedAddress = + chainId === BIP122_TESTNET_CAIP2 + ? (signer.BTC_TESTNET?.address ?? signer.BTC?.address) + : (signer.BTC?.address ?? signer.BTC_TESTNET?.address) + if (!expectedAddress || address !== expectedAddress) { + return false + } + } + } else { + return false + } + } + return true +} diff --git a/src/sections/WalletConnect/utils/supportedNamespaces.ts b/src/sections/WalletConnect/utils/supportedNamespaces.ts index 76f1c4b..ec7063a 100644 --- a/src/sections/WalletConnect/utils/supportedNamespaces.ts +++ b/src/sections/WalletConnect/utils/supportedNamespaces.ts @@ -10,10 +10,16 @@ import { import { EIP155_CHAINS, EIP155_SIGNING_METHODS } from "../data/EIP155Data" import { SOLANA_CHAINS, SOLANA_SIGNING_METHODS } from "../data/SolanaData" -// External wallets broadcast themselves, so raw-signing methods are not offered. +// External wallets broadcast themselves, so eth_signTransaction is not offered. +// eth_sendRawTransaction is excluded for all signers since approveEIP155Request +// has no raw-broadcast handler. +export const UNSUPPORTED_EIP155_METHODS = new Set([ + EIP155_SIGNING_METHODS.ETH_SEND_RAW_TRANSACTION, +]) + export const RAW_SIGNING_METHODS = new Set([ EIP155_SIGNING_METHODS.ETH_SIGN_TRANSACTION, - EIP155_SIGNING_METHODS.ETH_SEND_RAW_TRANSACTION, + ...UNSUPPORTED_EIP155_METHODS, ]) export const isExternalWallet = (signer: ISigner) => @@ -21,7 +27,9 @@ export const isExternalWallet = (signer: ISigner) => export const getEip155Methods = (signer: ISigner) => Object.values(EIP155_SIGNING_METHODS).filter( - (method) => !isExternalWallet(signer) || !RAW_SIGNING_METHODS.has(method), + (method) => + !UNSUPPORTED_EIP155_METHODS.has(method) && + (!isExternalWallet(signer) || !RAW_SIGNING_METHODS.has(method)), ) export type SupportedNamespace = { diff --git a/src/stores/transactionHistoryStore.test.ts b/src/stores/transactionHistoryStore.test.ts index 63e9c7c..cbcbe21 100644 --- a/src/stores/transactionHistoryStore.test.ts +++ b/src/stores/transactionHistoryStore.test.ts @@ -19,6 +19,7 @@ vi.mock("@/chain/provider/provider", () => ({ import { activityHistoryStore, clearActivityHistory, + initActivityAccount, } from "./transactionHistoryStore" const makeAddresses = (evmAddress: string): Addresses => { @@ -78,7 +79,10 @@ describe("transactionHistoryStore", () => { // Add propagating tx const propagatingTx = mockTx("0xpropagating", 1500) - activityHistoryStore.addPropagatingTx(propagatingTx) + activityHistoryStore.addPropagatingTx( + propagatingTx, + activityHistoryStore.getCurrentAccountToken(), + ) expect(activityHistoryStore.perChain.get(42220)?.length).toBe(2) clearActivityHistory() @@ -176,4 +180,78 @@ describe("transactionHistoryStore", () => { const history = activityHistoryStore.perChain.get(42220) expect(history?.some((tx) => tx.hash === "0xreplacementhash")).toBe(true) }) + + it("replaces propagating transaction hash on speed-up and drops stale entries", async () => { + const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + initActivityAccount(account) + const token = activityHistoryStore.getCurrentAccountToken() + + const initialTx = mockTx("0xinitialhash", 1600) + activityHistoryStore.addPropagatingTx(initialTx, token) + expect( + activityHistoryStore.perChain + .get(42220) + ?.some((tx) => tx.hash === "0xinitialhash"), + ).toBe(true) + + // Reconcile with replacement hash + activityHistoryStore.replacePropagatingTx( + 42220, + "0xinitialhash", + "0xspeeduphash", + token, + ) + + const updated = activityHistoryStore.perChain.get(42220) + expect(updated?.some((tx) => tx.hash === "0xspeeduphash")).toBe(true) + expect(updated?.some((tx) => tx.hash === "0xinitialhash")).toBe(false) + }) + + it("removes propagating transaction on cancellation or failure", async () => { + const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + initActivityAccount(account) + const token = activityHistoryStore.getCurrentAccountToken() + + const initialTx = mockTx("0xcancelledhash", 1600) + activityHistoryStore.addPropagatingTx(initialTx, token) + expect( + activityHistoryStore.perChain + .get(42220) + ?.some((tx) => tx.hash === "0xcancelledhash"), + ).toBe(true) + + activityHistoryStore.removePropagatingTx(42220, "0xcancelledhash", token) + + const updated = activityHistoryStore.perChain.get(42220) + expect(updated?.some((tx) => tx.hash === "0xcancelledhash")).toBe(false) + }) + + it("initializes account token on session start without invalidating same-session operations", async () => { + const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + // Session starts: initActivityAccount called + initActivityAccount(account) + const initialToken = activityHistoryStore.getCurrentAccountToken() + + // Immediate operation on Crypto tab before Activity tab opened + const immediateTx = mockTx("0ximmediate", 1500) + activityHistoryStore.addPropagatingTx(immediateTx, initialToken) + + expect( + activityHistoryStore.perChain + .get(42220) + ?.some((tx) => tx.hash === "0ximmediate"), + ).toBe(true) + + // Activity tab mounts and calls refreshHistory + await activityHistoryStore.refreshHistory(makeAddresses(account)) + + // Token must not be invalidated and immediate transaction must not be cleared + expect(activityHistoryStore.getCurrentAccountToken()).toBe(initialToken) + expect( + activityHistoryStore.perChain + .get(42220) + ?.some((tx) => tx.hash === "0ximmediate"), + ).toBe(true) + }) }) diff --git a/src/stores/transactionHistoryStore.ts b/src/stores/transactionHistoryStore.ts index 5daf09e..aff5a4d 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -18,6 +18,17 @@ let lastRefreshAllTimestamp = 0 export const getCurrentAccountToken = () => currentAccountToken +/** + * Initializes the active account and token when a session begins. + * If the account matches the currently active account, existing history is kept. + */ +export const initActivityAccount = (account: string | undefined) => { + if (account !== activeAccount) { + clearActivityHistory() + activeAccount = account + } +} + /** * Clears all cached history and resets the refresh timer. * Must be called whenever the active account changes so stale data from a @@ -35,7 +46,14 @@ type ActivityHistoryStore = { perChain: Map isLoading: boolean refreshHistory: (addresses: Addresses | undefined) => Promise - addPropagatingTx: (tx: Tx, token?: number) => void + addPropagatingTx: (tx: Tx, token: number) => void + replacePropagatingTx: ( + chainId: number, + oldHash: string, + newHash: string, + token: number, + ) => void + removePropagatingTx: (chainId: number, hash: string, token: number) => void getCurrentAccountToken: () => number } @@ -47,8 +65,8 @@ const combineTxs = (a: Tx[], b: Tx[]) => { return combined.sort((tx1, tx2) => tx2.timestamp - tx1.timestamp) } -const addPropagatingTx = (tx: Tx, token?: number) => { - if (token !== undefined && token !== currentAccountToken) { +const addPropagatingTx = (tx: Tx, token: number) => { + if (token !== currentAccountToken) { return } const internalTxsForChain = internalTxs.get(tx.chainId) || [] @@ -67,6 +85,57 @@ const addPropagatingTx = (tx: Tx, token?: number) => { activityHistoryStore.perChain.set(tx.chainId, combinedTxsForChain) } +const replacePropagatingTx = ( + chainId: number, + oldHash: string, + newHash: string, + token: number, +) => { + if (token !== currentAccountToken) { + return + } + const oldLower = oldHash.toLowerCase() + const internal = internalTxs.get(chainId) + if (internal) { + internalTxs.set( + chainId, + internal.map((tx) => + tx.hash.toLowerCase() === oldLower ? { ...tx, hash: newHash } : tx, + ), + ) + } + const perChain = activityHistoryStore.perChain.get(chainId) + if (perChain) { + activityHistoryStore.perChain.set( + chainId, + perChain.map((tx) => + tx.hash.toLowerCase() === oldLower ? { ...tx, hash: newHash } : tx, + ), + ) + } +} + +const removePropagatingTx = (chainId: number, hash: string, token: number) => { + if (token !== currentAccountToken) { + return + } + const hashLower = hash.toLowerCase() + const internal = internalTxs.get(chainId) + if (internal) { + internalTxs.set( + chainId, + internal.filter((tx) => tx.hash.toLowerCase() !== hashLower), + ) + } + const perChain = activityHistoryStore.perChain.get(chainId) + if (perChain) { + activityHistoryStore.perChain.set( + chainId, + perChain.filter((tx) => tx.hash.toLowerCase() !== hashLower), + ) + } +} + const refreshHistory = async (addresses: Addresses | undefined) => { const now = Date.now() @@ -138,5 +207,7 @@ export const activityHistoryStore = proxy({ isLoading: false, refreshHistory: refreshHistory, addPropagatingTx: addPropagatingTx, + replacePropagatingTx: replacePropagatingTx, + removePropagatingTx: removePropagatingTx, getCurrentAccountToken: getCurrentAccountToken, }) From c91ac4011d2947724066072ff710f0ec90796d10 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Fri, 6 Nov 2026 21:37:17 +0100 Subject: [PATCH 18/21] fix: prevent superseded session pruning, preserve uncertain activity, and deduplicate speed-ups - Check generation before pruning restored sessions during WalletKit init to prevent superseded signers from pruning active connections - Keep pending activity entries when transaction outcome is uncertain; remove only on confirmed cancellation, different-intent replacement, or on-chain revert - Deduplicate speed-up replacements in activity history by dropping original propagating entry when replacement hash is already present --- src/ethers-utils/utils.test.ts | 51 ++++++++++++++++- src/ethers-utils/utils.ts | 31 ++++++++++ src/sections/Send/hooks/evmTransaction.ts | 11 +++- .../store/walletConnectStore.test.ts | 41 ++++++++++++++ .../WalletConnect/store/walletConnectStore.ts | 4 +- src/stores/transactionHistoryStore.test.ts | 33 +++++++++++ src/stores/transactionHistoryStore.ts | 56 ++++++++++++++----- 7 files changed, 208 insertions(+), 19 deletions(-) diff --git a/src/ethers-utils/utils.test.ts b/src/ethers-utils/utils.test.ts index e547f07..4491d70 100644 --- a/src/ethers-utils/utils.test.ts +++ b/src/ethers-utils/utils.test.ts @@ -50,7 +50,7 @@ vi.mock("@/gooddollar/methods/divvi", () => ({ // ── Import after mocks ───────────────────────────────────────────────────── -import { sendTx, sendTxWaitForMining } from "./utils" +import { isConfirmedTxFailure, sendTx, sendTxWaitForMining } from "./utils" // ── Helpers ───────────────────────────────────────────────────────────────── @@ -662,4 +662,53 @@ describe("ethers-utils", () => { ) }) }) + + describe("isConfirmedTxFailure", () => { + it("returns true for confirmed cancellation", () => { + const err = Object.assign(new Error("transaction cancelled"), { + code: "TRANSACTION_REPLACED", + reason: "cancelled", + }) + expect(isConfirmedTxFailure(err)).toBe(true) + }) + + it("returns true for different-intent replacement", () => { + const err = Object.assign(new Error("transaction replaced"), { + code: "TRANSACTION_REPLACED", + reason: "replaced", + }) + expect(isConfirmedTxFailure(err)).toBe(true) + }) + + it("returns true for reverted replacement transaction (status 0)", () => { + const err = Object.assign(new Error("replacement reverted"), { + code: "TRANSACTION_REPLACED", + reason: "repriced", + receipt: { status: 0 }, + }) + expect(isConfirmedTxFailure(err)).toBe(true) + }) + + it("returns true for on-chain revert CALL_EXCEPTION", () => { + const err = Object.assign(new Error("execution reverted"), { + code: "CALL_EXCEPTION", + receipt: { status: 0 }, + }) + expect(isConfirmedTxFailure(err)).toBe(true) + }) + + it("returns false for uncertain errors like timeouts, network errors, or polling retry exhaustion", () => { + expect(isConfirmedTxFailure(new Error("timeout"))).toBe(false) + expect( + isConfirmedTxFailure( + new Error("Transaction was not found after 5 retries"), + ), + ).toBe(false) + expect( + isConfirmedTxFailure( + Object.assign(new Error("network error"), { code: "NETWORK_ERROR" }), + ), + ).toBe(false) + }) + }) }) diff --git a/src/ethers-utils/utils.ts b/src/ethers-utils/utils.ts index 3d9a889..14e1973 100644 --- a/src/ethers-utils/utils.ts +++ b/src/ethers-utils/utils.ts @@ -417,3 +417,34 @@ export const parseAmountBytes = ( signed, }).toFormat({ decimals: DEFAULT_DECIMALS, width: WIDTH, signed }) } + +/** + * Determines whether a transaction error represents a confirmed terminal failure + * (cancelled, replaced with different intent, or reverted on-chain). + * Uncertain failures (e.g. RPC timeouts, network errors, or exhausted polling retries) + * return false to prevent prematurely dropping pending activity entries. + */ +export const isConfirmedTxFailure = (err: unknown): boolean => { + if (isError(err, "TRANSACTION_REPLACED")) { + return ( + err.reason === "cancelled" || + err.reason === "replaced" || + err.receipt?.status === 0 + ) + } + if (isError(err, "CALL_EXCEPTION")) { + return true + } + const errorObj = err as { + reason?: string + receipt?: { status?: number } + status?: number + } + if (errorObj?.reason === "cancelled" || errorObj?.reason === "replaced") { + return true + } + if (errorObj?.receipt?.status === 0 || errorObj?.status === 0) { + return true + } + return false +} diff --git a/src/sections/Send/hooks/evmTransaction.ts b/src/sections/Send/hooks/evmTransaction.ts index 2047bfc..ce916a2 100644 --- a/src/sections/Send/hooks/evmTransaction.ts +++ b/src/sections/Send/hooks/evmTransaction.ts @@ -11,6 +11,7 @@ import { estimateGasLimit, estimateTypeAndFees, isAmountSafe, + isConfirmedTxFailure, normalizeEvmAddress, parseAmount, sendTx, @@ -321,8 +322,14 @@ export const useSubmitTransaction = () => { ) } }) - .catch(() => { - activityHistoryStore.removePropagatingTx(chainId, hash, accountToken) + .catch((err) => { + if (isConfirmedTxFailure(err)) { + activityHistoryStore.removePropagatingTx( + chainId, + hash, + accountToken, + ) + } }) return { hash, trxMinedPromise } diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts index c585e58..c42821c 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.test.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -464,4 +464,45 @@ describe("walletConnectStore - session compatibility and isolation", () => { // Stale initializer must NOT publish signerA after logout expect(() => getActiveSigner()).toThrow("No active wallet session") }) + + it("prevents superseded initialization from pruning restored sessions of a newer signer", async () => { + let resolveInit!: (wallet: unknown) => void + const initDelay = new Promise((resolve) => { + resolveInit = resolve + }) + + const sessionB = { + topic: "topic-b", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressB}`], + methods: ["eth_sendTransaction", "personal_sign"], + events: [], + }, + }, + } + + const mockWallet = { + getActiveSessions: () => ({ + "topic-b": sessionB, + }), + disconnectSession: mockDisconnectSession, + pair: vi.fn(), + } + mockWalletKitInit.mockImplementationOnce(() => initDelay) + + // User A starts initialization + const p1 = walletConnectState.initializeWalletConnect(signerA) + // User B supersedes user A while WalletKit is still initializing + const p2 = walletConnectState.initializeWalletConnect(signerB) + + resolveInit(mockWallet) + await Promise.all([p1, p2]) + + // Signer A was superseded, so it must NOT have pruned signer B's restored session + expect(mockDisconnectSession).not.toHaveBeenCalledWith( + expect.objectContaining({ topic: "topic-b" }), + ) + expect(getActiveSigner()).toBe(signerB) + }) }) diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 64d87b8..403c46e 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -126,7 +126,9 @@ const initializeWalletConnect = async (signer: ISigner) => { // Restored sessions must not silently rebind to a new signer: // disconnect incompatible sessions before the new signer is usable. - if (wallet) { + // Check generation first: if this init was superseded or disconnected while + // awaiting WalletKit, do not prune active/restored sessions with a stale signer. + if (generation === initGeneration && wallet) { await disconnectIncompatibleSessions(wallet, signer) } diff --git a/src/stores/transactionHistoryStore.test.ts b/src/stores/transactionHistoryStore.test.ts index cbcbe21..0dd631d 100644 --- a/src/stores/transactionHistoryStore.test.ts +++ b/src/stores/transactionHistoryStore.test.ts @@ -207,6 +207,39 @@ describe("transactionHistoryStore", () => { expect(updated?.some((tx) => tx.hash === "0xinitialhash")).toBe(false) }) + it("drops original propagating entry instead of duplicating when replacement is already present", async () => { + const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + initActivityAccount(account) + const token = activityHistoryStore.getCurrentAccountToken() + + const initialTx = mockTx("0xinitialhash", 1600) + activityHistoryStore.addPropagatingTx(initialTx, token) + + // A background history refresh already discovered and fetched the mined replacement + const replacementTx = mockTx("0xspeeduphash", 1605) + mockGetTransactions.mockResolvedValueOnce([replacementTx]) + await activityHistoryStore.refreshHistory(makeAddresses(account)) + + const beforeReconcile = activityHistoryStore.perChain.get(42220) || [] + expect(beforeReconcile.some((tx) => tx.hash === "0xinitialhash")).toBe(true) + expect(beforeReconcile.some((tx) => tx.hash === "0xspeeduphash")).toBe(true) + + // Reconcile speed-up when replacement is already in history + activityHistoryStore.replacePropagatingTx( + 42220, + "0xinitialhash", + "0xspeeduphash", + token, + ) + + const afterReconcile = activityHistoryStore.perChain.get(42220) || [] + const speedUpMatches = afterReconcile.filter( + (tx) => tx.hash.toLowerCase() === "0xspeeduphash", + ) + expect(speedUpMatches.length).toBe(1) + expect(afterReconcile.some((tx) => tx.hash === "0xinitialhash")).toBe(false) + }) + it("removes propagating transaction on cancellation or failure", async () => { const account = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" initActivityAccount(account) diff --git a/src/stores/transactionHistoryStore.ts b/src/stores/transactionHistoryStore.ts index aff5a4d..ed47af0 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -95,23 +95,49 @@ const replacePropagatingTx = ( return } const oldLower = oldHash.toLowerCase() + const newLower = newHash.toLowerCase() + const internal = internalTxs.get(chainId) - if (internal) { - internalTxs.set( - chainId, - internal.map((tx) => - tx.hash.toLowerCase() === oldLower ? { ...tx, hash: newHash } : tx, - ), - ) - } const perChain = activityHistoryStore.perChain.get(chainId) - if (perChain) { - activityHistoryStore.perChain.set( - chainId, - perChain.map((tx) => - tx.hash.toLowerCase() === oldLower ? { ...tx, hash: newHash } : tx, - ), - ) + + const isReplacementPresent = + (perChain && perChain.some((tx) => tx.hash.toLowerCase() === newLower)) || + (internal && internal.some((tx) => tx.hash.toLowerCase() === newLower)) + + if (isReplacementPresent) { + // If the replacement is already present (e.g. discovered by a history refresh + // before trxMinedPromise resolved), drop the original propagating entry instead + // of renaming it to avoid duplicate entries with the same hash in Activity. + if (internal) { + internalTxs.set( + chainId, + internal.filter((tx) => tx.hash.toLowerCase() !== oldLower), + ) + } + if (perChain) { + activityHistoryStore.perChain.set( + chainId, + perChain.filter((tx) => tx.hash.toLowerCase() !== oldLower), + ) + } + } else { + // Replacement is not yet present; map the original hash to the replacement hash. + if (internal) { + internalTxs.set( + chainId, + internal.map((tx) => + tx.hash.toLowerCase() === oldLower ? { ...tx, hash: newHash } : tx, + ), + ) + } + if (perChain) { + activityHistoryStore.perChain.set( + chainId, + perChain.map((tx) => + tx.hash.toLowerCase() === oldLower ? { ...tx, hash: newHash } : tx, + ), + ) + } } } From 8e2ec84e24e9ce2748edb7cd60483bfe9ca8c1bd Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Fri, 6 Nov 2026 21:49:54 +0100 Subject: [PATCH 19/21] fix: refine superseded init abort, activity tracking, and speed-up deduplication - Return immediately from initializeWalletConnect when superseded before pruning or publishing (F1) - Add dedicated test suite for handleTrxMinedOutcome covering unknown tracking errors vs cancellations (F2) - Match duplicate replacement check per list excluding the hash being renamed in replacePropagatingTx (F3) --- .../Send/hooks/evmTransaction.test.ts | 102 ++++++++++++++++++ src/sections/Send/hooks/evmTransaction.ts | 49 +++++---- .../WalletConnect/store/walletConnectStore.ts | 12 ++- src/stores/transactionHistoryStore.ts | 46 ++++---- 4 files changed, 162 insertions(+), 47 deletions(-) create mode 100644 src/sections/Send/hooks/evmTransaction.test.ts diff --git a/src/sections/Send/hooks/evmTransaction.test.ts b/src/sections/Send/hooks/evmTransaction.test.ts new file mode 100644 index 0000000..2e277e6 --- /dev/null +++ b/src/sections/Send/hooks/evmTransaction.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/chain/provider/provider", () => ({ + getChainProvider: vi.fn(), +})) + +vi.mock("@/login", () => ({ + useSessionContext: vi.fn(), +})) + +vi.mock("@/tokens/utils", () => ({ + mulWithTokenBalanceUsdPrice: vi.fn(), +})) + +vi.mock("./form", () => ({ + useFormSnapshot: vi.fn(), +})) + +vi.mock("./transaction", () => ({ + useAmountLimits: vi.fn(), + useAmountUnits: vi.fn(), + useSelectedTokens: vi.fn(), + useBroadcastRequest: vi.fn(), + useBroadcastOptions: vi.fn(), + useGasLimit: vi.fn(), + useIsTransactionValid: vi.fn(), +})) + +vi.mock("@/stores/transactionHistoryStore", () => ({ + activityHistoryStore: { + replacePropagatingTx: vi.fn(), + removePropagatingTx: vi.fn(), + }, +})) + +import { activityHistoryStore } from "@/stores/transactionHistoryStore" + +import { handleTrxMinedOutcome } from "./evmTransaction" + +describe("handleTrxMinedOutcome", () => { + const chainId = 42220 + const initialHash = "0xinitialhash" + const accountToken = 1 + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("(a) keeps propagating entry when trxMinedPromise rejects with a generic tracking error", async () => { + const trackingError = new Error("Transaction was not found after 5 retries") + const trxMinedPromise = Promise.reject(trackingError) + + await handleTrxMinedOutcome( + trxMinedPromise, + chainId, + initialHash, + accountToken, + ) + + expect(activityHistoryStore.removePropagatingTx).not.toHaveBeenCalled() + }) + + it("(b) removes propagating entry when trxMinedPromise rejects with a cancellation error", async () => { + const cancelError = Object.assign(new Error("transaction cancelled"), { + code: "TRANSACTION_REPLACED", + reason: "cancelled", + }) + const trxMinedPromise = Promise.reject(cancelError) + + await handleTrxMinedOutcome( + trxMinedPromise, + chainId, + initialHash, + accountToken, + ) + + expect(activityHistoryStore.removePropagatingTx).toHaveBeenCalledWith( + chainId, + initialHash, + accountToken, + ) + }) + + it("replaces propagating entry when trxMinedPromise resolves with a speed-up receipt", async () => { + const replacementReceipt = { hash: "0xspeeduphash" } + const trxMinedPromise = Promise.resolve(replacementReceipt) + + await handleTrxMinedOutcome( + trxMinedPromise, + chainId, + initialHash, + accountToken, + ) + + expect(activityHistoryStore.replacePropagatingTx).toHaveBeenCalledWith( + chainId, + initialHash, + "0xspeeduphash", + accountToken, + ) + }) +}) diff --git a/src/sections/Send/hooks/evmTransaction.ts b/src/sections/Send/hooks/evmTransaction.ts index ce916a2..bdfb0c0 100644 --- a/src/sections/Send/hooks/evmTransaction.ts +++ b/src/sections/Send/hooks/evmTransaction.ts @@ -254,6 +254,31 @@ export const useIsTransactionValid = (): boolean => { return true } +export const handleTrxMinedOutcome = ( + trxMinedPromise: Promise, + chainId: number, + hash: string, + accountToken: number, +) => { + return trxMinedPromise + .then((receipt: unknown) => { + const rx = receipt as { hash?: string } | null | undefined + if (rx?.hash && rx.hash.toLowerCase() !== hash.toLowerCase()) { + activityHistoryStore.replacePropagatingTx( + chainId, + hash, + rx.hash, + accountToken, + ) + } + }) + .catch((err) => { + if (isConfirmedTxFailure(err)) { + activityHistoryStore.removePropagatingTx(chainId, hash, accountToken) + } + }) +} + export const useSubmitTransaction = () => { const { signer } = useSessionContext() @@ -308,29 +333,7 @@ export const useSubmitTransaction = () => { accountToken, ) - trxMinedPromise - .then((receipt) => { - if ( - receipt?.hash && - receipt.hash.toLowerCase() !== hash.toLowerCase() - ) { - activityHistoryStore.replacePropagatingTx( - chainId, - hash, - receipt.hash, - accountToken, - ) - } - }) - .catch((err) => { - if (isConfirmedTxFailure(err)) { - activityHistoryStore.removePropagatingTx( - chainId, - hash, - accountToken, - ) - } - }) + handleTrxMinedOutcome(trxMinedPromise, chainId, hash, accountToken) return { hash, trxMinedPromise } }) diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 403c46e..2cef0d1 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -126,13 +126,17 @@ const initializeWalletConnect = async (signer: ISigner) => { // Restored sessions must not silently rebind to a new signer: // disconnect incompatible sessions before the new signer is usable. - // Check generation first: if this init was superseded or disconnected while - // awaiting WalletKit, do not prune active/restored sessions with a stale signer. - if (generation === initGeneration && wallet) { + // Check generation first: if this initialization has been superseded, + // return without pruning and without publishing. + if (generation !== initGeneration) { + return + } + + if (wallet) { await disconnectIncompatibleSessions(wallet, signer) } - // Guard signer publication: do not publish if superseded or disconnected. + // Guard signer publication: do not publish if superseded or disconnected during pruning. if (generation === initGeneration) { activeSigner = signer refreshSessions() diff --git a/src/stores/transactionHistoryStore.ts b/src/stores/transactionHistoryStore.ts index ed47af0..e033302 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -100,29 +100,27 @@ const replacePropagatingTx = ( const internal = internalTxs.get(chainId) const perChain = activityHistoryStore.perChain.get(chainId) - const isReplacementPresent = - (perChain && perChain.some((tx) => tx.hash.toLowerCase() === newLower)) || - (internal && internal.some((tx) => tx.hash.toLowerCase() === newLower)) - - if (isReplacementPresent) { - // If the replacement is already present (e.g. discovered by a history refresh - // before trxMinedPromise resolved), drop the original propagating entry instead - // of renaming it to avoid duplicate entries with the same hash in Activity. - if (internal) { + const replacementInPerChain = + perChain?.some( + (tx) => + tx.hash.toLowerCase() === newLower && + tx.hash.toLowerCase() !== oldLower, + ) ?? false + + if (internal) { + const hasReplacement = + replacementInPerChain || + internal.some( + (tx) => + tx.hash.toLowerCase() === newLower && + tx.hash.toLowerCase() !== oldLower, + ) + if (hasReplacement) { internalTxs.set( chainId, internal.filter((tx) => tx.hash.toLowerCase() !== oldLower), ) - } - if (perChain) { - activityHistoryStore.perChain.set( - chainId, - perChain.filter((tx) => tx.hash.toLowerCase() !== oldLower), - ) - } - } else { - // Replacement is not yet present; map the original hash to the replacement hash. - if (internal) { + } else { internalTxs.set( chainId, internal.map((tx) => @@ -130,7 +128,15 @@ const replacePropagatingTx = ( ), ) } - if (perChain) { + } + + if (perChain) { + if (replacementInPerChain) { + activityHistoryStore.perChain.set( + chainId, + perChain.filter((tx) => tx.hash.toLowerCase() !== oldLower), + ) + } else { activityHistoryStore.perChain.set( chainId, perChain.map((tx) => From 0de4c78aa88a0b3e7272af1692c33119558cd838 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Fri, 6 Nov 2026 22:21:27 +0100 Subject: [PATCH 20/21] fix: disconnect restored sessions when logged out during WalletKit initialization - Record pending logout cleanup when disconnectAllWalletConnectSessions is called while WalletKit is initializing - Execute disconnect on all active sessions when initialization resolves with pending logout cleanup - Add regression test verifying restored sessions are disconnected and signer remains unpublished upon logout during init --- .../store/walletConnectStore.test.ts | 25 ++++++++++- .../WalletConnect/store/walletConnectStore.ts | 44 ++++++++++++++----- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/sections/WalletConnect/store/walletConnectStore.test.ts b/src/sections/WalletConnect/store/walletConnectStore.test.ts index c42821c..350cf19 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.test.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -439,14 +439,27 @@ describe("walletConnectStore - session compatibility and isolation", () => { expect(getActiveSigner()).toBe(signerB) }) - it("does not publish signer if initialization was superseded or logged out", async () => { + it("does not publish signer and disconnects restored sessions if logged out during initialization", async () => { let resolveInit!: (wallet: unknown) => void const initDelay = new Promise((resolve) => { resolveInit = resolve }) + const restoredSession = { + topic: "topic-restored-a", + namespaces: { + eip155: { + accounts: [`eip155:1:${evmAddressA}`], + methods: ["eth_sendTransaction", "personal_sign"], + events: [], + }, + }, + } + const mockWallet = { - getActiveSessions: () => ({}), + getActiveSessions: () => ({ + "topic-restored-a": restoredSession, + }), disconnectSession: mockDisconnectSession, pair: vi.fn(), } @@ -461,6 +474,14 @@ describe("walletConnectStore - session compatibility and isolation", () => { resolveInit(mockWallet) await p1 + // Restored session must be disconnected on logout + expect(mockDisconnectSession).toHaveBeenCalledWith({ + topic: "topic-restored-a", + reason: expect.objectContaining({ + message: expect.stringContaining("User disconnected"), + }), + }) + // Stale initializer must NOT publish signerA after logout expect(() => getActiveSigner()).toThrow("No active wallet session") }) diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 2cef0d1..8cb78e2 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -52,17 +52,31 @@ export const disconnectIncompatibleSessions = async ( let walletKitInitPromise: Promise | undefined let initGeneration = 0 +let pendingLogoutCleanup = false export const _resetWalletConnectForTesting = () => { walletKitInitPromise = undefined initGeneration = 0 activeSigner = undefined + pendingLogoutCleanup = false +} + +const disconnectAllSessions = async (wallet: WalletKit) => { + await Promise.allSettled( + Object.values(wallet.getActiveSessions()).map((session) => + wallet.disconnectSession({ + topic: session.topic, + reason: getSdkError("USER_DISCONNECTED"), + }), + ), + ) } const initializeWalletConnect = async (signer: ISigner) => { if (!config.walletConnectEnabled) { return } + pendingLogoutCleanup = false const generation = ++initGeneration activeSigner = undefined @@ -124,9 +138,20 @@ const initializeWalletConnect = async (signer: ISigner) => { } } + // If a logout occurred while WalletKit was initializing and no newer login has + // taken over, disconnect all active/restored sessions before returning. + if (pendingLogoutCleanup) { + pendingLogoutCleanup = false + if (wallet) { + await disconnectAllSessions(wallet) + refreshSessions() + } + return + } + // Restored sessions must not silently rebind to a new signer: // disconnect incompatible sessions before the new signer is usable. - // Check generation first: if this initialization has been superseded, + // Check generation first: if this initialization has been superseded by a newer login, // return without pruning and without publishing. if (generation !== initGeneration) { return @@ -165,14 +190,13 @@ export const disconnectAllWalletConnectSessions = async () => { initGeneration++ activeSigner = undefined const wallet = walletConnectState.walletConnectInstance - if (!wallet || wallet === "initializing") return - await Promise.allSettled( - Object.values(wallet.getActiveSessions()).map((session) => - wallet.disconnectSession({ - topic: session.topic, - reason: getSdkError("USER_DISCONNECTED"), - }), - ), - ) + if (!wallet || wallet === "initializing") { + if (wallet === "initializing") { + pendingLogoutCleanup = true + } + return + } + pendingLogoutCleanup = false + await disconnectAllSessions(wallet) refreshSessions() } From c1831d07f765ad87005726f845d4a7ba8a1b83a9 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Fri, 6 Nov 2026 23:00:59 +0100 Subject: [PATCH 21/21] test(swap): add regression test for RouteSlider token-capture guard (R2) - Add regression test verifying executeSelectedRoute captures initialAccountToken before dynamic LiFi import and aborts if token changes in-flight - Assert executeRoute and addPropagatingTx are not called on account change - Assert happy path executes route and records propagating tx when token is unchanged --- .../RouteSlider/RouteSlider.test.tsx | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 src/sections/Swap/components/RouteSlider/RouteSlider.test.tsx diff --git a/src/sections/Swap/components/RouteSlider/RouteSlider.test.tsx b/src/sections/Swap/components/RouteSlider/RouteSlider.test.tsx new file mode 100644 index 0000000..c9264a1 --- /dev/null +++ b/src/sections/Swap/components/RouteSlider/RouteSlider.test.tsx @@ -0,0 +1,312 @@ +import React from "react" +import type { Route } from "@lifi/sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mockCaptureEvent = vi.fn() +const mockMutateBalances = vi.fn() +const mockAddPropagatingTx = vi.fn() +const mockExecuteRoute = vi.fn() + +let currentAccountToken = 1 +let onImportStarted: (() => void) | undefined +let resolveImport: ((mod: Record) => void) | undefined + +vi.mock("@/config", () => ({ + config: { + env: "development", + }, +})) + +vi.mock("@/analytics/useAnalytics", () => ({ + useAnalytics: () => ({ captureEvent: mockCaptureEvent }), +})) + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock("valtio", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSnapshot: (store: unknown) => store, + } +}) + +vi.mock("@/translations/hooks/useTranslation", () => ({ + useTranslation: () => ({ + translations: { + swap: { + swapNotification: { + swapInitiated: (amt: string, sym: string) => + `Initiated ${amt} ${sym}`, + swapSucceeded: (amt: string, sym: string) => + `Succeeded ${amt} ${sym}`, + swapFailed: (amt: string, sym: string) => `Failed ${amt} ${sym}`, + swapSlow: "Slow", + userFriendlyErrors: { gasPrecheckFailed: "Gas failed" }, + }, + swapDialog: { + title: "Swap", + acceptBtnText: "Accept", + rejectBtnText: "Reject", + }, + routesSearchingTag: "Searching", + routesSelectRouteTag: "Select", + routesNoRoutesTag: "No routes", + routesReviewBtn: "Review", + }, + }, + }), +})) + +vi.mock("@/hooks/useTokenBalances", () => ({ + useTokenBalances: () => ({ + mutateBalancesForChain: mockMutateBalances, + balances: { + byChain: { + getNativeBy: () => ({ + amount: "1000", + decimals: 18, + }), + }, + }, + tokens: { + getNativeBy: () => ({ + decimals: 18, + symbol: "CELO", + }), + }, + }), +})) + +vi.mock("../SwapDialog/swapDialogStore", () => ({ + openSwapDialog: vi.fn().mockResolvedValue("accepted"), +})) + +vi.mock("ui", () => ({ + Button: (props: Record) => + React.createElement("button", props), + createToast: () => "toast-1", + updateToast: vi.fn(), + toastStore: {}, + Text: () => null, +})) + +vi.mock("../SwapDialog/ConfirmSwapDialog", () => ({ + ConfirmSwapDialog: () => null, +})) +vi.mock("../RouteBox/RouteBox", () => ({ + RouteBox: () => null, +})) +vi.mock("../OutOfGasWarn/OutOfGasWarn", () => ({ + OutOfGasWarn: () => null, +})) +vi.mock("@/components/Snippet/LoadingSpinner", () => ({ + LoadingSpinner: () => null, +})) + +vi.mock("../../lifiUtils", () => ({ + getPropagatingTxs: () => [ + { + hash: "0xpropagatinghash", + chainId: 42220, + method: "Swap", + from: "0xfrom", + to: "0xto", + tokenAddress: "0xtoken", + amount: "100", + timestamp: 1000, + }, + ], +})) + +vi.mock("@/stores/transactionHistoryStore", () => ({ + activityHistoryStore: { + getCurrentAccountToken: () => currentAccountToken, + addPropagatingTx: (...args: unknown[]) => mockAddPropagatingTx(...args), + }, +})) + +vi.mock("../../lifiStore", () => ({ + lifiState: { + isInitialized: true, + client: { dummy: true }, + }, +})) + +vi.mock("@lifi/sdk", () => { + onImportStarted?.() + return new Promise>((resolve) => { + resolveImport = resolve + }) +}) + +const mockRoute: Route = { + id: "route-1", + fromChainId: 42220, + toChainId: 42220, + fromAmount: "1000000000000000000", + toAmount: "1000000000000000000", + toAmountMin: "1000000000000000000", + fromAmountUSD: "1.0", + toAmountUSD: "1.0", + fromToken: { + address: "0xTokenA", + chainId: 42220, + symbol: "G$", + decimals: 18, + name: "GoodDollar", + priceUSD: "1.0", + }, + toToken: { + address: "0xTokenB", + chainId: 42220, + symbol: "cUSD", + decimals: 18, + name: "Celo Dollar", + priceUSD: "1.0", + }, + steps: [ + { + id: "step-1", + type: "lifi", + tool: "tool", + action: {} as unknown as Route["steps"][0]["action"], + estimate: { + gasCosts: [], + feeCosts: [], + } as unknown as Route["steps"][0]["estimate"], + execution: { + status: "DONE", + process: [], + }, + } as unknown as Route["steps"][0], + ], + tags: [], + insurance: { state: "NOT_INSURABLE", feeAmountUsd: "0" }, +} + +vi.mock("swr/immutable", () => ({ + default: () => ({ + data: [mockRoute], + isValidating: false, + error: undefined, + }), +})) + +import RouteSlider from "./RouteSlider" + +describe("RouteSlider - account token capture guard (B15/R2)", () => { + const setupDispatcher = () => { + const internals = ( + React as unknown as { + __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { + H: unknown + } + } + ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE + + internals.H = { + useState: (initial: unknown) => { + const val = + initial === undefined + ? mockRoute + : typeof initial === "function" + ? (initial as () => unknown)() + : initial + return [val, () => {}] + }, + useCallback: (fn: unknown) => fn, + useMemo: (fn: () => unknown) => fn(), + useEffect: () => {}, + useLayoutEffect: () => {}, + useDebugValue: () => {}, + useRef: (initial: unknown) => ({ current: initial }), + useContext: () => ({}), + useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => + getSnapshot(), + useId: () => "id", + } + } + + beforeEach(() => { + vi.clearAllMocks() + setupDispatcher() + currentAccountToken = 1 + mockExecuteRoute.mockResolvedValue({ + ...mockRoute, + steps: [ + { + id: "step-1", + execution: { status: "DONE" }, + }, + ], + }) + }) + + it("aborts execution and does not record propagating tx when account changes while LiFi SDK import is in flight", async () => { + let importStarted = false + const importStartedPromise = new Promise((resolve) => { + onImportStarted = () => { + importStarted = true + resolve() + } + }) + + const element = RouteSlider() + const children = React.Children.toArray(element.props.children) + const button = children.find( + (c) => + React.isValidElement(c) && + typeof (c.props as Record)?.onClick === "function", + ) as React.ReactElement<{ onClick: () => Promise }> + + expect(button).toBeDefined() + + // Account A is active when user clicks swap + currentAccountToken = 1 + const swapExecutionPromise = button.props.onClick() + + // Wait until executeSelectedRoute has begun awaiting import("@lifi/sdk") + await importStartedPromise + expect(importStarted).toBe(true) + + // While @lifi/sdk import is pending, session switches to Account B + currentAccountToken = 2 + + // Now the dynamic import resolves + resolveImport?.({ executeRoute: mockExecuteRoute }) + await swapExecutionPromise + + // Route must NOT be executed under the wrong account + expect(mockExecuteRoute).not.toHaveBeenCalled() + // No propagating tx must be recorded under Account B for Account A's swap + expect(mockAddPropagatingTx).not.toHaveBeenCalled() + }) + + it("proceeds with execution and records propagating tx when account remains unchanged during import", async () => { + const element = RouteSlider() + const children = React.Children.toArray(element.props.children) + const button = children.find( + (c) => + React.isValidElement(c) && + typeof (c.props as Record)?.onClick === "function", + ) as React.ReactElement<{ onClick: () => Promise }> + + expect(button).toBeDefined() + + // Account A is active throughout + currentAccountToken = 1 + const swapExecutionPromise = button.props.onClick() + + // Dynamic import is already resolved or resolves with account token unchanged + resolveImport?.({ executeRoute: mockExecuteRoute }) + await swapExecutionPromise + + // Route is executed + expect(mockExecuteRoute).toHaveBeenCalled() + // Propagating tx is recorded with Account A's initial token + expect(mockAddPropagatingTx).toHaveBeenCalledWith(expect.anything(), 1) + }) +})