From bb9d8d521bb18d27b9329724323fddb7c67afb06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Jul 2026 15:39:24 +0400 Subject: [PATCH] fix(walletconnect): retry stale pairings with a fresh QR pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto develop: the MetaMask partial-grant half of this PR landed separately as #135 — this keeps only the WC stale-pairing fallback (client.connect with a reused pairingTopic throws on a stale relay topic; fall back to a fresh pairing so the QR flow still appears) and its tests. Co-Authored-By: Claude Fable 5 --- .changeset/resilient-session-pairings.md | 5 ++ packages/wallets/src/walletconnect/index.ts | 67 ++++++++++---- .../test/walletconnect-session-reuse.test.ts | 87 ++++++++++++++++++- 3 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 .changeset/resilient-session-pairings.md diff --git a/.changeset/resilient-session-pairings.md b/.changeset/resilient-session-pairings.md new file mode 100644 index 0000000..5dbf478 --- /dev/null +++ b/.changeset/resilient-session-pairings.md @@ -0,0 +1,5 @@ +--- +"@swapkit/wallets": patch +--- + +Retry stale WalletConnect pairings with a fresh QR pairing instead of failing the connect. diff --git a/packages/wallets/src/walletconnect/index.ts b/packages/wallets/src/walletconnect/index.ts index 5d7240d..23c8e7c 100644 --- a/packages/wallets/src/walletconnect/index.ts +++ b/packages/wallets/src/walletconnect/index.ts @@ -7,7 +7,7 @@ import type { TronSignedTransaction, TronSigner, TronTransaction } from "@swapki import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; import type { WalletConnectModal } from "@walletconnect/modal"; import type SignClientClient from "@walletconnect/sign-client"; -import type { PairingTypes, SessionTypes, SignClientTypes } from "@walletconnect/types"; +import type { PairingTypes, ProposalTypes, SessionTypes, SignClientTypes } from "@walletconnect/types"; import { DEFAULT_APP_METADATA, DEFAULT_COSMOS_METHODS, @@ -481,16 +481,13 @@ async function getWalletconnect( ...walletconnectOptions?.core, }); - const pairingTopic = getPreferredPairingTopic(client); - // @walletconnect/sign-client deprecates pairingTopic; an offline wallet yields no QR URI and approval can wait - // for the ~5-minute proposal TTL. Accept for now; revisit on the next WalletConnect major bump. - const { uri, approval } = await client.connect({ optionalNamespaces, pairingTopic, requiredNamespaces }); - - if (uri) { - modal.openModal({ uri }); - } - - const session = await approval(); + const session = await connectWithPairingFallback({ + client, + onUri: (uri) => modal?.openModal({ uri }), + optionalNamespaces, + pairingTopic: getPreferredPairingTopic(client), + requiredNamespaces, + }); if (!session) { throw new SwapKitError("wallet_walletconnect_connection_not_established"); @@ -593,21 +590,61 @@ export interface PreferredPairingClient { session: { getAll(): SessionTypes.Struct[] }; } +export interface PairingConnectClient { + connect(params: { + optionalNamespaces: ProposalTypes.OptionalNamespaces; + pairingTopic?: string; + requiredNamespaces: ProposalTypes.RequiredNamespaces; + }): Promise<{ approval: () => Promise; uri?: string }>; +} + +export async function connectWithPairingFallback({ + client, + onUri, + optionalNamespaces, + pairingTopic, + requiredNamespaces, +}: { + client: PairingConnectClient; + onUri: (uri: string) => void; + optionalNamespaces: ProposalTypes.OptionalNamespaces; + pairingTopic?: string; + requiredNamespaces: ProposalTypes.RequiredNamespaces; +}) { + const connect = (topic?: string) => { + // @walletconnect/sign-client deprecates pairingTopic. Try a reusable pairing once for compatibility, then + // fall back to a fresh pairing so a stale relay topic cannot suppress the QR flow. + return client.connect({ optionalNamespaces, requiredNamespaces, ...(topic ? { pairingTopic: topic } : {}) }); + }; + + let connection: Awaited>; + try { + connection = await connect(pairingTopic); + } catch (error) { + if (!pairingTopic) throw error; + connection = await connect(); + } + + if (connection.uri) onUri(connection.uri); + return connection.approval(); +} + export function getPreferredPairingTopic(client: PreferredPairingClient) { const sessions = client.session .getAll() .filter((session) => !isExpired(session.expiry)) .sort((sessionA, sessionB) => sessionB.expiry - sessionA.expiry); - if (sessions[0]?.pairingTopic) { - return sessions[0].pairingTopic; - } - const pairings = client.core.pairing .getPairings() .filter((pairing) => pairing.active && !isExpired(pairing.expiry)) .sort((pairingA, pairingB) => pairingB.expiry - pairingA.expiry); + const sessionPairingTopic = sessions[0]?.pairingTopic; + if (sessionPairingTopic && pairings.some((pairing) => pairing.topic === sessionPairingTopic)) { + return sessionPairingTopic; + } + return pairings[0]?.topic; } diff --git a/packages/wallets/test/walletconnect-session-reuse.test.ts b/packages/wallets/test/walletconnect-session-reuse.test.ts index 4f6407f..eb09e4b 100644 --- a/packages/wallets/test/walletconnect-session-reuse.test.ts +++ b/packages/wallets/test/walletconnect-session-reuse.test.ts @@ -3,6 +3,7 @@ import { Chain } from "@swapkit/helpers"; import type { PairingTypes, SessionTypes, SignClientTypes } from "@walletconnect/types"; import { + connectWithPairingFallback, createWalletconnectConnection, DEFAULT_COSMOS_METHODS, DEFAULT_EIP_155_EVENTS, @@ -12,6 +13,7 @@ import { getPreferredPairingTopic, getPreferredSession, getSessionDirectSigningSupport, + type PairingConnectClient, type WalletconnectLifecycleClient, } from "../src/walletconnect"; import { chainToChainId } from "../src/walletconnect/helpers"; @@ -281,7 +283,7 @@ describe("WalletConnect session reuse", () => { it("prefers the most recent unexpired session pairing topic", () => { const client = { - core: { pairing: { getPairings: () => [makePairing({ expiry: now() + 300, topic: "pairing" })] } }, + core: { pairing: { getPairings: () => [makePairing({ expiry: now() + 300, topic: "later-pairing" })] } }, session: { getAll: () => [ makeSession({ expiry: now() + 60, pairingTopic: "earlier-pairing", topic: "earlier" }), @@ -294,6 +296,42 @@ describe("WalletConnect session reuse", () => { expect(getPreferredPairingTopic(client)).toBe("later-pairing"); }); + it("ignores a session pairing topic that is not an active unexpired pairing", () => { + const client = { + core: { + pairing: { + getPairings: () => [ + makePairing({ expiry: now() + 120, topic: "active-pairing" }), + makePairing({ active: false, expiry: now() + 300, topic: "session-pairing" }), + ], + }, + }, + session: { + getAll: () => [makeSession({ expiry: now() + 300, pairingTopic: "session-pairing", topic: "session" })], + }, + }; + + expect(getPreferredPairingTopic(client)).toBe("active-pairing"); + }); + + it("returns a session pairing topic when its pairing is active and unexpired", () => { + const client = { + core: { + pairing: { + getPairings: () => [ + makePairing({ expiry: now() + 60, topic: "session-pairing" }), + makePairing({ expiry: now() + 300, topic: "newer-pairing" }), + ], + }, + }, + session: { + getAll: () => [makeSession({ expiry: now() + 300, pairingTopic: "session-pairing", topic: "session" })], + }, + }; + + expect(getPreferredPairingTopic(client)).toBe("session-pairing"); + }); + it("falls back to the most recent active unexpired pairing", () => { const client = { core: { @@ -329,6 +367,53 @@ describe("WalletConnect session reuse", () => { }); }); +describe("connectWithPairingFallback", () => { + it("retries a rejected reused pairing with a fresh pairing and opens its URI", async () => { + const freshSession = makeSession({ expiry: now() + 300, topic: "fresh-session" }); + const connectCalls: Parameters[0][] = []; + const client = { + connect(params: Parameters[0]) { + connectCalls.push(params); + if (params.pairingTopic) return Promise.reject(new Error("Failed to publish custom payload")); + + return Promise.resolve({ approval: () => Promise.resolve(freshSession), uri: "wc:fresh-uri" }); + }, + } satisfies PairingConnectClient; + const uris: string[] = []; + + const session = await connectWithPairingFallback({ + client, + onUri: (uri) => uris.push(uri), + optionalNamespaces: {}, + pairingTopic: "stale-pairing", + requiredNamespaces: {}, + }); + + expect(connectCalls).toHaveLength(2); + expect(connectCalls[0]).toHaveProperty("pairingTopic", "stale-pairing"); + expect(connectCalls[1]).not.toHaveProperty("pairingTopic"); + expect(uris).toEqual(["wc:fresh-uri"]); + expect(session).toBe(freshSession); + }); + + it("propagates a fresh pairing failure after one attempt", async () => { + const connectCalls: Parameters[0][] = []; + const connectionError = new Error("Fresh pairing failed"); + const client = { + connect(params: Parameters[0]) { + connectCalls.push(params); + return Promise.reject(connectionError); + }, + } satisfies PairingConnectClient; + + await expect( + connectWithPairingFallback({ client, onUri: () => {}, optionalNamespaces: {}, requiredNamespaces: {} }), + ).rejects.toBe(connectionError); + expect(connectCalls).toHaveLength(1); + expect(connectCalls[0]).not.toHaveProperty("pairingTopic"); + }); +}); + describe("createWalletconnectConnection", () => { it("clears the current session and accounts on session_delete", () => { const session = makeSession({ expiry: now() + 60, topic: "session" });