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.
-
-
-
openModal()}
- className="w-full bg-primary text-primary-foreground py-4 rounded-lg font-label-caps text-label-caps hover:brightness-110 transition-all flex items-center justify-center gap-2 shadow-lg cursor-pointer"
- >
- account_balance_wallet
- CONNECT WALLET TO ACCESS
-
-
-
- );
- }
-
// 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.
-
-
-
- account_balance_wallet
- CONNECT WALLET TO ACCESS
-
-
-
- );
- }
-
// 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() {
void disconnect().then(() => openModal())}
className="w-full bg-outline text-on-outline py-4 rounded-lg font-label-caps text-label-caps hover:brightness-90 transition-all flex items-center justify-center gap-2 shadow-lg cursor-pointer"
>
swap_horiz
diff --git a/app/premium/page.tsx b/app/premium/page.tsx
index 17c8d15..4cf1569 100644
--- a/app/premium/page.tsx
+++ b/app/premium/page.tsx
@@ -162,12 +162,10 @@ export default function PremiumPage() {
? fees.totalFee - reserve.data.spendable
: null;
- const openCheckout = (tier: Tier) => {
+ const openCheckout = async (tier: Tier) => {
if (!tier.priceRaw) return;
- if (!isConnected) {
- void openModal();
- return;
- }
+ // Connecting stays on this page; keep the chosen tier if the wallet connects.
+ if (!isConnected && !(await openModal())) return;
setStatusMessage(null);
setSelectedTier(tier);
};
@@ -186,7 +184,7 @@ export default function PremiumPage() {
setIsSubmitting(true);
try {
- setStatusMessage("Signing in to OracleDesk…");
+ setStatusMessage("One more signature: this logs you in to OracleDesk and doesn't move funds.");
if (!(await ensureSession())) {
setStatusMessage("You need to sign in with your wallet before paying.");
return;
diff --git a/components/layout/WalletDropdown.tsx b/components/layout/WalletDropdown.tsx
index 4ef92c5..0010e2d 100644
--- a/components/layout/WalletDropdown.tsx
+++ b/components/layout/WalletDropdown.tsx
@@ -3,9 +3,9 @@
import React, { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useWallet } from "@/lib/contexts/WalletContext";
-import { useEnableUsdc, useUsdcBalance, useUsdcTrustline } from "@/lib/hooks/useStellar";
-import { NETWORK_LABEL, explorerAccount } from "@/lib/stellar/config";
-import { formatUsdc, shortAddress } from "@/lib/stellar/units";
+import { useAccountReserve, useEnableUsdc, useRpcNetwork, useUsdcBalance, useUsdcTrustline } from "@/lib/hooks/useStellar";
+import { NETWORK_LABEL, NETWORK_PASSPHRASE, TESTNET_PASSPHRASE, explorerAccount } from "@/lib/stellar/config";
+import { formatUsdc, formatXlm, shortAddress } from "@/lib/stellar/units";
const WalletDropdown = () => {
const { address, isConnected, isConnecting, isWrongNetwork, openModal, disconnect, error } = useWallet();
@@ -15,6 +15,8 @@ const WalletDropdown = () => {
const balance = useUsdcBalance(address);
const trustline = useUsdcTrustline(address);
const enableUsdc = useEnableUsdc();
+ const reserve = useAccountReserve(address);
+ const rpcNetwork = useRpcNetwork();
// Close dropdown when clicking outside
useEffect(() => {
@@ -48,6 +50,15 @@ const WalletDropdown = () => {
: balance.data !== undefined
? formatUsdc(balance.data, { maxDecimals: 2, minDecimals: 2, grouping: true })
: balance.isError ? "—" : "…";
+ const notActivated = !isWrongNetwork && reserve.data?.funded === false;
+ const xlmText = reserve.data?.funded
+ ? formatXlm(reserve.data.balance, { maxDecimals: 2, minDecimals: 2, grouping: true })
+ : reserve.isError ? "—" : "…";
+ // Friendbot exists only on testnet; the RPC says where it is.
+ const friendbotUrl =
+ NETWORK_PASSPHRASE === TESTNET_PASSPHRASE && rpcNetwork.data?.friendbotUrl
+ ? `${rpcNetwork.data.friendbotUrl}?addr=${encodeURIComponent(address)}`
+ : null;
const handleEnableUsdc = async () => {
try {
@@ -115,6 +126,31 @@ const WalletDropdown = () => {
USDC
+ {!isWrongNetwork && (
+
+ {xlmText}
+ XLM
+ (fees and reserve)
+
+ )}
+ {notActivated && (
+
+
Account not activated
+
+ This Stellar account needs a little XLM before it can trade. Fund it to continue.
+
+ {friendbotUrl && (
+
+ Fund with Friendbot
+
+ )}
+
+ )}
{trustlineMissing && !isWrongNetwork && (
{children}>;
+
+ return (
+
+
+
+ lock
+
+
+
{title}
+
{description}
+
+
void openModal()}
+ disabled={isConnecting}
+ className="w-full bg-primary text-primary-foreground py-4 rounded-lg font-label-caps text-label-caps hover:brightness-110 transition-all flex items-center justify-center gap-2 shadow-lg cursor-pointer disabled:opacity-60"
+ >
+ account_balance_wallet
+ {isConnecting ? "CONNECTING…" : "CONNECT WALLET"}
+
+
+
+ );
+}
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,
+ };
+ }
+}