From 9c9b30f27d6639f9299ac03392386cfb7e4b47a8 Mon Sep 17 00:00:00 2001 From: QueenGiba <336065620+QueenGiba@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:53:18 +0100 Subject: [PATCH] feat: wallet state machine, WalletConnect, account activation and connect-in-place - Pure wallet state machine with an interpreter and kit adapter (#32) - openModal stays on the current page; returnTo is validated; no sign-in on connect; withSession (#33) - WalletConnect module behind NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID (#31) - XLM balance, account activation state, Friendbot link and write guard for unfunded accounts (#34) --- .env.example | 7 + README.md | 2 + app/admin/create/page.tsx | 58 ++-- app/admin/page.tsx | 42 +-- app/premium/page.tsx | 10 +- components/layout/WalletDropdown.tsx | 42 ++- components/require-wallet.tsx | 44 +++ docs/manual-test.md | 22 +- lib/__tests__/navigation.test.ts | 27 ++ lib/hooks/useStellar.ts | 29 +- lib/navigation.ts | 20 ++ lib/stellar/WalletProvider.tsx | 237 +++++-------- lib/stellar/__tests__/errors.test.ts | 12 + lib/stellar/__tests__/wallet-machine.test.ts | 247 ++++++++++++++ lib/stellar/config.ts | 10 + lib/stellar/errors.ts | 9 + lib/stellar/wallet-interpreter.ts | 194 +++++++++++ lib/stellar/wallet-kit-adapter.ts | 86 +++++ lib/stellar/wallet-machine.ts | 337 +++++++++++++++++++ 19 files changed, 1207 insertions(+), 228 deletions(-) create mode 100644 components/require-wallet.tsx create mode 100644 lib/__tests__/navigation.test.ts create mode 100644 lib/navigation.ts create mode 100644 lib/stellar/__tests__/wallet-machine.test.ts create mode 100644 lib/stellar/wallet-interpreter.ts create mode 100644 lib/stellar/wallet-kit-adapter.ts create mode 100644 lib/stellar/wallet-machine.ts diff --git a/.env.example b/.env.example index 6cab37e..8f08ae4 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,13 @@ NEXT_PUBLIC_DAILY_PASS_PRICE_USDC=0.5 # backend enforces its own ADMIN_ADDRESSES. NEXT_PUBLIC_ADMIN_ADDRESSES= +# Mobile wallets over WalletConnect (LOBSTR, Freighter Mobile). Empty turns it off. +# Get a project id at https://cloud.reown.com (WalletConnect Cloud). Add this app's +# origin to the project's allowed origins, or pairing is refused. +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= +# This app's public URL, shown to the wallet while it pairs. +NEXT_PUBLIC_APP_URL=http://localhost:3000 + # IPFS gateway for reading reasoning traces. NEXT_PUBLIC_IPFS_GATEWAY_URL=https://ipfs.io/ipfs # The contracts repo's x402 trace service (per-trace unlocks). Empty if not deployed. diff --git a/README.md b/README.md index 0f0f778..cdda04b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ To trade on testnet you need XLM and test USDC; follow [docs/manual-test.md](doc All variables are listed and explained in [.env.example](.env.example). Everything defaults to Stellar Testnet and the contract ids in the synced deployments file. Every `NEXT_PUBLIC_*` value is compiled into browser JavaScript, so never put a secret in one. +**Mobile wallets (WalletConnect).** Set `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` to show WalletConnect in the connect modal, so phone wallets such as LOBSTR or Freighter Mobile can pair with a QR code. The kit and WalletConnect load only when this is set, behind a dynamic import, so pages that don't need them are unchanged. Set `NEXT_PUBLIC_APP_URL` to the deployed origin and allow that origin in the WalletConnect Cloud project. + ## Scripts | Script | What it does | diff --git a/app/admin/create/page.tsx b/app/admin/create/page.tsx index ea0d1da..f799481 100644 --- a/app/admin/create/page.tsx +++ b/app/admin/create/page.tsx @@ -1,14 +1,27 @@ "use client"; import React, { useState, useEffect, useRef } from "react"; +import { RequireWallet } from "@/components/require-wallet"; import { useWallet } from "@/lib/contexts/WalletContext"; import { useGenerateMarket } from "@/lib/hooks/useMarkets"; +import { UserFacingError } from "@/lib/stellar/errors"; import type { MarketCategory } from "@/lib/api/markets"; import { isAdminAddress } from "@/lib/stellar/config"; import { BACKEND_CATEGORIES, BACKEND_CATEGORY_LABELS, BACKEND_TO_CONTRACT_CATEGORY } from "@/lib/stellar/categories"; export default function AdminPage() { - const { isConnected, address, openModal, ensureSession } = useWallet(); + return ( + + + + ); +} + +function AdminCreateContent() { + const { address, withSession } = useWallet(); const generateMarket = useGenerateMarket(); const [question, setQuestion] = useState(""); @@ -64,49 +77,24 @@ export default function AdminPage() { } addLog("INITIATING_GEN_AI_ORACLE..."); - if (!(await ensureSession())) { - addLog("ERROR: WALLET_SIGN_IN_REQUIRED"); - return; - } try { - const result = await generateMarket.mutateAsync({ - question, - category, - expiry - }); + // Signs in to the backend only now, right before the protected call. + const result = await withSession( + () => generateMarket.mutateAsync({ question, category, expiry }), + "generate a market", + ); addLog(`SUCCESS: JOB_${result.jobId.substring(0,8)}`); setQuestion(""); } catch (err) { + if (err instanceof UserFacingError) { + addLog(`ERROR: ${err.message}`); + return; + } console.error("Market generation failed:", err); addLog("ERROR: DEPLOYMENT_FAILED"); } }; - if (!isConnected) { - return ( -
-
-
- lock -
-
-

Restricted Access

-

- The Market Deployment Terminal requires a secure wallet connection for institutional authentication. -

-
- -
-
- ); - } - // UI gate only; the backend enforces ADMIN_ADDRESSES on /markets/generate. if (!isAdminAddress(address)) { return ( diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 796e1dd..52bb7f6 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,6 +2,7 @@ import React from "react"; import Link from "next/link"; +import { RequireWallet } from "@/components/require-wallet"; import { useWallet } from "@/lib/contexts/WalletContext"; import { useMarkets } from "@/lib/hooks/useMarkets"; import { usePlatformStats } from "@/lib/hooks/usePortfolio"; @@ -36,36 +37,22 @@ const MarketItem = ({ title, status, volume }: { title: string, status: string, ); export default function AdminDashboard() { - const { isConnected, address, openModal } = useWallet(); + return ( + + + + ); +} + +function AdminContent() { + const { address, disconnect, openModal } = useWallet(); const { data: marketsData, isLoading } = useMarkets({ limit: 10 }); const { data: stats } = usePlatformStats(); const markets = marketsData?.markets ?? []; - if (!isConnected) { - return ( -
-
-
- lock -
-
-

Restricted Access

-

- The Admin Control Center requires a secure wallet connection for institutional authentication. -

-
- -
-
- ); - } - // UI gate only. The backend checks ADMIN_ADDRESSES itself on every admin // request; hiding this page does not protect anything. const isAuthorized = isAdminAddress(address); @@ -87,7 +74,8 @@ export default function AdminDashboard() {

+ + + ); +} diff --git a/docs/manual-test.md b/docs/manual-test.md index 8b4bc45..e89336b 100644 --- a/docs/manual-test.md +++ b/docs/manual-test.md @@ -26,8 +26,9 @@ These steps cover what automated checks can't: a real browser wallet signing rea ## 3. Fund the account with Friendbot -- [ ] Open `https://friendbot.stellar.org/?addr=` (or Freighter's "Fund with Friendbot" button). -- [ ] Freighter shows about 10,000 XLM. +- [ ] Before funding, the wallet menu shows **Account not activated** with a **Fund with Friendbot** link (testnet only). A buy is refused with "Your Stellar account isn't activated yet", not a raw SDK error. +- [ ] Open `https://friendbot.stellar.org/?addr=` (or the menu's **Fund with Friendbot** link). +- [ ] Freighter shows about 10,000 XLM. Within about 10 seconds the menu flips from "Account not activated" to an XLM balance (about 10,000 XLM). ## 4. Get test USDC @@ -50,9 +51,20 @@ stellar contract invoke --id CA2WQQJ4OHQCLHQW6XN4BCLILGRV6V4YDYDT3GVIWXB53BTOO7E ## 5. Connect - [ ] Open `http://localhost:3000`, click **Connect Wallet**, choose Freighter, approve. -- [ ] You're taken to `/markets` once. Reload the page: you stay where you are and are still connected (the old app redirected on every reload). -- [ ] Freighter asks you to sign a transaction for OracleDesk login. It is never submitted; it proves you own the key. Approve it. -- [ ] Expected: no error under the button. In DevTools → Application → Local Storage, `oracledesk_wallet` equals your address. +- [ ] After approving, you **stay on the page you were on**. No login signature is requested yet. Reload the page: you stay where you are and are still connected. +- [ ] No login signature appears until you do something that needs OracleDesk's server (copy trade, daily pass, admin). Then Freighter asks you to sign a transaction for OracleDesk login. It is never submitted; it proves you own the key. Approve it, and a sentence explains why it appears. +- [ ] Sub-step: start the connect from `/copy-trade?traceId=…` with a non-default slider value. After approving, the URL and the slider value are unchanged. +- [ ] Expected: no error under the button. In DevTools → Application → Local Storage, `oracledesk_wallet` equals your address once you have signed in. + +## 5a. Mobile wallet (WalletConnect) + +Needs `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` set and this origin allowed in the project. + +- [ ] Desktop: click **Connect Wallet**, pick the WalletConnect option, scan the QR code with LOBSTR or Freighter Mobile on **testnet**. +- [ ] Approve the pairing on the phone. The app shows the account and stays on the current page. +- [ ] Log in (step 5) and buy. Each signature is approved on the phone; the page waits for the wallet's answer. +- [ ] Reload: the session restores without a prompt. +- [ ] If the phone's session has ended, a trade shows "Your mobile wallet session ended. Connect your mobile wallet again." (not a raw error). ## 6. See your balance diff --git a/lib/__tests__/navigation.test.ts b/lib/__tests__/navigation.test.ts new file mode 100644 index 0000000..1c3aa6f --- /dev/null +++ b/lib/__tests__/navigation.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { safeReturnTo } from "../navigation"; + +const BASE = "https://oracledesk.test"; + +describe("safeReturnTo", () => { + it("accepts same-origin paths with query and hash", () => { + expect(safeReturnTo("/copy-trade?traceId=x", BASE)).toBe("/copy-trade?traceId=x"); + expect(safeReturnTo("/markets/quick-view?onChainId=3#ticket", BASE)).toBe("/markets/quick-view?onChainId=3#ticket"); + }); + + it("rejects protocol-relative and backslash hosts", () => { + expect(safeReturnTo("//evil.com", BASE)).toBeNull(); + expect(safeReturnTo("/\\evil.com", BASE)).toBeNull(); + expect(safeReturnTo("/\t/evil.com", BASE)).toBeNull(); + }); + + it("rejects absolute URLs and script schemes", () => { + expect(safeReturnTo("https://evil.com", BASE)).toBeNull(); + expect(safeReturnTo("javascript:alert(1)", BASE)).toBeNull(); + }); + + it("rejects empty input", () => { + expect(safeReturnTo(null, BASE)).toBeNull(); + expect(safeReturnTo("", BASE)).toBeNull(); + }); +}); diff --git a/lib/hooks/useStellar.ts b/lib/hooks/useStellar.ts index 1bc4a3a..5748c4a 100644 --- a/lib/hooks/useStellar.ts +++ b/lib/hooks/useStellar.ts @@ -43,6 +43,7 @@ export const stellarKeys = { position: (id: string, holder: string) => ["stellar", "position", id, holder] as const, balance: (owner: string) => ["stellar", "usdcBalance", owner] as const, account: (addr: string) => ["stellar", "account", addr] as const, + rpcNetwork: ["stellar", "rpcNetwork"] as const, quoteBuy: (id: string, outcome: OutcomeTag, amount: string) => ["stellar", "quoteBuy", id, outcome, amount] as const, exactBuy: (id: string, outcome: OutcomeTag, target: string) => ["stellar", "exactBuy", id, outcome, target] as const, trace: (id: string) => ["stellar", "trace", id] as const, @@ -189,11 +190,16 @@ export interface AccountReserveState extends AccountReserve { funded: boolean; } -/** Reads account entry via getLedgerEntries on the account key. */ +/** + * Reads account entry via getLedgerEntries on the account key. Polls faster + * while the account is unfunded, so the UI flips soon after a Friendbot top-up. + */ export function useAccountReserve(address: string | null | undefined) { return useQuery({ queryKey: stellarKeys.account(address ?? ""), enabled: Boolean(address), + staleTime: 15_000, + refetchInterval: (q) => (q.state.data?.funded === false ? 10_000 : 60_000), queryFn: async (): Promise => { if (!address) { return { @@ -420,6 +426,7 @@ export function useVerifiedTrace(onChainTraceId: bigint | string | null | undefi function useWriteGuard(): () => Signer { const { signer, isWrongNetwork } = useWallet(); + const queryClient = useQueryClient(); return () => { if (!signer) { throw new UserFacingError({ kind: "disconnected", message: "Connect your wallet first." }); @@ -427,6 +434,15 @@ function useWriteGuard(): () => Signer { if (isWrongNetwork) { throw new UserFacingError({ kind: "wrong-network", message: "Switch your wallet to Stellar Testnet and try again." }); } + // An unfunded account has no ledger entry, so building any transaction + // from it would fail with a raw SDK error. Refuse here, in plain words. + const account = queryClient.getQueryData(stellarKeys.account(signer.publicKey)); + if (account && !account.funded) { + throw new UserFacingError({ + kind: "account-not-found", + message: "Your Stellar account isn't activated yet. Fund it with XLM first.", + }); + } return signer; }; } @@ -437,9 +453,20 @@ export function useInvalidateAfterTrade() { for (const key of tradeInvalidationKeys(marketId, holder)) { queryClient.invalidateQueries({ queryKey: key }); } + // Fees change the XLM balance, so the account entry is stale after a trade. + queryClient.invalidateQueries({ queryKey: stellarKeys.account(holder) }); }; } +/** The RPC's network info, including the Friendbot URL on testnet. */ +export function useRpcNetwork() { + return useQuery({ + queryKey: stellarKeys.rpcNetwork, + queryFn: () => rpcServer().getNetwork(), + staleTime: Infinity, + }); +} + function useTxRunner(contract: ContractName) { const ref = useRef | null>(null); const phase = useSyncExternalStore( diff --git a/lib/navigation.ts b/lib/navigation.ts new file mode 100644 index 0000000..cc73926 --- /dev/null +++ b/lib/navigation.ts @@ -0,0 +1,20 @@ +/** + * Returns `value` as a same-origin path (pathname, query and hash), or null. + * + * Only single-slash paths are accepted, so `//host` and `/\host` cannot leave + * the site. The URL is parsed and its origin checked as well, which catches + * tricks such as tabs inside the path. Used for `returnTo`, which can come + * from a query string (deep links), so it is an open-redirect guard. + */ +export function safeReturnTo(value: string | null | undefined, base?: string): string | null { + const origin = base ?? (typeof window !== "undefined" ? window.location.origin : "http://localhost"); + if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return null; + let url: URL; + try { + url = new URL(value, origin); + } catch { + return null; + } + if (url.origin !== new URL(origin).origin) return null; + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/lib/stellar/WalletProvider.tsx b/lib/stellar/WalletProvider.tsx index 715d2f5..78dd667 100644 --- a/lib/stellar/WalletProvider.tsx +++ b/lib/stellar/WalletProvider.tsx @@ -1,15 +1,25 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { useRouter } from "next/navigation"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { clearAuthSession, getStoredSessionWallet, signInWithWallet } from "@/lib/api/auth"; +import { safeReturnTo } from "@/lib/navigation"; import { NETWORK_PASSPHRASE } from "./config"; -import { describeError } from "./errors"; +import { describeError, UserFacingError } from "./errors"; import type { Signer } from "./clients"; +import { loadStellarWalletsKit, type WalletKitAdapter } from "./wallet-kit-adapter"; +import { WalletInterpreter } from "./wallet-interpreter"; +import { viewOf } from "./wallet-machine"; import { TransactionTrackerProvider } from "./tx-tracker"; -type Kit = typeof import("@creit.tech/stellar-wallets-kit").StellarWalletsKit; +export interface OpenModalOptions { + /** + * Where to go after a successful connect. Only a same-origin path is used + * (see safeReturnTo). Without it the user stays on the page they are on. + */ + returnTo?: string; +} export interface WalletContextValue { address: string | null; @@ -24,9 +34,15 @@ export interface WalletContextValue { isAuthenticated: boolean; /** Last connect/sign-in error, in plain language. */ error: string | null; - openModal: () => Promise; + /** Resolves true if a wallet is connected when the attempt ends. Stays on the current page. */ + openModal: (opts?: OpenModalOptions) => Promise; /** Runs the backend challenge login if there's no session for this address. */ ensureSession: () => Promise; + /** + * Signs in to the backend right before `fn`, which calls a protected endpoint. + * Throws a readable error if the user declines, so `fn` never runs without a session. + */ + withSession: (fn: () => Promise, why: string) => Promise; disconnect: () => Promise; } @@ -38,8 +54,19 @@ export function useWallet(): WalletContextValue { return ctx; } -export function WalletProvider({ children }: { children: ReactNode }) { +export interface WalletProviderProps { + children: ReactNode; + /** Replaces the real kit in tests. Defaults to loading Stellar Wallets Kit in the browser. */ + kitLoader?: () => Promise; +} + +export function WalletProvider({ children, kitLoader = loadStellarWalletsKit }: WalletProviderProps) { const router = useRouter(); + const routerRef = useRef(router); + useEffect(() => { + routerRef.current = router; + }, [router]); + const [queryClient] = useState( () => new QueryClient({ @@ -47,161 +74,69 @@ export function WalletProvider({ children }: { children: ReactNode }) { }), ); - const [kit, setKit] = useState(null); - const [address, setAddress] = useState(null); - const [walletNetworkPassphrase, setWalletNetworkPassphrase] = useState(null); - const [isConnecting, setIsConnecting] = useState(false); - const [sessionWallet, setSessionWallet] = useState(null); - const [error, setError] = useState(null); - - // The kit reads localStorage when its module is evaluated, which breaks - // server rendering, so it's only imported in the browser. - useEffect(() => { - let cancelled = false; - const unsubscribe: Array<() => void> = []; - (async () => { - const [{ StellarWalletsKit, KitEventType, Networks }, { defaultModules }] = await Promise.all([ - import("@creit.tech/stellar-wallets-kit"), - import("@creit.tech/stellar-wallets-kit/modules/utils"), - ]); - if (cancelled) return; - StellarWalletsKit.init({ modules: defaultModules(), network: Networks.TESTNET }); - setKit(() => StellarWalletsKit); + // All wallet logic lives in the state machine; this component only renders it. + const [interpreter] = useState( + () => + new WalletInterpreter({ + loadKit: kitLoader, + storedSessionWallet: getStoredSessionWallet, + clearBackendSession: clearAuthSession, + signIn: async (address, sign) => { + const session = await signInWithWallet(address, async (xdr, networkPassphrase) => { + const { signedTxXdr } = await sign(xdr, { networkPassphrase, address }); + return signedTxXdr; + }); + return session.walletAddress; + }, + purgeStellarQueries: () => queryClient.removeQueries({ queryKey: ["stellar"] }), + navigate: (to) => routerRef.current.replace(to), + describe: (err) => describeError(err), + }), + ); - unsubscribe.push( - StellarWalletsKit.on(KitEventType.STATE_UPDATED, ({ payload }) => { - const next = payload.address ?? null; - // A backend session for a different wallet is never reused. - const stored = getStoredSessionWallet(); - if (stored && next && stored !== next) { - clearAuthSession(); - setSessionWallet(null); - } - setAddress(next); - setWalletNetworkPassphrase(payload.networkPassphrase || null); - }), - StellarWalletsKit.on(KitEventType.DISCONNECT, () => { - setAddress(null); - setWalletNetworkPassphrase(null); - clearAuthSession(); - setSessionWallet(null); - }), - ); + // Starts the kit, restores a session the kit persisted (never redirects or + // prompts), and tears the kit subscriptions down on unmount. + useEffect(() => interpreter.start(), [interpreter]); - // Restore a session the kit persisted. This is not a fresh connect, - // so it never redirects and never prompts the wallet. - try { - const { address: restored } = await StellarWalletsKit.getAddress(); - if (!cancelled && restored) setAddress(restored); - } catch { - // Nothing persisted. - } - if (!cancelled) setSessionWallet(getStoredSessionWallet()); - })(); - return () => { - cancelled = true; - unsubscribe.forEach((fn) => fn()); - }; - }, []); + const state = useSyncExternalStore(interpreter.subscribe, interpreter.getState, interpreter.getState); + const view = useMemo(() => viewOf(state), [state]); + const { address } = view; const signer = useMemo(() => { - if (!address || !kit) return null; + if (!address) return null; return { publicKey: address, signTransaction: (xdr, opts) => - kit.signTransaction(xdr, { networkPassphrase: opts?.networkPassphrase ?? NETWORK_PASSPHRASE, address }), + interpreter.signTransaction(xdr, { networkPassphrase: opts?.networkPassphrase ?? NETWORK_PASSPHRASE, address }), }; - }, [address, kit]); - - const signIn = useCallback(async (forAddress: string) => { - if (!kit) throw new Error("Wallet is still loading"); - const session = await signInWithWallet(forAddress, async (xdr, networkPassphrase) => { - const { signedTxXdr } = await kit.signTransaction(xdr, { networkPassphrase, address: forAddress }); - return signedTxXdr; - }); - setSessionWallet(session.walletAddress); - }, [kit]); - - const ensureSession = useCallback(async () => { - if (!address) return false; - if (sessionWallet === address) return true; - try { - setError(null); - await signIn(address); - return true; - } catch (err) { - setError(describeError(err).message); - return false; - } - }, [address, sessionWallet, signIn]); - - const openModal = useCallback(async () => { - if (!kit) return; - setIsConnecting(true); - setError(null); - try { - const { address: connected } = await kit.authModal(); - const stored = getStoredSessionWallet(); - if (stored && stored !== connected) { - clearAuthSession(); - setSessionWallet(null); - } - setAddress(connected); - try { - const { networkPassphrase } = await kit.getNetwork(); - setWalletNetworkPassphrase(networkPassphrase); - } catch { - // Some wallets can't report their network; leave it unknown. - } - if (getStoredSessionWallet() !== connected) { - try { - await signIn(connected); - } catch (err) { - // Connected, but not signed in to the backend. Pages that need a - // session call ensureSession() again. - setError(describeError(err).message); + }, [address, interpreter]); + + const value = useMemo( + () => ({ + address, + isConnected: view.isConnected, + isConnecting: view.isConnecting, + walletNetworkPassphrase: view.walletNetworkPassphrase, + isWrongNetwork: Boolean(view.walletNetworkPassphrase) && view.walletNetworkPassphrase !== NETWORK_PASSPHRASE, + signer, + isAuthenticated: view.isAuthenticated, + error: view.error, + openModal: (opts) => interpreter.openModal(safeReturnTo(opts?.returnTo)), + ensureSession: () => interpreter.ensureSession(), + withSession: async (fn, why) => { + if (!(await interpreter.ensureSession())) { + const connected = viewOf(interpreter.getState()).isConnected; + throw new UserFacingError({ + kind: "disconnected", + message: connected ? `Sign in with your wallet to ${why}.` : `Connect your wallet to ${why}.`, + }); } - } else { - setSessionWallet(connected); - } - // Only a connect the user just completed in this tab redirects. - router.push("/markets"); - } catch (err) { - const described = describeError(err); - // Closing the picker is not an error worth showing. - if (described.kind !== "rejected" && !/closed/i.test(String((err as { message?: string })?.message))) { - setError(described.message); - } - } finally { - setIsConnecting(false); - } - }, [kit, router, signIn]); - - const disconnect = useCallback(async () => { - try { - await kit?.disconnect(); - } finally { - setAddress(null); - setWalletNetworkPassphrase(null); - clearAuthSession(); - setSessionWallet(null); - queryClient.removeQueries({ queryKey: ["stellar"] }); - } - }, [kit, queryClient]); - - const value: WalletContextValue = { - address, - isConnected: Boolean(address), - isConnecting, - walletNetworkPassphrase, - isWrongNetwork: Boolean(walletNetworkPassphrase) && walletNetworkPassphrase !== NETWORK_PASSPHRASE, - signer, - isAuthenticated: Boolean(address) && sessionWallet === address, - error, - openModal, - ensureSession, - disconnect, - }; + return fn(); + }, + disconnect: async () => interpreter.disconnect(), + }), + [address, view, signer, interpreter], + ); return ( diff --git a/lib/stellar/__tests__/errors.test.ts b/lib/stellar/__tests__/errors.test.ts index 2cd1c20..430e15a 100644 --- a/lib/stellar/__tests__/errors.test.ts +++ b/lib/stellar/__tests__/errors.test.ts @@ -52,6 +52,18 @@ describe("describeError", () => { expect(describeError("op_underfunded").kind).toBe("insufficient-balance"); }); + it("maps a missing WalletConnect session to a reconnect message", () => { + const described = describeError(new Error("No WalletConnect session found or it expired for the selected address.")); + expect(described).toMatchObject({ kind: "disconnected" }); + expect(described.message).toMatch(/connect your mobile wallet again/i); + }); + + it("maps an unfunded source account to account-not-found, before USDC rules", () => { + expect(describeError(new Error("Account not found: GABC123")).kind).toBe("account-not-found"); + expect(describeError("op_no_source_account").kind).toBe("account-not-found"); + expect(describeError("balance is not sufficient to spend").kind).toBe("insufficient-balance"); + }); + it("falls back to a readable message", () => { expect(describeError(new TypeError("Failed to fetch")).kind).toBe("network"); expect(describeError(42).message).toMatch(/something went wrong/i); diff --git a/lib/stellar/__tests__/wallet-machine.test.ts b/lib/stellar/__tests__/wallet-machine.test.ts new file mode 100644 index 0000000..cb1e57c --- /dev/null +++ b/lib/stellar/__tests__/wallet-machine.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { initialWalletState, transition, viewOf, type WalletEffect, type WalletEvent, type WalletState } from "../wallet-machine"; + +const A = "GAAAA"; +const B = "GBBBB"; + +/** Runs events in order and collects every effect emitted. */ +function run(events: WalletEvent[], from: WalletState = initialWalletState) { + let state = from; + const effects: WalletEffect[] = []; + for (const e of events) { + const next = transition(state, e); + state = next.state; + effects.push(...next.effects); + } + return { state, effects }; +} + +const types = (effects: WalletEffect[]) => effects.map((e) => e.type); + +/** A state where the kit has loaded, restore found nothing, and the epoch is 0. */ +const disconnected = run([{ type: "KIT_LOADED" }, { type: "RESTORED", epoch: 0, address: null, storedSessionWallet: null }]).state; + +describe("wallet machine: restore", () => { + it("restores a stored session without redirecting or prompting", () => { + const { state, effects } = run([ + { type: "KIT_LOADED" }, + { type: "RESTORED", epoch: 0, address: A, storedSessionWallet: A }, + ]); + expect(state).toMatchObject({ tag: "connected", address: A, session: { wallet: A } }); + expect(types(effects)).toEqual(["restore"]); + expect(types(effects)).not.toContain("navigate"); + expect(types(effects)).not.toContain("signIn"); + }); + + it("restores with no address to disconnected", () => { + expect(disconnected).toMatchObject({ tag: "disconnected", error: null }); + }); + + it("ignores a late RESTORED after disconnect", () => { + const connected = run([ + { type: "KIT_LOADED" }, + { type: "RESTORED", epoch: 0, address: A, storedSessionWallet: null }, + ]).state; + const after = run([{ type: "DISCONNECT_REQUESTED" }], connected).state; + const late = run([{ type: "RESTORED", epoch: 0, address: A, storedSessionWallet: null }], after).state; + expect(late).toMatchObject({ tag: "disconnected" }); + }); +}); + +describe("wallet machine: connect", () => { + it("connect never starts backend sign-in and redirects only with a safe returnTo", () => { + const { state, effects } = run([ + { type: "CONNECT_REQUESTED", returnTo: "/copy-trade?traceId=x" }, + { type: "CONNECT_SUCCEEDED", epoch: 0, address: A, storedSessionWallet: null }, + ], disconnected); + expect(state).toMatchObject({ tag: "connected", address: A, session: null }); + expect(types(effects)).not.toContain("signIn"); + expect(effects.filter((e) => e.type === "navigate")).toEqual([{ type: "navigate", to: "/copy-trade?traceId=x" }]); + }); + + it("connect without returnTo emits no navigation", () => { + const { effects } = run([ + { type: "CONNECT_REQUESTED", returnTo: null }, + { type: "CONNECT_SUCCEEDED", epoch: 0, address: A, storedSessionWallet: null }, + ], disconnected); + expect(types(effects)).not.toContain("navigate"); + }); + + it("closing the picker yields disconnected without an error", () => { + const { state } = run([ + { type: "CONNECT_REQUESTED", returnTo: null }, + { type: "CONNECT_FAILED", epoch: 0, silent: true, message: "closed" }, + ], disconnected); + expect(state).toMatchObject({ tag: "disconnected", error: null }); + }); + + it("a real connect failure keeps the previous account and shows the error", () => { + const connected = run([ + { type: "KIT_LOADED" }, + { type: "RESTORED", epoch: 0, address: A, storedSessionWallet: null }, + ]).state; + const { state } = run([ + { type: "CONNECT_REQUESTED", returnTo: null }, + { type: "CONNECT_FAILED", epoch: 0, silent: false, message: "Wallet is locked" }, + ], connected); + expect(state).toMatchObject({ tag: "connected", address: A, error: "Wallet is locked" }); + }); + + it("the picker keeps the previous account visible while it is open", () => { + const connected = run([ + { type: "KIT_LOADED" }, + { type: "RESTORED", epoch: 0, address: A, storedSessionWallet: null }, + ]).state; + const view = viewOf(run([{ type: "CONNECT_REQUESTED", returnTo: null }], connected).state); + expect(view).toMatchObject({ address: A, isConnecting: true }); + }); + + it("a connect requested before the kit loads runs once the kit has restored", () => { + const { state, effects } = run([ + { type: "CONNECT_REQUESTED", returnTo: "/premium" }, + { type: "KIT_LOADED" }, + { type: "RESTORED", epoch: 0, address: null, storedSessionWallet: null }, + ]); + expect(state).toMatchObject({ tag: "connecting", returnTo: "/premium" }); + expect(types(effects)).toContain("openAuthModal"); + }); + + it("a kit state update during connect waits for CONNECT_SUCCEEDED", () => { + const { state, effects } = run([ + { type: "CONNECT_REQUESTED", returnTo: "/premium" }, + { type: "KIT_STATE_UPDATED", address: A, passphrase: null, storedSessionWallet: null }, + ], disconnected); + expect(state.tag).toBe("connecting"); + expect(effects).toEqual([]); + }); +}); + +describe("wallet machine: sign-in", () => { + const connectedA = run([ + { type: "CONNECT_REQUESTED", returnTo: null }, + { type: "CONNECT_SUCCEEDED", epoch: 0, address: A, storedSessionWallet: null }, + ], disconnected).state; + + it("ensure session signs in once and resolves true on success", () => { + const started = run([{ type: "ENSURE_SESSION" }], connectedA); + expect(started.effects).toEqual([{ type: "signIn", epoch: 0, address: A, attempt: 1 }]); + const done = run([{ type: "SIGNIN_SUCCEEDED", epoch: 0, attempt: 1, wallet: A }], started.state); + expect(done.state).toMatchObject({ tag: "connected", session: { wallet: A } }); + expect(done.effects).toEqual([{ type: "resolveEnsure", ok: true }]); + }); + + it("two ENSURE_SESSION events produce one signIn effect", () => { + const { effects } = run([{ type: "ENSURE_SESSION" }, { type: "ENSURE_SESSION" }], connectedA); + expect(types(effects).filter((t) => t === "signIn")).toHaveLength(1); + }); + + it("ensure session when already authenticated resolves without signing in", () => { + const authed = run([{ type: "ENSURE_SESSION" }, { type: "SIGNIN_SUCCEEDED", epoch: 0, attempt: 1, wallet: A }], connectedA).state; + const { effects } = run([{ type: "ENSURE_SESSION" }], authed); + expect(effects).toEqual([{ type: "resolveEnsure", ok: true }]); + }); + + it("sign-in failure keeps the account and resolves false with the message", () => { + const failed = run( + [{ type: "ENSURE_SESSION" }, { type: "SIGNIN_FAILED", epoch: 0, attempt: 1, message: "Login was declined" }], + connectedA, + ); + expect(failed.state).toMatchObject({ tag: "connected", address: A, session: null, error: "Login was declined" }); + expect(failed.effects).toEqual([{ type: "resolveEnsure", ok: false }]); + }); + + it("account change during authenticating drops the stale sign-in result", () => { + const pending = run([{ type: "ENSURE_SESSION" }], connectedA); + const switched = run([{ type: "KIT_STATE_UPDATED", address: B, passphrase: null, storedSessionWallet: null }], pending.state); + expect(switched.state).toMatchObject({ tag: "connected", address: B, session: null }); + expect(types(switched.effects)).toContain("resolveEnsure"); + const late = run([{ type: "SIGNIN_SUCCEEDED", epoch: 0, attempt: 1, wallet: A }], switched.state); + expect(late.state).toMatchObject({ tag: "connected", address: B, session: null }); + }); + + it("a sign-in result for an older attempt is ignored", () => { + const first = run([{ type: "ENSURE_SESSION" }], connectedA).state; + const failed = run([{ type: "SIGNIN_FAILED", epoch: 0, attempt: 1, message: "x" }], first).state; + const second = run([{ type: "ENSURE_SESSION" }], failed).state; + const stale = run([{ type: "SIGNIN_SUCCEEDED", epoch: 0, attempt: 1, wallet: A }], second).state; + expect(stale).toMatchObject({ tag: "authenticating", attempt: 2 }); + }); + + it("a stored session for another wallet is cleared on connect", () => { + const { effects } = run([ + { type: "CONNECT_REQUESTED", returnTo: null }, + { type: "CONNECT_SUCCEEDED", epoch: 0, address: B, storedSessionWallet: A }, + ], connectedA); + expect(types(effects)).toContain("clearBackendSession"); + }); + + it("ensure session while disconnected resolves false", () => { + expect(run([{ type: "ENSURE_SESSION" }], disconnected).effects).toEqual([{ type: "resolveEnsure", ok: false }]); + }); +}); + +describe("wallet machine: disconnect and kit events", () => { + const connectedA = run([ + { type: "KIT_LOADED" }, + { type: "RESTORED", epoch: 0, address: A, storedSessionWallet: A }, + ]).state; + + it("disconnect clears the session, purges stellar queries and bumps the epoch", () => { + const { state, effects } = run([{ type: "DISCONNECT_REQUESTED" }], connectedA); + expect(state).toMatchObject({ tag: "disconnected", epoch: 1 }); + expect(types(effects)).toEqual(expect.arrayContaining(["kitDisconnect", "clearBackendSession", "purgeStellarQueries"])); + }); + + it("KIT_DISCONNECTED clears session and purges stellar queries", () => { + const { state, effects } = run([{ type: "KIT_DISCONNECTED" }], connectedA); + expect(state).toMatchObject({ tag: "disconnected" }); + expect(types(effects)).toEqual(expect.arrayContaining(["clearBackendSession", "purgeStellarQueries"])); + }); + + it("the kit's launch notification for the restored account is not an account change", () => { + const { state, effects } = run([ + { type: "KIT_STATE_UPDATED", address: A, passphrase: "Test SDF Network ; September 2015", storedSessionWallet: A }, + ], connectedA); + expect(state).toMatchObject({ tag: "connected", address: A, session: { wallet: A }, network: "Test SDF Network ; September 2015" }); + expect(effects).toEqual([]); + }); + + it("network reports from a previous epoch are ignored", () => { + const after = run([{ type: "DISCONNECT_REQUESTED" }], connectedA).state; + const { state } = run([{ type: "NETWORK_REPORTED", epoch: 0, address: A, passphrase: "Other" }], after); + expect(state).toMatchObject({ tag: "disconnected", epoch: 1 }); + }); +}); + +describe("wallet machine: invariants", () => { + // Every event kind, with an epoch that matches the starting state, so the + // interesting branches are reachable from any state. + const alphabet = (epoch: number): WalletEvent[] => [ + { type: "KIT_LOADED" }, + { type: "RESTORED", epoch, address: A, storedSessionWallet: A }, + { type: "CONNECT_REQUESTED", returnTo: "/premium" }, + { type: "CONNECT_SUCCEEDED", epoch, address: A, storedSessionWallet: null }, + { type: "CONNECT_FAILED", epoch, silent: true, message: "closed" }, + { type: "KIT_STATE_UPDATED", address: A, passphrase: null, storedSessionWallet: null }, + { type: "KIT_DISCONNECTED" }, + { type: "ENSURE_SESSION" }, + { type: "SIGNIN_SUCCEEDED", epoch, attempt: 1, wallet: A }, + { type: "SIGNIN_FAILED", epoch, attempt: 1, message: "no" }, + { type: "DISCONNECT_REQUESTED" }, + ]; + + it("no event sequence without CONNECT_REQUESTED ever emits navigate", () => { + // Every sequence of up to 4 events from the alphabet, after a restore. + // Restore and kit events alone must never redirect; only a connect the + // user started may (its CONNECT_REQUESTED is the only way into "connecting"). + const events = alphabet(0).filter((e) => e.type !== "CONNECT_REQUESTED"); + let sequences: WalletEvent[][] = [[]]; + for (let depth = 0; depth < 4; depth++) { + sequences = sequences.flatMap((seq) => events.map((e) => [...seq, e])); + for (const seq of sequences) { + const { effects } = run([{ type: "KIT_LOADED" }, { type: "RESTORED", epoch: 0, address: A, storedSessionWallet: A }, ...seq]); + expect(types(effects)).not.toContain("navigate"); + } + } + }); +}); diff --git a/lib/stellar/config.ts b/lib/stellar/config.ts index be4b833..9048d9d 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -51,6 +51,16 @@ export const IPFS_GATEWAY = env(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL, "https /** The contracts repo's x402 trace service (per-trace unlocks). Empty if not deployed. */ export const X402_URL = env(process.env.NEXT_PUBLIC_X402_URL, ""); +/** + * WalletConnect (mobile wallets such as LOBSTR and Freighter Mobile). Off + * unless a WalletConnect Cloud project id is set. The project's allowed-origin + * list is what protects it, not the id itself. + */ +export const WALLETCONNECT_PROJECT_ID = env(process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, ""); +/** Public URL of this app, shown to the wallet while it pairs. */ +export const APP_URL = env(process.env.NEXT_PUBLIC_APP_URL, "http://localhost:3000"); +export const WALLETCONNECT_CHAIN = NETWORK_PASSPHRASE === TESTNET_PASSPHRASE ? "stellar:testnet" : "stellar:pubnet"; + export const explorerTx = (hash: string) => `${EXPLORER_URL}/tx/${hash}`; export const explorerAccount = (address: string) => `${EXPLORER_URL}/${address.startsWith("C") ? "contract" : "account"}/${address}`; diff --git a/lib/stellar/errors.ts b/lib/stellar/errors.ts index 90a8098..bb3000c 100644 --- a/lib/stellar/errors.ts +++ b/lib/stellar/errors.ts @@ -50,6 +50,7 @@ export interface DescribedError { | "rejected" | "insufficient-balance" | "insufficient-xlm" + | "account-not-found" | "no-trustline" | "contract" | "wrong-network" @@ -103,6 +104,14 @@ export function describeError(err: unknown, contract: ContractName = "marketCore if (/user (rejected|declined|denied)|rejected by (the )?user|request (was )?rejected|declined|cancell?ed by user|user closed/i.test(text)) { return { kind: "rejected", message: "You cancelled the request in your wallet." }; } + // The kit throws this after a reload when the phone's WalletConnect session has ended. + if (/No WalletConnect session found/i.test(text)) { + return { kind: "disconnected", message: "Your mobile wallet session ended. Connect your mobile wallet again." }; + } + // The SDK throws this when the source account has no ledger entry (never funded). + if (/Account not found|op_no_source_account|tx_no_source_account|txNoAccount/i.test(text)) { + return { kind: "account-not-found", message: "Your Stellar account isn't activated yet. Fund it with XLM first." }; + } if (/trustline entry is missing|TrustlineMissing|trustline.*(missing|not found)|op_no_trust/i.test(text)) { return { kind: "no-trustline", diff --git a/lib/stellar/wallet-interpreter.ts b/lib/stellar/wallet-interpreter.ts new file mode 100644 index 0000000..be8da76 --- /dev/null +++ b/lib/stellar/wallet-interpreter.ts @@ -0,0 +1,194 @@ +/** + * Runs the wallet state machine: holds the current state, feeds events to + * `transition`, and carries out the effects it returns against a kit adapter. + * No React here, so tests can drive it with a scripted adapter. WalletProvider + * subscribes to it with useSyncExternalStore. + */ +import { transition, initialWalletState, type WalletEffect, type WalletEvent, type WalletState } from "./wallet-machine"; +import type { WalletKitAdapter } from "./wallet-kit-adapter"; + +export interface WalletInterpreterDeps { + /** Loads the kit. Called once, on start (browser only). */ + loadKit: () => Promise; + storedSessionWallet: () => string | null; + clearBackendSession: () => void; + /** Runs the backend challenge login for `address`; resolves with the wallet it signed in as. */ + signIn: (address: string, signTransaction: WalletKitAdapter["signTransaction"]) => Promise; + purgeStellarQueries: () => void; + navigate: (to: string) => void; + /** Turns a thrown value into a plain message. */ + describe: (err: unknown) => { kind: string; message: string }; +} + +export class WalletInterpreter { + private state: WalletState = initialWalletState; + private readonly listeners = new Set<() => void>(); + private readonly ensureWaiters: Array<(ok: boolean) => void> = []; + private readonly connectWaiters: Array<(ok: boolean) => void> = []; + private kitPromise: Promise | null = null; + private started = false; + /** Bumped on every start and stop, so a late kit load from an old run is ignored. */ + private generation = 0; + private unsubscribeKit: Array<() => void> = []; + + constructor(private readonly deps: WalletInterpreterDeps) {} + + getState = (): WalletState => this.state; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + /** Loads the kit, subscribes to its events and restores a session. Returns the teardown. */ + start = (): (() => void) => { + if (this.started) return this.stop; + this.started = true; + const generation = ++this.generation; + this.kit().then( + (kit) => { + if (generation !== this.generation) return; + this.unsubscribeKit = [ + kit.onStateUpdated(({ address, passphrase }) => + this.send({ + type: "KIT_STATE_UPDATED", + address, + passphrase, + storedSessionWallet: this.deps.storedSessionWallet(), + }), + ), + kit.onDisconnect(() => this.send({ type: "KIT_DISCONNECTED" })), + ]; + this.send({ type: "KIT_LOADED" }); + }, + (err) => this.send({ type: "KIT_FAILED", error: this.deps.describe(err).message }), + ); + return this.stop; + }; + + stop = (): void => { + this.generation++; + this.started = false; + this.unsubscribeKit.forEach((fn) => fn()); + this.unsubscribeKit = []; + }; + + send = (event: WalletEvent): void => { + const next = transition(this.state, event); + this.state = next.state; + this.listeners.forEach((fn) => fn()); + for (const effect of next.effects) this.run(effect); + }; + + /** Resolves once the backend session is established for the current account. */ + ensureSession = (): Promise => + new Promise((resolve) => { + this.ensureWaiters.push(resolve); + this.send({ type: "ENSURE_SESSION" }); + }); + + /** Resolves true when a wallet is connected by the time the attempt ends. */ + openModal = (returnTo: string | null): Promise => + new Promise((resolve) => { + this.connectWaiters.push(resolve); + this.send({ type: "CONNECT_REQUESTED", returnTo }); + }); + + disconnect = (): void => this.send({ type: "DISCONNECT_REQUESTED" }); + + /** Signs with the connected kit; used by the signer the provider exposes. */ + signTransaction = async (xdr: string, opts: { networkPassphrase: string; address: string }) => + (await this.kit()).signTransaction(xdr, opts); + + private kit(): Promise { + this.kitPromise ??= this.deps.loadKit(); + return this.kitPromise; + } + + private run(effect: WalletEffect): void { + switch (effect.type) { + case "restore": + this.kit() + .then((kit) => kit.getAddress().then((r) => r.address, () => null)) + .then((address) => + this.send({ + type: "RESTORED", + epoch: effect.epoch, + address, + storedSessionWallet: this.deps.storedSessionWallet(), + }), + ); + return; + + case "openAuthModal": + this.kit() + .then((kit) => kit.authModal()) + .then( + ({ address }) => + this.send({ + type: "CONNECT_SUCCEEDED", + epoch: effect.epoch, + address, + storedSessionWallet: this.deps.storedSessionWallet(), + }), + (err) => { + const described = this.deps.describe(err); + // Closing the picker, or declining there, is not an error worth showing. + const silent = described.kind === "rejected" || /closed/i.test(String((err as { message?: string })?.message)); + this.send({ type: "CONNECT_FAILED", epoch: effect.epoch, silent, message: described.message }); + }, + ); + return; + + case "queryNetwork": + // Some wallets can't report their network; the unknown state is fine. + this.kit() + .then((kit) => kit.getNetwork()) + .then( + ({ networkPassphrase }) => + this.send({ type: "NETWORK_REPORTED", epoch: effect.epoch, address: effect.address, passphrase: networkPassphrase }), + () => {}, + ); + return; + + case "signIn": + this.deps + .signIn(effect.address, (xdr, opts) => this.signTransaction(xdr, opts)) + .then( + (wallet) => this.send({ type: "SIGNIN_SUCCEEDED", epoch: effect.epoch, attempt: effect.attempt, wallet }), + (err) => + this.send({ + type: "SIGNIN_FAILED", + epoch: effect.epoch, + attempt: effect.attempt, + message: this.deps.describe(err).message, + }), + ); + return; + + case "clearBackendSession": + this.deps.clearBackendSession(); + return; + + case "kitDisconnect": + this.kit().then((kit) => kit.disconnect()).catch(() => {}); + return; + + case "purgeStellarQueries": + this.deps.purgeStellarQueries(); + return; + + case "navigate": + this.deps.navigate(effect.to); + return; + + case "resolveEnsure": + this.ensureWaiters.splice(0).forEach((resolve) => resolve(effect.ok)); + return; + + case "resolveConnect": + this.connectWaiters.splice(0).forEach((resolve) => resolve(effect.ok)); + return; + } + } +} diff --git a/lib/stellar/wallet-kit-adapter.ts b/lib/stellar/wallet-kit-adapter.ts new file mode 100644 index 0000000..34c8885 --- /dev/null +++ b/lib/stellar/wallet-kit-adapter.ts @@ -0,0 +1,86 @@ +import { APP_URL, WALLETCONNECT_CHAIN, WALLETCONNECT_PROJECT_ID } from "./config"; + +/** + * The slice of Stellar Wallets Kit that WalletProvider uses. An interface so + * tests can drive the wallet logic with a scripted fake (no extension, no DOM). + */ +export interface WalletKitAdapter { + authModal(): Promise<{ address: string }>; + getAddress(): Promise<{ address: string }>; + getNetwork(): Promise<{ networkPassphrase: string }>; + signTransaction(xdr: string, opts: { networkPassphrase: string; address: string }): Promise<{ signedTxXdr: string }>; + disconnect(): Promise; + onStateUpdated(cb: (payload: { address: string | null; passphrase: string | null }) => void): () => void; + onDisconnect(cb: () => void): () => void; +} + +let loading: Promise | null = null; + +/** How long to wait for WalletConnect's async init before the wallet list is refreshed. */ +const WALLETCONNECT_READY_TIMEOUT_MS = 5_000; + +/** + * Loads the kit once, in the browser only. The kit reads localStorage when its + * module is evaluated, which breaks server rendering, so the dynamic imports + * stay inside this function. + */ +export function loadStellarWalletsKit(): Promise { + loading ??= (async () => { + const [{ StellarWalletsKit, KitEventType, Networks }, { defaultModules }] = await Promise.all([ + import("@creit.tech/stellar-wallets-kit"), + import("@creit.tech/stellar-wallets-kit/modules/utils"), + ]); + + const modules = defaultModules(); + // WalletConnect is loaded only when a project id is configured, so the + // default bundle does not change. + let walletConnect: { isAvailable(): Promise } | null = null; + if (WALLETCONNECT_PROJECT_ID) { + const { WalletConnectModule, WalletConnectTargetChain } = await import( + "@creit.tech/stellar-wallets-kit/modules/wallet-connect" + ); + walletConnect = new WalletConnectModule({ + projectId: WALLETCONNECT_PROJECT_ID, + metadata: { + name: "OracleDesk", + description: "AI prediction markets on Stellar", + url: APP_URL, + icons: [`${APP_URL}/icon.png`], + }, + allowedChains: [WALLETCONNECT_CHAIN === "stellar:testnet" ? WalletConnectTargetChain.TESTNET : WalletConnectTargetChain.PUBLIC], + }); + modules.push(walletConnect as (typeof modules)[number]); + } + StellarWalletsKit.init({ modules, network: Networks.TESTNET }); + + if (walletConnect) { + // Its client starts asynchronously; once it is ready, list it in the modal. + const wc = walletConnect; + const deadline = Date.now() + WALLETCONNECT_READY_TIMEOUT_MS; + const timer = setInterval(async () => { + if (await wc.isAvailable()) { + clearInterval(timer); + StellarWalletsKit.refreshSupportedWallets(); + } else if (Date.now() > deadline) { + clearInterval(timer); + } + }, 250); + } + + const adapter: WalletKitAdapter = { + authModal: () => StellarWalletsKit.authModal(), + getAddress: () => StellarWalletsKit.getAddress(), + getNetwork: () => StellarWalletsKit.getNetwork(), + signTransaction: (xdr, opts) => StellarWalletsKit.signTransaction(xdr, opts), + disconnect: () => StellarWalletsKit.disconnect(), + // The kit's `on` returns the unsubscribe function. + onStateUpdated: (cb) => + StellarWalletsKit.on(KitEventType.STATE_UPDATED, ({ payload }) => + cb({ address: payload.address ?? null, passphrase: payload.networkPassphrase || null }), + ), + onDisconnect: (cb) => StellarWalletsKit.on(KitEventType.DISCONNECT, () => cb()), + }; + return adapter; + })(); + return loading; +} diff --git a/lib/stellar/wallet-machine.ts b/lib/stellar/wallet-machine.ts new file mode 100644 index 0000000..f3960c8 --- /dev/null +++ b/lib/stellar/wallet-machine.ts @@ -0,0 +1,337 @@ +/** + * Pure state machine behind WalletProvider. No React, no SDK types: the + * provider feeds events in, and runs the effects that come out. + * + * Every async result carries the `epoch` it was started in. A disconnect + * bumps the epoch, so late results from before it are ignored. + */ + +export interface SessionInfo { + wallet: string; +} + +export interface PendingConnect { + returnTo: string | null; +} + +export type WalletState = + | { tag: "loading-kit"; epoch: number; pendingConnect: PendingConnect | null } + | { tag: "restoring"; epoch: number; pendingConnect: PendingConnect | null } + | { tag: "disconnected"; epoch: number; error: string | null } + | { + tag: "connecting"; + epoch: number; + returnTo: string | null; + /** The connected state to fall back to if the picker is closed. */ + previous: ConnectedState | null; + } + | ConnectedState + | { + tag: "authenticating"; + epoch: number; + address: string; + network: string | null; + /** Sign-in attempt number. Results for any other attempt are stale. */ + attempt: number; + }; + +export interface ConnectedState { + tag: "connected"; + epoch: number; + address: string; + network: string | null; + session: SessionInfo | null; + attempt: number; + error: string | null; +} + +export type WalletEvent = + | { type: "KIT_LOADED" } + | { type: "KIT_FAILED"; error: string } + | { type: "RESTORED"; epoch: number; address: string | null; storedSessionWallet: string | null } + | { type: "CONNECT_REQUESTED"; returnTo: string | null } + | { type: "CONNECT_SUCCEEDED"; epoch: number; address: string; storedSessionWallet: string | null } + /** `silent` is true for a closed picker or a rejected request: not an error to show. */ + | { type: "CONNECT_FAILED"; epoch: number; silent: boolean; message: string } + | { type: "NETWORK_REPORTED"; epoch: number; address: string; passphrase: string | null } + | { type: "KIT_STATE_UPDATED"; address: string | null; passphrase: string | null; storedSessionWallet: string | null } + | { type: "KIT_DISCONNECTED" } + | { type: "ENSURE_SESSION" } + | { type: "SIGNIN_SUCCEEDED"; epoch: number; attempt: number; wallet: string } + | { type: "SIGNIN_FAILED"; epoch: number; attempt: number; message: string } + | { type: "DISCONNECT_REQUESTED" }; + +export type WalletEffect = + | { type: "restore"; epoch: number } + | { type: "openAuthModal"; epoch: number } + | { type: "queryNetwork"; epoch: number; address: string } + | { type: "signIn"; epoch: number; address: string; attempt: number } + | { type: "clearBackendSession" } + | { type: "kitDisconnect" } + | { type: "purgeStellarQueries" } + | { type: "navigate"; to: string } + | { type: "resolveEnsure"; ok: boolean } + | { type: "resolveConnect"; ok: boolean }; + +export interface Transition { + state: WalletState; + effects: WalletEffect[]; +} + +export const initialWalletState: WalletState = { tag: "loading-kit", epoch: 0, pendingConnect: null }; + +const stay = (state: WalletState): Transition => ({ state, effects: [] }); + +const sessionFor = (wallet: string | null): SessionInfo | null => (wallet ? { wallet } : null); + +/** A backend session is only ever reused for the wallet it was issued to. */ +const isAuthenticatedState = (s: WalletState): boolean => + s.tag === "connected" && s.session?.wallet === s.address; + +export function transition(s: WalletState, e: WalletEvent): Transition { + switch (e.type) { + case "KIT_LOADED": + if (s.tag !== "loading-kit") return stay(s); + return { + state: { tag: "restoring", epoch: s.epoch, pendingConnect: s.pendingConnect }, + effects: [{ type: "restore", epoch: s.epoch }], + }; + + case "KIT_FAILED": + if (s.tag !== "loading-kit") return stay(s); + return { + state: { tag: "disconnected", epoch: s.epoch, error: e.error }, + effects: [{ type: "resolveConnect", ok: false }], + }; + + case "RESTORED": { + // Restore never redirects and never prompts: it only reads state. + if (s.tag !== "restoring" || e.epoch !== s.epoch) return stay(s); + const restored: WalletState = e.address + ? { + tag: "connected", + epoch: s.epoch, + address: e.address, + network: null, + session: sessionFor(e.storedSessionWallet), + attempt: 0, + error: null, + } + : { tag: "disconnected", epoch: s.epoch, error: null }; + // A connect requested while the kit was still restoring runs now. + if (s.pendingConnect) return transition(restored, { type: "CONNECT_REQUESTED", returnTo: s.pendingConnect.returnTo }); + return stay(restored); + } + + case "CONNECT_REQUESTED": + switch (s.tag) { + case "loading-kit": + case "restoring": + return stay({ ...s, pendingConnect: { returnTo: e.returnTo } }); + case "connecting": + // The picker is already open; the caller waits for that result. + return stay(s); + case "authenticating": + // Sign-in in flight: abandon it, the picker takes over. + return { + state: { tag: "connecting", epoch: s.epoch, returnTo: e.returnTo, previous: null }, + effects: [{ type: "resolveEnsure", ok: false }, { type: "openAuthModal", epoch: s.epoch }], + }; + case "connected": + return { + state: { tag: "connecting", epoch: s.epoch, returnTo: e.returnTo, previous: s }, + effects: [{ type: "openAuthModal", epoch: s.epoch }], + }; + case "disconnected": + return { + state: { tag: "connecting", epoch: s.epoch, returnTo: e.returnTo, previous: null }, + effects: [{ type: "openAuthModal", epoch: s.epoch }], + }; + } + + case "CONNECT_SUCCEEDED": { + if (s.tag !== "connecting" || e.epoch !== s.epoch) return stay(s); + // The connect itself never signs in: that waits for ENSURE_SESSION. + const effects: WalletEffect[] = [ + { type: "queryNetwork", epoch: s.epoch, address: e.address }, + { type: "resolveConnect", ok: true }, + ]; + if (e.storedSessionWallet && e.storedSessionWallet !== e.address) effects.push({ type: "clearBackendSession" }); + if (s.returnTo) effects.push({ type: "navigate", to: s.returnTo }); + return { + state: { + tag: "connected", + epoch: s.epoch, + address: e.address, + network: null, + session: e.storedSessionWallet === e.address ? sessionFor(e.address) : null, + attempt: s.previous?.attempt ?? 0, + error: null, + }, + effects, + }; + } + + case "CONNECT_FAILED": { + if (s.tag !== "connecting" || e.epoch !== s.epoch) return stay(s); + const error = e.silent ? null : e.message; + const fallback: WalletState = s.previous + ? { ...s.previous, error } + : { tag: "disconnected", epoch: s.epoch, error }; + return { state: fallback, effects: [{ type: "resolveConnect", ok: false }] }; + } + + case "NETWORK_REPORTED": + if (e.epoch !== s.epoch) return stay(s); + if ((s.tag === "connected" || s.tag === "authenticating") && s.address === e.address) { + return stay({ ...s, network: e.passphrase ?? s.network }); + } + return stay(s); + + case "KIT_STATE_UPDATED": { + // Restore and connect own the first answer; the kit's launch + // notification is not an account change. + if (s.tag === "loading-kit" || s.tag === "restoring" || s.tag === "connecting") return stay(s); + const clear: WalletEffect[] = + e.address && e.storedSessionWallet && e.storedSessionWallet !== e.address + ? [{ type: "clearBackendSession" }] + : []; + if (!e.address) { + const effects: WalletEffect[] = + s.tag === "authenticating" ? [{ type: "resolveEnsure", ok: false }] : []; + return { state: { tag: "disconnected", epoch: s.epoch, error: null }, effects }; + } + if ((s.tag === "connected" || s.tag === "authenticating") && s.address === e.address) { + return stay({ ...s, network: e.passphrase ?? s.network }); + } + // A different account (or a first account after disconnect): no session carries over. + const effects: WalletEffect[] = [...clear]; + if (s.tag === "authenticating") effects.push({ type: "resolveEnsure", ok: false }); + return { + state: { + tag: "connected", + epoch: s.epoch, + address: e.address, + network: e.passphrase, + session: e.storedSessionWallet === e.address ? sessionFor(e.address) : null, + attempt: "attempt" in s ? s.attempt : 0, + error: null, + }, + effects, + }; + } + + case "KIT_DISCONNECTED": { + const epoch = s.epoch + 1; + const effects: WalletEffect[] = [ + { type: "clearBackendSession" }, + { type: "purgeStellarQueries" }, + { type: "resolveEnsure", ok: false }, + { type: "resolveConnect", ok: false }, + ]; + return { state: { tag: "disconnected", epoch, error: null }, effects }; + } + + case "ENSURE_SESSION": { + if (s.tag === "connected") { + if (isAuthenticatedState(s)) return { state: s, effects: [{ type: "resolveEnsure", ok: true }] }; + const attempt = s.attempt + 1; + return { + state: { tag: "authenticating", epoch: s.epoch, address: s.address, network: s.network, attempt }, + effects: [{ type: "signIn", epoch: s.epoch, address: s.address, attempt }], + }; + } + // Single-flight: a sign-in already in progress is shared, not repeated. + if (s.tag === "authenticating") return stay(s); + return { state: s, effects: [{ type: "resolveEnsure", ok: false }] }; + } + + case "SIGNIN_SUCCEEDED": { + if (s.tag !== "authenticating" || e.epoch !== s.epoch || e.attempt !== s.attempt) return stay(s); + // A login for a different wallet than the one in the kit is not ours to keep. + if (e.wallet !== s.address) return stay(s); + return { + state: { + tag: "connected", + epoch: s.epoch, + address: s.address, + network: s.network, + session: sessionFor(e.wallet), + attempt: s.attempt, + error: null, + }, + effects: [{ type: "resolveEnsure", ok: true }], + }; + } + + case "SIGNIN_FAILED": { + if (s.tag !== "authenticating" || e.epoch !== s.epoch || e.attempt !== s.attempt) return stay(s); + return { + state: { + tag: "connected", + epoch: s.epoch, + address: s.address, + network: s.network, + session: null, + attempt: s.attempt, + error: e.message, + }, + effects: [{ type: "resolveEnsure", ok: false }], + }; + } + + case "DISCONNECT_REQUESTED": { + const effects: WalletEffect[] = [ + { type: "kitDisconnect" }, + { type: "clearBackendSession" }, + { type: "purgeStellarQueries" }, + { type: "resolveEnsure", ok: false }, + { type: "resolveConnect", ok: false }, + ]; + return { state: { tag: "disconnected", epoch: s.epoch + 1, error: null }, effects }; + } + } +} + +/** The fields WalletContextValue exposes, derived from the state. */ +export interface WalletView { + address: string | null; + isConnected: boolean; + isConnecting: boolean; + walletNetworkPassphrase: string | null; + isAuthenticated: boolean; + error: string | null; +} + +export function viewOf(s: WalletState): WalletView { + const base = { walletNetworkPassphrase: null, isAuthenticated: false, error: null } as const; + switch (s.tag) { + case "loading-kit": + case "restoring": + return { ...base, address: null, isConnected: false, isConnecting: s.pendingConnect !== null }; + case "disconnected": + return { ...base, address: null, isConnected: false, isConnecting: false, error: s.error }; + case "connecting": + // Keep showing the account the user already had while the picker is open. + if (s.previous) return { ...viewOf(s.previous), isConnecting: true }; + return { ...base, address: null, isConnected: false, isConnecting: true }; + case "authenticating": + return { + address: s.address, + isConnected: true, + isConnecting: false, + walletNetworkPassphrase: s.network, + isAuthenticated: false, + error: null, + }; + case "connected": + return { + address: s.address, + isConnected: true, + isConnecting: false, + walletNetworkPassphrase: s.network, + isAuthenticated: isAuthenticatedState(s), + error: s.error, + }; + } +}