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. +

)}