Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/resilient-session-pairings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@swapkit/wallets": patch
---

Retry stale WalletConnect pairings with a fresh QR pairing instead of failing the connect.
67 changes: 52 additions & 15 deletions packages/wallets/src/walletconnect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<SessionTypes.Struct>; 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<ReturnType<PairingConnectClient["connect"]>>;
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;
}

Expand Down
87 changes: 86 additions & 1 deletion packages/wallets/test/walletconnect-session-reuse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +13,7 @@ import {
getPreferredPairingTopic,
getPreferredSession,
getSessionDirectSigningSupport,
type PairingConnectClient,
type WalletconnectLifecycleClient,
} from "../src/walletconnect";
import { chainToChainId } from "../src/walletconnect/helpers";
Expand Down Expand Up @@ -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" }),
Expand All @@ -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: {
Expand Down Expand Up @@ -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<PairingConnectClient["connect"]>[0][] = [];
const client = {
connect(params: Parameters<PairingConnectClient["connect"]>[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<PairingConnectClient["connect"]>[0][] = [];
const connectionError = new Error("Fresh pairing failed");
const client = {
connect(params: Parameters<PairingConnectClient["connect"]>[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" });
Expand Down
Loading