From 83bfd8b58f3e0a4b7a33be988454759c1cd26a05 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Mon, 17 Aug 2026 23:32:13 -0700 Subject: [PATCH 1/6] Support connecting mobile wallets via WalletConnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the kit's WalletConnectModule so users can connect mobile wallets, including Freighter mobile, by scanning a QR code. Closes #2172. WalletConnect is offered on mainnet and testnet only, since those are the only Stellar chains it exposes (stellar:pubnet / stellar:testnet), and is loaded on demand — when a wallet modal opens or a saved session is restored — rather than at startup. It pulls in @reown/appkit and the WalletConnect sign client, about 320 kB gzipped that most sessions never need, so the module lives in its own chunk behind a dynamic import. CSP additions in middleware.ts, all found by exercising the connect flow in a browser: wss://relay.walletconnect.org for the relay (the `https:` source doesn't cover the wss: scheme), fonts.reown.com, api.web3modal.org for wallet images, and verify.walletconnect.org for the domain-attestation iframe. Sessions survive a reload — the kit persists the address and session topic to localStorage — so a saved session is restored without re-scanning. The restore path verifies the sign client actually holds an unexpired session authorizing that address on the current chain before reporting a connected wallet, otherwise every later signing request would fail with no explanation. Auto-connect can't go through the module's getAddress(), which ignores skipRequestAccess and would start a fresh pairing on every load. Switching networks ends a WalletConnect session: it's approved for a single chain, and updating allowedChains only affects the next pairing, so signing over the old topic with a new chain id would fail. Disconnect also tears down the kit's own state, and the kit's DISCONNECT event is mirrored into Lab's so a session dropped by the wallet doesn't leave a stale address. The project id comes from NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID, plumbed through the Dockerfile, Makefile and laboratory-build.yml via the WALLET_CONNECT_PROJECT_ID secret. A default is committed so the kube PR preview — which can't inject build-time env vars — has one; production should override it with an SDF-owned project. Whichever project is used, its allowlist must include the deployed origin or the relay refuses the connection outright with 3000 (Unauthorized: origin not allowed). Note that WalletConnect signing requires an https origin: from http://localhost, Freighter mobile approves the session and returns the account but then refuses to sign, reporting the domain as not connected. Local testing needs a tunnel or the kube preview. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/laboratory-build.yml | 3 + Dockerfile | 4 + Makefile | 2 +- README.md | 14 + src/components/SignMessage/index.tsx | 4 + src/components/WalletKit/ConnectWallet.tsx | 120 +++++++- .../WalletKit/WalletKitContextProvider.tsx | 105 ++++++- src/components/WalletKit/walletConnect.ts | 261 ++++++++++++++++++ src/hooks/useSignWithExtensionWallet.ts | 4 + src/middleware.ts | 14 +- tests/unit/walletConnect.test.ts | 82 ++++++ 11 files changed, 584 insertions(+), 29 deletions(-) create mode 100644 src/components/WalletKit/walletConnect.ts create mode 100644 tests/unit/walletConnect.test.ts diff --git a/.github/workflows/laboratory-build.yml b/.github/workflows/laboratory-build.yml index 1d0087c46..2b8c6d562 100644 --- a/.github/workflows/laboratory-build.yml +++ b/.github/workflows/laboratory-build.yml @@ -29,6 +29,9 @@ jobs: env: ECR_REGISTRY: ${{ steps.ecr-login.outputs.ecr-registry }} LABEL: ${{ steps.vars.outputs.label }} + # Reown (WalletConnect) project id. When unset, WalletConnect is left + # out of the Connect Wallet modal. + NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} run: | set -eu export TAG="${ECR_REGISTRY}/dev/laboratory-v2:${LABEL}" diff --git a/Dockerfile b/Dockerfile index c210e18c3..f8e7c173f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,10 @@ COPY . . # Passing env var to be used on client side ARG NEXT_PUBLIC_COMMIT_HASH +# Reown (WalletConnect) project id. WalletConnect is left out of the Connect +# Wallet modal when this is empty. +ARG NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID +ENV NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID RUN corepack enable RUN pnpm install diff --git a/Makefile b/Makefile index 26d3bc9fa..0ad00db58 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ TAG ?= stellar/laboratory:$(LABEL) BUILD_DATE := $(shell date -u +%FT%TZ) docker-build: - $(SUDO) docker build --pull --label org.opencontainers.image.created="$(BUILD_DATE)" -t $(TAG) --build-arg=NEXT_PUBLIC_COMMIT_HASH="$(shell git rev-parse --short HEAD)" . + $(SUDO) docker build --pull --label org.opencontainers.image.created="$(BUILD_DATE)" -t $(TAG) --build-arg=NEXT_PUBLIC_COMMIT_HASH="$(shell git rev-parse --short HEAD)" --build-arg=NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="$(NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID)" . docker-push: $(SUDO) docker push $(TAG) diff --git a/README.md b/README.md index fbbe23022..53a603716 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,20 @@ import { MAINNET_LIMITS, NETWORK_LIMITS } from "@/constants/networkLimits"; To manually fetch limits: `pnpm fetch-limits`. +### WalletConnect + +Connecting mobile wallets over WalletConnect requires a project id from +[Reown Cloud](https://cloud.reown.com/). Set it before starting the app: + +``` +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_project_id +``` + +Without it, WalletConnect is left out of the Connect Wallet modal and everything +else works as before. WalletConnect is only offered on mainnet and testnet, +since those are the only Stellar chains it exposes (`stellar:pubnet` and +`stellar:testnet`). + ### Hardware Wallets Testing hardware wallets requires an HTTPS connection to enable U2F. The diff --git a/src/components/SignMessage/index.tsx b/src/components/SignMessage/index.tsx index 11f1fc7e0..d1e8c5cb9 100644 --- a/src/components/SignMessage/index.tsx +++ b/src/components/SignMessage/index.tsx @@ -74,6 +74,10 @@ export const SignMessage = ({ // Not connected via the main nav — open the kit's auth modal to pick one. if (!address) { + // Register WalletConnect first — the kit snapshots its wallet list when + // the modal opens, so a module added later wouldn't appear in it. + await walletKitInstance.ensureWalletConnect(); + const auth = await StellarWalletsKit.authModal(); address = auth.address; diff --git a/src/components/WalletKit/ConnectWallet.tsx b/src/components/WalletKit/ConnectWallet.tsx index 40e5fa278..f25a1f9fb 100644 --- a/src/components/WalletKit/ConnectWallet.tsx +++ b/src/components/WalletKit/ConnectWallet.tsx @@ -1,8 +1,11 @@ "use client"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { Button, Modal, Text } from "@stellar/design-system"; -import { StellarWalletsKit } from "@creit.tech/stellar-wallets-kit"; +import { + KitEventType, + StellarWalletsKit, +} from "@creit.tech/stellar-wallets-kit"; import { useStore } from "@/store/useStore"; import { useAccountInfo } from "@/query/useAccountInfo"; @@ -13,6 +16,10 @@ import { localStorageSavedWallet } from "@/helpers/localStorageSavedWallet"; import { ConnectedModal } from "@/components/WalletKit/ConnectedModal"; import { WalletKitContext } from "@/components/WalletKit/WalletKitContextProvider"; +import { + hasLiveWalletConnectSession, + WALLET_CONNECT_ID, +} from "@/components/WalletKit/walletConnect"; import { trackEvent, TrackingEvent } from "@/metrics/tracking"; @@ -25,6 +32,7 @@ export const ConnectWallet = () => { useState(false); const walletKitInstance = useContext(WalletKitContext); const savedWallet = localStorageSavedWallet.get(); + const isSavedWalletConnect = savedWallet?.id === WALLET_CONNECT_ID; const { data: accountInfo, refetch: fetchAccountInfo } = useAccountInfo({ publicKey: walletKit?.publicKey || "", @@ -32,7 +40,7 @@ export const ConnectWallet = () => { headers: network ? getNetworkHeaders(network, "horizon") : {}, }); - const disconnect = () => { + const clearWalletState = useCallback(() => { updateWalletKit({ publicKey: undefined, walletType: undefined, @@ -42,8 +50,31 @@ export const ConnectWallet = () => { setConnected(false); setHasAttemptedAutoConnect(false); localStorageSavedWallet.remove(); + }, [updateWalletKit]); + + // Let the kit tear down its own state too. For WalletConnect this closes the + // session with the wallet; other wallets have nothing to close. + const disconnectKit = async () => { + try { + await StellarWalletsKit.disconnect(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + // Clearing Lab's state matters more than a clean wallet-side teardown + } + }; + + const disconnect = async () => { + await disconnectKit(); + clearWalletState(); }; + // The kit can end a session on its own — a WalletConnect session the wallet + // dropped while Lab was closed surfaces here once the relay reconnects — so + // mirror that into Lab's state instead of showing a stale connected address. + useEffect(() => { + return StellarWalletsKit.on(KitEventType.DISCONNECT, clearWalletState); + }, [clearWalletState]); + useEffect(() => { let t: NodeJS.Timeout; @@ -51,7 +82,7 @@ export const ConnectWallet = () => { !connected && !hasAttemptedAutoConnect && !!savedWallet?.id && - ![undefined, "false", "wallet_connect"].includes(savedWallet?.id) && + ![undefined, "false"].includes(savedWallet?.id) && savedWallet.network.id === network.id ) { t = setTimeout(async () => { @@ -59,6 +90,16 @@ export const ConnectWallet = () => { return; } + // WalletConnect isn't registered at startup, so restoring a saved + // session has to pull in its chunk first. + if ( + isSavedWalletConnect && + !(await walletKitInstance.ensureWalletConnect()) + ) { + setHasAttemptedAutoConnect(true); + return; + } + try { StellarWalletsKit.setWallet(savedWallet.id); const success = await handleSetWalletAddress({ @@ -83,29 +124,82 @@ export const ConnectWallet = () => { }; // Not including savedWallet.network.id // eslint-disable-next-line react-hooks/exhaustive-deps - }, [savedWallet?.id, connected, hasAttemptedAutoConnect, walletKitInstance]); + }, [ + savedWallet?.id, + isSavedWalletConnect, + connected, + hasAttemptedAutoConnect, + walletKitInstance, + ]); // Reset auto-connect attempt when network changes useEffect(() => { setHasAttemptedAutoConnect(false); }, [network.id]); + // A WalletConnect session is approved for a single chain, and updating + // `allowedChains` only affects the next pairing — it can't renegotiate a + // session the wallet already approved. After an in-app mainnet/testnet + // switch the kit would keep signing over the same topic with the new chain + // id, which the session never authorized, so signing fails with no + // explanation. End the session instead and let the user pair again. + // + // The ref guard means this only runs on an actual switch, never on mount, + // where it would tear down a session that was just restored. + const previousNetworkId = useRef(network.id); + + useEffect(() => { + if (previousNetworkId.current === network.id) { + return; + } + + previousNetworkId.current = network.id; + + if (walletKit?.walletType === WALLET_CONNECT_ID) { + disconnect(); + } + // `disconnect` is recreated every render; including it would re-run this + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [network.id, walletKit?.walletType]); + const handleSetWalletAddress = async ({ skipRequestAccess, }: { skipRequestAccess: boolean; }): Promise => { try { - const addressResult = await StellarWalletsKit.selectedModule.getAddress({ - skipRequestAccess, - }); + // The WalletConnect module ignores `skipRequestAccess` and always starts + // a fresh pairing, which would pop a QR code modal on every page load. + // Its session outlives the page though — the kit rehydrates the address + // and the session topic from localStorage — so read the restored address + // from the kit rather than asking the module for it. + const addressResult = isSavedWalletConnect + ? await StellarWalletsKit.getAddress() + : await StellarWalletsKit.selectedModule.getAddress({ + skipRequestAccess, + }); - if (!addressResult?.address) { + const publicKey = addressResult?.address; + + if (!publicKey) { return false; } - const publicKey = addressResult.address; - if (!publicKey) { + // The cached address survives independently of the session, so confirm + // the sign client actually restored one that still authorizes this + // address on this chain. Otherwise Lab would show a connected wallet + // whose every signing request fails, and the user would have no way to + // tell why. Clearing the stale state sends them back to a fresh pairing. + if ( + isSavedWalletConnect && + !(await hasLiveWalletConnectSession({ + address: publicKey, + networkId: network.id, + })) + ) { + await disconnectKit(); + clearWalletState(); + return false; } @@ -124,6 +218,10 @@ export const ConnectWallet = () => { const connectWallet = async () => { try { + // Register WalletConnect before the modal opens: the kit snapshots its + // wallet list on open, so a module added later wouldn't appear. + await walletKitInstance.ensureWalletConnect(); + const { address } = await StellarWalletsKit.authModal(); if (!address) { diff --git a/src/components/WalletKit/WalletKitContextProvider.tsx b/src/components/WalletKit/WalletKitContextProvider.tsx index cb3ff045a..83b1bb424 100644 --- a/src/components/WalletKit/WalletKitContextProvider.tsx +++ b/src/components/WalletKit/WalletKitContextProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useEffect, useState } from "react"; +import { createContext, useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "@/store/useStore"; import { @@ -18,17 +18,36 @@ import { LedgerModule } from "@creit.tech/stellar-wallets-kit/modules/ledger"; import { LobstrModule } from "@creit.tech/stellar-wallets-kit/modules/lobstr"; import { RabetModule } from "@creit.tech/stellar-wallets-kit/modules/rabet"; import { xBullModule } from "@creit.tech/stellar-wallets-kit/modules/xbull"; +import type { ModuleInterface } from "@creit.tech/stellar-wallets-kit/types"; +import { + loadWalletConnectModule, + WALLET_CONNECT_ID, +} from "@/components/WalletKit/walletConnect"; import { getWalletKitNetwork } from "@/helpers/getWalletKitNetwork"; import { localStorageSavedTheme } from "@/helpers/localStorageSavedTheme"; import { localStorageSavedWallet } from "@/helpers/localStorageSavedWallet"; type WalletKitProps = { isInitialized: boolean; + /** + * Loads WalletConnect's chunk and registers it with the kit, resolving once + * it's usable. Safe to call repeatedly — the module itself is created once. + * + * WalletConnect isn't registered at startup because it pulls in + * `@reown/appkit` and the WalletConnect sign client, roughly 320 kB gzipped + * that most sessions never need. Call this before anything that requires the + * module — opening the wallet modal, or restoring a saved session — so it's + * registered before the kit reads its module list. + * + * Resolves `false` when WalletConnect isn't available for the current network. + */ + ensureWalletConnect: () => Promise; }; export const WalletKitContext = createContext({ isInitialized: false, + ensureWalletConnect: () => Promise.resolve(false), }); export const WalletKitContextProvider = ({ @@ -40,6 +59,10 @@ export const WalletKitContextProvider = ({ const [isInitialized, setIsInitialized] = useState(false); const networkType = getWalletKitNetwork(network.id); + // Set by the init effect below so `ensureWalletConnect` can re-initialize the + // kit with the module list, network and theme that are currently in effect. + const registerWalletConnect = useRef<(() => Promise) | null>(null); + useEffect(() => { const savedTheme = localStorageSavedTheme.get(); @@ -80,22 +103,78 @@ export const WalletKitContextProvider = ({ const PROD_MODULES = [...TEST_MODULES, new HotWalletModule()]; - StellarWalletsKit.init({ - network: networkType, - selectedWalletId: walletIdForNetwork, - modules: network.id === "mainnet" ? PROD_MODULES : TEST_MODULES, - theme: isDarkTheme ? SwkAppDarkTheme : SwkAppLightTheme, - }); - + // Build the list once so a later re-init reuses the same module instances + // instead of swapping in fresh ones (which would, for example, drop the + // transport a connected Ledger is holding onto). + const modules = network.id === "mainnet" ? PROD_MODULES : TEST_MODULES; + + const initKit = ( + kitModules: ModuleInterface[], + selectedWalletId: string, + ) => { + StellarWalletsKit.init({ + network: networkType, + selectedWalletId, + modules: kitModules, + theme: isDarkTheme ? SwkAppDarkTheme : SwkAppLightTheme, + }); + }; + + // `init` throws when `selectedWalletId` isn't in the module list, and + // WalletConnect isn't registered until something asks for it. The kit + // rehydrates its own `selectedModuleId` from localStorage regardless, so + // nothing is lost by leaving the id out here. + const selectedWalletId = + walletIdForNetwork === WALLET_CONNECT_ID ? "" : walletIdForNetwork; + + initKit(modules, selectedWalletId); setIsInitialized(true); + + let isStale = false; + + registerWalletConnect.current = async () => { + try { + const walletConnectModule = await loadWalletConnectModule({ + networkId: network.id, + isDarkTheme, + }); + + // Either unsupported on this network, or the network/theme changed while + // the chunk was loading and a newer effect run owns the kit now. + if (!walletConnectModule || isStale) { + return false; + } + + initKit([...modules, walletConnectModule], selectedWalletId); + + return true; + } catch { + // Leave the kit initialized without WalletConnect + return false; + } + }; + + return () => { + isStale = true; + }; }, [network.id, networkType, theme]); + // Memoized because `useStore()` subscribes to the whole store, so this + // provider re-renders on any store change anywhere in Lab. Consumers list the + // context value in effect dependency arrays — `ConnectWallet`'s auto-connect + // timer among them — so an unstable reference would restart those effects on + // every unrelated keystroke. + const contextValue = useMemo( + () => ({ + isInitialized, + ensureWalletConnect: () => + registerWalletConnect.current?.() ?? Promise.resolve(false), + }), + [isInitialized], + ); + return ( - + {children} ); diff --git a/src/components/WalletKit/walletConnect.ts b/src/components/WalletKit/walletConnect.ts new file mode 100644 index 000000000..f1fce152b --- /dev/null +++ b/src/components/WalletKit/walletConnect.ts @@ -0,0 +1,261 @@ +import type { + WalletConnectModule, + WalletConnectTargetChain, +} from "@creit.tech/stellar-wallets-kit/modules/wallet-connect"; + +import { getPublicResourcePath } from "@/helpers/getPublicResourcePath"; +import { NetworkType } from "@/types/types"; + +/** + * The wallet id the kit assigns to WalletConnect. It mirrors the kit's + * `WALLET_CONNECT_ID` export, which we can't import without pulling the whole + * WalletConnect chunk into the main bundle. + */ +export const WALLET_CONNECT_ID = "wallet_connect"; + +/** + * Default Reown project id, used when no env var is supplied. + * + * This is not a secret — it ships in every client bundle and appears in the + * relay URL — and it can't be used from an arbitrary domain, because the relay + * refuses origins missing from the project's allowlist with + * `3000 (Unauthorized: origin not allowed)`. + * + * It is committed because `NEXT_PUBLIC_*` values are inlined at build time and + * the kube PR preview pipeline can't inject build-time env vars, so a preview + * build would otherwise have no id and hide WalletConnect entirely. + * + * Deployments should override it with `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` + * (wired to the `WALLET_CONNECT_PROJECT_ID` GitHub secret in + * `laboratory-build.yml`) so production runs against an SDF-owned project + * rather than this one's quota and dashboard. Whichever project is used, its + * allowlist must include the deployed origin — `https://lab.stellar.org` — or + * WalletConnect cannot connect there at all. + * + * See https://github.com/stellar/laboratory/issues/2172 + */ +const DEFAULT_PROJECT_ID = "4f7610b5e90f0af6984d5e4a53da7024"; + +// An explicit `||` fallback rather than a committed `.env`: the Dockerfile sets +// this as an ENV, which would be an empty string when no build-arg is passed +// and would take precedence over any `.env` file. +const PROJECT_ID = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || DEFAULT_PROJECT_ID; + +/** + * WalletConnect exposes Stellar as `stellar:pubnet` and `stellar:testnet` only, + * so the module is registered on mainnet and testnet and left out on futurenet + * and custom networks. + */ +const SUPPORTED_NETWORKS: NetworkType[] = ["mainnet", "testnet"]; + +/** + * Whether WalletConnect can be offered on the given network. Requires a Reown + * project id, which is only set in deployments that have one configured. + */ +export const isWalletConnectSupported = (networkId: NetworkType): boolean => + Boolean(PROJECT_ID) && SUPPORTED_NETWORKS.includes(networkId); + +type LoadedWalletConnect = { + walletConnectModule: WalletConnectModule; + chainFor: (networkId: NetworkType) => WalletConnectTargetChain; +}; + +/** + * The in-flight (or settled) load. This caches the *promise* rather than the + * resolved module on purpose: the provider effect can run twice in quick + * succession — theme hydration re-runs it — and a check against a not-yet-set + * instance after an `await` lets both callers construct a module. That starts + * two sign clients on one relay ("Init() was called 2 times"), and the wallet's + * reply then lands on the client that has no matching key for it, which surfaces + * as "No matching key. proposal:" and "Pending session not found for topic". + */ +let loadedWalletConnect: Promise | undefined; + +/** + * How long to give the relay to come up once pairing has started. Generous on + * purpose: overshooting only delays an error message, while undershooting would + * wrongly reject a working wallet on a slow connection. + */ +const RELAY_READY_TIMEOUT_MS = 8000; + +/** How long to wait for the sign client, which the module creates async. */ +const SIGN_CLIENT_READY_TIMEOUT_MS = 4000; + +const isRelayConnected = (loaded: WalletConnectModule): boolean => + Boolean(loaded.signClient?.core?.relayer?.connected); + +const waitForSignClient = async ( + loaded: WalletConnectModule, +): Promise => { + const deadline = Date.now() + SIGN_CLIENT_READY_TIMEOUT_MS; + + while (!loaded.signClient && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + return Boolean(loaded.signClient); +}; + +/** + * Waits for the WalletConnect relay to report a live connection. + * + * This has to be called *after* pairing has been initiated: the relay socket is + * opened by `connect()`, not when the sign client is constructed, so before that + * point `connected` is legitimately false and says nothing about health. + */ +const waitForRelay = async (loaded: WalletConnectModule): Promise => { + const deadline = Date.now() + RELAY_READY_TIMEOUT_MS; + + while (!isRelayConnected(loaded) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + return isRelayConnected(loaded); +}; + +const importAndCreate = async ( + networkId: NetworkType, +): Promise => { + const { WalletConnectModule: Module, WalletConnectTargetChain } = + await import("@creit.tech/stellar-wallets-kit/modules/wallet-connect"); + + const chainFor = (id: NetworkType) => + id === "mainnet" + ? WalletConnectTargetChain.PUBLIC + : WalletConnectTargetChain.TESTNET; + + // Constructing the module starts a sign client (which opens a relay + // websocket) and a Reown modal, so this must happen exactly once. + const walletConnectModule = new Module({ + projectId: PROJECT_ID, + metadata: { + name: "Stellar Lab", + description: + "Build, sign, and submit Stellar transactions, and make requests to Stellar RPC and Horizon.", + url: window.location.origin, + icons: [`${window.location.origin}${getPublicResourcePath("icon2.png")}`], + }, + allowedChains: [chainFor(networkId)], + }); + + // The kit lists the module as available as soon as the sign client object + // exists, but `SignClient.init` resolves before the relay handshake finishes. + // If the relay then refuses the origin — it closes with + // `3000 (Unauthorized: origin not allowed)` when the page's domain isn't on + // the Reown project's allowlist — the user gets a QR code that can never + // pair, and the only clue is a console message. Fail loudly instead. + const originalGetAddress = + walletConnectModule.getAddress.bind(walletConnectModule); + + walletConnectModule.getAddress = async () => { + // Start pairing first — this is what opens the relay socket — then watch for + // the connection to come up. Only the relay is raced, never the user: once + // it's live they can take as long as they need to scan. + const addressPromise = originalGetAddress(); + + // Keeps a rejection from being reported as unhandled while we wait; the + // real rejection still reaches the caller when the promise is returned. + addressPromise.catch(() => undefined); + + if (!(await waitForRelay(walletConnectModule))) { + // The kit uses code -1 for "the user dismissed the modal", which Lab + // deliberately swallows, so this needs a code of its own to be shown. + throw { + code: -2, + message: + "Couldn’t reach WalletConnect. The relay refused the connection — this domain may not be allowed for Lab’s WalletConnect project.", + }; + } + + return addressPromise; + }; + + return { walletConnectModule, chainFor }; +}; + +/** + * Dynamically imports and returns the kit's WalletConnect module, or + * `undefined` when WalletConnect isn't available for the given network. + * + * The import is dynamic on purpose: the module depends on `@reown/appkit` and + * the WalletConnect sign client, which we don't want in Lab's main bundle. + */ +export const loadWalletConnectModule = async ({ + networkId, + isDarkTheme, +}: { + networkId: NetworkType; + isDarkTheme: boolean; +}): Promise => { + if (!isWalletConnectSupported(networkId)) { + return undefined; + } + + if (!loadedWalletConnect) { + // Assigned before the first `await` so concurrent callers share it. On + // failure the cache is cleared so a later call can retry. + loadedWalletConnect = importAndCreate(networkId).catch((error) => { + loadedWalletConnect = undefined; + throw error; + }); + } + + const { walletConnectModule, chainFor } = await loadedWalletConnect; + + // Network and theme can change after the module exists, so keep them in sync + // on the one instance instead of rebuilding it. Note that `allowedChains` + // only affects the *next* pairing — it cannot renegotiate a session that the + // wallet has already approved, which is why `ConnectWallet` ends the session + // when the network changes. + walletConnectModule.wcParams.allowedChains = [chainFor(networkId)]; + walletConnectModule.modal.setThemeMode(isDarkTheme ? "dark" : "light"); + + return walletConnectModule; +}; + +/** + * Whether the sign client has a restored, unexpired session that authorizes + * `address` on the chain for `networkId`. + * + * `StellarWalletsKit.getAddress()` only returns the address the kit cached in + * localStorage, which survives independently of the session itself — the wallet + * can drop or expire a session while Lab is closed. Without this check Lab would + * show a connected wallet whose every signing request fails. + */ +export const hasLiveWalletConnectSession = async ({ + address, + networkId, +}: { + address: string; + networkId: NetworkType; +}): Promise => { + // No load has been started, so there cannot be a session to restore. + if (!isWalletConnectSupported(networkId) || !loadedWalletConnect) { + return false; + } + + try { + const { walletConnectModule, chainFor } = await loadedWalletConnect; + + if (!(await waitForSignClient(walletConnectModule))) { + return false; + } + + // Accounts are formatted `:
`, e.g. + // `stellar:testnet:GABC…`, so this checks the address and chain together. + const account = `${chainFor(networkId)}:${address}`; + const nowInSeconds = Math.floor(Date.now() / 1000); + const sessions = await walletConnectModule.getSessions(); + + return sessions.some( + (session) => + session.expiry > nowInSeconds && + (session.namespaces.stellar?.accounts || []).includes(account), + ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + // Treat an unreadable session store as "no session" + return false; + } +}; diff --git a/src/hooks/useSignWithExtensionWallet.ts b/src/hooks/useSignWithExtensionWallet.ts index df6f2cdea..efdabb15a 100644 --- a/src/hooks/useSignWithExtensionWallet.ts +++ b/src/hooks/useSignWithExtensionWallet.ts @@ -67,6 +67,10 @@ export const useSignWithExtensionWallet = ({ } else { // if a user didn't log in via stellar wallet kit in the main nav // open a wallet kit modal to sign in + // Register WalletConnect first — the kit snapshots its wallet list when + // the modal opens, so a module added later wouldn't appear in it. + await walletKitInstance.ensureWalletConnect(); + const { address } = await StellarWalletsKit.authModal(); if (address && txXdr) { diff --git a/src/middleware.ts b/src/middleware.ts index 187bc04a7..ae454ae58 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,16 +9,22 @@ export function middleware(request: NextRequest) { // script-src 'unsafe-eval' is needed for XDR JSON WebAssembly scripts // connect-src http://localhost:* to allow local network + // connect-src wss://relay.walletconnect.org is the WalletConnect relay + // (the `https:` source doesn't cover the `wss:` scheme) + // img-src/font-src api.web3modal.org and fonts.reown.com are used by the + // Reown modal that renders the WalletConnect QR code + // frame-src verify.walletconnect.org is the iframe WalletConnect uses to + // attest the dapp's domain to the wallet const cspHeader = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline' 'unsafe-eval'; script-src-elem 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com/ https: 'unsafe-inline'; style-src 'self' https: 'unsafe-inline'; - img-src 'self' https://stellar.creit.tech/wallet-icons/ https://www.googletagmanager.com/ https://storage.herewallet.app/ blob: data:; - connect-src 'self' http://localhost:* https:; - font-src 'self' https://fonts.gstatic.com/ https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf; + img-src 'self' https://stellar.creit.tech/wallet-icons/ https://www.googletagmanager.com/ https://storage.herewallet.app/ https://api.web3modal.org/ blob: data:; + connect-src 'self' http://localhost:* https: wss://relay.walletconnect.org; + font-src 'self' https://fonts.gstatic.com/ https://fonts.reown.com/ https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf; object-src 'none'; - frame-src 'self' https://connect.trezor.io/ https://hot-labs.org/ https://www.youtube.com/; + frame-src 'self' https://connect.trezor.io/ https://hot-labs.org/ https://www.youtube.com/ https://verify.walletconnect.org/; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; diff --git a/tests/unit/walletConnect.test.ts b/tests/unit/walletConnect.test.ts new file mode 100644 index 000000000..140e56152 --- /dev/null +++ b/tests/unit/walletConnect.test.ts @@ -0,0 +1,82 @@ +import { NetworkType } from "@/types/types"; + +const ALL_NETWORKS: NetworkType[] = [ + "mainnet", + "testnet", + "futurenet", + "custom", +]; + +/** + * The project id is read from `process.env` when the module is first evaluated, + * so each case needs a fresh module registry. + */ +const loadIsWalletConnectSupported = async (projectId?: string) => { + jest.resetModules(); + + if (projectId === undefined) { + delete process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + } else { + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID = projectId; + } + + const { isWalletConnectSupported } = await import( + "@/components/WalletKit/walletConnect" + ); + + return isWalletConnectSupported; +}; + +describe("isWalletConnectSupported", () => { + const originalProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + + afterEach(() => { + if (originalProjectId === undefined) { + delete process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + } else { + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID = originalProjectId; + } + }); + + it("supports mainnet and testnet when a project id is set", async () => { + const isWalletConnectSupported = + await loadIsWalletConnectSupported("project-id"); + + expect(isWalletConnectSupported("mainnet")).toBe(true); + expect(isWalletConnectSupported("testnet")).toBe(true); + }); + + it("doesn't support networks WalletConnect has no Stellar chain for", async () => { + const isWalletConnectSupported = + await loadIsWalletConnectSupported("project-id"); + + expect(isWalletConnectSupported("futurenet")).toBe(false); + expect(isWalletConnectSupported("custom")).toBe(false); + }); + + /** + * `DEFAULT_PROJECT_ID` means there is always a project id, so an unset or + * empty env var falls back rather than disabling WalletConnect. If that + * default is ever removed, these two cases go back to expecting `false` for + * every network. + */ + it("falls back to the committed preview project id when the env var is missing", async () => { + const isWalletConnectSupported = await loadIsWalletConnectSupported(); + + expect(isWalletConnectSupported("mainnet")).toBe(true); + expect(isWalletConnectSupported("testnet")).toBe(true); + // Network gating still applies regardless of where the id came from. + expect(isWalletConnectSupported("futurenet")).toBe(false); + expect(isWalletConnectSupported("custom")).toBe(false); + }); + + it("falls back to the committed preview project id when the env var is empty", async () => { + const isWalletConnectSupported = await loadIsWalletConnectSupported(""); + + expect(isWalletConnectSupported("mainnet")).toBe(true); + expect(isWalletConnectSupported("testnet")).toBe(true); + expect(isWalletConnectSupported("futurenet")).toBe(false); + expect(isWalletConnectSupported("custom")).toBe(false); + }); +}); From c6e3da82e572708a15fe5e03283c32714682b927 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Mon, 17 Aug 2026 23:38:32 -0700 Subject: [PATCH 2/6] remove unused variable value --- .github/workflows/laboratory-build.yml | 3 --- Dockerfile | 4 ---- Makefile | 2 +- README.md | 23 ++++++++++++----------- src/components/WalletKit/walletConnect.ts | 18 +++++++++--------- 5 files changed, 22 insertions(+), 28 deletions(-) diff --git a/.github/workflows/laboratory-build.yml b/.github/workflows/laboratory-build.yml index 2b8c6d562..1d0087c46 100644 --- a/.github/workflows/laboratory-build.yml +++ b/.github/workflows/laboratory-build.yml @@ -29,9 +29,6 @@ jobs: env: ECR_REGISTRY: ${{ steps.ecr-login.outputs.ecr-registry }} LABEL: ${{ steps.vars.outputs.label }} - # Reown (WalletConnect) project id. When unset, WalletConnect is left - # out of the Connect Wallet modal. - NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} run: | set -eu export TAG="${ECR_REGISTRY}/dev/laboratory-v2:${LABEL}" diff --git a/Dockerfile b/Dockerfile index f8e7c173f..c210e18c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,6 @@ COPY . . # Passing env var to be used on client side ARG NEXT_PUBLIC_COMMIT_HASH -# Reown (WalletConnect) project id. WalletConnect is left out of the Connect -# Wallet modal when this is empty. -ARG NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID -ENV NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID RUN corepack enable RUN pnpm install diff --git a/Makefile b/Makefile index 0ad00db58..26d3bc9fa 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ TAG ?= stellar/laboratory:$(LABEL) BUILD_DATE := $(shell date -u +%FT%TZ) docker-build: - $(SUDO) docker build --pull --label org.opencontainers.image.created="$(BUILD_DATE)" -t $(TAG) --build-arg=NEXT_PUBLIC_COMMIT_HASH="$(shell git rev-parse --short HEAD)" --build-arg=NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="$(NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID)" . + $(SUDO) docker build --pull --label org.opencontainers.image.created="$(BUILD_DATE)" -t $(TAG) --build-arg=NEXT_PUBLIC_COMMIT_HASH="$(shell git rev-parse --short HEAD)" . docker-push: $(SUDO) docker push $(TAG) diff --git a/README.md b/README.md index 53a603716..b08e39d7c 100644 --- a/README.md +++ b/README.md @@ -66,17 +66,18 @@ To manually fetch limits: `pnpm fetch-limits`. ### WalletConnect -Connecting mobile wallets over WalletConnect requires a project id from -[Reown Cloud](https://cloud.reown.com/). Set it before starting the app: - -``` -NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_project_id -``` - -Without it, WalletConnect is left out of the Connect Wallet modal and everything -else works as before. WalletConnect is only offered on mainnet and testnet, -since those are the only Stellar chains it exposes (`stellar:pubnet` and -`stellar:testnet`). +WalletConnect is offered on mainnet and testnet only, since those are the only +Stellar chains it exposes (`stellar:pubnet` and `stellar:testnet`). It uses a +[Reown Cloud](https://cloud.reown.com/) project id committed in +`src/components/WalletKit/walletConnect.ts`. + +**Testing it locally needs an https origin.** From `http://localhost`, Freighter +mobile approves the session and returns the account, but then refuses to sign, +reporting the domain as not connected. Use a tunnel (see Hardware Wallets below) +or a deployed preview, and add that origin to the Reown project's allowed +domains — the relay rejects unlisted origins outright with +`3000 (Unauthorized: origin not allowed)`. Allowlist changes can take a few +hours to propagate. ### Hardware Wallets diff --git a/src/components/WalletKit/walletConnect.ts b/src/components/WalletKit/walletConnect.ts index f1fce152b..4a1de8e92 100644 --- a/src/components/WalletKit/walletConnect.ts +++ b/src/components/WalletKit/walletConnect.ts @@ -21,16 +21,16 @@ export const WALLET_CONNECT_ID = "wallet_connect"; * refuses origins missing from the project's allowlist with * `3000 (Unauthorized: origin not allowed)`. * - * It is committed because `NEXT_PUBLIC_*` values are inlined at build time and - * the kube PR preview pipeline can't inject build-time env vars, so a preview - * build would otherwise have no id and hide WalletConnect entirely. + * It is committed rather than injected because `NEXT_PUBLIC_*` values are + * inlined at build time, and not every pipeline that builds Lab can supply + * build-time env vars — without a default those builds would hide WalletConnect + * entirely. * - * Deployments should override it with `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` - * (wired to the `WALLET_CONNECT_PROJECT_ID` GitHub secret in - * `laboratory-build.yml`) so production runs against an SDF-owned project - * rather than this one's quota and dashboard. Whichever project is used, its - * allowlist must include the deployed origin — `https://lab.stellar.org` — or - * WalletConnect cannot connect there at all. + * A build can still override it by setting + * `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID`, which is worth doing so production + * runs against an SDF-owned project rather than this one's quota and dashboard. + * Whichever project is used, its allowlist must include the deployed origin — + * `https://lab.stellar.org` — or WalletConnect cannot connect there at all. * * See https://github.com/stellar/laboratory/issues/2172 */ From 96ee3cf57f18192add44d060ee28d98c3d5ecf87 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Tue, 18 Aug 2026 10:27:26 -0700 Subject: [PATCH 3/6] Remove install badge for walletconnect --- src/components/WalletKit/ConnectWallet.tsx | 22 +++++++++++++++++++--- src/components/WalletKit/walletConnect.ts | 9 +++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/WalletKit/ConnectWallet.tsx b/src/components/WalletKit/ConnectWallet.tsx index f25a1f9fb..9a0c5a905 100644 --- a/src/components/WalletKit/ConnectWallet.tsx +++ b/src/components/WalletKit/ConnectWallet.tsx @@ -28,6 +28,7 @@ export const ConnectWallet = () => { const [connected, setConnected] = useState(false); const [isModalVisible, setShowModal] = useState(false); const [errorMessageOnConnect, setErrorMessageOnConnect] = useState(""); + const [isPreparingWallets, setIsPreparingWallets] = useState(false); const [hasAttemptedAutoConnect, setHasAttemptedAutoConnect] = useState(false); const walletKitInstance = useContext(WalletKitContext); @@ -219,8 +220,17 @@ export const ConnectWallet = () => { const connectWallet = async () => { try { // Register WalletConnect before the modal opens: the kit snapshots its - // wallet list on open, so a module added later wouldn't appear. - await walletKitInstance.ensureWalletConnect(); + // wallet list on open, so a module added later wouldn't appear. This + // fetches a chunk and waits for the sign client, roughly a second, so the + // button shows a loading state until the modal is ready to open. Only this + // step is covered — `authModal` then waits on the user scanning a QR code. + setIsPreparingWallets(true); + + try { + await walletKitInstance.ensureWalletConnect(); + } finally { + setIsPreparingWallets(false); + } const { address } = await StellarWalletsKit.authModal(); @@ -313,7 +323,13 @@ export const ConnectWallet = () => { {renderModal()} ) : ( - diff --git a/src/components/WalletKit/walletConnect.ts b/src/components/WalletKit/walletConnect.ts index 4a1de8e92..a825e35db 100644 --- a/src/components/WalletKit/walletConnect.ts +++ b/src/components/WalletKit/walletConnect.ts @@ -203,6 +203,15 @@ export const loadWalletConnectModule = async ({ const { walletConnectModule, chainFor } = await loadedWalletConnect; + // The kit's `isAvailable()` is just `!!signClient && !!modal`, and the module + // assigns `signClient` asynchronously in its constructor. Callers here open + // the wallet modal right after this resolves, and the kit snapshots + // availability at that moment — so without waiting, WalletConnect is listed + // as unavailable with an "Install" link that sends the user to + // walletconnect.com. Returns either way: a module that's merely slow to + // initialise should still be registered for the next attempt. + await waitForSignClient(walletConnectModule); + // Network and theme can change after the module exists, so keep them in sync // on the one instance instead of rebuilding it. Note that `allowedChains` // only affects the *next* pairing — it cannot renegotiate a session that the From 2f4f6c13bdc8541c250e39d6b260bec364a59ee0 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Tue, 18 Aug 2026 19:10:21 -0700 Subject: [PATCH 4/6] Preload WalletConnect when the saved wallet is WalletConnect --- src/components/WalletKit/WalletKitContextProvider.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/WalletKit/WalletKitContextProvider.tsx b/src/components/WalletKit/WalletKitContextProvider.tsx index 83b1bb424..775256ee2 100644 --- a/src/components/WalletKit/WalletKitContextProvider.tsx +++ b/src/components/WalletKit/WalletKitContextProvider.tsx @@ -154,6 +154,15 @@ export const WalletKitContextProvider = ({ } }; + // A returning WalletConnect user needs the module either way, so start + // loading it now instead of making the restore path wait for the chunk and + // the sign client after its 750ms delay. Everyone else still never fetches + // it. Deliberately not awaited: this only warms the cache that + // `ensureWalletConnect` reads, and failures surface there instead. + if (walletIdForNetwork === WALLET_CONNECT_ID) { + registerWalletConnect.current().catch(() => undefined); + } + return () => { isStale = true; }; From 987846c3cf1ede225d4be819cbc57f7bbfbb02f8 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Fri, 21 Aug 2026 14:56:19 -0700 Subject: [PATCH 5/6] Update comments to make it clear that it uses hardcoded project id --- src/components/WalletKit/walletConnect.ts | 23 ++++--------------- tests/unit/walletConnect.test.ts | 28 +---------------------- 2 files changed, 5 insertions(+), 46 deletions(-) diff --git a/src/components/WalletKit/walletConnect.ts b/src/components/WalletKit/walletConnect.ts index a825e35db..e3e4ab370 100644 --- a/src/components/WalletKit/walletConnect.ts +++ b/src/components/WalletKit/walletConnect.ts @@ -14,25 +14,13 @@ import { NetworkType } from "@/types/types"; export const WALLET_CONNECT_ID = "wallet_connect"; /** - * Default Reown project id, used when no env var is supplied. + * Default Reown project id * * This is not a secret — it ships in every client bundle and appears in the * relay URL — and it can't be used from an arbitrary domain, because the relay * refuses origins missing from the project's allowlist with * `3000 (Unauthorized: origin not allowed)`. * - * It is committed rather than injected because `NEXT_PUBLIC_*` values are - * inlined at build time, and not every pipeline that builds Lab can supply - * build-time env vars — without a default those builds would hide WalletConnect - * entirely. - * - * A build can still override it by setting - * `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID`, which is worth doing so production - * runs against an SDF-owned project rather than this one's quota and dashboard. - * Whichever project is used, its allowlist must include the deployed origin — - * `https://lab.stellar.org` — or WalletConnect cannot connect there at all. - * - * See https://github.com/stellar/laboratory/issues/2172 */ const DEFAULT_PROJECT_ID = "4f7610b5e90f0af6984d5e4a53da7024"; @@ -43,18 +31,15 @@ const PROJECT_ID = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || DEFAULT_PROJECT_ID; /** - * WalletConnect exposes Stellar as `stellar:pubnet` and `stellar:testnet` only, - * so the module is registered on mainnet and testnet and left out on futurenet - * and custom networks. + * WalletConnect exposes Stellar as `stellar:pubnet` and `stellar:testnet` only */ const SUPPORTED_NETWORKS: NetworkType[] = ["mainnet", "testnet"]; /** - * Whether WalletConnect can be offered on the given network. Requires a Reown - * project id, which is only set in deployments that have one configured. + * Whether WalletConnect can be offered on the given network. */ export const isWalletConnectSupported = (networkId: NetworkType): boolean => - Boolean(PROJECT_ID) && SUPPORTED_NETWORKS.includes(networkId); + SUPPORTED_NETWORKS.includes(networkId); type LoadedWalletConnect = { walletConnectModule: WalletConnectModule; diff --git a/tests/unit/walletConnect.test.ts b/tests/unit/walletConnect.test.ts index 140e56152..ddd03cfab 100644 --- a/tests/unit/walletConnect.test.ts +++ b/tests/unit/walletConnect.test.ts @@ -28,8 +28,7 @@ const loadIsWalletConnectSupported = async (projectId?: string) => { }; describe("isWalletConnectSupported", () => { - const originalProjectId = - process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + const originalProjectId = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; afterEach(() => { if (originalProjectId === undefined) { @@ -54,29 +53,4 @@ describe("isWalletConnectSupported", () => { expect(isWalletConnectSupported("futurenet")).toBe(false); expect(isWalletConnectSupported("custom")).toBe(false); }); - - /** - * `DEFAULT_PROJECT_ID` means there is always a project id, so an unset or - * empty env var falls back rather than disabling WalletConnect. If that - * default is ever removed, these two cases go back to expecting `false` for - * every network. - */ - it("falls back to the committed preview project id when the env var is missing", async () => { - const isWalletConnectSupported = await loadIsWalletConnectSupported(); - - expect(isWalletConnectSupported("mainnet")).toBe(true); - expect(isWalletConnectSupported("testnet")).toBe(true); - // Network gating still applies regardless of where the id came from. - expect(isWalletConnectSupported("futurenet")).toBe(false); - expect(isWalletConnectSupported("custom")).toBe(false); - }); - - it("falls back to the committed preview project id when the env var is empty", async () => { - const isWalletConnectSupported = await loadIsWalletConnectSupported(""); - - expect(isWalletConnectSupported("mainnet")).toBe(true); - expect(isWalletConnectSupported("testnet")).toBe(true); - expect(isWalletConnectSupported("futurenet")).toBe(false); - expect(isWalletConnectSupported("custom")).toBe(false); - }); }); From 7af04deb9bace214c101dc40ed752df3e4ce7156 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Fri, 21 Aug 2026 17:44:37 -0700 Subject: [PATCH 6/6] use existing Kit's Module by using class and update test --- .../components/InvokeContractForm.tsx | 2 +- src/components/SignMessage/index.tsx | 10 +- src/components/WalletKit/ConnectWallet.tsx | 2 +- .../WalletKit/WalletKitContextProvider.tsx | 23 ++-- .../WalletKit/labWalletConnectModule.ts | 69 ++++++++++ src/components/WalletKit/walletConnect.ts | 87 ++---------- src/hooks/useSignWithExtensionWallet.ts | 14 +- tests/unit/walletConnect.test.ts | 128 +++++++++++++----- 8 files changed, 191 insertions(+), 144 deletions(-) create mode 100644 src/components/WalletKit/labWalletConnectModule.ts diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx index e93642bd2..2af3567a2 100644 --- a/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx @@ -180,7 +180,7 @@ export const InvokeContractForm = ({ const responseErrorEl = useRef(null); const signTx = async (xdr: string): Promise => { - if (!walletKitInstance?.isInitialized || !walletKit?.publicKey) { + if (!walletKitInstance.isInitialized || !walletKit?.publicKey) { return null; } diff --git a/src/components/SignMessage/index.tsx b/src/components/SignMessage/index.tsx index d1e8c5cb9..6d0fa13e2 100644 --- a/src/components/SignMessage/index.tsx +++ b/src/components/SignMessage/index.tsx @@ -63,7 +63,7 @@ export const SignMessage = ({ }; const onSignExtension = async (): Promise => { - if (!walletKitInstance?.isInitialized) { + if (!walletKitInstance.isInitialized) { return { errorMessage: "Wallet is not initialized, please try again" }; } @@ -91,13 +91,11 @@ export const SignMessage = ({ return {}; } - const { signedMessage, signerAddress } = await StellarWalletsKit.signMessage( - message, - { + const { signedMessage, signerAddress } = + await StellarWalletsKit.signMessage(message, { address, networkPassphrase, - }, - ); + }); if (!signedMessage) { onSigned?.(null); diff --git a/src/components/WalletKit/ConnectWallet.tsx b/src/components/WalletKit/ConnectWallet.tsx index 9a0c5a905..ae52c1e56 100644 --- a/src/components/WalletKit/ConnectWallet.tsx +++ b/src/components/WalletKit/ConnectWallet.tsx @@ -87,7 +87,7 @@ export const ConnectWallet = () => { savedWallet.network.id === network.id ) { t = setTimeout(async () => { - if (!walletKitInstance?.isInitialized) { + if (!walletKitInstance.isInitialized) { return; } diff --git a/src/components/WalletKit/WalletKitContextProvider.tsx b/src/components/WalletKit/WalletKitContextProvider.tsx index 775256ee2..838da1047 100644 --- a/src/components/WalletKit/WalletKitContextProvider.tsx +++ b/src/components/WalletKit/WalletKitContextProvider.tsx @@ -108,26 +108,21 @@ export const WalletKitContextProvider = ({ // transport a connected Ledger is holding onto). const modules = network.id === "mainnet" ? PROD_MODULES : TEST_MODULES; - const initKit = ( - kitModules: ModuleInterface[], - selectedWalletId: string, - ) => { + const initKit = (kitModules: ModuleInterface[], walletId: string) => { StellarWalletsKit.init({ network: networkType, - selectedWalletId, + // `init` calls `setWallet`, which throws on an id that isn't in the + // module list — and WalletConnect is registered lazily. Passing "" skips + // that call, and the kit rehydrates `selectedModuleId` from localStorage + // anyway, so the saved selection survives. + selectedWalletId: + kitModules.find((m) => m.productId === walletId)?.productId ?? "", modules: kitModules, theme: isDarkTheme ? SwkAppDarkTheme : SwkAppLightTheme, }); }; - // `init` throws when `selectedWalletId` isn't in the module list, and - // WalletConnect isn't registered until something asks for it. The kit - // rehydrates its own `selectedModuleId` from localStorage regardless, so - // nothing is lost by leaving the id out here. - const selectedWalletId = - walletIdForNetwork === WALLET_CONNECT_ID ? "" : walletIdForNetwork; - - initKit(modules, selectedWalletId); + initKit(modules, walletIdForNetwork); setIsInitialized(true); let isStale = false; @@ -145,7 +140,7 @@ export const WalletKitContextProvider = ({ return false; } - initKit([...modules, walletConnectModule], selectedWalletId); + initKit([...modules, walletConnectModule], walletIdForNetwork); return true; } catch { diff --git a/src/components/WalletKit/labWalletConnectModule.ts b/src/components/WalletKit/labWalletConnectModule.ts new file mode 100644 index 000000000..80eac294f --- /dev/null +++ b/src/components/WalletKit/labWalletConnectModule.ts @@ -0,0 +1,69 @@ +import { WalletConnectModule } from "@creit.tech/stellar-wallets-kit/modules/wallet-connect"; + +// Re-exported so `walletConnect.ts` gets the enum from the same dynamic import +// that loads this file, instead of needing a second one. +export { WalletConnectTargetChain } from "@creit.tech/stellar-wallets-kit/modules/wallet-connect"; + +/** + * How long to give the relay to come up once pairing has started. Generous on + * purpose: overshooting only delays an error message, while undershooting would + * wrongly reject a working wallet on a slow connection. + */ +const RELAY_READY_TIMEOUT_MS = 8000; + +const isRelayConnected = (loaded: WalletConnectModule): boolean => + Boolean(loaded.signClient?.core?.relayer?.connected); + +/** + * Waits for the WalletConnect relay to report a live connection. + * + * This has to be called *after* pairing has been initiated: the relay socket is + * opened by `connect()`, not when the sign client is constructed, so before that + * point `connected` is legitimately false and says nothing about health. + */ +const waitForRelay = async (loaded: WalletConnectModule): Promise => { + const deadline = Date.now() + RELAY_READY_TIMEOUT_MS; + + while (!isRelayConnected(loaded) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + return isRelayConnected(loaded); +}; + +/** + * The kit's WalletConnect module, with a relay health check added to + * `getAddress`. + * + * Subclassed rather than wrapped because the kit calls `getAddress()` itself — + * from its wallet-picker page — so there is no Lab call site to wrap, and the + * check has to run inside the call: `getAddress` is what starts pairing, and + * pairing is what opens the relay socket. + * + * This module is only ever reached through a dynamic `import()`, which is what + * keeps `@reown/appkit` and the sign client out of Lab's main bundle. + */ +export class LabWalletConnectModule extends WalletConnectModule { + async getAddress(): Promise<{ address: string }> { + // Start pairing first — this is what opens the relay socket — then watch for + // the connection to come up. Only the relay is raced, never the user: once + // it's live they can take as long as they need to scan. + const addressPromise = super.getAddress(); + + // Keeps a rejection from being reported as unhandled while we wait; the + // real rejection still reaches the caller when the promise is returned. + addressPromise.catch(() => undefined); + + if (!(await waitForRelay(this))) { + // The kit uses code -1 for "the user dismissed the modal", which Lab + // deliberately swallows, so this needs a code of its own to be shown. + throw { + code: -2, + message: + "Couldn’t reach WalletConnect. The relay refused the connection — this domain may not be allowed for Lab’s WalletConnect project.", + }; + } + + return addressPromise; + } +} diff --git a/src/components/WalletKit/walletConnect.ts b/src/components/WalletKit/walletConnect.ts index e3e4ab370..8e2f495ab 100644 --- a/src/components/WalletKit/walletConnect.ts +++ b/src/components/WalletKit/walletConnect.ts @@ -20,13 +20,8 @@ export const WALLET_CONNECT_ID = "wallet_connect"; * relay URL — and it can't be used from an arbitrary domain, because the relay * refuses origins missing from the project's allowlist with * `3000 (Unauthorized: origin not allowed)`. - * */ const DEFAULT_PROJECT_ID = "4f7610b5e90f0af6984d5e4a53da7024"; - -// An explicit `||` fallback rather than a committed `.env`: the Dockerfile sets -// this as an ENV, which would be an empty string when no build-arg is passed -// and would take precedence over any `.env` file. const PROJECT_ID = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || DEFAULT_PROJECT_ID; @@ -47,29 +42,21 @@ type LoadedWalletConnect = { }; /** - * The in-flight (or settled) load. This caches the *promise* rather than the - * resolved module on purpose: the provider effect can run twice in quick - * succession — theme hydration re-runs it — and a check against a not-yet-set - * instance after an `await` lets both callers construct a module. That starts - * two sign clients on one relay ("Init() was called 2 times"), and the wallet's - * reply then lands on the client that has no matching key for it, which surfaces - * as "No matching key. proposal:" and "Pending session not found for topic". + * The cached load. This holds the promise rather than the resolved module, so a + * second caller awaits the first load instead of starting its own. + * + * That matters because the provider effect can fire twice in quick succession — + * theme hydration re-runs it. If only the resolved module were cached, the + * second call would find it still unset and build a module of its own, putting + * two sign clients on one relay. It fails as "Init() was called 2 times", then + * "No matching key. proposal:" once the wallet replies to whichever client + * didn't send the request. */ let loadedWalletConnect: Promise | undefined; -/** - * How long to give the relay to come up once pairing has started. Generous on - * purpose: overshooting only delays an error message, while undershooting would - * wrongly reject a working wallet on a slow connection. - */ -const RELAY_READY_TIMEOUT_MS = 8000; - /** How long to wait for the sign client, which the module creates async. */ const SIGN_CLIENT_READY_TIMEOUT_MS = 4000; -const isRelayConnected = (loaded: WalletConnectModule): boolean => - Boolean(loaded.signClient?.core?.relayer?.connected); - const waitForSignClient = async ( loaded: WalletConnectModule, ): Promise => { @@ -82,28 +69,12 @@ const waitForSignClient = async ( return Boolean(loaded.signClient); }; -/** - * Waits for the WalletConnect relay to report a live connection. - * - * This has to be called *after* pairing has been initiated: the relay socket is - * opened by `connect()`, not when the sign client is constructed, so before that - * point `connected` is legitimately false and says nothing about health. - */ -const waitForRelay = async (loaded: WalletConnectModule): Promise => { - const deadline = Date.now() + RELAY_READY_TIMEOUT_MS; - - while (!isRelayConnected(loaded) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 200)); - } - - return isRelayConnected(loaded); -}; - const importAndCreate = async ( networkId: NetworkType, ): Promise => { - const { WalletConnectModule: Module, WalletConnectTargetChain } = - await import("@creit.tech/stellar-wallets-kit/modules/wallet-connect"); + const { LabWalletConnectModule, WalletConnectTargetChain } = await import( + "./labWalletConnectModule" + ); const chainFor = (id: NetworkType) => id === "mainnet" @@ -112,7 +83,7 @@ const importAndCreate = async ( // Constructing the module starts a sign client (which opens a relay // websocket) and a Reown modal, so this must happen exactly once. - const walletConnectModule = new Module({ + const walletConnectModule = new LabWalletConnectModule({ projectId: PROJECT_ID, metadata: { name: "Stellar Lab", @@ -124,38 +95,6 @@ const importAndCreate = async ( allowedChains: [chainFor(networkId)], }); - // The kit lists the module as available as soon as the sign client object - // exists, but `SignClient.init` resolves before the relay handshake finishes. - // If the relay then refuses the origin — it closes with - // `3000 (Unauthorized: origin not allowed)` when the page's domain isn't on - // the Reown project's allowlist — the user gets a QR code that can never - // pair, and the only clue is a console message. Fail loudly instead. - const originalGetAddress = - walletConnectModule.getAddress.bind(walletConnectModule); - - walletConnectModule.getAddress = async () => { - // Start pairing first — this is what opens the relay socket — then watch for - // the connection to come up. Only the relay is raced, never the user: once - // it's live they can take as long as they need to scan. - const addressPromise = originalGetAddress(); - - // Keeps a rejection from being reported as unhandled while we wait; the - // real rejection still reaches the caller when the promise is returned. - addressPromise.catch(() => undefined); - - if (!(await waitForRelay(walletConnectModule))) { - // The kit uses code -1 for "the user dismissed the modal", which Lab - // deliberately swallows, so this needs a code of its own to be shown. - throw { - code: -2, - message: - "Couldn’t reach WalletConnect. The relay refused the connection — this domain may not be allowed for Lab’s WalletConnect project.", - }; - } - - return addressPromise; - }; - return { walletConnectModule, chainFor }; }; diff --git a/src/hooks/useSignWithExtensionWallet.ts b/src/hooks/useSignWithExtensionWallet.ts index efdabb15a..373775f4c 100644 --- a/src/hooks/useSignWithExtensionWallet.ts +++ b/src/hooks/useSignWithExtensionWallet.ts @@ -49,7 +49,7 @@ export const useSignWithExtensionWallet = ({ }; const signTx = useCallback(async () => { - if (isInProgress.current || !walletKitInstance?.isInitialized) { + if (isInProgress.current || !walletKitInstance.isInitialized) { return; } @@ -65,10 +65,8 @@ export const useSignWithExtensionWallet = ({ setSignedTxXdr(result.signedTxXdr); setSuccessMsg(SUCCESS_MSG); } else { - // if a user didn't log in via stellar wallet kit in the main nav - // open a wallet kit modal to sign in // Register WalletConnect first — the kit snapshots its wallet list when - // the modal opens, so a module added later wouldn't appear in it. + // the modal opens, so a module added later wouldn't appear in it await walletKitInstance.ensureWalletConnect(); const { address } = await StellarWalletsKit.authModal(); @@ -103,13 +101,7 @@ export const useSignWithExtensionWallet = ({ } finally { isInProgress.current = false; } - }, [ - networkPassphrase, - txXdr, - updateWalletKit, - walletKitInstance.isInitialized, - walletKit, - ]); + }, [networkPassphrase, txXdr, updateWalletKit, walletKitInstance, walletKit]); useEffect(() => { if (isEnabled) { diff --git a/tests/unit/walletConnect.test.ts b/tests/unit/walletConnect.test.ts index ddd03cfab..b6bcb4f13 100644 --- a/tests/unit/walletConnect.test.ts +++ b/tests/unit/walletConnect.test.ts @@ -1,56 +1,110 @@ -import { NetworkType } from "@/types/types"; - -const ALL_NETWORKS: NetworkType[] = [ - "mainnet", - "testnet", - "futurenet", - "custom", -]; - /** - * The project id is read from `process.env` when the module is first evaluated, - * so each case needs a fresh module registry. + * `loadWalletConnectModule` caches the load in module scope, so every case needs + * a fresh module registry — and the mock has to be re-imported alongside it, or + * the assertions would run against a stale generation of the spy. */ -const loadIsWalletConnectSupported = async (projectId?: string) => { - jest.resetModules(); +jest.mock("@/components/WalletKit/labWalletConnectModule", () => ({ + WalletConnectTargetChain: { + PUBLIC: "stellar:pubnet", + TESTNET: "stellar:testnet", + }, + // `signClient` is truthy so `waitForSignClient` resolves without polling. + LabWalletConnectModule: jest.fn(() => ({ + wcParams: {} as { allowedChains?: string[] }, + modal: { setThemeMode: jest.fn() }, + signClient: {}, + })), +})); - if (projectId === undefined) { - delete process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; - } else { - process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID = projectId; - } +const loadFreshModules = async () => { + jest.resetModules(); - const { isWalletConnectSupported } = await import( + const { loadWalletConnectModule } = await import( "@/components/WalletKit/walletConnect" ); + const { LabWalletConnectModule } = await import( + "@/components/WalletKit/labWalletConnectModule" + ); - return isWalletConnectSupported; + return { + loadWalletConnectModule, + constructor: LabWalletConnectModule as unknown as jest.Mock, + }; }; -describe("isWalletConnectSupported", () => { - const originalProjectId = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; +describe("loadWalletConnectModule", () => { + beforeAll(() => { + // The module's metadata reads `window.location.origin`; jest runs in the + // node environment, so there is no DOM to read it from. + (global as unknown as { window: unknown }).window = { + location: { origin: "https://lab.stellar.org" }, + }; + }); + + it("never loads the chunk on a network WalletConnect has no Stellar chain for", async () => { + const { loadWalletConnectModule, constructor } = await loadFreshModules(); - afterEach(() => { - if (originalProjectId === undefined) { - delete process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; - } else { - process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID = originalProjectId; + for (const networkId of ["futurenet", "custom"] as const) { + expect( + await loadWalletConnectModule({ networkId, isDarkTheme: false }), + ).toBeUndefined(); } + + expect(constructor).not.toHaveBeenCalled(); }); - it("supports mainnet and testnet when a project id is set", async () => { - const isWalletConnectSupported = - await loadIsWalletConnectSupported("project-id"); + it("constructs one module for concurrent calls", async () => { + const { loadWalletConnectModule, constructor } = await loadFreshModules(); + + const [first, second] = await Promise.all([ + loadWalletConnectModule({ networkId: "testnet", isDarkTheme: false }), + loadWalletConnectModule({ networkId: "testnet", isDarkTheme: true }), + ]); - expect(isWalletConnectSupported("mainnet")).toBe(true); - expect(isWalletConnectSupported("testnet")).toBe(true); + // Two sign clients on one relay is the failure this guards against. + expect(constructor).toHaveBeenCalledTimes(1); + expect(first).toBe(second); }); - it("doesn't support networks WalletConnect has no Stellar chain for", async () => { - const isWalletConnectSupported = - await loadIsWalletConnectSupported("project-id"); + it("re-syncs chain and theme onto the cached instance", async () => { + const { loadWalletConnectModule } = await loadFreshModules(); + + const mainnet = await loadWalletConnectModule({ + networkId: "mainnet", + isDarkTheme: true, + }); + + expect(mainnet?.wcParams.allowedChains).toEqual(["stellar:pubnet"]); + expect(mainnet?.modal.setThemeMode).toHaveBeenCalledWith("dark"); + + const testnet = await loadWalletConnectModule({ + networkId: "testnet", + isDarkTheme: false, + }); + + // Same instance, updated in place rather than rebuilt. + expect(testnet).toBe(mainnet); + expect(testnet?.wcParams.allowedChains).toEqual(["stellar:testnet"]); + expect(testnet?.modal.setThemeMode).toHaveBeenLastCalledWith("light"); + }); + + it("clears the cache after a failure so a later call retries", async () => { + const { loadWalletConnectModule, constructor } = await loadFreshModules(); + + constructor.mockImplementationOnce(() => { + throw new Error("construction failed"); + }); + + await expect( + loadWalletConnectModule({ networkId: "testnet", isDarkTheme: false }), + ).rejects.toThrow("construction failed"); - expect(isWalletConnectSupported("futurenet")).toBe(false); - expect(isWalletConnectSupported("custom")).toBe(false); + expect( + await loadWalletConnectModule({ + networkId: "testnet", + isDarkTheme: false, + }), + ).toBeDefined(); + expect(constructor).toHaveBeenCalledTimes(2); }); });