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 3f4225a..4491d70 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, })), })) @@ -47,7 +50,7 @@ vi.mock("@/gooddollar/methods/divvi", () => ({ // ── Import after mocks ───────────────────────────────────────────────────── -import { sendTx, sendTxWaitForMining } from "./utils" +import { isConfirmedTxFailure, sendTx, sendTxWaitForMining } from "./utils" // ── Helpers ───────────────────────────────────────────────────────────────── @@ -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,252 @@ 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) + // 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", + chainId: request.chainId, + }) + }) + + 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", + reason: "cancelled", + receipt: cancelReceipt, + replacement: { hash: "0xcancelhash" }, + }) + + txResponse.replaceableTransaction.mockReturnValue({ + ...txResponse, + wait: vi.fn().mockRejectedValue(cancelError), + }) + 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("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" }, + }, + ) + + 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("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 + .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("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[] = [] + mockGetBlockNumber.mockImplementation(async () => { + callOrder.push("getBlockNumber") + return START_BLOCK + }) + walletSigner.submitTransaction.mockImplementation(async () => { + callOrder.push("submitTransaction") + return TX_HASH + }) + mockGetTransaction.mockResolvedValue(txResponse) + + await sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + expect(callOrder).toEqual(["getBlockNumber", "submitTransaction"]) + expect(txResponse.replaceableTransaction).toHaveBeenCalledWith( + START_BLOCK, + ) + }) + }) + + 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 10cad07..14e1973 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,17 @@ const SUBMITTED_TX_MAX_ATTEMPTS = 40 const getSubmittedTransaction = async ( provider: AbstractProvider, hash: string, -): Promise => { + 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 + 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`) @@ -70,13 +82,19 @@ 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) - 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, startBlock) + return { hash, txInfo } } export const sendTx = async ( @@ -90,15 +108,21 @@ 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(async ({ txResponse, startBlock }) => { + const { txReceipt } = await waitForMinedStatus( + txResponse, + startBlock, + broadcastRequest, + signer, + ) + return txReceipt + }) return { hash, trxMinedPromise } } @@ -115,8 +139,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, + ).then((res) => res.txReceipt) return { hash: trx.hash, trxMinedPromise } } finally { lock.release(broadcastRequest.chainId) @@ -136,13 +167,19 @@ export const sendTxWaitForMining = async ( gasLimit, broadcastRequest, ) - const txResponse = await submitted.txResponse - const txReceipt = await waitForMinedStatus( + const { txResponse, startBlock } = await submitted.txInfo + 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 @@ -162,14 +199,21 @@ export const sendTxWaitForMining = async ( const txResponse = await provider.broadcastTransaction(signedTx) console.info("sendTx got response", { txResponse }) - const txReceipt = await waitForMinedStatus( + // broadcastTransaction already attaches replacement tracking; pass undefined. + const { txReceipt, replacementTxResponse } = await waitForMinedStatus( txResponse, + undefined, broadcastRequest, signer, ) 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) } @@ -177,10 +221,65 @@ export const sendTxWaitForMining = async ( async function waitForMinedStatus( txResponse: TransactionResponse, + startBlock: number | undefined, broadcastRequest: BroadcastRequest, signer: EVMSigner, -) { - const txReceipt = await txResponse.wait() +): 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 + // rather than hanging until the original hash is eventually dropped. + const tracked = + startBlock !== undefined + ? txResponse.replaceableTransaction(startBlock) + : txResponse + + let txReceipt: TransactionReceipt | null + let replacementTxResponse: TransactionResponse | undefined + try { + txReceipt = await tracked.wait() + } catch (err) { + // TRANSACTION_REPLACED is thrown when a replacement tx mines with the same + // 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 + } + 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 + } + } // send marker to divvi and mark as done if (broadcastRequest.data?.includes(GD_DATASUFFIX) && txReceipt?.hash) { @@ -196,7 +295,7 @@ async function waitForMinedStatus( console.error("divvi failed", { error }) }) } - return txReceipt + return { txReceipt, replacementTxResponse } } async function prepareTx( @@ -318,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/hooks/useTokenBalances/stores/amountsStore.test.ts b/src/hooks/useTokenBalances/stores/amountsStore.test.ts new file mode 100644 index 0000000..1af2f78 --- /dev/null +++ b/src/hooks/useTokenBalances/stores/amountsStore.test.ts @@ -0,0 +1,353 @@ +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 { sessionState } from "@/login/context/SessionContext/storage" +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(async () => { + vi.resetAllMocks() + sessionState.addresses = undefined + await refreshAmounts(undefined, undefined, "all") + 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 + 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") + + // 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 + }) + + sessionState.addresses = addressesA + mockGetAmounts.mockImplementationOnce(() => delayedPromiseA) + const refreshPromiseA = refreshAmounts(tokens, addressesA, 42220) + + sessionState.addresses = addressesB + 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("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", [ + [ + "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") + 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 + 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") + + 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"), + ], + ]) + + 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 + }) + 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 + }) + + 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 30f3594..a34bc26 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.ts @@ -44,32 +44,64 @@ async function getAmountsForChain( return provider.getAmounts(chainId, tokensOfChain, address) } +// 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", ) => { if (!tokens || !addresses) { + sessionEpoch++ + 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 or an ended session. + if (!sessionState.addresses || addresses !== sessionState.addresses) { return } + if (lastAddresses !== addresses) { + sessionEpoch++ + chainVersions.clear() + lastAddresses = addresses + amountsStore.amounts.clear() + 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, - ) - 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) => @@ -78,17 +110,33 @@ 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, ) + // 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 { - amountsStore.isLoading = false - amountsStore.isValidating = false + if (currentEpoch === sessionEpoch) { + inFlightRequests-- + if (inFlightRequests <= 0) { + inFlightRequests = 0 + amountsStore.isLoading = false + amountsStore.isValidating = false + } + } } } refreshAmounts(tokensStore.tokens, sessionState.addresses, "all") @@ -97,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 new file mode 100644 index 0000000..d0da53b --- /dev/null +++ b/src/login/context/SessionContext/storage.test.ts @@ -0,0 +1,75 @@ +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(), + initActivityAccount: 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 fa855b0..1ff5a32 100644 --- a/src/login/context/SessionContext/storage.ts +++ b/src/login/context/SessionContext/storage.ts @@ -9,6 +9,10 @@ import { } from "@/login/reown" import type { Addresses, ISigner, ISignerSession } from "@/login/types" import { resetWalletConnectDialogs } from "@/sections/WalletConnect/store/walletConnectDialogStore" +import { + clearActivityHistory, + initActivityAccount, +} from "@/stores/transactionHistoryStore" const SIGNER_SESSION_KEY = "SIGNER_SESSION" @@ -61,7 +65,10 @@ const fromJSON = async ( return null } -export type ReconnectReason = "accountChanged" | "restoreFailed" +export type ReconnectReason = + | "accountChanged" + | "restoreFailed" + | "disconnected" type SessionState = { session: null | ISignerSession @@ -94,24 +101,33 @@ 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. + 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 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("disconnected"), ) } } @@ -129,10 +145,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..72d395a --- /dev/null +++ b/src/login/reown.test.ts @@ -0,0 +1,325 @@ +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") + }) + + 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") + }) + + 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 4b1b887..0838032 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, @@ -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 ( @@ -279,15 +295,27 @@ 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) => { - 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 ( + 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 +333,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 +366,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") } 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/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/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.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 25598b4..bdfb0c0 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, @@ -253,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() @@ -283,25 +309,32 @@ 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, + ) + + handleTrxMinedOutcome(trxMinedPromise, chainId, hash, accountToken) + return { hash, trxMinedPromise } }) }, [ 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) + }) +}) diff --git a/src/sections/Swap/components/RouteSlider/RouteSlider.tsx b/src/sections/Swap/components/RouteSlider/RouteSlider.tsx index 54b181f..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) => { @@ -429,7 +435,7 @@ export default function RouteSlider( if (allStepsSucceeded) { const propagatingTxs = getPropagatingTxs(routeExtended) for (const tx of propagatingTxs) { - addPropagatingTx(tx) + addPropagatingTx(tx, initialAccountToken) } captureEvent({ diff --git a/src/sections/WalletConnect/requests/RequestHandler.test.ts b/src/sections/WalletConnect/requests/RequestHandler.test.ts new file mode 100644 index 0000000..7073272 --- /dev/null +++ b/src/sections/WalletConnect/requests/RequestHandler.test.ts @@ -0,0 +1,366 @@ +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" + +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: MockWalletKit + 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, + getActiveSessions: vi.fn(() => ({})), + 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"), + }), + }) + }) + + 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", + }), + ) + }) + + 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 3707213..40e40d4 100644 --- a/src/sections/WalletConnect/requests/RequestHandler.ts +++ b/src/sections/WalletConnect/requests/RequestHandler.ts @@ -16,87 +16,28 @@ 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 { isSessionCompatibleWithSigner } from "../utils/sessionValidation" +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, @@ -110,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 { @@ -154,6 +107,63 @@ 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 + } + + 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 } @@ -241,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`) } @@ -260,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": @@ -267,7 +288,7 @@ export const setupListeners = ( break case "eip155": response = await approveEIP155Request( - new WalletWrapper(signer.EVM), + new WalletWrapper(currentSigner.EVM), requestEvent, ) break @@ -278,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 @@ -288,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 new file mode 100644 index 0000000..350cf19 --- /dev/null +++ b/src/sections/WalletConnect/store/walletConnectStore.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("../requests/RequestHandler", () => ({ + setupListeners: vi.fn(), +})) + +vi.mock("@/config", () => ({ + config: { + walletConnectEnabled: true, + walletConnectProjectId: "test-project-id", + }, +})) + +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" + +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, + walletConnectState, +} 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 + + 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(), + }) + _resetWalletConnectForTesting() + walletConnectState.walletConnectInstance = undefined + walletConnectState.sessions = [] + }) + + it("considers an EVM session compatible only if its account matches the active signer", () => { + 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 (helper)", async () => { + 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"), + }), + }) + }) + + 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]) + }) + + 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) + }) + + 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) + }) + + 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 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: () => ({ + "topic-restored-a": restoredSession, + }), + 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 + + // 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") + }) + + 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 d5138f4..8cb78e2 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -10,6 +10,9 @@ import type { ISigner } from "@/login" import { getWCUri } from "@/utils/reownAppKitWrapper" import { setupListeners } from "../requests/RequestHandler" +import { isSessionCompatibleWithSigner } from "../utils/sessionValidation" + +export { isSessionCompatibleWithSigner } export const INITIALIZE_WALLET_CONNECT_KEY = "INITIALIZE_WALLET_CONNECT" @@ -22,52 +25,147 @@ 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 } +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"), + }), + ), + ) + } +} + +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 } - activeSigner = signer + pendingLogoutCleanup = false + const generation = ++initGeneration + activeSigner = 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", - ) + 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 + })() } - const url = window.location.origin - const iconUrls = icons.map((icon) => `${url}${icon.src}`) - const metadata = { - name, - description, - icons: iconUrls, - url, + try { + wallet = await walletKitInitPromise + } catch (e) { + walletKitInitPromise = undefined + walletConnectState.walletConnectInstance = undefined + throw e } - const wallet = await WalletKit.init({ - core, - metadata, - }) - setupListeners(getActiveSigner, wallet, refreshSessions) - const wcUri = getWCUri() - - if (wcUri) { - wallet.pair({ uri: wcUri }) + } + + // 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() } - walletConnectState.walletConnectInstance = ref(wallet) + 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 by a newer login, + // 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 during pruning. + if (generation === initGeneration) { + activeSigner = signer + refreshSessions() } - refreshSessions() } const refreshSessions = () => { @@ -89,15 +187,16 @@ export const walletConnectState = proxy({ }) 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() } diff --git a/src/sections/WalletConnect/utils/WalletWrapper.test.ts b/src/sections/WalletConnect/utils/WalletWrapper.test.ts index 553457f..472a6dc 100644 --- a/src/sections/WalletConnect/utils/WalletWrapper.test.ts +++ b/src/sections/WalletConnect/utils/WalletWrapper.test.ts @@ -25,6 +25,69 @@ 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("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 39bf8d2..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 { @@ -37,6 +38,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. 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}`, + ) + } return await this.#signer.submitTransaction({ ...tx, from: this.address }) } return (await this.sendTransaction(tx)).hash 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 new file mode 100644 index 0000000..ec7063a --- /dev/null +++ b/src/sections/WalletConnect/utils/supportedNamespaces.ts @@ -0,0 +1,93 @@ +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 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, + ...UNSUPPORTED_EIP155_METHODS, +]) + +export const isExternalWallet = (signer: ISigner) => + !!signer.EVM?.submitTransaction + +export const getEip155Methods = (signer: ISigner) => + Object.values(EIP155_SIGNING_METHODS).filter( + (method) => + !UNSUPPORTED_EIP155_METHODS.has(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/stores/transactionHistoryStore.test.ts b/src/stores/transactionHistoryStore.test.ts new file mode 100644 index 0000000..0dd631d --- /dev/null +++ b/src/stores/transactionHistoryStore.test.ts @@ -0,0 +1,290 @@ +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, + initActivityAccount, +} 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() + mockGetTransactions.mockResolvedValue([]) + 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, + activityHistoryStore.getCurrentAccountToken(), + ) + expect(activityHistoryStore.perChain.get(42220)?.length).toBe(2) + + 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 () => { + 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) + }) + + 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) + }) + + 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("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) + 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 be1de42..e033302 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -7,14 +7,54 @@ 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 +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 + * previous session cannot appear under a new account. + */ +export const clearActivityHistory = () => { + activeAccount = undefined + currentAccountToken++ + internalTxs.clear() + lastRefreshAllTimestamp = 0 + activityHistoryStore.perChain = proxyMap() +} + type ActivityHistoryStore = { perChain: Map isLoading: boolean refreshHistory: (addresses: Addresses | undefined) => Promise - addPropagatingTx: (tx: Tx) => 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 } const combineTxs = (a: Tx[], b: Tx[]) => { @@ -25,7 +65,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 !== currentAccountToken) { + return + } const internalTxsForChain = internalTxs.get(tx.chainId) || [] const combinedInternalTxsForChain = combineTxs(internalTxsForChain, [tx]) //If same length, no new internal txs added @@ -42,27 +85,124 @@ const addPropagatingTx = (tx: Tx) => { 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 newLower = newHash.toLowerCase() + + const internal = internalTxs.get(chainId) + const perChain = activityHistoryStore.perChain.get(chainId) + + 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), + ) + } else { + internalTxs.set( + chainId, + internal.map((tx) => + tx.hash.toLowerCase() === oldLower ? { ...tx, hash: newHash } : tx, + ), + ) + } + } + + if (perChain) { + if (replacementInPerChain) { + activityHistoryStore.perChain.set( + chainId, + perChain.filter((tx) => tx.hash.toLowerCase() !== oldLower), + ) + } else { + 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() + + // 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) { + clearActivityHistory() + activeAccount = account + } + 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 +210,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 +218,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( @@ -91,4 +239,7 @@ export const activityHistoryStore = proxy({ isLoading: false, refreshHistory: refreshHistory, addPropagatingTx: addPropagatingTx, + replacePropagatingTx: replacePropagatingTx, + removePropagatingTx: removePropagatingTx, + getCurrentAccountToken: getCurrentAccountToken, }) 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: { 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"),