diff --git a/README.md b/README.md index fbbe23022..b08e39d7c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,21 @@ import { MAINNET_LIMITS, NETWORK_LIMITS } from "@/constants/networkLimits"; To manually fetch limits: `pnpm fetch-limits`. +### WalletConnect + +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 Testing hardware wallets requires an HTTPS connection to enable U2F. The 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 11f1fc7e0..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" }; } @@ -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; @@ -87,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 40e5fa278..ae52c1e56 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"; @@ -21,10 +28,12 @@ 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); const savedWallet = localStorageSavedWallet.get(); + const isSavedWalletConnect = savedWallet?.id === WALLET_CONNECT_ID; const { data: accountInfo, refetch: fetchAccountInfo } = useAccountInfo({ publicKey: walletKit?.publicKey || "", @@ -32,7 +41,7 @@ export const ConnectWallet = () => { headers: network ? getNetworkHeaders(network, "horizon") : {}, }); - const disconnect = () => { + const clearWalletState = useCallback(() => { updateWalletKit({ publicKey: undefined, walletType: undefined, @@ -42,8 +51,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,11 +83,21 @@ 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 () => { - if (!walletKitInstance?.isInitialized) { + if (!walletKitInstance.isInitialized) { + 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; } @@ -83,29 +125,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, + }); + + const publicKey = addressResult?.address; - if (!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 +219,19 @@ 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. 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(); if (!address) { @@ -215,7 +323,13 @@ export const ConnectWallet = () => { {renderModal()} ) : ( - diff --git a/src/components/WalletKit/WalletKitContextProvider.tsx b/src/components/WalletKit/WalletKitContextProvider.tsx index cb3ff045a..838da1047 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,82 @@ 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[], walletId: string) => { + StellarWalletsKit.init({ + network: networkType, + // `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, + }); + }; + + initKit(modules, walletIdForNetwork); 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], walletIdForNetwork); + + return true; + } catch { + // Leave the kit initialized without WalletConnect + return false; + } + }; + + // 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; + }; }, [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/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 new file mode 100644 index 000000000..8e2f495ab --- /dev/null +++ b/src/components/WalletKit/walletConnect.ts @@ -0,0 +1,194 @@ +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 + * + * 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)`. + */ +const DEFAULT_PROJECT_ID = "4f7610b5e90f0af6984d5e4a53da7024"; +const PROJECT_ID = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || DEFAULT_PROJECT_ID; + +/** + * 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. + */ +export const isWalletConnectSupported = (networkId: NetworkType): boolean => + SUPPORTED_NETWORKS.includes(networkId); + +type LoadedWalletConnect = { + walletConnectModule: WalletConnectModule; + chainFor: (networkId: NetworkType) => WalletConnectTargetChain; +}; + +/** + * 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 wait for the sign client, which the module creates async. */ +const SIGN_CLIENT_READY_TIMEOUT_MS = 4000; + +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); +}; + +const importAndCreate = async ( + networkId: NetworkType, +): Promise => { + const { LabWalletConnectModule, WalletConnectTargetChain } = await import( + "./labWalletConnectModule" + ); + + 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 LabWalletConnectModule({ + 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)], + }); + + 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; + + // 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 + // 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..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,8 +65,10 @@ 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 + await walletKitInstance.ensureWalletConnect(); + const { address } = await StellarWalletsKit.authModal(); if (address && txXdr) { @@ -99,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/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..b6bcb4f13 --- /dev/null +++ b/tests/unit/walletConnect.test.ts @@ -0,0 +1,110 @@ +/** + * `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. + */ +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: {}, + })), +})); + +const loadFreshModules = async () => { + jest.resetModules(); + + const { loadWalletConnectModule } = await import( + "@/components/WalletKit/walletConnect" + ); + const { LabWalletConnectModule } = await import( + "@/components/WalletKit/labWalletConnectModule" + ); + + return { + loadWalletConnectModule, + constructor: LabWalletConnectModule as unknown as jest.Mock, + }; +}; + +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(); + + for (const networkId of ["futurenet", "custom"] as const) { + expect( + await loadWalletConnectModule({ networkId, isDarkTheme: false }), + ).toBeUndefined(); + } + + expect(constructor).not.toHaveBeenCalled(); + }); + + 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 }), + ]); + + // Two sign clients on one relay is the failure this guards against. + expect(constructor).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + }); + + 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( + await loadWalletConnectModule({ + networkId: "testnet", + isDarkTheme: false, + }), + ).toBeDefined(); + expect(constructor).toHaveBeenCalledTimes(2); + }); +});