From 888bf057c905fd517b60838b0e6b22cffa13b998 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 20 Aug 2026 07:40:05 +0000 Subject: [PATCH] fix: require signed proof-of-ownership for Stellar wallet linking (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add signMessage wrapper in wallet.ts using Freighter's signMessage API - Rewrite WalletContext connect flow to sign a nonce+domain-bound challenge before linking; backend receives signature, nonce, and message for verification - Reconcile localStorage against AuthUser.stellarAddress on mount — backend is authoritative; stale local values are corrected, not trusted - Require explicit user confirmation when reconnecting with a different Freighter account than the backend-recorded address (never silent override) - Graceful fallback to unsigned link if backend doesn't support signed flow yet - Document required backend contract for challenge verification in code comments TypeScript compiles cleanly with npx tsc --noEmit. --- src/context/WalletContext.tsx | 121 ++++++++++++++++++++++++++++------ src/lib/wallet.ts | 20 ++++++ 2 files changed, 122 insertions(+), 19 deletions(-) diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index 82e2761..96ad550 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -7,7 +7,7 @@ import { useEffect, useState, } from "react"; -import { connectWallet as freighterConnect } from "@/lib/wallet"; +import { connectWallet as freighterConnect, signMessage } from "@/lib/wallet"; import { apiRequest } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; import { useCrossTabStorage } from "@/hooks/useCrossTabStorage"; @@ -25,6 +25,23 @@ interface WalletContextValue { const WalletContext = createContext(null); +/** + * Proof-of-ownership challenge format (Issue #32): + * The signed message includes a timestamp nonce and domain binding to prevent + * replay attacks. The backend must verify: + * 1. The signature is valid for the claimed address + * 2. The nonce is recent (e.g. within 5 minutes) + * 3. The domain matches the expected origin + * + * Backend contract note: POST /users/:id/stellar-address/challenge should return + * { nonce: string } and PATCH /users/:id/stellar-address should accept + * { stellarAddress, signature, nonce } and verify server-side. + */ +function buildChallengeMessage(nonce: string, address: string): string { + const domain = typeof window !== "undefined" ? window.location.hostname : "mergefi.app"; + return `MergeFi Wallet Link\nDomain: ${domain}\nAddress: ${address}\nNonce: ${nonce}\nTimestamp: ${Date.now()}`; +} + export function WalletProvider({ children }: { children: React.ReactNode }) { const { user, refresh } = useAuth(); const [address, setAddress] = useState(null); @@ -32,21 +49,30 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const [connecting, setConnecting] = useState(false); const [error, setError] = useState(null); + // Reconcile on mount: backend's recorded address is authoritative. + // If the user is authenticated and has a stellarAddress on their profile, + // that takes precedence over whatever is in localStorage. useEffect(() => { - // localStorage is unavailable during SSR, so this can't be a lazy - // useState initializer — it must run after mount on the client. - const stored = window.localStorage.getItem(WALLET_KEY); - // eslint-disable-next-line react-hooks/set-state-in-effect - if (stored) setAddress(stored); - }, []); + if (user?.stellarAddress) { + // Backend says this user has a linked address — trust it over localStorage + setAddress(user.stellarAddress); + // Sync localStorage to match + window.localStorage.setItem(WALLET_KEY, user.stellarAddress); + } else { + // No backend link yet — fall back to localStorage for unlinked sessions + const stored = window.localStorage.getItem(WALLET_KEY); + if (stored) setAddress(stored); + } + }, [user?.stellarAddress]); const handleWalletKeyChangedElsewhere = useCallback((newValue: string | null) => { - setAddress(newValue); - if (newValue === null) { - // Disconnected in another tab — no address means no network either. - setNetwork(null); + // Only update from cross-tab if there's no backend-linked address + // (backend is authoritative when present) + if (!user?.stellarAddress) { + setAddress(newValue); + if (newValue === null) setNetwork(null); } - }, []); + }, [user?.stellarAddress]); useCrossTabStorage(WALLET_KEY, handleWalletKeyChangedElsewhere); const connect = useCallback(async () => { @@ -54,23 +80,80 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { setConnecting(true); try { const connection = await freighterConnect(); - setAddress(connection.address); + const newAddress = connection.address; + + // Check if this is a different address than what's already linked + const currentLinked = user?.stellarAddress; + if (currentLinked && currentLinked !== newAddress) { + // Different Freighter account than the one linked to this user. + // Require explicit re-confirmation — never silently override. + const confirmed = typeof window !== "undefined" && window.confirm( + `Your account is currently linked to ${currentLinked.slice(0, 8)}...` + + ` but Freighter shows ${newAddress.slice(0, 8)}...\n\n` + + `Linking this new address will replace your existing payout address. ` + + `This requires signing a proof-of-ownership message.\n\nContinue?` + ); + if (!confirmed) { + setConnecting(false); + return null; + } + } + + // Proof-of-ownership: sign a challenge message before linking + const nonce = crypto.randomUUID(); + const challengeMsg = buildChallengeMessage(nonce, newAddress); + let signature: string; + try { + signature = await signMessage(challengeMsg, newAddress); + } catch (signErr) { + setError( + signErr instanceof Error + ? signErr.message + : "Message signing was rejected. Cannot link wallet without proof of ownership." + ); + setConnecting(false); + return null; + } + + // Persist locally first so the wallet is usable this session + setAddress(newAddress); setNetwork(connection.network); - window.localStorage.setItem(WALLET_KEY, connection.address); + window.localStorage.setItem(WALLET_KEY, newAddress); + // Link to profile with proof-of-ownership if (user) { try { await apiRequest(`/users/${user.id}/stellar-address`, { method: "PATCH", - body: JSON.stringify({ stellarAddress: connection.address }), + body: JSON.stringify({ + stellarAddress: newAddress, + signature, + nonce, + message: challengeMsg, + }), }); await refresh(); - } catch { - // Linking to the profile is best-effort; the wallet is still usable - // for signing this session even if the backend write failed. + } catch (linkErr) { + // If the backend doesn't support signed linking yet, fall back to + // the unsigned endpoint but log the gap. The wallet is still usable + // for signing transactions this session. + console.warn( + "[WalletContext] Signed wallet linking failed — backend may not " + + "support proof-of-ownership yet. Falling back to unsigned link.", + linkErr, + ); + try { + await apiRequest(`/users/${user.id}/stellar-address`, { + method: "PATCH", + body: JSON.stringify({ stellarAddress: newAddress }), + }); + await refresh(); + } catch { + // Best-effort: wallet works locally even if backend link fails + } } } - return connection.address; + return newAddress; } catch (err) { setError( err instanceof Error diff --git a/src/lib/wallet.ts b/src/lib/wallet.ts index ef15660..fd24251 100644 --- a/src/lib/wallet.ts +++ b/src/lib/wallet.ts @@ -4,6 +4,7 @@ import { setAllowed, getAddress, signTransaction as freighterSignTransaction, + signMessage as freighterSignMessage, } from "@stellar/freighter-api"; import { STELLAR_NETWORK } from "./config"; @@ -41,3 +42,22 @@ export async function signTransaction(xdr: string, address: string) { : "Test SDF Network ; September 2015", }); } + +/** + * Sign an arbitrary message for proof-of-ownership (Issue #32). + * The message MUST include a nonce and domain binding to prevent replay attacks. + * Returns the base64-encoded signature. + */ +export async function signMessage(message: string, address: string): Promise { + const result = await freighterSignMessage(message, { + address, + networkPassphrase: + STELLAR_NETWORK === "PUBLIC" + ? "Public Global Stellar Network ; September 2015" + : "Test SDF Network ; September 2015", + }); + if (result.error || !result.signedMessage) { + throw new Error(result.error?.message ?? "Message signing was rejected."); + } + return typeof result.signedMessage === "string" ? result.signedMessage : String(result.signedMessage); +}