From 77ab6d7f8025a1b4e18cf9ab14960d8c6d5af669 Mon Sep 17 00:00:00 2001 From: bellabuks Date: Sat, 22 Aug 2026 15:08:37 +0100 Subject: [PATCH] feat(wallet): real Freighter signing, trustline detection, real fee estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements wallet onboarding and non-custodial payment UX (issue #143): - FreighterAdapter (app/src/lib/wallet/freighterAdapter.ts): a real WalletAdapter implementing signTransaction against window.freighter — previously nothing in the app ever called it. Error classification mirrors the TypeScript SDK's TransactionPipeline.classifySignerError (same regexes, same disconnect-before-reject ordering) so wallet errors are handled identically wherever they're thrown from. checkNetwork() detects a wallet pointed at the wrong network before signing, added as an optional WalletAdapter method. - adapters.ts: explicit per-provider adapter resolution — never "whichever extension loaded first." LOBSTR/xBull throw a clear, typed WalletUnavailableError; WalletButton now renders them as visibly disabled with "Coming soon," not a silent stub that only fails on click. - paymentFlow.ts's requestWalletSignature is real now: given an XDR, it resolves the connected wallet's adapter, checks network, and signs — reachable and tested through the exact call site runPaymentSubmission uses. It's still a no-op when no XDR is given, because building the actual deposit/early-exit transaction depends on the SDK becoming a real app dependency, which needs a pnpm install to regenerate app/pnpm-lock.yaml correctly — this environment can't safely run that (same blocker already documented on placeholderSubmissionHash). Neither page passes a real XDR yet, so today's actual signing behavior is unchanged; this wires the real, tested path for the moment one does. - Trustline detection (services/horizon.ts) is fully real and dependency-free: Horizon's /accounts/{id} returns trustlines as plain JSON, no XDR encoding needed. The deposit flow gets a new step that checks for a missing USDC trustline before confirm and explains what to do — actually adding the trustline in-app remains blocked by the same SDK dependency (changeTrust also needs XDR construction), stated honestly in the UI rather than faked with a placeholder signing prompt. - Fee estimate (lib/feeEstimate.ts) replaces the hardcoded '0.00001 XLM' with getFeeStats() — real, current network-wide fee data requiring no transaction to build, presented as an "up to" (p99) ceiling with a one-line network-fee-not-YieldLadder-fee note, never a fabricated number on failure. - Non-custodial explainer copy added at the deposit confirm step and EarlyExitModal, grounded in the README's actual architecture (the Strategist can propose allocations but never withdraw funds; every withdrawal needs the user's own signature) rather than generic marketing language. Note: EarlyExitModal's "Exit Early" button was already wired to the real submission pipeline as of #142 — the issue's description of it as unhandled predates that merge. This PR adds the explainer copy there and leaves the already-working handler alone. --- .env.example | 15 +- app/src/app/deposit/page.tsx | 193 ++++++++++++++++++-- app/src/components/EarlyExitModal.tsx | 18 ++ app/src/components/wallet/WalletButton.tsx | 118 +++++++----- app/src/lib/feeEstimate.test.ts | 118 ++++++++++++ app/src/lib/feeEstimate.ts | 67 +++++++ app/src/lib/paymentFlow.test.ts | 86 ++++++++- app/src/lib/paymentFlow.ts | 69 +++++-- app/src/lib/wallet/adapters.test.ts | 28 +++ app/src/lib/wallet/adapters.ts | 28 +++ app/src/lib/wallet/errors.ts | 34 ++++ app/src/lib/wallet/freighterAdapter.test.ts | 169 +++++++++++++++++ app/src/lib/wallet/freighterAdapter.ts | 138 ++++++++++++++ app/src/lib/wallet/network.ts | 11 ++ app/src/lib/wallet/types.ts | 10 + app/src/services/horizon.test.ts | 104 +++++++++++ app/src/services/horizon.ts | 83 +++++++++ app/src/services/rpc.ts | 34 ++++ 18 files changed, 1243 insertions(+), 80 deletions(-) create mode 100644 app/src/lib/feeEstimate.test.ts create mode 100644 app/src/lib/feeEstimate.ts create mode 100644 app/src/lib/wallet/adapters.test.ts create mode 100644 app/src/lib/wallet/adapters.ts create mode 100644 app/src/lib/wallet/freighterAdapter.test.ts create mode 100644 app/src/lib/wallet/freighterAdapter.ts create mode 100644 app/src/lib/wallet/network.ts create mode 100644 app/src/services/horizon.test.ts create mode 100644 app/src/services/horizon.ts diff --git a/.env.example b/.env.example index 57e6638..c067eb7 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,17 @@ HARVESTER_ADDRESS= GOVERNANCE_ADDRESS= # Next.js public API base URL -NEXT_PUBLIC_API_URL=http://localhost:3000 \ No newline at end of file +NEXT_PUBLIC_API_URL=http://localhost:3000 + +# Wallet onboarding & USDC trustline detection (issue #143) +# Network this app targets — used to detect a connected wallet pointed at +# a different one before the user attempts to sign. +NEXT_PUBLIC_STELLAR_NETWORK=testnet +# Horizon endpoint used for trustline detection (app/src/services/horizon.ts). +NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.org +# Classic asset code/issuer for the deposit asset. Leave +# NEXT_PUBLIC_USDC_ISSUER unset to skip the trustline check cleanly (the +# deposit flow proceeds without it) until this deployment's USDC issuer is +# known. +NEXT_PUBLIC_USDC_ASSET_CODE=USDC +NEXT_PUBLIC_USDC_ISSUER= \ No newline at end of file diff --git a/app/src/app/deposit/page.tsx b/app/src/app/deposit/page.tsx index 02d14a8..519fb95 100644 --- a/app/src/app/deposit/page.tsx +++ b/app/src/app/deposit/page.tsx @@ -13,6 +13,15 @@ import { } from '@/lib/paymentIntent'; import { toast } from '@/lib/toast'; import { runPaymentSubmission, defaultPaymentSubmissionDeps } from '@/lib/paymentFlow'; +import { estimateNetworkFee, type FeeEstimate } from '@/lib/feeEstimate'; +import { + checkUsdcTrustline, + AccountNotFoundError, + HorizonUnavailableError, +} from '@/services/horizon'; + +const USDC_ASSET_CODE = process.env.NEXT_PUBLIC_USDC_ASSET_CODE ?? 'USDC'; +const USDC_ISSUER = process.env.NEXT_PUBLIC_USDC_ISSUER; const TIERS = [ { id: 'flex', label: 'Flex', lock: 'None', lockMonths: 0, multiplier: 1.0, multiplierLabel: '1.00x', exitFee: '0%', minDeposit: 1, apy: 4.2 }, @@ -22,8 +31,11 @@ const TIERS = [ ] as const; type TierId = typeof TIERS[number]['id']; -type Step = 1 | 2 | 3 | 4; +// 3 (Trustline) is only ever visited when a check finds one missing — +// otherwise step 2 -> 4 directly. See handleContinueFromAmount. +type Step = 1 | 2 | 3 | 4 | 5; type TxStatus = 'pending' | 'confirmed' | 'failed' | 'expired'; +type TrustlineState = 'checking' | 'missing' | 'present' | 'unknown'; function lockExpiry(lockMonths: number): string { if (lockMonths === 0) return 'No lock'; @@ -70,12 +82,29 @@ function DepositFlow() { const [txError, setTxError] = useState(''); const [intent, setIntent] = useState(null); const [submitting, setSubmitting] = useState(false); + const [trustlineState, setTrustlineState] = useState('unknown'); + const [trustlineError, setTrustlineError] = useState(''); + const [feeEstimate, setFeeEstimate] = useState(null); // Not gated behind a "must connect wallet" requirement (out of scope // here) — falls back to a placeholder so the idempotency key is still // well-formed if a user somehow reaches this page unconnected. const address = loadSession()?.account.publicKey ?? 'anonymous'; + // Sourced from real, current network fee data (issue #143) — see + // lib/feeEstimate.ts for why this is an "up to" ceiling rather than a + // fixed per-transaction quote (that needs simulating this specific + // transaction, which is still blocked — see that file's doc). + useEffect(() => { + let cancelled = false; + estimateNetworkFee().then((estimate) => { + if (!cancelled) setFeeEstimate(estimate); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (paramTier) setTierId(paramTier); }, [paramTier]); @@ -88,7 +117,7 @@ function DepositFlow() { if (!active) return; setIntent(active); setAmount(active.amount); - setStep(4); + setStep(5); // findActiveIntent already excludes terminal states (confirmed/failed/ // expired — see isTerminal), so a rehydrated intent is always // mid-flight; anything short of that bucket falls back to 'pending'. @@ -106,15 +135,63 @@ function DepositFlow() { ? (amountNum * tier.apy / 100).toFixed(2) : (amountNum * tier.apy / 100 * (tier.lockMonths / 12)).toFixed(2); const shares = (amountNum * tier.multiplier).toFixed(0); - const estimatedFee = '0.00001 XLM'; // Generated once per arrival at the confirm step (issue #140) — a fresh - // nonce every time, so re-entering step 3 with a changed amount/tier - // never silently reuses a stale key, while double-clicking Confirm on - // the SAME step-3 visit reuses the SAME intent. - function handleEnterConfirmStep() { - setIntent(createIntent('deposit', address, tierId, 'USDC', amount)); + // nonce every time, so re-entering the confirm step with a changed + // amount/tier never silently reuses a stale key, while double-clicking + // Confirm on the SAME visit reuses the SAME intent. + function enterConfirmStep() { + setIntent(createIntent('deposit', address, tierId, USDC_ASSET_CODE, amount)); + setStep(4); + } + + // USDC trustline check (issue #143): run once when leaving the amount + // step, before the confirm step is ever reached, rather than letting a + // missing trustline surface as an opaque simulation failure at deposit + // time. A check failure (network down, account not yet funded) doesn't + // hard-block the flow — the user can retry the check or continue + // anyway, since the deposit itself will still fail cleanly through the + // normal error path if a trustline genuinely is missing. + async function handleContinueFromAmount() { + if (!USDC_ISSUER) { + // Not configured for this deployment yet — can't check, so don't + // pretend to. See services/horizon.ts's `configured` field. + enterConfirmStep(); + return; + } + + setTrustlineState('checking'); setStep(3); + try { + const result = await checkUsdcTrustline(address, USDC_ASSET_CODE, USDC_ISSUER); + if (result.hasTrustline) { + enterConfirmStep(); + } else { + setTrustlineState('missing'); + } + } catch (err) { + setTrustlineState('unknown'); + setTrustlineError( + err instanceof AccountNotFoundError + ? 'Your wallet account was not found on the network yet — it may need to be funded first.' + : err instanceof HorizonUnavailableError + ? "Couldn't check your trustline status — the network may be unreachable." + : err instanceof Error + ? err.message + : 'Could not check trustline status.', + ); + } + } + + async function handleRecheckTrustline() { + setTrustlineState('checking'); + setTrustlineError(''); + try { + const result = await checkUsdcTrustline(address, USDC_ASSET_CODE, USDC_ISSUER); + setTrustlineState(result.hasTrustline ? 'present' : 'missing'); + } catch { + setTrustlineState('unknown'); + } } async function handleConfirm() { @@ -128,7 +205,7 @@ function DepositFlow() { setSubmitting(true); setIntent(started); - setStep(4); + setStep(5); setTxStatus('pending'); const awaiting = markAwaitingSignature(started); @@ -158,7 +235,7 @@ function DepositFlow() { // should not get a new nonce. setTxStatus(null); setTxError(''); - setStep(3); + setStep(4); } return ( @@ -232,7 +309,7 @@ function DepositFlow() { style={belowMin || !amountNum ? s.btnDisabled : s.btnPrimary} type="button" disabled={belowMin || !amountNum} - onClick={handleEnterConfirmStep} + onClick={handleContinueFromAmount} > Continue @@ -240,8 +317,69 @@ function DepositFlow() { )} - {/* Step 3 — Confirmation modal */} + {/* Step 3 — USDC trustline check (only reached when one is actually missing/unknown) */} {step === 3 && ( +
+

USDC Trustline

+ {trustlineState === 'checking' && ( + <> +
+

Checking your wallet for a USDC trustline…

+ + )} + {trustlineState === 'missing' && ( + <> +

+ Your wallet doesn't hold {USDC_ASSET_CODE} yet — Stellar + accounts need a one-time trustline before they + can hold an asset. This is a separate transaction from your + deposit, and it's something only your wallet can add. +

+

+ Add a {USDC_ASSET_CODE} trustline in your wallet (most wallets, + including Freighter, have an "Add asset" option), then + recheck below. +

+
+ + +
+ + )} + {trustlineState === 'present' && ( + <> +

Trustline found — you're all set.

+
+ + +
+ + )} + {trustlineState === 'unknown' && ( + <> +

+ {trustlineError || 'Could not check your trustline status.'} +

+
+ + + +
+ + )} +
+ )} + + {/* Step 4 — Confirmation */} + {step === 4 && (

Confirm Deposit

@@ -251,8 +389,20 @@ function DepositFlow() { - +
+ {feeEstimate?.available &&

{feeEstimate.note}

} +

+ YieldLadder never takes custody of your funds. Depositing signs + and submits a transaction directly from your own wallet, and the + protocol is non-custodial and immutable — the only privileged + role (the Strategist) can propose pool allocations but can never + withdraw your funds. Every withdrawal, at maturity or via early + exit, requires your own wallet signature. +

- ))} + {PROVIDERS.map(({ id, label }) => { + const implemented = IMPLEMENTED_WALLET_PROVIDERS.includes(id); + return ( + + ); + })} +

+ Freighter is fully supported today. LOBSTR and xBull support is planned. +

)}