From 0ba305bea174f6ab3978ee43623cd02976eed8b4 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 17:07:32 +0800 Subject: [PATCH 1/9] feat(earn): deposit idle USDC into a reviewed Morpho vault from /me Base now pitches apps on routing their users into lending markets on Base (base.org/earn-borrow). This adds the one integration worth shipping: a fee-free, non-custodial Earn card on /me that puts a wallet's own USDC into Steakhouse Prime USDC (a Morpho Vault V2 on Base) and takes it out again. - Off unless the runtime secret EARN=on is set; GET /api/earn then says only { enabled: false } and the card and the command-palette entry stay hidden. - Plain ERC-4626 calls with exact approvals and a simulation before every signature. No SDK, no new dependency, no new host; the CSP is unchanged. - Every call ends with the bc_ly9ism19 builder-code suffix, so Base credits the deposits to us; smart wallets that batch also get it as the sendCalls dataSuffix capability. - Deposits stop if the vault's fees, gates or asset ever differ from the reviewed terms; withdrawals never depend on that check. - The rate shown is the vault's real share-price growth over 30 days, read on-chain by the server, so nobody else's number is involved. Verified on a Base fork (exact approve, deposit, partial withdraw, redeem all, suffix decoded to bc_ly9ism19) and by an opt-in live read test. A real-wallet deposit and withdrawal is still to do before the switch is turned on (docs/earn.md). --- README.md | 3 + app/.env.example | 3 + app/src/app/api/earn/earn-route.test.ts | 24 ++ app/src/app/api/earn/route.ts | 20 ++ app/src/app/ui-review-earn/EarnReview.tsx | 87 ++++++ app/src/app/ui-review-earn/page.tsx | 11 + app/src/components/CommandPalette.tsx | 5 +- app/src/components/earn/EarnCard.tsx | 87 ++++++ app/src/components/earn/EarnDialog.module.css | 73 +++++ app/src/components/earn/EarnDialog.tsx | 53 ++++ app/src/components/earn/EarnPanel.tsx | 123 +++++++++ app/src/components/earn/earn-ui.test.ts | 72 +++++ app/src/components/earn/earnInfo.ts | 36 +++ app/src/components/earn/useEarn.ts | 250 ++++++++++++++++++ app/src/components/launchpad/MeDashboard.tsx | 3 + app/src/lib/command-palette.test.ts | 14 +- app/src/lib/command-palette.ts | 7 +- app/src/lib/earn/abi.ts | 62 +++++ app/src/lib/earn/calls.test.ts | 59 +++++ app/src/lib/earn/calls.ts | 53 ++++ app/src/lib/earn/info.ts | 25 ++ app/src/lib/earn/math.test.ts | 83 ++++++ app/src/lib/earn/math.ts | 120 +++++++++ app/src/lib/earn/server.test.ts | 110 ++++++++ app/src/lib/earn/server.ts | 77 ++++++ app/src/lib/earn/terms.test.ts | 32 +++ app/src/lib/earn/terms.ts | 38 +++ app/src/lib/earn/vault.live.test.ts | 24 ++ app/src/lib/earn/vaults.test.ts | 25 ++ app/src/lib/earn/vaults.ts | 62 +++++ app/src/lib/errors.ts | 2 + docs/earn.md | 62 +++++ 32 files changed, 1701 insertions(+), 4 deletions(-) create mode 100644 app/src/app/api/earn/earn-route.test.ts create mode 100644 app/src/app/api/earn/route.ts create mode 100644 app/src/app/ui-review-earn/EarnReview.tsx create mode 100644 app/src/app/ui-review-earn/page.tsx create mode 100644 app/src/components/earn/EarnCard.tsx create mode 100644 app/src/components/earn/EarnDialog.module.css create mode 100644 app/src/components/earn/EarnDialog.tsx create mode 100644 app/src/components/earn/EarnPanel.tsx create mode 100644 app/src/components/earn/earn-ui.test.ts create mode 100644 app/src/components/earn/earnInfo.ts create mode 100644 app/src/components/earn/useEarn.ts create mode 100644 app/src/lib/earn/abi.ts create mode 100644 app/src/lib/earn/calls.test.ts create mode 100644 app/src/lib/earn/calls.ts create mode 100644 app/src/lib/earn/info.ts create mode 100644 app/src/lib/earn/math.test.ts create mode 100644 app/src/lib/earn/math.ts create mode 100644 app/src/lib/earn/server.test.ts create mode 100644 app/src/lib/earn/server.ts create mode 100644 app/src/lib/earn/terms.test.ts create mode 100644 app/src/lib/earn/terms.ts create mode 100644 app/src/lib/earn/vault.live.test.ts create mode 100644 app/src/lib/earn/vaults.test.ts create mode 100644 app/src/lib/earn/vaults.ts create mode 100644 docs/earn.md diff --git a/README.md b/README.md index 9830e9bd..84b90ec0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ Creators choose a 0 / 1 / 3 % trading fee that is pushed **in full** to benefici - **Holders & trust panel.** Every transfer of every launched token is indexed: holder count, top-10 share, creator holdings and sells, launch-window snipers, locked-pool share. Facts, no score. - **Creator dashboard.** Fees earned, collect in one click, signed metadata edits, per-token share cards. +- **Earn on idle USDC** (switched off until launch). From the dashboard, put a wallet's own USDC into one reviewed + Morpho vault on Base and take it out again. Wallet to vault and back: no openlaunch contract, custody or fee + (`docs/earn.md`). - **Posts.** Wallet-signed comments on token pages and a human feed, with reports and creator mute. - **Agents welcome.** Plain JSON APIs, `llms.txt`, and a launch flow that needs nothing but a wallet. diff --git a/app/.env.example b/app/.env.example index 92461b96..8b82b5fa 100644 --- a/app/.env.example +++ b/app/.env.example @@ -49,6 +49,9 @@ IMAGE_STORE=local # The hero locker's coin wears a new launch's own picture (our images only, never a copy, never a look-alike name). Set to "off" # to keep the coin plain (the caption still reacts). On Fly: `fly secrets set HERO_COIN_PICTURES=off` restarts the app, no deploy. # HERO_COIN_PICTURES=off +# Earn (deposit idle USDC into the reviewed Morpho vault from /me, docs/earn.md) is OFF unless this is "on". On Fly: +# `fly secrets set EARN=on` restarts the app, no deploy; any other value (or unsetting it) switches it off again. +# EARN=on # Wallets allowed to moderate posts (comma-separated, lowercase) ADMIN_WALLETS= diff --git a/app/src/app/api/earn/earn-route.test.ts b/app/src/app/api/earn/earn-route.test.ts new file mode 100644 index 00000000..0af401e5 --- /dev/null +++ b/app/src/app/api/earn/earn-route.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +// Source contract, like write-limits.test.ts: the route is read as text, not run. +const src = readFileSync(new URL("./route.ts", import.meta.url), "utf8"); + +test("the per-IP limit runs before anything else", () => { + const limit = src.indexOf("rateLimited(`earn:ip:"); + assert.ok(limit > 0); + assert.ok(limit < src.indexOf("earnEnabled()")); + assert.ok(limit < src.indexOf("earnInfo()")); +}); + +test("switched off, the answer is exactly { enabled: false } and nothing is read", () => { + assert.match(src, /if \(!earnEnabled\(\)\) return NextResponse\.json\(\{ enabled: false \}/); + assert.ok(src.indexOf("if (!earnEnabled())") < src.indexOf("await earnInfo()")); +}); + +test("the route never takes a wallet and is never cached", () => { + assert.doesNotMatch(src, /searchParams|req\.url|req\.json|req\.body/); + assert.match(src, /export const dynamic = "force-dynamic"/); + assert.match(src, /"cache-control": "no-store"/); +}); diff --git a/app/src/app/api/earn/route.ts b/app/src/app/api/earn/route.ts new file mode 100644 index 00000000..f0377fa5 --- /dev/null +++ b/app/src/app/api/earn/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { clientIp } from "@/lib/profiles/http"; +import { earnEnabled, earnInfo } from "@/lib/earn/server"; + +export const dynamic = "force-dynamic"; + +const NO_STORE = { "cache-control": "no-store" }; + +/** + * GET /api/earn → `{ enabled: false }` while the EARN switch is off. On: the lending vault(s) the /me dashboard offers, + * each with figures read from the chain (rate over the last 30 days, fees, size, and whether its live terms still match + * the reviewed ones), or `snapshot: null` when the chain could not be read. Never a wallet's balances: the browser reads + * those itself through /api/rpc. Rate limited per IP; the figures are memoised for 10 minutes. + */ +export async function GET(req: Request) { + if (rateLimited(`earn:ip:${clientIp(req)}`, 60)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + if (!earnEnabled()) return NextResponse.json({ enabled: false }, { headers: NO_STORE }); + return NextResponse.json(await earnInfo(), { headers: NO_STORE }); +} diff --git a/app/src/app/ui-review-earn/EarnReview.tsx b/app/src/app/ui-review-earn/EarnReview.tsx new file mode 100644 index 00000000..94541b60 --- /dev/null +++ b/app/src/app/ui-review-earn/EarnReview.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import { X } from "lucide-react"; +import { EarnCardView } from "@/components/earn/EarnCard"; +import { EarnShell } from "@/components/earn/EarnDialog"; +import EarnPanel, { type EarnView } from "@/components/earn/EarnPanel"; +import type { EarnVaultSnapshot } from "@/lib/earn/info"; +import { EARN_VAULTS } from "@/lib/earn/vaults"; +import styles from "@/components/earn/EarnDialog.module.css"; + +// Synthetic figures shaped like the real vault on 2026-10-14. Nothing here reads a wallet or sends anything. +const vault = EARN_VAULTS.base!; +const snapshot: EarnVaultSnapshot = { rate: 0.0436, rateFrom: "2026-09-14T08:49:55.000Z", rateTo: "2026-10-14T08:49:55.000Z", totalAssets: "182843577259894", performanceFeePct: 0, managementFeeYearPct: 0, terms: { ok: true } }; +const noop = () => {}; +const base: EarnView = { + vault, isConnected: true, onChain: true, switching: false, tab: "deposit", text: "", all: false, amount: null, problem: "Enter an amount", needsApproval: null, + paused: null, phase: { k: "idle" }, busy: false, balance: 1_250_500_000n, shares: 0n, position: 0n, unreadable: false, noGas: false, + setTab: noop, setAmount: noop, setMax: noop, reset: noop, switchToVaultChain: async () => {}, submit: async () => {}, +}; +const typed = (text: string, amount: bigint): Partial => ({ text, amount, problem: null }); +const hash = `0x${"ab".repeat(32)}` as const; +const deposited: Partial = { shares: 478_838_632_555_005_049_702n, position: 500_000_000n }; + +const scenes: { id: string; title: string; view: Partial; snap?: EarnVaultSnapshot | null }[] = [ + { id: "disconnected", title: "Disconnected", view: { isConnected: false, balance: undefined, shares: undefined, position: undefined } }, + { id: "empty", title: "Connected, nothing typed", view: {} }, + { id: "deposit_first", title: "First deposit (needs an approval)", view: { ...typed("250", 250_000_000n), needsApproval: true } }, + { id: "deposit_again", title: "Deposit (already approved)", view: { ...typed("250", 250_000_000n), needsApproval: false, ...deposited } }, + { id: "over_balance", title: "More than the wallet holds", view: { text: "5000", amount: 5_000_000_000n, problem: "More than your USDC" } }, + { id: "wrong_network", title: "Wallet on another network", view: { ...typed("250", 250_000_000n), onChain: false } }, + { id: "approving", title: "Approving", view: { ...typed("250", 250_000_000n), needsApproval: true, phase: { k: "approving" }, busy: true } }, + { id: "signing", title: "Confirm in wallet", view: { ...typed("250", 250_000_000n), phase: { k: "signing" }, busy: true } }, + { id: "sent", title: "Confirming", view: { ...typed("250", 250_000_000n), phase: { k: "sent", hash }, busy: true } }, + { id: "done_deposit", title: "Deposited", view: { phase: { k: "done", hash, tab: "deposit", amount: 250_000_000n } } }, + { id: "withdraw_all", title: "Withdraw everything", view: { tab: "withdraw", ...deposited, text: "500", amount: 500_000_000n, all: true, problem: null } }, + { id: "withdraw_part", title: "Withdraw part", view: { tab: "withdraw", ...deposited, ...typed("120", 120_000_000n) } }, + { id: "error_liquidity", title: "Vault short of free funds", view: { tab: "withdraw", ...deposited, ...typed("500", 500_000_000n), all: true, phase: { k: "error", message: "The vault cannot pay out that much right now: most of it is lent out. Try a smaller amount, or try again later." } } }, + { id: "done_withdraw", title: "Withdrawn", view: { tab: "withdraw", phase: { k: "done", hash, tab: "withdraw", amount: 500_000_000n } } }, + { id: "paused", title: "Vault terms changed", view: { ...typed("250", 250_000_000n), paused: "The vault now keeps 10% of the interest it earns." } }, + { id: "no_rate", title: "Rate unavailable, no gas", view: { noGas: true }, snap: null }, +]; + +function Panel({ view, snap }: { view: EarnView; snap: EarnVaultSnapshot | null }) { + return ( +
+ Earn on USDC} close={} + description={

Lend your USDC through a Morpho vault on Base. Take it out whenever the vault has it free.

}> + +
+
+ ); +} + +export default function EarnReview() { + const params = useSearchParams(); + const only = params.get("scene"); + // ?dark=1 / ?dark=0 picks the theme, for screenshots of both + const dark = params.get("dark"); + const { setTheme } = useTheme(); + useEffect(() => { + if (dark === "1" || dark === "0") setTheme(dark === "1" ? "dark" : "light"); + }, [dark, setTheme]); + if (only === "card") return
; + const shown = only ? scenes.filter((s) => s.id === only) : scenes; + return ( +
+ {!only ? ( +
+

/me card

+ + +
+ ) : null} +
+ {shown.map((s) => ( +
+

{s.title} ?scene={s.id}

+ +
+ ))} +
+
+ ); +} diff --git a/app/src/app/ui-review-earn/page.tsx b/app/src/app/ui-review-earn/page.tsx new file mode 100644 index 00000000..e046d64e --- /dev/null +++ b/app/src/app/ui-review-earn/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import EarnReview from "./EarnReview"; + +export const dynamic = "force-dynamic"; + +/** Local visual fixture for the Earn card and sheet. Never exposes a mock wallet or transaction on production. */ +export default function EarnReviewPage() { + if (process.env.NODE_ENV !== "development") notFound(); + return ; +} diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx index f14e155a..4d08d0a4 100644 --- a/app/src/components/CommandPalette.tsx +++ b/app/src/components/CommandPalette.tsx @@ -12,6 +12,7 @@ import { marketChange, marketUsd } from "@/lib/launchpad/market-format"; import { setPendingToken } from "@/lib/launchpad/token-transition"; import { buildPaletteGroups, isSearchableQuery, paletteTokenFrom, type PaletteGroup, type PaletteItem, type PaletteToken } from "@/lib/command-palette"; import { useOpenBridge } from "./bridge/BridgeProvider"; +import { useEarnInfo } from "./earn/earnInfo"; type LaunchesResponse = { launches?: Parameters[0][] }; @@ -64,7 +65,9 @@ export default function CommandPalette({ open, onOpenChange }: { open: boolean; const current = results && results.q === q ? results : null; const searching = isSearchableQuery(q) && !current; const theme = resolvedTheme === "dark" ? "dark" : "light"; - const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme }), [query, current, trending, theme]); + // the Earn entry exists only while the feature is switched on; asked once, on the first open + const earn = useEarnInfo(open)?.enabled === true; + const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme, earn }), [query, current, trending, theme, earn]); const status = searching ? "Searching tokens…" : current?.failed ? "Token search is unavailable right now. Pages and actions still work." : ""; const close = () => { diff --git a/app/src/components/earn/EarnCard.tsx b/app/src/components/earn/EarnCard.tsx new file mode 100644 index 00000000..b352d69f --- /dev/null +++ b/app/src/components/earn/EarnCard.tsx @@ -0,0 +1,87 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, useRef, useState } from "react"; +import { ArrowDownToLine, ArrowUpFromLine, Landmark } from "lucide-react"; +import type { Address } from "viem"; +import { btn } from "@/components/ui"; +import { CHAIN_LABELS } from "@/lib/chainPublic"; +import { fmtAmount, fmtRate } from "@/lib/earn/math"; +import type { EarnVaultSnapshot } from "@/lib/earn/info"; +import { EARN_VAULTS, type EarnVault } from "@/lib/earn/vaults"; +import { useEarnInfo } from "./earnInfo"; +import useEarn, { type EarnTab } from "./useEarn"; + +const EarnDialog = dynamic(() => import("./EarnDialog"), { ssr: false }); + +/** + * /me: put the wallet's idle USDC into the reviewed lending vault and take it out again. Renders nothing unless the + * EARN switch is on (GET /api/earn). The dialog loads on first use. + */ +export default function EarnCard({ address }: { address: Address }) { + const info = useEarnInfo(); + // the server names the vault it read; only an entry naming the vault this build lists for that chain is used + const entry = info?.enabled ? info.vaults.find((x) => EARN_VAULTS[x.chain]?.address.toLowerCase() === x.address.toLowerCase()) : undefined; + const vault = entry ? EARN_VAULTS[entry.chain] : null; + if (!entry || !vault) return null; + // keyed by wallet: another account starts from a clean form, with its own balances + return ; +} + +function EarnCardLive({ vault, snapshot }: { vault: EarnVault; snapshot: EarnVaultSnapshot | null }) { + const earn = useEarn(vault, "deposit"); + // the dialog mounts on first open and stays mounted, like the bridge panel + const [activated, setActivated] = useState(false); + const [open, setOpen] = useState(false); + const section = useRef(null); + // /me#earn (the ⌘K entry): the card arrives after the page, so the browser's own jump to the anchor missed it + useEffect(() => { + if (window.location.hash === "#earn") section.current?.scrollIntoView({ block: "center" }); + }, []); + function show(tab: EarnTab) { + // a transaction in flight keeps its tab and progress; otherwise open on the button pressed, with a clean form + if (!earn.busy) { + earn.reset(); + earn.setTab(tab); + } + setActivated(true); + setOpen(true); + } + return ( + <> + + {activated ? : null} + + ); +} + +/** The card itself, with no wallet behind it (the review page renders it with fixtures). */ +export function EarnCardView({ ref, vault, snapshot, balance, position, onOpen }: { ref?: React.Ref; vault: EarnVault; snapshot: EarnVaultSnapshot | null; balance: bigint | undefined; position: bigint | undefined; onOpen: (tab: EarnTab) => void }) { + const sym = vault.assetSymbol; + const amt = (raw: bigint | undefined) => (raw === undefined ? "…" : fmtAmount(raw, vault.assetDecimals)); + const cell = (label: string, value: string, hint: string) => ( +
+

{label}

+

{value}

+

{hint}

+
+ ); + return ( +
+
+

+

{CHAIN_LABELS[vault.chain]} · {vault.name}

+
+
+ {cell("Last 30 days", fmtRate(snapshot?.rate ?? null), snapshot?.rate != null ? "Annualised. Variable." : "Rate unavailable right now")} + {cell(`${sym} in your wallet`, amt(balance), `On ${CHAIN_LABELS[vault.chain]}`)} + {cell("Your deposit", position === 0n ? "None" : amt(position), position !== undefined && position > 0n ? "What it withdraws for now" : "Nothing deposited yet")} +
+
+ + +

Run by {vault.curator} on {vault.protocol}. Can lose money. No openlaunch fee.

+
+
+ ); +} diff --git a/app/src/components/earn/EarnDialog.module.css b/app/src/components/earn/EarnDialog.module.css new file mode 100644 index 00000000..4899f844 --- /dev/null +++ b/app/src/components/earn/EarnDialog.module.css @@ -0,0 +1,73 @@ +/* Earn dialog. Same visual language as the bridge panel (BridgeDialog.module.css): raised sheet, hairline rules, one primary action. */ +.backdrop { position: fixed; inset: 0; z-index: 80; background: color-mix(in srgb, var(--color-scrim) 45%, transparent); opacity: 1; transition: opacity 180ms ease-out; } +.backdrop[data-starting-style], .backdrop[data-ending-style] { opacity: 0; } +.panel { position: fixed; z-index: 81; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; padding: 26px 28px 20px; border: 1px solid var(--color-line); border-radius: 24px; background: var(--color-raised); color: var(--color-ink); box-shadow: var(--shadow-dialog); outline: none; scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); } +.panel[data-starting-style], .panel[data-ending-style] { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(.98); } +.panel ::selection { background: var(--color-brand-soft); color: var(--color-brand); } +/* the review page shows the panel in place, not as a modal */ +.static { position: relative; top: auto; left: auto; transform: none; max-height: none; } +.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.title { margin: 0; font-size: 24px; line-height: 1.25; letter-spacing: -.035em; font-weight: 650; } +.close { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; margin: -4px -8px -4px 0; border-radius: 50%; color: var(--color-muted); cursor: pointer; } +.description { margin: 6px 0 20px; color: var(--color-muted); font-size: 14px; line-height: 1.5; text-wrap: pretty; } + +.figures { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-paper); } +.figures > div { min-width: 0; padding: 14px 16px; } +.figures > div + div { border-left: 1px solid var(--color-line); } +.figureLabel { display: block; color: var(--color-muted); font-size: 12px; line-height: 1.5; } +.figureValue { display: block; margin-top: 2px; font-size: 24px; font-weight: 600; line-height: 1.25; letter-spacing: -.025em; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.figureValue small { margin-left: 4px; color: var(--color-muted); font-size: 13px; font-weight: 500; letter-spacing: 0; } +.figureHint { display: block; margin-top: 4px; color: var(--color-muted); font-size: 11px; line-height: 1.5; } + +.tabs { margin-top: 20px; } +.amountSection { padding: 20px 0 16px; } +.smallLabel { display: block; font-size: 12px; font-weight: 500; color: var(--color-muted); line-height: 1.5; } +.amountRow { display: flex; align-items: center; gap: 16px; margin-top: 4px; } +.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-size: 36px; line-height: 1.35; letter-spacing: -.035em; font-variant-numeric: tabular-nums; font-weight: 500; } +.amountRow input::placeholder { color: var(--color-muted); } +.currency { display: inline-flex; flex-shrink: 0; align-items: center; gap: 6px; font-size: 18px; font-weight: 550; } +.balanceRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; color: var(--color-muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.max { min-height: 28px; padding: 0 10px; border: 1px solid var(--color-line-strong); border-radius: 999px; color: var(--color-ink); font-size: 12px; font-weight: 600; cursor: pointer; } + +.facts { display: grid; gap: 9px; padding: 16px 0; border-top: 1px solid var(--color-line); font-size: 12px; } +.facts > div { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } +.facts dt { color: var(--color-body); } +.facts dd { display: inline-flex; align-items: center; gap: 4px; margin: 0; text-align: right; color: var(--color-ink); font-variant-numeric: tabular-nums; } +.facts a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } + +.notice { margin: 4px 0 14px; padding-left: 12px; border-left: 2px solid var(--color-brand); color: var(--color-body); font-size: 12px; line-height: 1.65; } +.paused { display: flex; align-items: flex-start; gap: 8px; margin: 4px 0 14px; padding: 12px; border-radius: 10px; background: var(--color-warm-soft); color: var(--color-warm-ink); font-size: 12px; line-height: 1.6; } +.paused svg, .error svg { flex-shrink: 0; margin-top: 2px; } +.error { display: flex; align-items: flex-start; gap: 8px; min-width: 0; margin: 4px 0 14px; padding: 12px; color: var(--color-down-ink); background: var(--color-down-soft); border-radius: 8px; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } +.error > span { flex: 1; min-width: 0; } + +.primary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; width: 100%; padding: 12px 16px; border: 1px solid transparent; border-radius: 12px; background: var(--color-brand); color: var(--color-inverse); font-size: 14px; font-weight: 600; cursor: pointer; transition: background 160ms, transform 160ms cubic-bezier(.16, 1, .3, 1); } +.primary:disabled { cursor: not-allowed; background: var(--color-paper); border-color: var(--color-line); color: var(--color-muted); } +.disclaimer { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.65; text-wrap: pretty; } + +.done { margin: 8px 0 4px; text-align: center; } +.doneIcon { display: inline-flex; align-items: center; justify-content: center; height: 52px; width: 52px; border-radius: 50%; background: var(--color-up-soft); color: var(--color-up); } +.done h3 { margin: 14px 0 8px; font-size: 20px; font-weight: 600; letter-spacing: -.025em; } +.done p { color: var(--color-body); font-size: 13px; line-height: 1.65; } +.externalAction { display: flex; align-items: center; justify-content: space-between; min-height: 46px; margin-top: 18px; padding: 0 14px; border: 1px solid var(--color-line-strong); border-radius: 10px; font-size: 13px; font-weight: 500; transition: background 160ms; } +.secondary { display: flex; align-items: center; justify-content: center; min-height: 44px; width: 100%; margin-top: 10px; color: var(--color-brand); font-size: 13px; font-weight: 500; cursor: pointer; } + +.footer { display: flex; justify-content: space-between; gap: 12px; padding-top: 16px; margin-top: 18px; border-top: 1px solid var(--color-line); font-size: 11px; color: var(--color-muted); } +.footer a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } +.spinner { animation: earn-spin 1s linear infinite; } +@keyframes earn-spin { to { transform: rotate(360deg); } } +.panel :is(button, a, input, summary):focus-visible { outline: 2px solid var(--color-brand); outline-offset: 4px; } +@media (hover: hover) and (pointer: fine) { + .close:hover, .externalAction:hover { color: var(--color-ink); background: var(--color-paper); } + .max:not(:disabled):hover { border-color: var(--color-brand); color: var(--color-brand); } + .primary:not(:disabled):hover { background: var(--color-brand-strong); } + .facts a:hover, .footer a:hover { text-decoration: underline; } +} +.primary:not(:disabled):active { transform: scale(.97); } +@media (max-width: 520px) { + .panel:not(.static) { width: 100%; top: auto; bottom: 0; left: 0; max-height: calc(100dvh - 16px); transform: none; padding: 24px 22px calc(20px + env(safe-area-inset-bottom)); border-radius: 22px 22px 0 0; border-bottom: 0; box-shadow: var(--shadow-sheet); } + .panel:not(.static)[data-starting-style], .panel:not(.static)[data-ending-style] { transform: translateY(16px); } + .amountRow input { font-size: 32px; } + .figureValue { font-size: 20px; } +} +@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary { transition: none; } .spinner { animation: none; } } diff --git a/app/src/components/earn/EarnDialog.tsx b/app/src/components/earn/EarnDialog.tsx new file mode 100644 index 00000000..c1342372 --- /dev/null +++ b/app/src/components/earn/EarnDialog.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { ArrowUpRight, X } from "lucide-react"; +import type { EarnVaultSnapshot } from "@/lib/earn/info"; +import type { EarnVault } from "@/lib/earn/vaults"; +import WalletPicker from "../WalletPicker"; +import EarnPanel, { type EarnView } from "./EarnPanel"; +import styles from "./EarnDialog.module.css"; + +/** + * The Earn sheet, lazy-loaded by the /me card on first open. The state lives in the card (useEarn), so closing the sheet + * mid-transaction loses nothing: the transaction still confirms, the toast still fires, reopening shows where it is. + */ +export default function EarnDialog({ open, onOpenChange, earn, snapshot }: { open: boolean; onOpenChange: (open: boolean) => void; earn: EarnView; snapshot: EarnVaultSnapshot | null }) { + const popup = useRef(null); + const [connecting, setConnecting] = useState(false); + const v = earn.vault; + return ( + <> + + + + + Earn on {v.assetSymbol}} + close={} + description={Lend your {v.assetSymbol} through a {v.protocol} vault on Base. Take it out whenever the vault has it free.}> + { onOpenChange(false); setConnecting(true); }} /> + + + + + {connecting ? { setConnecting(false); onOpenChange(true); }} /> : null} + + ); +} + +/** Heading, body and footer of the sheet; the review page renders it without a dialog around it. */ +export function EarnShell({ vault, title, description, close, children }: { vault: EarnVault; title: React.ReactNode; description: React.ReactNode; close: React.ReactNode; children: React.ReactNode }) { + return ( + <> +
{title}{close}
+ {description} + {children} +
+ Run by {vault.curator} + 0 openlaunch fee +
+ + ); +} diff --git a/app/src/components/earn/EarnPanel.tsx b/app/src/components/earn/EarnPanel.tsx new file mode 100644 index 00000000..411d09f0 --- /dev/null +++ b/app/src/components/earn/EarnPanel.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Image from "next/image"; +import { ArrowRight, ArrowUpRight, Check, CircleAlert, LoaderCircle, PauseCircle, Wallet } from "lucide-react"; +import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; +import { CHAIN_LABELS, explorerAddress, explorerTx } from "@/lib/chainPublic"; +import { fmtAmount, fmtFeePct, fmtRate } from "@/lib/earn/math"; +import { pausedCopy } from "@/lib/earn/terms"; +import type { EarnVaultSnapshot } from "@/lib/earn/info"; +import type { Earn, EarnTab } from "./useEarn"; +import styles from "./EarnDialog.module.css"; + +/** Everything the panel shows, with no wallet behind it: the dialog passes the live hook, the review page fixtures. */ +export type EarnView = Omit; + +/** "182.8M": how much the vault holds, for scale. */ +function compactAssets(raw: string, decimals: number): string { + const n = Number(BigInt(raw) / 10n ** BigInt(decimals)); + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); +} + +function day(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); +} + +export default function EarnPanel({ view: e, snapshot, onConnect }: { view: EarnView; snapshot: EarnVaultSnapshot | null; onConnect: () => void }) { + const v = e.vault; + const network = CHAIN_LABELS[v.chain]; + const sym = v.assetSymbol; + const amt = (raw: bigint) => fmtAmount(raw, v.assetDecimals); + const deposit = e.tab === "deposit"; + const pauseReason = e.paused ?? (snapshot && !snapshot.terms.ok ? snapshot.terms.reason : null); + const everything = !deposit && (e.all || (e.amount !== null && e.position !== undefined && e.position > 0n && e.amount >= e.position)); + const vaultFee = snapshot ? snapshot.performanceFeePct : 0; + + if (e.phase.k === "done") { + const p = e.phase; + return ( +
+
+ +

{p.tab === "deposit" ? `Deposited ${amt(p.amount)} ${sym}` : `Withdrew ${amt(p.amount)} ${sym}`}

+

{p.tab === "deposit" ? `Your ${sym} is in ${v.name} on ${network}. Its value updates as the vault earns.` : `The ${sym} is back in your wallet on ${network}.`}

+
+ {p.hash ? View the transaction : null} + +
+ ); + } + + const label = !e.isConnected ? "Connect wallet" + : !e.onChain ? (e.switching ? "Confirm the network switch…" : `Switch to ${network}`) + : e.phase.k === "preparing" ? "Checking the vault…" + : e.phase.k === "approving" ? `Allow exactly ${e.amount !== null ? amt(e.amount) : ""} ${sym} in your wallet…` + : e.phase.k === "signing" ? "Confirm in your wallet…" + : e.phase.k === "sent" ? `Confirming on ${network}…` + : deposit && pauseReason ? "Deposits paused" + : e.problem ? e.problem + : deposit ? (e.needsApproval ? `Allow and deposit ${amt(e.amount!)} ${sym}` : `Deposit ${amt(e.amount!)} ${sym}`) + : everything ? "Withdraw everything" : `Withdraw ${amt(e.amount!)} ${sym}`; + const ready = e.isConnected && e.onChain && !e.busy && !e.problem && !(deposit && pauseReason); + const clickable = !e.isConnected || (!e.onChain && !e.switching) || ready; + const onClick = !e.isConnected ? onConnect : !e.onChain ? e.switchToVaultChain : e.submit; + + return ( +
{ event.preventDefault(); if (clickable) void onClick(); }}> +
+
+ Last 30 days + {fmtRate(snapshot?.rate ?? null)} + {snapshot?.rate != null ? "Annualised. Variable." : "Rate unavailable right now"} +
+
+ Your deposit + {!e.isConnected ? "—" : e.position === undefined ? "…" : e.position === 0n ? "None" : amt(e.position)}{e.isConnected && e.position !== undefined && e.position > 0n ? {sym} : null} + {e.position !== undefined && e.position > 0n ? "What it withdraws for now" : `Nothing in ${v.name}`} +
+
+ + { if (t[0] && !e.busy) e.setTab(t[0] as EarnTab); }} className={`${styles.tabs} grid w-full grid-cols-2`}> + {(["deposit", "withdraw"] as EarnTab[]).map((t) => {t === "deposit" ? "Deposit" : "Withdraw"})} + + +
+ +
+ e.setAmount(event.target.value)} disabled={e.busy} aria-describedby="earn-balance earn-risk" /> + {sym} +
+
+ {!e.isConnected ? `${sym} on ${network}` : deposit ? (e.balance === undefined ? "Reading your balance…" : `In your wallet: ${amt(e.balance)} ${sym}`) : (e.position === undefined ? "Reading your deposit…" : `Deposited: ${amt(e.position)} ${sym}`)} + {e.isConnected ? : null} +
+
+ +
+ + +
Protocol
{v.protocol}
+
Vault fee
{fmtFeePct(vaultFee)} of interest
+
openlaunch fee
None
+
In the vault
{snapshot ? `${compactAssets(snapshot.totalAssets, v.assetDecimals)} ${sym}` : "—"}
+
Network
{network} · gas in ETH
+
+ + {deposit && pauseReason ?

{pausedCopy(pauseReason)}

: null} + {deposit && !pauseReason && e.needsApproval && !e.busy && e.isConnected ?

The first deposit asks your wallet twice: once to allow the vault exactly this amount, then to deposit it. Smart wallets do both in one step.

: null} + {everything && !e.busy ?

Withdraws your whole deposit, with the interest earned up to the moment it confirms.

: null} + {e.isConnected && e.noGas ?

This wallet has no ETH on {network} to pay network gas.

: null} + {e.unreadable ?

Your balances could not be read just now. They refresh on their own.

: null} + {e.phase.k === "error" ?

{e.phase.message}

: null} + + +

+ Deposits can lose money. Your {sym} is lent to borrowers through {v.protocol}, in markets chosen by {v.curator}, not by openlaunch. openlaunch never holds your funds, cannot move them and cannot guarantee a withdrawal: the vault pays out from what is not lent at that moment. The rate changes all the time; past rates promise nothing. +

+
+ ); +} diff --git a/app/src/components/earn/earn-ui.test.ts b/app/src/components/earn/earn-ui.test.ts new file mode 100644 index 00000000..2ff1a41b --- /dev/null +++ b/app/src/components/earn/earn-ui.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +// Source contracts for the Earn components (like bridge-ui.test.ts): they hold the money-moving rules in place. +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +const hook = read("./useEarn.ts"); +const card = read("./EarnCard.tsx"); +const dialog = read("./EarnDialog.tsx"); +const panel = read("./EarnPanel.tsx"); +const calls = read("../../lib/earn/calls.ts"); +const dashboard = read("../launchpad/MeDashboard.tsx"); +const reviewPage = read("../../app/ui-review-earn/page.tsx"); +const allEarn = [hook, card, dialog, panel, calls, read("../../lib/earn/math.ts"), read("../../lib/earn/terms.ts"), read("../../lib/earn/server.ts"), read("../../lib/earn/vaults.ts")]; + +test("every wallet write is credited to the builder code", () => { + const direct = hook.match(/writeContract\(\{[^}]*\}/g) ?? []; + assert.ok(direct.length >= 1); + for (const w of direct) assert.match(w, /dataSuffix: BUILDER_DATA_SUFFIX/, w); + const simulated = hook.match(/simulateContract\(\{[^}]*\}/g) ?? []; + assert.equal(simulated.length, 3); // deposit, redeem, withdraw + for (const s of simulated) assert.match(s, /dataSuffix: BUILDER_DATA_SUFFIX/, s); + // a prepared request is only ever sent as simulated (the suffix travels inside it) + assert.equal((hook.match(/writeContract\(request\)/g) ?? []).length, 3); + assert.match(hook, /sendCalls\(config, \{ account, chainId, calls, capabilities: EARN_BATCH_CAPABILITIES, forceAtomic: true \}\)/); + assert.match(calls, /const tagged = \(data: Hex\): Hex => concatHex\(\[data, BUILDER_DATA_SUFFIX\]\)/); +}); + +test("approvals are exact: no unlimited allowance anywhere in Earn", () => { + for (const src of allEarn) assert.doesNotMatch(src, /maxUint256|maxUint160|2n \*\* 256n/); + assert.match(hook, /functionName: "approve", args: \[vault\.address, amount\]/); +}); + +test("both actions lock before their first await and simulate before any signature", () => { + for (const name of ["deposit", "withdraw"]) { + const body = hook.slice(hook.indexOf(`async function ${name}()`)); + const lockAt = body.indexOf("lock.current = true"); + assert.ok(lockAt > 0, name); + assert.ok(lockAt < body.indexOf("await "), `${name} awaits before locking`); + } + const deposit = hook.slice(hook.indexOf("async function deposit()"), hook.indexOf("async function withdraw()")); + assert.ok(deposit.indexOf("termsCheck(vault, await readTerms(pub, vault))") < deposit.indexOf("sendBatch("), "terms are checked before any send"); + assert.ok(deposit.indexOf("simulateContract(") < deposit.indexOf("writeContract(request)")); +}); + +test("an upgrade-to-smart-account prompt is never triggered", () => { + assert.match(calls, /c\.atomic\?\.status === "supported"/); + assert.doesNotMatch(calls, /status === "ready"/); +}); + +test("the card shows only while the switch is on, and the sheet loads on first use", () => { + assert.match(card, /info\?\.enabled \?/); + assert.match(card, /if \(!entry \|\| !vault\) return null;/); + assert.match(card, /dynamic\(\(\) => import\("\.\/EarnDialog"\), \{ ssr: false \}\)/); + assert.match(card, /id="earn"/); + assert.equal((dashboard.match(/\n\s*/g) ?? []).length, 2); +}); + +test("the copy states the risk and the absence of any openlaunch fee, and makes no promise", () => { + assert.match(panel, /Deposits can lose money\./); + assert.match(panel, /openlaunch never holds your funds, cannot move them and cannot guarantee a withdrawal/); + assert.match(panel, /
openlaunch fee<\/dt>
None<\/dd>/); + assert.match(dialog, /0 openlaunch fee/); + assert.match(card, /Can lose money\. No openlaunch fee\./); + for (const src of [card, dialog, panel]) { + assert.doesNotMatch(src, /Base Earn|Coinbase|high[- ]yield|guaranteed (?:return|yield|rate)|risk[- ]free|\bsafe\b/i); + } +}); + +test("the review page is development-only", () => { + assert.match(reviewPage, /process\.env\.NODE_ENV !== "development"\) notFound\(\)/); +}); diff --git a/app/src/components/earn/earnInfo.ts b/app/src/components/earn/earnInfo.ts new file mode 100644 index 00000000..b35fd591 --- /dev/null +++ b/app/src/components/earn/earnInfo.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EarnInfo } from "@/lib/earn/info"; + +/** + * GET /api/earn, once per page load and shared by everything that asks (the /me card, the ⌘K list). A failed request + * reads as "off" and is not remembered, so the next caller tries again. + */ +let pending: Promise | null = null; + +export function loadEarnInfo(): Promise { + pending ??= fetch("/api/earn", { cache: "no-store" }) + .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(String(r.status))))) + .catch((): EarnInfo => { + pending = null; + return { enabled: false }; + }); + return pending; +} + +/** The answer, or null until it arrives. `active` false skips the request (the ⌘K list asks only once opened). */ +export function useEarnInfo(active = true): EarnInfo | null { + const [info, setInfo] = useState(null); + useEffect(() => { + if (!active) return; + let alive = true; + void loadEarnInfo().then((i) => { + if (alive) setInfo(i); + }); + return () => { + alive = false; + }; + }, [active]); + return info; +} diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts new file mode 100644 index 00000000..1fc410da --- /dev/null +++ b/app/src/components/earn/useEarn.ts @@ -0,0 +1,250 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; +import { getCapabilities, getPublicClient, getWalletClient, sendCalls, waitForCallsStatus } from "wagmi/actions"; +import type { Address, Hex } from "viem"; +import { useHydratedAccount } from "@/lib/useHydratedAccount"; +import { BUILDER_DATA_SUFFIX, CHAINS } from "@/lib/chainPublic"; +import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; +import { EARN_VAULT_ABI } from "@/lib/earn/abi"; +import { EARN_BATCH_CAPABILITIES, approveCall, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; +import { cleanAmountInput, depositProblem, fmtAmount, maxInput, parseAmount, withdrawPlan, withdrawProblem } from "@/lib/earn/math"; +import { pausedCopy, readTerms, termsCheck } from "@/lib/earn/terms"; +import type { EarnVault } from "@/lib/earn/vaults"; +import { friendlyError } from "@/lib/errors"; +import { toast } from "@/components/launchpad/TxToasts"; + +/** + * Earn's state and its two actions, for one vault and the connected wallet. + * + * Reads (balance, allowance, shares, what the shares redeem for) go through the same-origin RPC proxy like every other + * read in the app. Writes follow TradePanel: a synchronous lock before the first await, a network switch if needed, a + * simulation before every signature, then the receipt. Every write is credited to openlaunch's builder code + * (lib/earn/calls.ts): one-at-a-time wallets via `dataSuffix`, batching smart wallets via `sendCalls` with the suffix on + * each call and as the `dataSuffix` capability. Approvals are for the exact amount, never unlimited. + */ +export type EarnTab = "deposit" | "withdraw"; +export type EarnPhase = + | { k: "idle" } + | { k: "preparing" } + | { k: "approving" } + | { k: "signing" } + | { k: "sent"; hash: Hex | null } + | { k: "done"; hash: Hex | null; tab: EarnTab; amount: bigint } + | { k: "error"; message: string }; + +/** The wallet's USDC, its vault shares and what they redeem for now. Shared by the /me card and the dialog. */ +export function useEarnPosition(vault: EarnVault, address: Address | undefined) { + const chainId = CHAINS[vault.chain].id; + const on = Boolean(address); + const usdc = useReadContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: address ? [address] : undefined, chainId, query: { enabled: on, refetchInterval: 15_000 } }); + const shares = useReadContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "balanceOf", args: address ? [address] : undefined, chainId, query: { enabled: on, refetchInterval: 15_000 } }); + const held = shares.data; + const redeem = useReadContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "previewRedeem", args: held !== undefined ? [held] : undefined, chainId, query: { enabled: on && held !== undefined && held > 0n, refetchInterval: 15_000 } }); + const allowance = useReadContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "allowance", args: address ? [address, vault.address] : undefined, chainId, query: { enabled: on, refetchInterval: 15_000 } }); + return { + balance: usdc.data, + shares: held, + /** What every share redeems for right now (0 with no shares, undefined while reading). */ + position: held === undefined ? undefined : held === 0n ? 0n : redeem.data, + allowance: allowance.data, + unreadable: Boolean(usdc.error || shares.error || redeem.error), + refetch: () => Promise.all([usdc.refetch(), shares.refetch(), redeem.refetch(), allowance.refetch()]), + }; +} + +export default function useEarn(vault: EarnVault, initialTab: EarnTab) { + const config = useConfig(); + const chainId = CHAINS[vault.chain].id; + const { address, isConnected, chainId: walletChain } = useHydratedAccount(); + const { switchChainAsync, isPending: switching } = useSwitchChain(); + const position = useEarnPosition(vault, address); + const gas = useBalance({ address, chainId, query: { enabled: Boolean(address), refetchInterval: 30_000 } }); + const [tab, setTabState] = useState(initialTab); + const [text, setText] = useState(""); + // "Withdraw all" stays all even as interest grows the position after the tap, so no dust share is left behind + const [all, setAll] = useState(false); + const [phase, setPhase] = useState({ k: "idle" }); + // set when the pre-deposit check finds the vault's terms changed since review + const [paused, setPaused] = useState(null); + const lock = useRef(false); + + const decimals = vault.assetDecimals; + const typed = parseAmount(text, decimals); + const amount = tab === "withdraw" && all ? (position.position ?? null) : typed; + const busy = phase.k === "preparing" || phase.k === "approving" || phase.k === "signing" || phase.k === "sent"; + const onChain = walletChain === chainId; + const problem = tab === "deposit" ? depositProblem({ amount, balance: position.balance }) : withdrawProblem({ amount, shares: position.shares, position: position.position }); + const needsApproval = tab === "deposit" && amount !== null && position.allowance !== undefined ? position.allowance < amount : null; + + const settle = () => { if (phase.k === "done" || phase.k === "error") setPhase({ k: "idle" }); }; + function setTab(next: EarnTab) { + if (busy) return; + setTabState(next); + setText(""); + setAll(false); + settle(); + } + function setAmount(raw: string) { + setText(cleanAmountInput(raw, decimals)); + setAll(false); + settle(); + } + function setMax() { + if (tab === "deposit") setText(maxInput(position.balance ?? 0n, decimals)); + else { + setText(maxInput(position.position ?? 0n, decimals)); + setAll(true); + } + settle(); + } + + async function switchToVaultChain() { + try { + await switchChainAsync({ chainId }); + } catch (err) { + setPhase({ k: "error", message: friendlyError(err) }); + } + } + + /** Whether this wallet already executes atomic batches; any doubt means the one-at-a-time path. */ + async function batching(account: Address): Promise { + try { + return canBatch(await getCapabilities(config, { account, chainId })); + } catch { + return false; + } + } + + /** Sends through the wallet's batch API with the builder-code capability; resolves to the last transaction hash. */ + async function sendBatch(account: Address, calls: EarnCall[]): Promise { + setPhase({ k: "signing" }); + const { id } = await sendCalls(config, { account, chainId, calls, capabilities: EARN_BATCH_CAPABILITIES, forceAtomic: true }); + setPhase({ k: "sent", hash: null }); + const result = await waitForCallsStatus(config, { id, timeout: 180_000 }); + const receipts = result.receipts ?? []; + if (result.status !== "success" || receipts.some((r) => r.status !== "success")) throw new Error("Your wallet reports the transaction failed. Check its activity before trying again."); + return receipts.at(-1)?.transactionHash ?? null; + } + + async function deposit() { + if (!address || amount === null || problem || busy || lock.current) return; + // Lock before the first await, including the network switch and the vault check. + lock.current = true; + setPhase({ k: "preparing" }); + try { + if (!onChain) await switchChainAsync({ chainId }); + const pub = getPublicClient(config, { chainId })!; + const terms = termsCheck(vault, await readTerms(pub, vault)); + if (!terms.ok) { + setPaused(terms.reason); + throw new Error(pausedCopy(terms.reason)); + } + const [balance, allowance] = await Promise.all([ + pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: [address] }), + pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "allowance", args: [address, vault.address] }), + ]); + if (amount > balance) throw new Error("That is more USDC than this wallet holds."); + let hash: Hex | null; + if (await batching(address)) { + // one prompt; atomic, so a failed deposit takes the approval back with it + hash = await sendBatch(address, allowance < amount ? [approveCall(vault, amount), depositCall(vault, amount, address)] : [depositCall(vault, amount, address)]); + } else { + const wallet = await getWalletClient(config, { chainId }); + if (allowance < amount) { + setPhase({ k: "approving" }); + const approval = await wallet.writeContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "approve", args: [vault.address, amount], dataSuffix: BUILDER_DATA_SUFFIX }); + const rc = await pub.waitForTransactionReceipt({ hash: approval }); + if (rc.status !== "success") throw new Error("The approval reverted on-chain. Nothing was deposited."); + } + const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "deposit", args: [amount, address], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + setPhase({ k: "signing" }); + hash = await wallet.writeContract(request); + setPhase({ k: "sent", hash }); + const rc = await pub.waitForTransactionReceipt({ hash }); + if (rc.status !== "success") throw new Error("The deposit reverted on-chain."); + } + setPhase({ k: "done", hash, tab: "deposit", amount }); + toast({ kind: "info", title: `Deposited ${fmtAmount(amount, decimals)} ${vault.assetSymbol}`, sub: `Into ${vault.name}` }); + setText(""); + void position.refetch(); + } catch (err) { + setPhase({ k: "error", message: friendlyError(err) }); + } finally { + lock.current = false; + } + } + + async function withdraw() { + const plan = withdrawPlan({ amount, shares: position.shares, position: position.position }); + if (!address || !plan || problem || busy || lock.current) return; + lock.current = true; + setPhase({ k: "preparing" }); + try { + if (!onChain) await switchChainAsync({ chainId }); + const pub = getPublicClient(config, { chainId })!; + // "withdraw all" redeems the shares held at this moment, not the count from the last poll + const held = await pub.readContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "balanceOf", args: [address] }); + if (held <= 0n) throw new Error("Nothing is deposited from this wallet."); + const call = plan.fn === "redeem" ? redeemCall(vault, held, address) : withdrawCall(vault, plan.assets, address); + let hash: Hex | null; + if (await batching(address)) { + hash = await sendBatch(address, [call]); + } else { + const wallet = await getWalletClient(config, { chainId }); + if (plan.fn === "redeem") { + const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "redeem", args: [held, address, address], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + setPhase({ k: "signing" }); + hash = await wallet.writeContract(request); + } else { + const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "withdraw", args: [plan.assets, address, address], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + setPhase({ k: "signing" }); + hash = await wallet.writeContract(request); + } + setPhase({ k: "sent", hash }); + const rc = await pub.waitForTransactionReceipt({ hash }); + if (rc.status !== "success") throw new Error("The withdrawal reverted on-chain."); + } + setPhase({ k: "done", hash, tab: "withdraw", amount: plan.assets }); + toast({ kind: "info", title: `Withdrew ${plan.fn === "redeem" ? "everything" : `${fmtAmount(plan.assets, decimals)} ${vault.assetSymbol}`}`, sub: `From ${vault.name}` }); + setText(""); + setAll(false); + void position.refetch(); + } catch (err) { + setPhase({ k: "error", message: friendlyError(err) }); + } finally { + lock.current = false; + } + } + + return { + vault, + address, + isConnected, + onChain, + switching, + tab, + setTab, + text, + setAmount, + setMax, + all, + amount, + problem, + needsApproval, + paused, + phase, + busy, + reset: () => { if (!busy) setPhase({ k: "idle" }); }, + balance: position.balance, + shares: position.shares, + position: position.position, + unreadable: position.unreadable, + noGas: gas.data !== undefined && gas.data.value === 0n, + switchToVaultChain, + submit: () => (tab === "deposit" ? deposit() : withdraw()), + }; +} + +export type Earn = ReturnType; diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx index e40e2bb0..1ef62aa5 100644 --- a/app/src/components/launchpad/MeDashboard.tsx +++ b/app/src/components/launchpad/MeDashboard.tsx @@ -29,6 +29,7 @@ import { SkRow, SkStat } from "@/components/Skeleton"; import ConnectWallet from "@/components/ConnectWallet"; import ProfileIdentity from "@/components/profile/ProfileIdentity"; import SeasonCard from "@/components/points/SeasonCard"; +import EarnCard from "@/components/earn/EarnCard"; import styles from "./MeDashboard.module.css"; type Me = { wallet: string; ethUsd: number | null; launches: LaunchRow[]; tokens: (LaunchRow & { my_buys: number; my_sells: number; my_last_trade: string })[]; trades: WalletTrade[] }; @@ -205,6 +206,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
{walletBar} + {err ?

We couldn’t load your dashboard.

{err}. Your wallet and tokens are unchanged.

:
{Array.from({ length: 4 }, (_, k) => )}
    {Array.from({ length: 3 }, (_, k) => )}
}
); @@ -213,6 +215,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
{walletBar} + {err ?

Refresh failed: {err}. Showing the last loaded data. Try Refresh again.

: null}
} v={String(me.launches.length)} hint="Across all chains" /> diff --git a/app/src/lib/command-palette.test.ts b/app/src/lib/command-palette.test.ts index 0cc5f521..3ebc3521 100644 --- a/app/src/lib/command-palette.test.ts +++ b/app/src/lib/command-palette.test.ts @@ -1,6 +1,18 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { PALETTE_PAGES, buildPaletteGroups, isSearchableQuery, isTypingTarget, matchesQuery, paletteTokenFrom, type PaletteToken } from "./command-palette.ts"; +import { EARN_PAGE, PALETTE_PAGES, buildPaletteGroups, isSearchableQuery, isTypingTarget, matchesQuery, paletteTokenFrom, type PaletteToken } from "./command-palette.ts"; + +test("the Earn entry is listed only while the feature is on, and lands on the /me card", () => { + const off = buildPaletteGroups({ query: "", tokens: [], trending: [], theme: "dark" }); + assert.ok(!off.flatMap((g) => g.items).some((i) => i.id === EARN_PAGE.id)); + const on = buildPaletteGroups({ query: "", tokens: [], trending: [], theme: "dark", earn: true }); + assert.ok(on.flatMap((g) => g.items).some((i) => i.id === EARN_PAGE.id)); + assert.equal(EARN_PAGE.href, "/me#earn"); + const found = buildPaletteGroups({ query: "lend usdc", tokens: [], trending: [], theme: "dark", earn: true }); + assert.deepEqual(found.map((g) => g.items.map((i) => i.label)), [["Earn on idle USDC"]]); + const hidden = buildPaletteGroups({ query: "lend usdc", tokens: [], trending: [], theme: "dark" }); + assert.deepEqual(hidden, []); +}); const waifu: PaletteToken = { chain: "base", token: "0xf3018d16d3c75f86df76bdeed12027c813f4e4a0", name: "WAIFU", symbol: "WAIFU", image: null, capUsd: 53471, change: 18.7, holders: 196 }; const dottie: PaletteToken = { chain: "robinhood", token: "0x5b3c2cd87083ea5c4436525dca6213740405b69e", name: "DottieLand", symbol: "DOTTIE", image: null, capUsd: 53205, change: 9.54, holders: 285 }; diff --git a/app/src/lib/command-palette.ts b/app/src/lib/command-palette.ts index 096be4a5..6c2dcdb4 100644 --- a/app/src/lib/command-palette.ts +++ b/app/src/lib/command-palette.ts @@ -55,15 +55,18 @@ export function paletteTokenFrom(l: LaunchLike): PaletteToken { }; } +/** Listed only while the Earn switch is on (GET /api/earn); it lands on the /me card. */ +export const EARN_PAGE = { id: "page:earn", label: "Earn on idle USDC", href: "/me#earn", keywords: "earn lend deposit usdc vault morpho interest savings withdraw" } as const; + /** * Results in reading order: tokens first (search hits, or what is trending before anything is * typed), then pages, then actions. Empty groups are dropped. */ -export function buildPaletteGroups({ query, tokens, trending, theme }: { query: string; tokens: PaletteToken[]; trending: PaletteToken[]; theme: "light" | "dark" }): PaletteGroup[] { +export function buildPaletteGroups({ query, tokens, trending, theme, earn = false }: { query: string; tokens: PaletteToken[]; trending: PaletteToken[]; theme: "light" | "dark"; earn?: boolean }): PaletteGroup[] { const q = query.trim(); const tokenList = q ? (isSearchableQuery(q) ? tokens : []) : trending; const tokenItems: PaletteItem[] = tokenList.map((t) => ({ kind: "token", id: `token:${t.chain}:${t.token}`, label: t.name, token: t })); - const pages: PaletteItem[] = PALETTE_PAGES.filter((p) => !q || matchesQuery(q, `${p.label} ${p.keywords}`)).map((p) => ({ kind: "page", ...p })); + const pages: PaletteItem[] = [...PALETTE_PAGES, ...(earn ? [EARN_PAGE] : [])].filter((p) => !q || matchesQuery(q, `${p.label} ${p.keywords}`)).map((p) => ({ kind: "page", ...p })); const next = theme === "dark" ? "light" : "dark"; const actions: PaletteItem[] = [ { kind: "action" as const, id: "action:bridge", label: "Bridge funds between chains", action: "bridge" as const, keywords: "bridge relay move deposit base robinhood arc" }, diff --git a/app/src/lib/earn/abi.ts b/app/src/lib/earn/abi.ts new file mode 100644 index 00000000..7e2ea743 --- /dev/null +++ b/app/src/lib/earn/abi.ts @@ -0,0 +1,62 @@ +/** + * The slice of a Morpho Vault V2 the Earn feature calls. Vault V2 is ERC-4626 for deposit / withdraw / redeem / previews; + * fees and gates are its own getters. Hand-written on purpose: no SDK, nothing else reachable. + * Note: Vault V2's maxDeposit / maxWithdraw always return 0, so limits come from balances and a simulation, never from them. + */ +const gate = (name: string) => ({ type: "function", name, stateMutability: "view", inputs: [], outputs: [{ name: "", type: "address" }] }) as const; + +export const EARN_VAULT_ABI = [ + { type: "function", name: "asset", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "address" }] }, + { type: "function", name: "totalAssets", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint256" }] }, + { type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }, + { type: "function", name: "convertToAssets", stateMutability: "view", inputs: [{ name: "shares", type: "uint256" }], outputs: [{ name: "", type: "uint256" }] }, + { type: "function", name: "previewDeposit", stateMutability: "view", inputs: [{ name: "assets", type: "uint256" }], outputs: [{ name: "", type: "uint256" }] }, + { type: "function", name: "previewRedeem", stateMutability: "view", inputs: [{ name: "shares", type: "uint256" }], outputs: [{ name: "", type: "uint256" }] }, + /** WAD (1e18 = 100%) of the interest earned. */ + { type: "function", name: "performanceFee", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint96" }] }, + /** WAD per second of assets under management. */ + { type: "function", name: "managementFee", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint96" }] }, + gate("receiveSharesGate"), + gate("sendSharesGate"), + gate("receiveAssetsGate"), + gate("sendAssetsGate"), + { + type: "function", + name: "deposit", + stateMutability: "nonpayable", + inputs: [ + { name: "assets", type: "uint256" }, + { name: "onBehalf", type: "address" }, + ], + outputs: [{ name: "shares", type: "uint256" }], + }, + { + type: "function", + name: "withdraw", + stateMutability: "nonpayable", + inputs: [ + { name: "assets", type: "uint256" }, + { name: "receiver", type: "address" }, + { name: "onBehalf", type: "address" }, + ], + outputs: [{ name: "shares", type: "uint256" }], + }, + { + type: "function", + name: "redeem", + stateMutability: "nonpayable", + inputs: [ + { name: "shares", type: "uint256" }, + { name: "receiver", type: "address" }, + { name: "onBehalf", type: "address" }, + ], + outputs: [{ name: "assets", type: "uint256" }], + }, +] as const; + +/** The four Vault V2 gates. Any one set to a non-zero address means the vault restricts who may use it. */ +export const VAULT_GATES = ["receiveSharesGate", "sendSharesGate", "receiveAssetsGate", "sendAssetsGate"] as const; + +export const VAULT_V2_FACTORY_ABI = [ + { type: "function", name: "isVaultV2", stateMutability: "view", inputs: [{ name: "vault", type: "address" }], outputs: [{ name: "", type: "bool" }] }, +] as const; diff --git a/app/src/lib/earn/calls.test.ts b/app/src/lib/earn/calls.test.ts new file mode 100644 index 00000000..e096efe4 --- /dev/null +++ b/app/src/lib/earn/calls.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Attribution } from "ox/erc8021"; +import { concatHex, decodeFunctionData, encodeFunctionData, getAddress, maxUint256 } from "viem"; +import { BUILDER_CODE, BUILDER_DATA_SUFFIX } from "../chainPublic.ts"; +import { ERC20_MIN_ABI } from "../launchpad/abi.ts"; +import { EARN_VAULT_ABI } from "./abi.ts"; +import { EARN_BATCH_CAPABILITIES, approveCall, canBatch, depositCall, redeemCall, withdrawCall } from "./calls.ts"; +import { EARN_VAULTS } from "./vaults.ts"; + +const vault = EARN_VAULTS.base!; +const owner = getAddress("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + +test("every Earn call is credited to openlaunch's builder code", () => { + const calls = [approveCall(vault, 1_000_000_000n), depositCall(vault, 1_000_000_000n, owner), withdrawCall(vault, 100_000_000n, owner), redeemCall(vault, 5n, owner)]; + for (const c of calls) { + assert.ok(c.data.endsWith(BUILDER_DATA_SUFFIX.slice(2)), c.data); + assert.deepEqual(Attribution.fromData(c.data), { codes: [BUILDER_CODE], id: 0 }); + } + assert.equal(BUILDER_CODE, "bc_ly9ism19"); +}); + +test("the suffix is the same bytes viem appends for writeContract({ dataSuffix }) on the one-at-a-time path", () => { + const plain = encodeFunctionData({ abi: EARN_VAULT_ABI, functionName: "deposit", args: [7n, owner] }); + assert.equal(depositCall(vault, 7n, owner).data, concatHex([plain, BUILDER_DATA_SUFFIX])); +}); + +test("the approval is for exactly the amount, to the vault, on the asset", () => { + const c = approveCall(vault, 1_234_567n); + assert.equal(c.to, vault.asset); + const { functionName, args } = decodeFunctionData({ abi: ERC20_MIN_ABI, data: c.data }); + assert.equal(functionName, "approve"); + assert.deepEqual(args, [vault.address, 1_234_567n]); + assert.notEqual(args?.[1], maxUint256); +}); + +test("deposits, withdrawals and redemptions all pay and return to the same wallet", () => { + const d = decodeFunctionData({ abi: EARN_VAULT_ABI, data: depositCall(vault, 9n, owner).data }); + assert.deepEqual([d.functionName, d.args], ["deposit", [9n, owner]]); + const w = decodeFunctionData({ abi: EARN_VAULT_ABI, data: withdrawCall(vault, 9n, owner).data }); + assert.deepEqual([w.functionName, w.args], ["withdraw", [9n, owner, owner]]); + const r = decodeFunctionData({ abi: EARN_VAULT_ABI, data: redeemCall(vault, 9n, owner).data }); + assert.deepEqual([r.functionName, r.args], ["redeem", [9n, owner, owner]]); + for (const c of [depositCall(vault, 9n, owner), withdrawCall(vault, 9n, owner), redeemCall(vault, 9n, owner)]) assert.equal(c.to, vault.address); +}); + +test("a batching wallet is asked to tag its own operation too, and may decline", () => { + assert.deepEqual(EARN_BATCH_CAPABILITIES, { dataSuffix: { value: BUILDER_DATA_SUFFIX, optional: true } }); +}); + +test("only wallets that already batch take the batch path; an EIP-7702 upgrade prompt is never triggered", () => { + assert.equal(canBatch({ atomic: { status: "supported" } }), true); + assert.equal(canBatch({ atomicBatch: { supported: true } }), true); + assert.equal(canBatch({ atomic: { status: "ready" } }), false); + assert.equal(canBatch({ atomic: { status: "unsupported" } }), false); + assert.equal(canBatch({}), false); + assert.equal(canBatch(null), false); + assert.equal(canBatch(undefined), false); +}); diff --git a/app/src/lib/earn/calls.ts b/app/src/lib/earn/calls.ts new file mode 100644 index 00000000..1c6fed1d --- /dev/null +++ b/app/src/lib/earn/calls.ts @@ -0,0 +1,53 @@ +import { concatHex, encodeFunctionData, type Address, type Hex } from "viem"; +import { ERC20_MIN_ABI } from "../launchpad/abi.ts"; +import { BUILDER_DATA_SUFFIX } from "../chainPublic.ts"; +import { EARN_VAULT_ABI } from "./abi.ts"; +import type { EarnVault } from "./vaults.ts"; + +/** + * The exact calls an Earn action sends (pure). Every one ends with openlaunch's ERC-8021 builder-code suffix, so Base + * credits the deposit, withdrawal and approval to bc_ly9ism19 (lib/chainPublic.ts). The suffix sits after the ABI + * arguments, where the token and the vault ignore it (the fork run in docs/earn.md proves both accept it). + * + * Wallets that send one transaction at a time get these through simulateContract + writeContract with + * `dataSuffix: BUILDER_DATA_SUFFIX` (the same bytes); smart wallets that batch get them through `sendCalls`, which also + * carries the code as the `dataSuffix` capability so the wallet tags its own operation too. + */ +export type EarnCall = { to: Address; data: Hex }; + +const tagged = (data: Hex): Hex => concatHex([data, BUILDER_DATA_SUFFIX]); + +/** Allow the vault to take exactly `amount` of the asset. Never more: an unspent exact allowance is the worst case. */ +export function approveCall(vault: EarnVault, amount: bigint): EarnCall { + return { to: vault.asset, data: tagged(encodeFunctionData({ abi: ERC20_MIN_ABI, functionName: "approve", args: [vault.address, amount] })) }; +} + +/** Deposit `amount` of the asset; the shares go to `owner`, the wallet that pays. */ +export function depositCall(vault: EarnVault, amount: bigint, owner: Address): EarnCall { + return { to: vault.address, data: tagged(encodeFunctionData({ abi: EARN_VAULT_ABI, functionName: "deposit", args: [amount, owner] })) }; +} + +/** Take exactly `assets` out, back to the same wallet. */ +export function withdrawCall(vault: EarnVault, assets: bigint, owner: Address): EarnCall { + return { to: vault.address, data: tagged(encodeFunctionData({ abi: EARN_VAULT_ABI, functionName: "withdraw", args: [assets, owner, owner] })) }; +} + +/** Burn `shares` (every share, for "withdraw all") for the asset, back to the same wallet. */ +export function redeemCall(vault: EarnVault, shares: bigint, owner: Address): EarnCall { + return { to: vault.address, data: tagged(encodeFunctionData({ abi: EARN_VAULT_ABI, functionName: "redeem", args: [shares, owner, owner] })) }; +} + +/** The EIP-5792 capability that asks a batching wallet to tag its own operation with the builder code. */ +export const EARN_BATCH_CAPABILITIES = { dataSuffix: { value: BUILDER_DATA_SUFFIX, optional: true } } as const; + +/** + * Whether to send through `sendCalls`: only for a wallet that already executes atomic batches ("supported"), such as + * Coinbase Smart Wallet. "ready" means the wallet would first upgrade an ordinary account into a smart one (EIP-7702), + * a prompt nobody should meet in the middle of a deposit, so those wallets take the one-at-a-time path. Older wallets + * report the pre-2.0 `atomicBatch.supported` flag. + */ +export function canBatch(capabilities: unknown): boolean { + if (!capabilities || typeof capabilities !== "object") return false; + const c = capabilities as { atomic?: { status?: unknown }; atomicBatch?: { supported?: unknown } }; + return c.atomic?.status === "supported" || c.atomicBatch?.supported === true; +} diff --git a/app/src/lib/earn/info.ts b/app/src/lib/earn/info.ts new file mode 100644 index 00000000..9d4b0665 --- /dev/null +++ b/app/src/lib/earn/info.ts @@ -0,0 +1,25 @@ +import type { ChainKey } from "../chainKeys.ts"; +import type { TermsCheck } from "./terms.ts"; + +/** + * GET /api/earn's answer (client-safe types). Off: `{ enabled: false }` and nothing else, so a switched-off feature + * leaks nothing. On: per vault, figures read from the chain by the server, or `snapshot: null` when it could not read + * them (the card then shows its deposit and withdrawal controls without a rate). + */ +export type EarnVaultSnapshot = { + /** What the vault paid over about the last 30 days, simple annual rate; null when the older read was unavailable. */ + rate: number | null; + /** ISO times of the two share-price reads the rate is measured between. */ + rateFrom: string | null; + rateTo: string; + /** Assets in the vault, raw units of its asset (a string: JSON has no bigint). */ + totalAssets: string; + performanceFeePct: number; + managementFeeYearPct: number; + /** The live vault against what was reviewed (lib/earn/terms.ts). The browser checks again before each deposit. */ + terms: TermsCheck; +}; + +export type EarnInfoVault = { chain: ChainKey; address: string; snapshot: EarnVaultSnapshot | null }; + +export type EarnInfo = { enabled: false } | { enabled: true; vaults: EarnInfoVault[] }; diff --git a/app/src/lib/earn/math.test.ts b/app/src/lib/earn/math.test.ts new file mode 100644 index 00000000..e8d9f39a --- /dev/null +++ b/app/src/lib/earn/math.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { RATE_WINDOW_S, annualisedRate, cleanAmountInput, depositProblem, fmtAmount, fmtFeePct, fmtRate, managementFeeYearPct, maxInput, parseAmount, wadPct, withdrawPlan, withdrawProblem } from "./math.ts"; + +test("the 30-day rate from the vault's real share prices on 2026-10-14 reads 4.4%", () => { + // Steakhouse Prime USDC, convertToAssets(1e18) at blocks 51,280,486 and 52,576,486 (timestamps 2,592,000 s apart) + const rate = annualisedRate(1_040_432n, 1_044_155n, 1_791_942_319 - 1_789_350_319); + assert.ok(rate !== null); + assert.ok(Math.abs(rate - 0.043536) < 0.00001, String(rate)); + assert.equal(fmtRate(rate), "4.4%"); + assert.equal(RATE_WINDOW_S, 2_592_000); +}); + +test("a rate that cannot be told is null, and a falling share price is shown as negative", () => { + assert.equal(annualisedRate(0n, 1n, 100), null); + assert.equal(annualisedRate(1n, 0n, 100), null); + assert.equal(annualisedRate(1n, 2n, 0), null); + assert.equal(annualisedRate(1n, 2n, Number.NaN), null); + const loss = annualisedRate(1_000_000n, 997_500n, RATE_WINDOW_S); // −0.25% in 30 days + assert.equal(fmtRate(loss), "−3.0%"); + assert.equal(fmtRate(-0.0436), fmtRate(0.0436).replace(/^/, "−")); + assert.equal(fmtRate(null), "—"); + assert.equal(fmtRate(0.0001), "0.0%"); +}); + +test("fees read as people say them", () => { + assert.equal(wadPct(0n), 0); + assert.equal(wadPct(250_000_000_000_000_000n), 25); + assert.equal(fmtFeePct(25), "25%"); + assert.equal(fmtFeePct(0), "0%"); + assert.equal(fmtFeePct(2.5), "2.5%"); + // Vault V2's cap: 5% a year expressed per second + assert.ok(Math.abs(managementFeeYearPct(50_000_000_000_000_000n / 31_536_000n) - 5) < 0.0001); +}); + +test("the amount field keeps only what the asset can represent and refuses anything ambiguous", () => { + assert.equal(cleanAmountInput("1.1234567", 6), "1.123456"); + assert.equal(cleanAmountInput("1,234.5", 6), "1234.5"); + assert.equal(cleanAmountInput("1e5", 6), ""); + assert.equal(cleanAmountInput("$12", 6), "12"); + assert.equal(parseAmount("1.5", 6), 1_500_000n); + assert.equal(parseAmount("0.000001", 6), 1n); + assert.equal(parseAmount("1.1234567", 6), null); + assert.equal(parseAmount("", 6), null); + assert.equal(parseAmount(".", 6), null); + assert.equal(parseAmount("0", 6), null); + assert.equal(parseAmount("abc", 6), null); + assert.equal(parseAmount("1.2.3", 6), null); +}); + +test("amounts display rounded down, never above what is there", () => { + assert.equal(fmtAmount(1_234_567_891n, 6), "1,234.56"); + assert.equal(fmtAmount(999_999_999n, 6), "999.99"); + assert.equal(fmtAmount(5_000n, 6), "<0.01"); + assert.equal(fmtAmount(0n, 6), "0.00"); + assert.equal(maxInput(1_234_567_891n, 6), "1234.567891"); + assert.equal(maxInput(0n, 6), ""); +}); + +test("a deposit needs an amount within the wallet's USDC", () => { + assert.equal(depositProblem({ amount: null, balance: 10n }), "Enter an amount"); + assert.equal(depositProblem({ amount: 5n, balance: undefined }), "Reading your balance…"); + assert.equal(depositProblem({ amount: 11n, balance: 10n }), "More than your USDC"); + assert.equal(depositProblem({ amount: 10n, balance: 10n }), null); +}); + +test("asking for the whole position redeems every share; less withdraws that exact amount", () => { + const shares = 957_677_265_110_010_099_404n; + const position = 999_999_999n; + assert.deepEqual(withdrawPlan({ amount: position, shares, position }), { fn: "redeem", shares, assets: position }); + assert.deepEqual(withdrawPlan({ amount: position + 1n, shares, position }), { fn: "redeem", shares, assets: position }); + assert.deepEqual(withdrawPlan({ amount: 100_000_000n, shares, position }), { fn: "withdraw", assets: 100_000_000n }); + assert.equal(withdrawPlan({ amount: 1n, shares: 0n, position: 0n }), null); + assert.equal(withdrawPlan({ amount: null, shares, position }), null); +}); + +test("a withdrawal needs a deposit and an amount within it", () => { + assert.equal(withdrawProblem({ amount: 1n, shares: undefined, position: undefined }), "Reading your deposit…"); + assert.equal(withdrawProblem({ amount: 1n, shares: 0n, position: 0n }), "Nothing deposited"); + assert.equal(withdrawProblem({ amount: null, shares: 5n, position: 5n }), "Enter an amount"); + assert.equal(withdrawProblem({ amount: 6n, shares: 5n, position: 5n }), "More than your deposit"); + assert.equal(withdrawProblem({ amount: 5n, shares: 5n, position: 5n }), null); +}); diff --git a/app/src/lib/earn/math.ts b/app/src/lib/earn/math.ts new file mode 100644 index 00000000..19113cc7 --- /dev/null +++ b/app/src/lib/earn/math.ts @@ -0,0 +1,120 @@ +import { formatUnits, parseUnits } from "viem"; +import { sanitizeDecimalInput } from "../launchpad/decimal-input.ts"; + +/** + * Earn arithmetic (pure; node --test loads it directly). Amounts stay bigint in the asset's raw units; numbers appear + * only for display. + */ + +/** The rate window: what the vault paid over the last 30 days. */ +export const RATE_WINDOW_S = 30 * 86_400; +const YEAR_S = 365 * 86_400; +/** Shares used to read the share price: a million whole shares keeps 12 significant digits of a 6-decimal asset. */ +export const SHARE_PRICE_PROBE = 10n ** 24n; + +/** + * What the vault actually paid between two share-price reads, as a simple annual rate (not compounded): + * (after / before − 1) × year / elapsed. A share price that fell gives a negative rate, shown as it is. + * Null when it cannot be told (a missing or zero read, or no time between the reads). + */ +export function annualisedRate(before: bigint, after: bigint, elapsedS: number): number | null { + if (before <= 0n || after <= 0n || !Number.isFinite(elapsedS) || elapsedS <= 0) return null; + const growth = Number(after - before) / Number(before); // the difference in bigint keeps the precision + return (growth * YEAR_S) / elapsedS; +} + +/** "4.4%", "−0.3%", "—" when unknown. One decimal: the rate moves every block, more digits would be noise. */ +export function fmtRate(rate: number | null): string { + if (rate === null || !Number.isFinite(rate)) return "—"; + const tenths = Math.round(Math.abs(rate) * 1000); // rounded by size, so a loss rounds like the same gain + if (tenths === 0) return "0.0%"; + return `${rate < 0 ? "−" : ""}${(tenths / 10).toFixed(1)}%`; +} + +/** A WAD fee (1e18 = 100%) as a percent number. */ +export function wadPct(wad: bigint): number { + return Number(wad) / 1e16; +} + +/** Vault V2's management fee is a WAD rate per second; this is the yearly percent. */ +export function managementFeeYearPct(perSecondWad: bigint): number { + return (Number(perSecondWad) * YEAR_S) / 1e16; +} + +/** "0%", "10%", "2.5%": a fee as people read it. */ +export function fmtFeePct(pct: number): string { + if (!Number.isFinite(pct) || pct <= 0) return "0%"; + return `${Math.round(pct * 100) / 100}%`; +} + +/** + * What the amount field may hold: the shared sanitizer (which refuses anything ambiguous, see decimal-input.ts), then + * no more fraction digits than the asset has, so the field never shows a figure the chain cannot represent. + */ +export function cleanAmountInput(raw: string, decimals: number): string { + const s = sanitizeDecimalInput(raw); + const dot = s.indexOf("."); + return dot === -1 ? s : s.slice(0, dot + 1 + decimals); +} + +/** The field's text as raw units, or null when it is empty, not a number, or not above zero. */ +export function parseAmount(text: string, decimals: number): bigint | null { + const s = text.trim(); + if (!s || s === ".") return null; + if (!/^\d*\.?\d*$/.test(s)) return null; + const frac = s.split(".")[1] ?? ""; + if (frac.length > decimals) return null; + try { + const v = parseUnits(s, decimals); + return v > 0n ? v : null; + } catch { + return null; + } +} + +/** The Max button's text: the whole balance, every digit (no rounding up past what the wallet holds). */ +export function maxInput(raw: bigint, decimals: number): string { + return raw > 0n ? formatUnits(raw, decimals) : ""; +} + +/** + * An asset amount for display: grouped whole part and two decimals, always rounded DOWN so the screen never shows + * more than is there; "<0.01" for a dust balance above zero. + */ +export function fmtAmount(raw: bigint, decimals: number, places = 2): string { + if (raw < 0n) return `−${fmtAmount(-raw, decimals, places)}`; + const unit = 10n ** BigInt(decimals); + const whole = raw / unit; + const keep = Math.min(places, decimals); + const frac = ((raw % unit) / 10n ** BigInt(decimals - keep)).toString().padStart(keep, "0"); + if (raw > 0n && whole === 0n && /^0*$/.test(frac)) return `<0.${"0".repeat(Math.max(0, keep - 1))}1`; + return keep > 0 ? `${whole.toLocaleString("en-US")}.${frac}` : whole.toLocaleString("en-US"); +} + +/** Why a deposit cannot go ahead yet, as the button's text, or null when it can. */ +export function depositProblem({ amount, balance }: { amount: bigint | null; balance: bigint | undefined }): string | null { + if (amount === null) return "Enter an amount"; + if (balance === undefined) return "Reading your balance…"; + if (amount > balance) return "More than your USDC"; + return null; +} + +/** + * How to take an amount out. Asking for the whole position (or more) redeems every share, so no dust share is left + * behind and rounding can never ask for one share too many; anything less withdraws that exact amount of the asset. + */ +export type WithdrawPlan = { fn: "redeem"; shares: bigint; assets: bigint } | { fn: "withdraw"; assets: bigint }; +export function withdrawPlan({ amount, shares, position }: { amount: bigint | null; shares: bigint | undefined; position: bigint | undefined }): WithdrawPlan | null { + if (amount === null || shares === undefined || position === undefined || shares <= 0n) return null; + if (amount >= position) return { fn: "redeem", shares, assets: position }; + return { fn: "withdraw", assets: amount }; +} + +/** Why a withdrawal cannot go ahead yet, as the button's text, or null when it can. */ +export function withdrawProblem({ amount, shares, position }: { amount: bigint | null; shares: bigint | undefined; position: bigint | undefined }): string | null { + if (shares === undefined || position === undefined) return "Reading your deposit…"; + if (shares <= 0n) return "Nothing deposited"; + if (amount === null) return "Enter an amount"; + if (amount > position) return "More than your deposit"; + return null; +} diff --git a/app/src/lib/earn/server.test.ts b/app/src/lib/earn/server.test.ts new file mode 100644 index 00000000..9ac39210 --- /dev/null +++ b/app/src/lib/earn/server.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import { zeroAddress, type Address } from "viem"; +import { clearMemo } from "@/lib/launchpad/memo"; +import { BASE_USDC } from "../bridge/types.ts"; +import { earnEnabled, earnInfo, earnSnapshot, readSnapshot } from "./server.ts"; +import { EARN_VAULTS } from "./vaults.ts"; + +const vault = EARN_VAULTS.base!; +const LATEST = 52_576_486n; +const NOW = 1_791_942_319; +const THEN = 1_789_350_319; + +/** A chain reader answering like the real vault did on 2026-10-14 (share prices for 1e24 shares). */ +function reader(o: { priceThen?: bigint | Error; perf?: bigint; gate?: Address; down?: boolean } = {}) { + const asked: bigint[] = []; + const client = { + async getBlock(args?: { blockNumber?: bigint }) { + if (o.down) throw new Error("rpc down"); + if (args?.blockNumber === undefined) return { number: LATEST, timestamp: BigInt(NOW) }; + asked.push(args.blockNumber); + return { number: args.blockNumber, timestamp: BigInt(THEN) }; + }, + async readContract({ functionName, blockNumber }: { functionName: string; blockNumber?: bigint }) { + if (o.down) throw new Error("rpc down"); + switch (functionName) { + case "asset": return BASE_USDC; + case "totalAssets": return 182_619_001_080_731n; + case "performanceFee": return o.perf ?? 0n; + case "managementFee": return 0n; + case "convertToAssets": + if (blockNumber === LATEST) return 1_044_155_000_000n; + if (o.priceThen instanceof Error) throw o.priceThen; + return o.priceThen ?? 1_040_432_000_000n; + default: return o.gate ?? zeroAddress; + } + }, + }; + return { client: client as unknown as Parameters[1], asked }; +} + +beforeEach(() => clearMemo()); + +test("the snapshot measures what a share gained over the last 30 days, from two blocks' real timestamps", async () => { + const { client, asked } = reader(); + const s = await readSnapshot(vault, client); + assert.deepEqual(asked, [LATEST - 1_296_000n]); // 30 days of 2-second Base blocks back + assert.ok(s.rate !== null && Math.abs(s.rate - 0.043536) < 0.00001, String(s.rate)); + assert.equal(s.rateFrom, new Date(THEN * 1000).toISOString()); + assert.equal(s.rateTo, new Date(NOW * 1000).toISOString()); + assert.equal(s.totalAssets, "182619001080731"); + assert.deepEqual([s.performanceFeePct, s.managementFeeYearPct], [0, 0]); + assert.deepEqual(s.terms, { ok: true }); + assert.doesNotThrow(() => JSON.stringify(s)); +}); + +test("a node without historical state gives no rate, not an error", async () => { + const s = await readSnapshot(vault, reader({ priceThen: new Error("missing trie node") }).client); + assert.equal(s.rate, null); + assert.equal(s.rateFrom, null); + assert.deepEqual(s.terms, { ok: true }); +}); + +test("a raised fee or a gate shows up as changed terms", async () => { + const fee = await readSnapshot(vault, reader({ perf: 50_000_000_000_000_000n }).client); + assert.deepEqual(fee.terms, { ok: false, reason: "The vault now keeps 5% of the interest it earns." }); + const gated = await readSnapshot(vault, reader({ gate: "0x00000000000000000000000000000000000000aa" }).client); + assert.equal(gated.terms.ok, false); +}); + +test("an unreadable chain gives a null snapshot and is not remembered", async () => { + assert.equal(await earnSnapshot("base", reader({ down: true }).client), null); + const s = await earnSnapshot("base", reader().client); + assert.ok(s && s.rate !== null); +}); + +test("figures are memoised per vault, and chains without a vault have none", async () => { + const first = await earnSnapshot("base", reader().client); + const second = await earnSnapshot("base", reader({ perf: 1n }).client); + assert.deepEqual(second, first); + assert.equal(await earnSnapshot("arc", reader().client), null); + assert.equal(await earnSnapshot("robinhood", reader().client), null); +}); + +test("the API's on answer lists the Base vault with its figures", async () => { + const info = await earnInfo(reader().client); + assert.equal(info.enabled, true); + assert.ok(info.enabled); + assert.deepEqual(info.vaults.map((v) => [v.chain, v.address]), [["base", vault.address]]); + assert.ok(info.vaults[0].snapshot); +}); + +test("the feature is off unless EARN=on", () => { + const before = process.env.EARN; + try { + delete process.env.EARN; + assert.equal(earnEnabled(), false); + for (const v of ["off", "1", "true", "yes", ""]) { + process.env.EARN = v; + assert.equal(earnEnabled(), false, v); + } + for (const v of ["on", "ON", " on "]) { + process.env.EARN = v; + assert.equal(earnEnabled(), true, v); + } + } finally { + if (before === undefined) delete process.env.EARN; + else process.env.EARN = before; + } +}); diff --git a/app/src/lib/earn/server.ts b/app/src/lib/earn/server.ts new file mode 100644 index 00000000..89dc5a37 --- /dev/null +++ b/app/src/lib/earn/server.ts @@ -0,0 +1,77 @@ +import "server-only"; +import type { PublicClient } from "viem"; +import { publicClient } from "@/lib/chain"; +import { memo } from "@/lib/launchpad/memo"; +import type { ChainKey } from "@/lib/chainPublic"; +import { EARN_VAULT_ABI } from "./abi"; +import { RATE_WINDOW_S, SHARE_PRICE_PROBE, annualisedRate, managementFeeYearPct, wadPct } from "./math"; +import { readTerms, termsCheck } from "./terms"; +import { EARN_VAULTS, earnChains, type EarnVault } from "./vaults"; +import type { EarnInfo, EarnVaultSnapshot } from "./info"; + +/** + * Server half of Earn. The feature is OFF unless the runtime secret EARN=on is set (`fly secrets set EARN=on`, which + * restarts the app; no rebuild). Off, the API says only `{ enabled: false }` and the /me card and ⌘K entry stay hidden. + * Setting it back to anything else is the kill switch. + */ +export function earnEnabled(): boolean { + return process.env.EARN?.trim().toLowerCase() === "on"; +} + +/** Rough block times, to find the block about 30 days back; the rate itself uses the two blocks' real timestamps. */ +const BLOCK_TIME_S: Record = { base: 2, robinhood: 0.1, arc: 0.5 }; +/** The vault's figures move slowly and every visitor sees the same ones: one read per 10 minutes per vault. */ +const SNAPSHOT_TTL_MS = 10 * 60_000; + +type Reader = Pick; + +/** + * One vault's public figures, all read at the same block: assets, fees, gates (against the reviewed terms) and the share + * price now and about 30 days back. The rate is what a share actually gained, net of the vault's fees, with no third + * party involved. The older read needs a node that serves historical state; when it fails the rate is null, never an + * error. Only a failure of the current reads rejects. + */ +export async function readSnapshot(vault: EarnVault, client: Reader): Promise { + const latest = await client.getBlock(); + const at = { address: vault.address, abi: EARN_VAULT_ABI, blockNumber: latest.number } as const; + const [terms, totalAssets, price] = await Promise.all([ + readTerms(client, vault, latest.number), + client.readContract({ ...at, functionName: "totalAssets" }), + client.readContract({ ...at, functionName: "convertToAssets", args: [SHARE_PRICE_PROBE] }), + ]); + const back = latest.number - BigInt(Math.round(RATE_WINDOW_S / BLOCK_TIME_S[vault.chain])); + const earlier = back > 0n + ? await Promise.all([ + client.getBlock({ blockNumber: back }), + client.readContract({ ...at, blockNumber: back, functionName: "convertToAssets", args: [SHARE_PRICE_PROBE] }), + ]).then(([block, p]) => ({ time: Number(block.timestamp), price: p }), () => null) + : null; + const now = Number(latest.timestamp); + return { + rate: earlier ? annualisedRate(earlier.price, price, now - earlier.time) : null, + rateFrom: earlier ? new Date(earlier.time * 1000).toISOString() : null, + rateTo: new Date(now * 1000).toISOString(), + totalAssets: totalAssets.toString(), + performanceFeePct: wadPct(terms.performanceFee), + managementFeeYearPct: managementFeeYearPct(terms.managementFee), + terms: termsCheck(vault, terms), + }; +} + +/** The memoised snapshot for a chain's vault; null when the chain has none or the chain could not be read. */ +export async function earnSnapshot(chain: ChainKey, client?: Reader): Promise { + const vault = EARN_VAULTS[chain]; + if (!vault) return null; + try { + return await memo(`earn:snapshot:${chain}`, SNAPSHOT_TTL_MS, () => readSnapshot(vault, client ?? publicClient(chain))); + } catch (err) { + console.warn(`[earn] ${chain} vault read failed:`, err instanceof Error ? err.message : err); + return null; + } +} + +/** What GET /api/earn returns when the feature is on. */ +export async function earnInfo(client?: Reader): Promise { + const vaults = await Promise.all(earnChains().map(async (chain) => ({ chain, address: EARN_VAULTS[chain]!.address, snapshot: await earnSnapshot(chain, client) }))); + return { enabled: true, vaults }; +} diff --git a/app/src/lib/earn/terms.test.ts b/app/src/lib/earn/terms.test.ts new file mode 100644 index 00000000..b8402274 --- /dev/null +++ b/app/src/lib/earn/terms.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { zeroAddress } from "viem"; +import { pausedCopy, termsCheck, type LiveTerms } from "./terms.ts"; +import { EARN_VAULTS } from "./vaults.ts"; + +const vault = EARN_VAULTS.base!; +const asListed: LiveTerms = { asset: vault.asset, performanceFee: 0n, managementFee: 0n, gates: [zeroAddress, zeroAddress, zeroAddress, zeroAddress] }; + +test("the vault as reviewed passes, whatever the address case", () => { + assert.deepEqual(termsCheck(vault, asListed), { ok: true }); + assert.deepEqual(termsCheck(vault, { ...asListed, asset: vault.asset.toLowerCase() as `0x${string}` }), { ok: true }); +}); + +test("any change that costs or restricts a depositor stops deposits with the reason", () => { + const swapped = termsCheck(vault, { ...asListed, asset: "0x0000000000000000000000000000000000000001" }); + assert.deepEqual(swapped, { ok: false, reason: "The vault no longer takes USDC." }); + const perf = termsCheck(vault, { ...asListed, performanceFee: 100_000_000_000_000_000n }); + assert.deepEqual(perf, { ok: false, reason: "The vault now keeps 10% of the interest it earns." }); + const mgmt = termsCheck(vault, { ...asListed, managementFee: 10_000_000_000_000_000n / 31_536_000n }); + assert.equal(mgmt.ok, false); + assert.match((mgmt as { reason: string }).reason, /^The vault now charges a 1% yearly management fee\.$/); + for (let i = 0; i < 4; i++) { + const gates = [zeroAddress, zeroAddress, zeroAddress, zeroAddress] as `0x${string}`[]; + gates[i] = "0x00000000000000000000000000000000000000aa"; + assert.deepEqual(termsCheck(vault, { ...asListed, gates }), { ok: false, reason: "The vault now restricts who may use it." }); + } +}); + +test("the paused notice says withdrawals stay open", () => { + assert.match(pausedCopy("The vault now restricts who may use it."), /Withdrawals stay open\.$/); +}); diff --git a/app/src/lib/earn/terms.ts b/app/src/lib/earn/terms.ts new file mode 100644 index 00000000..5a6acd8c --- /dev/null +++ b/app/src/lib/earn/terms.ts @@ -0,0 +1,38 @@ +import { zeroAddress, type Address, type PublicClient } from "viem"; +import type { EarnVault } from "./vaults.ts"; +import { EARN_VAULT_ABI, VAULT_GATES } from "./abi.ts"; +import { fmtFeePct, managementFeeYearPct, wadPct } from "./math.ts"; + +/** + * The deposit tripwire (pure). A vault's owner and curator can change its fees and gates, some of them with no delay + * (Coinbase's own High Yield vault can raise its performance fee instantly). The vault was listed as it was on the + * review date; if the live vault differs in any way that costs or restricts a depositor, deposits here stop with a + * plain reason until someone reviews it again. Withdrawals never go through this check. + */ +export type LiveTerms = { asset: Address; performanceFee: bigint; managementFee: bigint; gates: readonly Address[] }; +export type TermsCheck = { ok: true } | { ok: false; reason: string }; + +export function termsCheck(vault: EarnVault, live: LiveTerms): TermsCheck { + if (live.asset.toLowerCase() !== vault.asset.toLowerCase()) return { ok: false, reason: `The vault no longer takes ${vault.assetSymbol}.` }; + if (live.performanceFee > vault.reviewed.performanceFee) return { ok: false, reason: `The vault now keeps ${fmtFeePct(wadPct(live.performanceFee))} of the interest it earns.` }; + if (live.managementFee > vault.reviewed.managementFee) return { ok: false, reason: `The vault now charges a ${fmtFeePct(managementFeeYearPct(live.managementFee))} yearly management fee.` }; + if (live.gates.some((g) => g.toLowerCase() !== zeroAddress)) return { ok: false, reason: "The vault now restricts who may use it." }; + return { ok: true }; +} + +/** The live terms, read in one go (at one block when given): the server's snapshot and the browser's pre-deposit check. */ +export async function readTerms(client: Pick, vault: EarnVault, blockNumber?: bigint): Promise { + const at = { address: vault.address, abi: EARN_VAULT_ABI, blockNumber } as const; + const [asset, performanceFee, managementFee, gates] = await Promise.all([ + client.readContract({ ...at, functionName: "asset" }), + client.readContract({ ...at, functionName: "performanceFee" }), + client.readContract({ ...at, functionName: "managementFee" }), + Promise.all(VAULT_GATES.map((g) => client.readContract({ ...at, functionName: g }))), + ]); + return { asset, performanceFee, managementFee, gates }; +} + +/** The sentence under a paused deposit form. */ +export function pausedCopy(reason: string): string { + return `Deposits are paused here: ${reason} openlaunch listed this vault without that and has not reviewed the change. Withdrawals stay open.`; +} diff --git a/app/src/lib/earn/vault.live.test.ts b/app/src/lib/earn/vault.live.test.ts new file mode 100644 index 00000000..fbcb4722 --- /dev/null +++ b/app/src/lib/earn/vault.live.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPublicClient, http } from "viem"; +import { base } from "viem/chains"; +import { EARN_VAULT_ABI, VAULT_V2_FACTORY_ABI } from "./abi.ts"; +import { readSnapshot } from "./server.ts"; +import { readTerms, termsCheck } from "./terms.ts"; +import { EARN_VAULTS, VAULT_V2_FACTORY_BASE } from "./vaults.ts"; +import { fmtRate } from "./math.ts"; + +// Explicit opt-in (RUN_EARN_LIVE_TESTS=1). Reads Base mainnet only: no wallet, no signature, no transaction. +// It is the check to run before relisting or after any news about the vault. +const skip = process.env.RUN_EARN_LIVE_TESTS !== "1"; + +test("read-only live: the pinned Base vault is still the reviewed Morpho Vault V2", { skip }, async (t) => { + const client = createPublicClient({ chain: base, transport: http(process.env.BASE_RPC_URL || "https://mainnet.base.org", { batch: true }) }); + const v = EARN_VAULTS.base!; + assert.equal(await client.readContract({ address: VAULT_V2_FACTORY_BASE, abi: VAULT_V2_FACTORY_ABI, functionName: "isVaultV2", args: [v.address] }), true); + assert.deepEqual(termsCheck(v, await readTerms(client, v)), { ok: true }); + assert.ok((await client.readContract({ address: v.address, abi: EARN_VAULT_ABI, functionName: "previewDeposit", args: [1_000_000n] })) > 0n); + const s = await readSnapshot(v, client); + t.diagnostic(JSON.stringify({ rate: fmtRate(s.rate), from: s.rateFrom, to: s.rateTo, totalAssets: s.totalAssets })); + assert.deepEqual(s.terms, { ok: true }); +}); diff --git a/app/src/lib/earn/vaults.test.ts b/app/src/lib/earn/vaults.test.ts new file mode 100644 index 00000000..6498bf48 --- /dev/null +++ b/app/src/lib/earn/vaults.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getAddress } from "viem"; +import { BASE_USDC } from "../bridge/types.ts"; +import { CHAIN_KEYS } from "../chainKeys.ts"; +import { EARN_VAULTS, VAULT_V2_FACTORY_BASE, earnChains } from "./vaults.ts"; + +test("Base has the one reviewed vault; every other chain says none explicitly", () => { + assert.deepEqual(earnChains(), ["base"]); + for (const chain of CHAIN_KEYS) assert.ok(chain in EARN_VAULTS, chain); + assert.equal(EARN_VAULTS.robinhood, null); + assert.equal(EARN_VAULTS.arc, null); +}); + +test("the Base vault is Steakhouse Prime USDC on Circle's USDC, listed with no fees", () => { + const v = EARN_VAULTS.base!; + assert.equal(v.address, getAddress("0xbeef0e0834849acc03f0089f01f4f1eeb06873c9")); + assert.equal(v.address, "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9"); + assert.equal(v.asset, BASE_USDC); + assert.equal(v.assetDecimals, 6); + assert.equal(v.shareDecimals, 18); + assert.equal(v.name, "Steakhouse Prime USDC"); + assert.deepEqual([v.reviewed.performanceFee, v.reviewed.managementFee], [0n, 0n]); + assert.equal(VAULT_V2_FACTORY_BASE, getAddress(VAULT_V2_FACTORY_BASE)); +}); diff --git a/app/src/lib/earn/vaults.ts b/app/src/lib/earn/vaults.ts new file mode 100644 index 00000000..28f477cd --- /dev/null +++ b/app/src/lib/earn/vaults.ts @@ -0,0 +1,62 @@ +import type { Address } from "viem"; +import type { ChainKey } from "../chainKeys.ts"; +import { BASE_USDC } from "../bridge/types.ts"; + +/** + * Earn: the lending vault a wallet can put its own USDC into from /me. CLIENT-SAFE and pure (node --test loads it). + * + * At most one reviewed vault per chain; a chain without one says null, so a new chain never inherits Base's vault. + * `reviewed` is what the vault looked like on-chain when it was listed. Before every deposit the app reads the live + * vault again and refuses the deposit if any of it changed (lib/earn/terms.ts). Withdrawals never depend on it. + * + * Funds go from the wallet straight into the vault and back. Openlaunch has no contract, key or fee in the path. + */ +export type EarnVault = { + chain: ChainKey; + /** The vault: an ERC-4626 share token (Morpho Vault V2). */ + address: Address; + /** What it takes in and pays out. */ + asset: Address; + assetSymbol: "USDC"; + assetDecimals: 6; + shareDecimals: 18; + /** The vault's own on-chain name. */ + name: string; + curator: string; + curatorUrl: string; + protocol: "Morpho"; + protocolUrl: string; + /** The vault as listed: fees (WAD; 0 = none) and no gates. Checked live before each deposit. */ + reviewed: { performanceFee: bigint; managementFee: bigint; on: string }; +}; + +/** + * Steakhouse Prime USDC, a Morpho Vault V2 on Base, read on 2026-10-14: VaultV2Factory.isVaultV2 = true, asset = Circle's + * Base USDC, performance and management fee 0, all four gates unset (anyone may deposit), about $182.6M deposited. It + * is the vault Base's own "integrate an earn product" guide uses. A simulated 1,000 USDC deposit from a fresh address + * succeeded, and a full deposit → partial withdraw → redeem-all cycle passed on a Base fork (docs/earn.md). + */ +const BASE_VAULT: EarnVault = { + chain: "base", + address: "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9", + asset: BASE_USDC, + assetSymbol: "USDC", + assetDecimals: 6, + shareDecimals: 18, + name: "Steakhouse Prime USDC", + curator: "Steakhouse Financial", + curatorUrl: "https://www.steakhouse.financial", + protocol: "Morpho", + protocolUrl: "https://morpho.org", + reviewed: { performanceFee: 0n, managementFee: 0n, on: "2026-10-14" }, +}; + +export const EARN_VAULTS: Record = { base: BASE_VAULT, robinhood: null, arc: null }; + +/** Morpho's VaultV2Factory on Base: `isVaultV2(vault)` proves a vault was deployed by it (the opt-in live test reads it). */ +export const VAULT_V2_FACTORY_BASE: Address = "0x4501125508079A99ebBebCE205DeC9593C2b5857"; + +/** The chains that have a vault, in display order. */ +export function earnChains(): ChainKey[] { + return (Object.keys(EARN_VAULTS) as ChainKey[]).filter((c) => EARN_VAULTS[c] !== null); +} diff --git a/app/src/lib/errors.ts b/app/src/lib/errors.ts index 70db135f..3534c579 100644 --- a/app/src/lib/errors.ts +++ b/app/src/lib/errors.ts @@ -21,6 +21,8 @@ export function friendlyError(err: unknown, opts: { slippagePct?: number } = {}) const rev = err.walk((e) => e instanceof ContractFunctionRevertedError) as ContractFunctionRevertedError | null; const name = rev?.data?.errorName; const slippage = /slippage|amountOutMinimum|TooLittleReceived/i; + // an Earn withdrawal larger than what the vault's markets have free (Morpho's "insufficient liquidity") + if (/insufficient liquidity/i.test(`${rev?.reason ?? ""} ${err.shortMessage || err.message}`)) return "The vault cannot pay out that much right now: most of it is lent out. Try a smaller amount, or try again later."; if (name && CONTRACT_ERROR_MESSAGES[name]) return CONTRACT_ERROR_MESSAGES[name]; if (name && slippage.test(name)) return `Price moved more than ${slippagePct}%. Try again.`; // decoded router error (e.g. V4TooLittleReceived) if (name) return `Reverted: ${name}`; diff --git a/docs/earn.md b/docs/earn.md new file mode 100644 index 00000000..78df0b9c --- /dev/null +++ b/docs/earn.md @@ -0,0 +1,62 @@ +# Earn on idle USDC + +A card on the **/me** dashboard lets a wallet put its own USDC into one reviewed lending vault on Base and take it out again. The vault is **Steakhouse Prime USDC**, a Morpho Vault V2 run by Steakhouse Financial (`0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9`). Funds go from the wallet straight into the vault and back. Openlaunch adds no contract, holds no key, takes no fee and never has custody. + +The feature is **off unless the runtime secret `EARN=on` is set**. Off, `GET /api/earn` answers `{ "enabled": false }` and the card and the ⌘K entry do not appear. `fly secrets set EARN=on` turns it on (the app restarts, no rebuild); unsetting it or any other value is the kill switch. + +## How it works + +- **Vault registry** (`app/src/lib/earn/vaults.ts`): one vault per chain at most, `null` on Robinhood Chain and Arc so a chain never inherits Base's vault. Each entry pins the address, the asset (Circle's Base USDC), the decimals and the terms as reviewed. +- **Calls** (`app/src/lib/earn/calls.ts`): plain ERC-4626 on the vault. `deposit(assets, wallet)`, `withdraw(assets, wallet, wallet)` for part, `redeem(allShares, wallet, wallet)` for everything (so no dust share is left). The approval is `approve(vault, exactAmount)` on USDC. No SDK, no router, no bundler. +- **Reads**: the wallet's USDC, allowance, shares and what they redeem for are read in the browser through the same-origin `/api/rpc` proxy. The vault's public figures (rate, size, fees, terms) come from `GET /api/earn`, which reads the chain on the server, once per 10 minutes. No third-party API: the CSP and `SECURITY.md` are unchanged. +- **Rate**: what one share actually gained over about the last 30 days, annualised (simple, not compounded), from `convertToAssets` at two blocks and their real timestamps. It is net of the vault's fees and excludes any external rewards. It needs a node that serves historical state; without one the rate shows as unavailable and everything else still works. +- **Writes** follow the trade panel: a synchronous lock before the first await, a network switch if needed, a simulation before every signature, then the receipt. One-at-a-time wallets: approve (only when the allowance is short), then deposit. Smart wallets that already execute atomic batches (`wallet_getCapabilities` → `atomic.status: "supported"`): one `wallet_sendCalls` with approve + deposit, atomic, so a failed deposit takes the approval back with it. A wallet that would first need an EIP-7702 upgrade (`"ready"`) takes the one-at-a-time path. + +## Builder code + +Every Earn transaction is credited to openlaunch's Base builder code `bc_ly9ism19` (ERC-8021, `BUILDER_DATA_SUFFIX` in `app/src/lib/chainPublic.ts`): + +- each call's data ends with the suffix: the approval, the deposit, the withdrawal and the redemption. One-at-a-time wallets get it as `dataSuffix` on `simulateContract`/`writeContract`, passed per call because a suffix set in the wagmi config is not applied for injected wallets (wagmi #5248); +- batching wallets also get it as the `dataSuffix` capability on `wallet_sendCalls` (`optional: true`), so the wallet can tag its own operation. + +Base's Dashboard reports lending TVL per builder code. Check a real transaction with Base's builder-code checker or by decoding its input (`Attribution.fromData` from `ox/erc8021`). + +## Safety rules + +- **Exact approvals only.** No unlimited allowance exists anywhere in the feature (enforced by `components/earn/earn-ui.test.ts`). An unspent exact approval remains on-chain until used or revoked. +- **Terms tripwire.** A vault's owner and curator can change fees and gates, some with no delay. Before every deposit the browser reads the vault's asset, performance fee, management fee and four gates. If any differ from the reviewed terms (a fee above 0, a gate set, a different asset), the deposit is refused with the reason. The server reports the same check, so the form shows "Deposits paused" before anyone tries. **Withdrawals never depend on it.** +- **Liquidity.** The vault pays withdrawals from what its markets have free. A withdrawal larger than that reverts in simulation and the panel says so; nothing is sent. +- **Copy.** The rate is labelled "Last 30 days · Annualised. Variable." The risk line is always visible: deposits can lose money; openlaunch never holds the funds, cannot move them and cannot guarantee a withdrawal; the vault is run by Steakhouse Financial, not openlaunch. No Base or Coinbase marks, no yield promises. + +## Limits + +- Base only, one vault, USDC only. No borrowing, no "earned so far" figure (it needs deposit history the app does not index), no country restrictions (the switch is global). +- The vault is a third party. Its availability, liquidity, rate and solvency are not guaranteed by openlaunch. +- The batch path for smart wallets is covered by unit tests of the exact calls and capability, not yet by a real wallet. + +## Development and verification + +From `app/`: `npm test`, `npm run lint`, `npm run typecheck`, `npm run build`. Set `EARN=on` in `.env.local` to see the card locally. + +`/ui-review-earn` is a development-only fixture of the card and every panel state (disconnected, first deposit with approval, approved deposit, over balance, wrong network, approving, confirming, done, withdraw all, withdraw part, liquidity error, paused terms, rate unavailable). `?scene=` shows one. The route returns 404 outside development. + +The opt-in live read test checks the pinned vault on Base mainnet (factory proof, asset, fees, gates, a deposit preview, the snapshot). It sends nothing: + +```sh +RUN_EARN_LIVE_TESTS=1 npx tsx --conditions=react-server --tsconfig tsconfig.test.json --test src/lib/earn/vault.live.test.ts +``` + +### Verification log (2026-10-14) + +- On-chain review of the vault: `VaultV2Factory.isVaultV2` true; asset `0x8335…2913` (USDC); performance and management fee 0; all four gates `0x0`; about 182.6M USDC deposited; a simulated 1,000 USDC deposit from a fresh address (state override) succeeded. +- Fork run on a local anvil fork of Base, anvil test key, every call carrying the suffix: exact approve 1,000 USDC → deposit (shares within 3e-8 of the preview; allowance back to 0; position 999.999999 USDC) → withdraw 100 USDC → redeem all (shares 0; 1,000.000002 USDC back). Gas: 55,895 / 327,074 / 325,836 / 303,949. Every mined input ended with the suffix, and the deposit's decoded to `{ codes: ["bc_ly9ism19"] }`. +- Live read test passed: rate 4.4% for 2026-09-14 → 2026-10-14, terms as reviewed. +- Not yet done: a real deposit and full withdrawal from a maintainer's own wallet (one ordinary wallet, one Coinbase Smart Wallet), with both transactions checked for the builder code and lending TVL seen on the Base Dashboard. Do this before setting `EARN=on` in production. + +## Primary references + +- [Morpho Vault V2](https://docs.morpho.org/learn/concepts/vault-v2/) and [contract addresses](https://docs.morpho.org/developers/contracts/addresses) +- [Base: integrate an earn product](https://docs.base.org/build-on-base/integrate-defi/integrate-earn-product) +- [Base builder codes for app developers](https://docs.base.org/specifications/builder-codes/for-app-developers) +- [EIP-5792 wallet call API](https://eips.ethereum.org/EIPS/eip-5792) +- [Steakhouse Financial](https://www.steakhouse.financial) From a2eff93e4e335c705eb508a45919ae425d998e67 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 17:25:01 +0800 Subject: [PATCH 2/9] fix(earn): a deposit pause lifts by itself once the vault's terms recover The pre-deposit check stored its "paused" reason in state and nothing ever cleared it, so after one failed check the form stayed on "Deposits paused" until the card remounted, even if the vault went back to the reviewed terms. Clearing it after a passing check would not help: the button is disabled while paused, so that check never runs again. The pause is now derived, not stored: the open card re-reads the vault's asset, fees and gates every minute (one multicall), and depositPause() takes that read over the server's up-to-10-minute-old figures. A failed send-time check refreshes the read so the pause shows at once. The send-time check itself is unchanged and still guards every deposit. --- app/src/app/ui-review-earn/EarnReview.tsx | 4 +-- app/src/components/earn/EarnPanel.tsx | 4 +-- app/src/components/earn/earn-ui.test.ts | 10 +++++++ app/src/components/earn/useEarn.ts | 36 +++++++++++++++++------ app/src/lib/earn/terms.test.ts | 14 ++++++++- app/src/lib/earn/terms.ts | 10 +++++++ docs/earn.md | 2 +- 7 files changed, 65 insertions(+), 15 deletions(-) diff --git a/app/src/app/ui-review-earn/EarnReview.tsx b/app/src/app/ui-review-earn/EarnReview.tsx index 94541b60..cf86db50 100644 --- a/app/src/app/ui-review-earn/EarnReview.tsx +++ b/app/src/app/ui-review-earn/EarnReview.tsx @@ -17,7 +17,7 @@ const snapshot: EarnVaultSnapshot = { rate: 0.0436, rateFrom: "2026-09-14T08:49: const noop = () => {}; const base: EarnView = { vault, isConnected: true, onChain: true, switching: false, tab: "deposit", text: "", all: false, amount: null, problem: "Enter an amount", needsApproval: null, - paused: null, phase: { k: "idle" }, busy: false, balance: 1_250_500_000n, shares: 0n, position: 0n, unreadable: false, noGas: false, + liveTerms: { ok: true }, phase: { k: "idle" }, busy: false, balance: 1_250_500_000n, shares: 0n, position: 0n, unreadable: false, noGas: false, setTab: noop, setAmount: noop, setMax: noop, reset: noop, switchToVaultChain: async () => {}, submit: async () => {}, }; const typed = (text: string, amount: bigint): Partial => ({ text, amount, problem: null }); @@ -39,7 +39,7 @@ const scenes: { id: string; title: string; view: Partial; snap?: EarnV { id: "withdraw_part", title: "Withdraw part", view: { tab: "withdraw", ...deposited, ...typed("120", 120_000_000n) } }, { id: "error_liquidity", title: "Vault short of free funds", view: { tab: "withdraw", ...deposited, ...typed("500", 500_000_000n), all: true, phase: { k: "error", message: "The vault cannot pay out that much right now: most of it is lent out. Try a smaller amount, or try again later." } } }, { id: "done_withdraw", title: "Withdrawn", view: { tab: "withdraw", phase: { k: "done", hash, tab: "withdraw", amount: 500_000_000n } } }, - { id: "paused", title: "Vault terms changed", view: { ...typed("250", 250_000_000n), paused: "The vault now keeps 10% of the interest it earns." } }, + { id: "paused", title: "Vault terms changed", view: { ...typed("250", 250_000_000n), liveTerms: { ok: false, reason: "The vault now keeps 10% of the interest it earns." } } }, { id: "no_rate", title: "Rate unavailable, no gas", view: { noGas: true }, snap: null }, ]; diff --git a/app/src/components/earn/EarnPanel.tsx b/app/src/components/earn/EarnPanel.tsx index 411d09f0..17b2556d 100644 --- a/app/src/components/earn/EarnPanel.tsx +++ b/app/src/components/earn/EarnPanel.tsx @@ -5,7 +5,7 @@ import { ArrowRight, ArrowUpRight, Check, CircleAlert, LoaderCircle, PauseCircle import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; import { CHAIN_LABELS, explorerAddress, explorerTx } from "@/lib/chainPublic"; import { fmtAmount, fmtFeePct, fmtRate } from "@/lib/earn/math"; -import { pausedCopy } from "@/lib/earn/terms"; +import { depositPause, pausedCopy } from "@/lib/earn/terms"; import type { EarnVaultSnapshot } from "@/lib/earn/info"; import type { Earn, EarnTab } from "./useEarn"; import styles from "./EarnDialog.module.css"; @@ -29,7 +29,7 @@ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: Earn const sym = v.assetSymbol; const amt = (raw: bigint) => fmtAmount(raw, v.assetDecimals); const deposit = e.tab === "deposit"; - const pauseReason = e.paused ?? (snapshot && !snapshot.terms.ok ? snapshot.terms.reason : null); + const pauseReason = depositPause(e.liveTerms, snapshot?.terms ?? null); const everything = !deposit && (e.all || (e.amount !== null && e.position !== undefined && e.position > 0n && e.amount >= e.position)); const vaultFee = snapshot ? snapshot.performanceFeePct : 0; diff --git a/app/src/components/earn/earn-ui.test.ts b/app/src/components/earn/earn-ui.test.ts index 2ff1a41b..8fcc6455 100644 --- a/app/src/components/earn/earn-ui.test.ts +++ b/app/src/components/earn/earn-ui.test.ts @@ -43,6 +43,16 @@ test("both actions lock before their first await and simulate before any signatu assert.ok(deposit.indexOf("simulateContract(") < deposit.indexOf("writeContract(request)")); }); +test("a deposit pause is derived from a polled read, never stored, so it cannot stick after the terms recover", () => { + assert.doesNotMatch(hook, /setPaused|useState/); + assert.match(hook, /useReadContracts\(\{[\s\S]*?query: \{ refetchInterval: 60_000 \}/); + for (const fn of ["asset", "performanceFee", "managementFee", "receiveSharesGate", "sendSharesGate", "receiveAssetsGate", "sendAssetsGate"]) assert.match(hook, new RegExp(`functionName: "${fn}"`), fn); + assert.match(panel, /const pauseReason = depositPause\(e\.liveTerms, snapshot\?\.terms \?\? null\);/); + // a failed send-time check refreshes the polled read so the form shows the pause at once + const deposit = hook.slice(hook.indexOf("async function deposit()"), hook.indexOf("async function withdraw()")); + assert.match(deposit, /if \(!check\.ok\) \{\s*void terms\.refetch\(\);[^\n]*\n\s*throw new Error\(pausedCopy\(check\.reason\)\);/); +}); + test("an upgrade-to-smart-account prompt is never triggered", () => { assert.match(calls, /c\.atomic\?\.status === "supported"/); assert.doesNotMatch(calls, /status === "ready"/); diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts index 1fc410da..ec29f6c7 100644 --- a/app/src/components/earn/useEarn.ts +++ b/app/src/components/earn/useEarn.ts @@ -1,7 +1,7 @@ "use client"; import { useRef, useState } from "react"; -import { useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; +import { useBalance, useConfig, useReadContract, useReadContracts, useSwitchChain } from "wagmi"; import { getCapabilities, getPublicClient, getWalletClient, sendCalls, waitForCallsStatus } from "wagmi/actions"; import type { Address, Hex } from "viem"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; @@ -10,7 +10,7 @@ import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { EARN_VAULT_ABI } from "@/lib/earn/abi"; import { EARN_BATCH_CAPABILITIES, approveCall, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; import { cleanAmountInput, depositProblem, fmtAmount, maxInput, parseAmount, withdrawPlan, withdrawProblem } from "@/lib/earn/math"; -import { pausedCopy, readTerms, termsCheck } from "@/lib/earn/terms"; +import { pausedCopy, readTerms, termsCheck, type TermsCheck } from "@/lib/earn/terms"; import type { EarnVault } from "@/lib/earn/vaults"; import { friendlyError } from "@/lib/errors"; import { toast } from "@/components/launchpad/TxToasts"; @@ -66,9 +66,25 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { // "Withdraw all" stays all even as interest grows the position after the tap, so no dust share is left behind const [all, setAll] = useState(false); const [phase, setPhase] = useState({ k: "idle" }); - // set when the pre-deposit check finds the vault's terms changed since review - const [paused, setPaused] = useState(null); const lock = useRef(false); + // The vault's terms, re-read every minute while the card is open (one multicall). The pause is derived from this on + // every render (lib/earn/terms.ts depositPause), never stored, so it lifts by itself when the terms are back. + const at = { address: vault.address, abi: EARN_VAULT_ABI, chainId } as const; + const terms = useReadContracts({ + contracts: [ + { ...at, functionName: "asset" }, + { ...at, functionName: "performanceFee" }, + { ...at, functionName: "managementFee" }, + { ...at, functionName: "receiveSharesGate" }, + { ...at, functionName: "sendSharesGate" }, + { ...at, functionName: "receiveAssetsGate" }, + { ...at, functionName: "sendAssetsGate" }, + ], + allowFailure: false, + query: { refetchInterval: 60_000 }, + }); + const t = terms.data; + const liveTerms: TermsCheck | null = t ? termsCheck(vault, { asset: t[0], performanceFee: t[1], managementFee: t[2], gates: [t[3], t[4], t[5], t[6]] }) : null; const decimals = vault.assetDecimals; const typed = parseAmount(text, decimals); @@ -136,10 +152,11 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { try { if (!onChain) await switchChainAsync({ chainId }); const pub = getPublicClient(config, { chainId })!; - const terms = termsCheck(vault, await readTerms(pub, vault)); - if (!terms.ok) { - setPaused(terms.reason); - throw new Error(pausedCopy(terms.reason)); + // authoritative: read fresh at send time, whatever the polled terms said + const check = termsCheck(vault, await readTerms(pub, vault)); + if (!check.ok) { + void terms.refetch(); // the form shows the pause now, not at the next poll + throw new Error(pausedCopy(check.reason)); } const [balance, allowance] = await Promise.all([ pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: [address] }), @@ -233,7 +250,8 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { amount, problem, needsApproval, - paused, + /** The live vault against the reviewed terms; null until the first read lands (or if it fails). */ + liveTerms, phase, busy, reset: () => { if (!busy) setPhase({ k: "idle" }); }, diff --git a/app/src/lib/earn/terms.test.ts b/app/src/lib/earn/terms.test.ts index b8402274..64bd621a 100644 --- a/app/src/lib/earn/terms.test.ts +++ b/app/src/lib/earn/terms.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { zeroAddress } from "viem"; -import { pausedCopy, termsCheck, type LiveTerms } from "./terms.ts"; +import { depositPause, pausedCopy, termsCheck, type LiveTerms } from "./terms.ts"; import { EARN_VAULTS } from "./vaults.ts"; const vault = EARN_VAULTS.base!; @@ -27,6 +27,18 @@ test("any change that costs or restricts a depositor stops deposits with the rea } }); +test("the pause follows the freshest read, so it lifts once the vault is back to the reviewed terms", () => { + const changed = { ok: false as const, reason: "The vault now restricts who may use it." }; + // the browser's own read wins over the server's (up to 10 minutes old) in both directions + assert.equal(depositPause({ ok: true }, changed), null); + assert.equal(depositPause(changed, { ok: true }), changed.reason); + // until the browser's read lands, the server's stands + assert.equal(depositPause(null, changed), changed.reason); + assert.equal(depositPause(null, { ok: true }), null); + // neither known: no pause shown (the send-time check still guards the deposit itself) + assert.equal(depositPause(null, null), null); +}); + test("the paused notice says withdrawals stay open", () => { assert.match(pausedCopy("The vault now restricts who may use it."), /Withdrawals stay open\.$/); }); diff --git a/app/src/lib/earn/terms.ts b/app/src/lib/earn/terms.ts index 5a6acd8c..ef8b6b59 100644 --- a/app/src/lib/earn/terms.ts +++ b/app/src/lib/earn/terms.ts @@ -32,6 +32,16 @@ export async function readTerms(client: Pick, vaul return { asset, performanceFee, managementFee, gates }; } +/** + * Why deposits are paused right now, or null. The browser's own read of the vault (re-read every minute while the card + * is open) wins over the server's figures, which can be up to 10 minutes old; until it arrives the server's stand. + * Derived on every render, never stored, so a pause lifts by itself once the vault is back to the reviewed terms. + */ +export function depositPause(live: TermsCheck | null, server: TermsCheck | null): string | null { + const t = live ?? server; + return t && !t.ok ? t.reason : null; +} + /** The sentence under a paused deposit form. */ export function pausedCopy(reason: string): string { return `Deposits are paused here: ${reason} openlaunch listed this vault without that and has not reviewed the change. Withdrawals stay open.`; diff --git a/docs/earn.md b/docs/earn.md index 78df0b9c..f602a8f2 100644 --- a/docs/earn.md +++ b/docs/earn.md @@ -24,7 +24,7 @@ Base's Dashboard reports lending TVL per builder code. Check a real transaction ## Safety rules - **Exact approvals only.** No unlimited allowance exists anywhere in the feature (enforced by `components/earn/earn-ui.test.ts`). An unspent exact approval remains on-chain until used or revoked. -- **Terms tripwire.** A vault's owner and curator can change fees and gates, some with no delay. Before every deposit the browser reads the vault's asset, performance fee, management fee and four gates. If any differ from the reviewed terms (a fee above 0, a gate set, a different asset), the deposit is refused with the reason. The server reports the same check, so the form shows "Deposits paused" before anyone tries. **Withdrawals never depend on it.** +- **Terms tripwire.** A vault's owner and curator can change fees and gates, some with no delay. Before every deposit the browser reads the vault's asset, performance fee, management fee and four gates. If any differ from the reviewed terms (a fee above 0, a gate set, a different asset), the deposit is refused with the reason. The form also shows "Deposits paused" before anyone tries: the open card re-reads the terms every minute (the server's 10-minute figures stand until that first read lands). The pause is derived from those reads, never stored, so it lifts by itself once the vault is back to the reviewed terms. **Withdrawals never depend on it.** - **Liquidity.** The vault pays withdrawals from what its markets have free. A withdrawal larger than that reverts in simulation and the panel says so; nothing is sent. - **Copy.** The rate is labelled "Last 30 days · Annualised. Variable." The risk line is always visible: deposits can lose money; openlaunch never holds the funds, cannot move them and cannot guarantee a withdrawal; the vault is run by Steakhouse Financial, not openlaunch. No Base or Coinbase marks, no yield promises. From d73db801037ace55a3807bfd4e32d39aabd995cd Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 17:27:31 +0800 Subject: [PATCH 3/9] docs(earn): document the remaining functions CodeRabbit's pre-merge check counted 71% docstring coverage against its 80% default. Comments only: one line on each function that had none, in the style of the rest of the feature. --- app/src/app/ui-review-earn/EarnReview.tsx | 2 ++ app/src/components/earn/EarnCard.tsx | 2 ++ app/src/components/earn/EarnPanel.tsx | 2 ++ app/src/components/earn/earnInfo.ts | 5 +++-- app/src/components/earn/useEarn.ts | 8 ++++++++ app/src/lib/earn/calls.ts | 1 + app/src/lib/earn/terms.ts | 1 + 7 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/app/ui-review-earn/EarnReview.tsx b/app/src/app/ui-review-earn/EarnReview.tsx index cf86db50..6e723348 100644 --- a/app/src/app/ui-review-earn/EarnReview.tsx +++ b/app/src/app/ui-review-earn/EarnReview.tsx @@ -43,6 +43,7 @@ const scenes: { id: string; title: string; view: Partial; snap?: EarnV { id: "no_rate", title: "Rate unavailable, no gas", view: { noGas: true }, snap: null }, ]; +/** The sheet drawn in place (no modal), with one scene's synthetic state. */ function Panel({ view, snap }: { view: EarnView; snap: EarnVaultSnapshot | null }) { return (
@@ -54,6 +55,7 @@ function Panel({ view, snap }: { view: EarnView; snap: EarnVaultSnapshot | null ); } +/** Every scene, or one with ?scene= (?scene=card for the /me card); ?dark=1 or 0 picks the theme. */ export default function EarnReview() { const params = useSearchParams(); const only = params.get("scene"); diff --git a/app/src/components/earn/EarnCard.tsx b/app/src/components/earn/EarnCard.tsx index b352d69f..3eeb1b61 100644 --- a/app/src/components/earn/EarnCard.tsx +++ b/app/src/components/earn/EarnCard.tsx @@ -28,6 +28,7 @@ export default function EarnCard({ address }: { address: Address }) { return ; } +/** The card for one wallet: owns the Earn state, so the sheet can close and reopen without losing a transaction. */ function EarnCardLive({ vault, snapshot }: { vault: EarnVault; snapshot: EarnVaultSnapshot | null }) { const earn = useEarn(vault, "deposit"); // the dialog mounts on first open and stays mounted, like the bridge panel @@ -38,6 +39,7 @@ function EarnCardLive({ vault, snapshot }: { vault: EarnVault; snapshot: EarnVau useEffect(() => { if (window.location.hash === "#earn") section.current?.scrollIntoView({ block: "center" }); }, []); + /** Opens the sheet on the tab of the button pressed. */ function show(tab: EarnTab) { // a transaction in flight keeps its tab and progress; otherwise open on the button pressed, with a clean form if (!earn.busy) { diff --git a/app/src/components/earn/EarnPanel.tsx b/app/src/components/earn/EarnPanel.tsx index 17b2556d..9df66b51 100644 --- a/app/src/components/earn/EarnPanel.tsx +++ b/app/src/components/earn/EarnPanel.tsx @@ -19,10 +19,12 @@ function compactAssets(raw: string, decimals: number): string { return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); } +/** "Sep 14": a read's date for the rate's tooltip, in UTC like the chain. */ function day(iso: string): string { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); } +/** The sheet's body: figures, Deposit/Withdraw, amount, vault facts, notices, the one action and the risk line. */ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: EarnView; snapshot: EarnVaultSnapshot | null; onConnect: () => void }) { const v = e.vault; const network = CHAIN_LABELS[v.chain]; diff --git a/app/src/components/earn/earnInfo.ts b/app/src/components/earn/earnInfo.ts index b35fd591..d3b0a8e2 100644 --- a/app/src/components/earn/earnInfo.ts +++ b/app/src/components/earn/earnInfo.ts @@ -3,12 +3,13 @@ import { useEffect, useState } from "react"; import type { EarnInfo } from "@/lib/earn/info"; +/** The one request per page load, shared by every caller. */ +let pending: Promise | null = null; + /** * GET /api/earn, once per page load and shared by everything that asks (the /me card, the ⌘K list). A failed request * reads as "off" and is not remembered, so the next caller tries again. */ -let pending: Promise | null = null; - export function loadEarnInfo(): Promise { pending ??= fetch("/api/earn", { cache: "no-store" }) .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(String(r.status))))) diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts index ec29f6c7..5435a50a 100644 --- a/app/src/components/earn/useEarn.ts +++ b/app/src/components/earn/useEarn.ts @@ -54,6 +54,7 @@ export function useEarnPosition(vault: EarnVault, address: Address | undefined) }; } +/** Earn's form state, its reads and its two actions for one vault and the connected wallet (see the file comment). */ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { const config = useConfig(); const chainId = CHAINS[vault.chain].id; @@ -94,7 +95,9 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { const problem = tab === "deposit" ? depositProblem({ amount, balance: position.balance }) : withdrawProblem({ amount, shares: position.shares, position: position.position }); const needsApproval = tab === "deposit" && amount !== null && position.allowance !== undefined ? position.allowance < amount : null; + /** An edit clears a finished or failed result, so the form reads fresh. */ const settle = () => { if (phase.k === "done" || phase.k === "error") setPhase({ k: "idle" }); }; + /** Switches between Deposit and Withdraw with an empty amount; ignored while a transaction is in flight. */ function setTab(next: EarnTab) { if (busy) return; setTabState(next); @@ -102,11 +105,13 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { setAll(false); settle(); } + /** The amount field's text, cleaned (lib/earn/math.ts cleanAmountInput); typing ends a "withdraw all". */ function setAmount(raw: string) { setText(cleanAmountInput(raw, decimals)); setAll(false); settle(); } + /** Max: the whole wallet balance on Deposit; on Withdraw, the whole position as "withdraw all". */ function setMax() { if (tab === "deposit") setText(maxInput(position.balance ?? 0n, decimals)); else { @@ -116,6 +121,7 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { settle(); } + /** The explicit "Switch to Base" step, before the form offers to send anything. */ async function switchToVaultChain() { try { await switchChainAsync({ chainId }); @@ -144,6 +150,7 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { return receipts.at(-1)?.transactionHash ?? null; } + /** Deposits the amount: fresh terms and balance checks, an exact approval only if short, simulate, send, receipt. */ async function deposit() { if (!address || amount === null || problem || busy || lock.current) return; // Lock before the first await, including the network switch and the vault check. @@ -193,6 +200,7 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { } } + /** Withdraws per withdrawPlan: every share for "withdraw all", else exactly the amount; simulate, send, receipt. */ async function withdraw() { const plan = withdrawPlan({ amount, shares: position.shares, position: position.position }); if (!address || !plan || problem || busy || lock.current) return; diff --git a/app/src/lib/earn/calls.ts b/app/src/lib/earn/calls.ts index 1c6fed1d..5d65a4ac 100644 --- a/app/src/lib/earn/calls.ts +++ b/app/src/lib/earn/calls.ts @@ -15,6 +15,7 @@ import type { EarnVault } from "./vaults.ts"; */ export type EarnCall = { to: Address; data: Hex }; +/** Calldata with the builder-code suffix appended after the ABI arguments. */ const tagged = (data: Hex): Hex => concatHex([data, BUILDER_DATA_SUFFIX]); /** Allow the vault to take exactly `amount` of the asset. Never more: an unspent exact allowance is the worst case. */ diff --git a/app/src/lib/earn/terms.ts b/app/src/lib/earn/terms.ts index ef8b6b59..59a6ed96 100644 --- a/app/src/lib/earn/terms.ts +++ b/app/src/lib/earn/terms.ts @@ -12,6 +12,7 @@ import { fmtFeePct, managementFeeYearPct, wadPct } from "./math.ts"; export type LiveTerms = { asset: Address; performanceFee: bigint; managementFee: bigint; gates: readonly Address[] }; export type TermsCheck = { ok: true } | { ok: false; reason: string }; +/** Whether the live vault still matches what was reviewed; the first difference found is the reason. */ export function termsCheck(vault: EarnVault, live: LiveTerms): TermsCheck { if (live.asset.toLowerCase() !== vault.asset.toLowerCase()) return { ok: false, reason: `The vault no longer takes ${vault.assetSymbol}.` }; if (live.performanceFee > vault.reviewed.performanceFee) return { ok: false, reason: `The vault now keeps ${fmtFeePct(wadPct(live.performanceFee))} of the interest it earns.` }; From f1332e0656778b25f4a51edb011953d98e93109b Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 18:51:49 +0800 Subject: [PATCH 4/9] fix(earn): a sent action is never retryable until it is settled; one account per action Review round 2 on PR 96 (Vasanth, CodeRabbit): - A receipt timeout or RPC error after the wallet returned a hash (or a batch id) set an error and unlocked the form with the amount still filled in, so a second deposit could go out while the first was pending or done. Such a send is now "unconfirmed": the form stays locked, the transaction link shows, and "Check again" asks the chain (or the wallet, for a batch) again. Only a final receipt or batch status ends it; a reverted one is the only failure after sending that allows sending again. - Every wallet prompt is bound to the account that started the action (getWalletClient({ account }), account on each write) and preceded by a check that it is still the connected account. A switch mid-action stops it before the next prompt instead of falling back from the batch path to a prompt for the other account. Leaving the page stops it too. - The vault's terms are read again after an approval confirms, right before the deposit is prepared, so a change during the approval wait stops it. - A failed latest terms poll no longer lets wagmi's last good data outrank the server's check (liveCheck). earn-flow.test.ts runs the real deposit / withdraw / recheck code against inert wallets and RPCs; with each fix reverted, its tests fail. --- app/src/app/ui-review-earn/EarnReview.tsx | 4 +- app/src/components/earn/EarnDialog.module.css | 3 + app/src/components/earn/EarnPanel.tsx | 25 +- app/src/components/earn/earn-flow.test.ts | 232 ++++++++++++++++++ app/src/components/earn/earn-ui.test.ts | 28 ++- app/src/components/earn/useEarn.ts | 226 ++++++++++++----- app/src/lib/earn/calls.test.ts | 9 +- app/src/lib/earn/calls.ts | 10 + app/src/lib/earn/phase.test.ts | 17 ++ app/src/lib/earn/phase.ts | 36 +++ app/src/lib/earn/terms.test.ts | 16 +- app/src/lib/earn/terms.ts | 13 + docs/earn.md | 9 +- 13 files changed, 554 insertions(+), 74 deletions(-) create mode 100644 app/src/components/earn/earn-flow.test.ts create mode 100644 app/src/lib/earn/phase.test.ts create mode 100644 app/src/lib/earn/phase.ts diff --git a/app/src/app/ui-review-earn/EarnReview.tsx b/app/src/app/ui-review-earn/EarnReview.tsx index 6e723348..5662d814 100644 --- a/app/src/app/ui-review-earn/EarnReview.tsx +++ b/app/src/app/ui-review-earn/EarnReview.tsx @@ -18,7 +18,7 @@ const noop = () => {}; const base: EarnView = { vault, isConnected: true, onChain: true, switching: false, tab: "deposit", text: "", all: false, amount: null, problem: "Enter an amount", needsApproval: null, liveTerms: { ok: true }, phase: { k: "idle" }, busy: false, balance: 1_250_500_000n, shares: 0n, position: 0n, unreadable: false, noGas: false, - setTab: noop, setAmount: noop, setMax: noop, reset: noop, switchToVaultChain: async () => {}, submit: async () => {}, + setTab: noop, setAmount: noop, setMax: noop, reset: noop, switchToVaultChain: async () => {}, submit: async () => {}, recheck: async () => {}, }; const typed = (text: string, amount: bigint): Partial => ({ text, amount, problem: null }); const hash = `0x${"ab".repeat(32)}` as const; @@ -34,6 +34,8 @@ const scenes: { id: string; title: string; view: Partial; snap?: EarnV { id: "approving", title: "Approving", view: { ...typed("250", 250_000_000n), needsApproval: true, phase: { k: "approving" }, busy: true } }, { id: "signing", title: "Confirm in wallet", view: { ...typed("250", 250_000_000n), phase: { k: "signing" }, busy: true } }, { id: "sent", title: "Confirming", view: { ...typed("250", 250_000_000n), phase: { k: "sent", hash }, busy: true } }, + { id: "unconfirmed_tx", title: "Sent, confirmation unreadable", view: { busy: true, phase: { k: "unconfirmed", tab: "deposit", amount: 250_000_000n, sent: { kind: "tx", hash }, checking: false, note: null } } }, + { id: "unconfirmed_batch", title: "Batch sent, still not confirmed", view: { busy: true, phase: { k: "unconfirmed", tab: "withdraw", amount: 500_000_000n, sent: { kind: "batch", id: "0x01" }, checking: false, note: "Still no confirmation. It can take a while when the network is busy; check again in a moment, or look at your wallet's activity." } } }, { id: "done_deposit", title: "Deposited", view: { phase: { k: "done", hash, tab: "deposit", amount: 250_000_000n } } }, { id: "withdraw_all", title: "Withdraw everything", view: { tab: "withdraw", ...deposited, text: "500", amount: 500_000_000n, all: true, problem: null } }, { id: "withdraw_part", title: "Withdraw part", view: { tab: "withdraw", ...deposited, ...typed("120", 120_000_000n) } }, diff --git a/app/src/components/earn/EarnDialog.module.css b/app/src/components/earn/EarnDialog.module.css index 4899f844..6ad10e73 100644 --- a/app/src/components/earn/EarnDialog.module.css +++ b/app/src/components/earn/EarnDialog.module.css @@ -49,6 +49,9 @@ .doneIcon { display: inline-flex; align-items: center; justify-content: center; height: 52px; width: 52px; border-radius: 50%; background: var(--color-up-soft); color: var(--color-up); } .done h3 { margin: 14px 0 8px; font-size: 20px; font-weight: 600; letter-spacing: -.025em; } .done p { color: var(--color-body); font-size: 13px; line-height: 1.65; } +.waitIcon { background: var(--color-warm-soft); color: var(--color-warm-ink); } +.actions { display: grid; gap: 12px; margin-top: 18px; } +.actions .externalAction, .actions .notice { margin: 0; } .externalAction { display: flex; align-items: center; justify-content: space-between; min-height: 46px; margin-top: 18px; padding: 0 14px; border: 1px solid var(--color-line-strong); border-radius: 10px; font-size: 13px; font-weight: 500; transition: background 160ms; } .secondary { display: flex; align-items: center; justify-content: center; min-height: 44px; width: 100%; margin-top: 10px; color: var(--color-brand); font-size: 13px; font-weight: 500; cursor: pointer; } diff --git a/app/src/components/earn/EarnPanel.tsx b/app/src/components/earn/EarnPanel.tsx index 9df66b51..bdf442a1 100644 --- a/app/src/components/earn/EarnPanel.tsx +++ b/app/src/components/earn/EarnPanel.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { ArrowRight, ArrowUpRight, Check, CircleAlert, LoaderCircle, PauseCircle, Wallet } from "lucide-react"; +import { ArrowRight, ArrowUpRight, Check, CircleAlert, Clock3, LoaderCircle, PauseCircle, Wallet } from "lucide-react"; import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; import { CHAIN_LABELS, explorerAddress, explorerTx } from "@/lib/chainPublic"; import { fmtAmount, fmtFeePct, fmtRate } from "@/lib/earn/math"; @@ -35,6 +35,29 @@ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: Earn const everything = !deposit && (e.all || (e.amount !== null && e.position !== undefined && e.position > 0n && e.amount >= e.position)); const vaultFee = snapshot ? snapshot.performanceFeePct : 0; + if (e.phase.k === "unconfirmed") { + const p = e.phase; + const what = p.tab === "deposit" ? "deposit" : "withdrawal"; + return ( +
+
+ +

Sent, not confirmed yet

+

Your wallet sent the {what} of {amt(p.amount)} {sym}, but its confirmation could not be read. It may still go through. Do not send it again until this is settled.

+
+
+ {p.sent.kind === "tx" ? View the transaction :

It was sent as one batch from your wallet; your wallet’s activity shows whether it went through.

} + {p.note ?

{p.note}

: null} + +
+

Closing this sheet keeps the check. Reloading the page forgets it, so look at your wallet’s activity before sending again.

+
+ ); + } + if (e.phase.k === "done") { const p = e.phase; return ( diff --git a/app/src/components/earn/earn-flow.test.ts b/app/src/components/earn/earn-flow.test.ts new file mode 100644 index 00000000..b25ebd5b --- /dev/null +++ b/app/src/components/earn/earn-flow.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; +import { zeroAddress, type Address } from "viem"; +import { BUILDER_DATA_SUFFIX } from "@/lib/chainPublic"; +import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; +import { EARN_VAULT_ABI } from "@/lib/earn/abi"; +import { EARN_BATCH_CAPABILITIES, approveCall, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "@/lib/earn/calls"; +import { fmtAmount, withdrawPlan } from "@/lib/earn/math"; +import { NOT_CONFIRMED_YET, isBusy, revertedCopy, type EarnPhase } from "@/lib/earn/phase"; +import { pausedCopy, termsCheck, type LiveTerms } from "@/lib/earn/terms"; +import { EARN_VAULTS } from "@/lib/earn/vaults"; + +/** + * Runs the real deposit / withdraw / recheck code from useEarn.ts (extracted from the source, like + * transaction-safety.test.ts) against inert wallets and RPCs: no real wallet, chain or transaction. + */ +const FLOW = ["ensureAccount", "walletFor", "batching", "requireTerms", "unconfirmed", "settleTx", "sendBatch", "finish", "deposit", "withdraw", "recheck"] as const; +type Flow = Record<(typeof FLOW)[number], (...args: unknown[]) => Promise>; + +function load(bindings: Record): Flow { + const source = readFileSync(new URL("./useEarn.ts", import.meta.url), "utf8"); + const ast = ts.createSourceFile("useEarn.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const found = new Map(); + const consts: string[] = []; + const visit = (node: ts.Node) => { + if (ts.isFunctionDeclaration(node) && node.name && (FLOW as readonly string[]).includes(node.name.text)) found.set(node.name.text, node.getText(ast)); + // the module's own message constants travel with the code + if (ts.isVariableStatement(node) && node.parent === ast && node.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && /^[A-Z_]+$/.test(d.name.text))) consts.push(node.getText(ast)); + ts.forEachChild(node, visit); + }; + visit(ast); + for (const name of FLOW) assert.ok(found.has(name), `useEarn.ts no longer has ${name}`); + const body = `(function () { ${consts.join("\n")}\n${[...found.values()].join("\n")}\n return { ${FLOW.join(", ")} }; })()`; + const { outputText } = ts.transpileModule(body, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }); + // same realm as the test (not node:vm), so the objects the flow builds compare with deepStrictEqual + const names = Object.keys(bindings); + return new Function(...names, `return ${outputText}`)(...names.map((n) => bindings[n])); +} + +const vault = EARN_VAULTS.base!; +const A: Address = "0x00000000000000000000000000000000000000a1"; +const B: Address = "0x00000000000000000000000000000000000000b2"; +const reviewed: LiveTerms = { asset: vault.asset, performanceFee: 0n, managementFee: 0n, gates: [zeroAddress, zeroAddress, zeroAddress, zeroAddress] }; +const feeRaised: LiveTerms = { ...reviewed, performanceFee: 100_000_000_000_000_000n }; +const timeout = () => Promise.reject(new Error("timed out waiting for the receipt")); + +/** A world of inert wallets and RPCs; every option changes one thing about it. */ +function world(o: { + allowance?: bigint; + batching?: boolean; + receipt?: () => Promise<{ status: string }>; + terms?: LiveTerms[]; + onRead?: (fn: string, w: { account: { value: Address }; mounted: { current: boolean } }) => void; + afterApproval?: (w: { account: { value: Address } }) => void; + phase?: EarnPhase; + getReceipt?: () => Promise<{ status: string }>; + callsStatus?: () => Promise; + waitForCalls?: () => Promise; + tab?: "deposit" | "withdraw"; +} = {}) { + const phases: EarnPhase[] = []; + const writes: { functionName?: string; account?: unknown }[] = []; + const walletRequests: unknown[] = []; + const batches: unknown[] = []; + const account = { value: A }; + const mounted = { current: true }; + const lock = { current: false }; + const termsQueue = [...(o.terms ?? [])]; + let refetches = 0; + const pub = { + readContract: async ({ address, functionName }: { address: string; functionName: string }) => { + o.onRead?.(functionName, { account, mounted }); + if (functionName === "allowance") return o.allowance ?? 10_000_000_000n; + if (functionName === "balanceOf") return address === vault.asset ? 10_000_000_000n : 957_000_000_000_000_000_000n; + throw new Error(`unexpected read ${functionName}`); + }, + simulateContract: async (args: { functionName: string }) => ({ request: { ...args } }), + waitForTransactionReceipt: async ({ hash }: { hash: string }) => { + if (hash === "0xapprove") { + o.afterApproval?.({ account }); + return { status: "success" }; + } + return (o.receipt ?? (async () => ({ status: "success" })))(); + }, + getTransactionReceipt: () => (o.getReceipt ?? timeout)(), + }; + const wallet = { + writeContract: async (req: { functionName?: string; account?: unknown }) => { + writes.push(req); + return req.functionName === "approve" ? "0xapprove" : "0xsent"; + }, + }; + const bindings = { + config: {}, chainId: 8453, vault, decimals: 6, lock, mounted, onChain: true, problem: null, busy: false, + address: A, amount: 250_000_000n, phase: o.phase ?? { k: "idle" }, + position: { shares: 957_000_000_000_000_000_000n, position: 1_000_000_000n, refetch: async () => {} }, + terms: { refetch: async () => { refetches++; } }, + switchChainAsync: async () => {}, + getPublicClient: () => pub, + getAccount: () => ({ address: account.value }), + getWalletClient: async (_config: unknown, opts: unknown) => { walletRequests.push(opts); return wallet; }, + getCapabilities: async (_config: unknown, opts: { account: Address }) => { + if (opts.account !== account.value) throw new Error("Account not found for connector"); + if (!o.batching) throw new Error("wallet_getCapabilities is not supported"); + return { atomic: { status: "supported" } }; + }, + sendCalls: async (_config: unknown, req: unknown) => { batches.push(req); return { id: "0xbatch" }; }, + waitForCallsStatus: () => (o.waitForCalls ?? (async () => ({ status: "success", receipts: [{ status: "success", transactionHash: "0xt" }] })))(), + getCallsStatus: () => (o.callsStatus ?? (async () => ({ status: "pending" })))(), + readTerms: async () => termsQueue.shift() ?? reviewed, + termsCheck, pausedCopy, approveCall, depositCall, redeemCall, withdrawCall, batchHash, canBatch, withdrawPlan, fmtAmount, + EARN_BATCH_CAPABILITIES, ERC20_MIN_ABI, EARN_VAULT_ABI, BUILDER_DATA_SUFFIX, NOT_CONFIRMED_YET, revertedCopy, + setPhase: (p: EarnPhase) => phases.push(p), setText: () => {}, setAll: () => {}, toast: () => {}, + friendlyError: (e: Error) => e.message, + }; + if (o.tab === "withdraw") Object.assign(bindings, { amount: 1_000_000_000n }); + const flow = load(bindings); + return { flow, phases, writes, walletRequests, batches, account, mounted, lock, refetches: () => refetches, last: () => phases.at(-1)!, rebind: (extra: Record) => load({ ...bindings, ...extra }) }; +} + +test("a deposit whose receipt cannot be read stays unconfirmed, and the form cannot send it again", async () => { + const w = world({ receipt: timeout }); + await w.flow.deposit(); + assert.deepEqual(w.phases.map((p) => p.k), ["preparing", "signing", "sent", "unconfirmed"]); + assert.deepEqual(w.last(), { k: "unconfirmed", tab: "deposit", amount: 250_000_000n, sent: { kind: "tx", hash: "0xsent" }, checking: false, note: null }); + assert.equal(w.lock.current, false); + assert.equal(isBusy(w.last()), true); + // the next render's flow sees busy = isBusy(phase): a second press sends nothing + await w.rebind({ busy: isBusy(w.last()) }).deposit(); + assert.equal(w.writes.length, 1, "exactly one deposit was sent"); +}); + +test("only a final reverted receipt allows sending again", async () => { + const w = world({ receipt: async () => ({ status: "reverted" }) }); + await w.flow.deposit(); + assert.deepEqual(w.last(), { k: "error", message: revertedCopy("deposit") }); + assert.equal(isBusy(w.last()), false); +}); + +test("Check again: no receipt keeps it unconfirmed with a note; a receipt settles it either way", async () => { + const pending: EarnPhase = { k: "unconfirmed", tab: "deposit", amount: 250_000_000n, sent: { kind: "tx", hash: "0xsent" }, checking: false, note: null }; + const still = world({ phase: pending }); + await still.flow.recheck(); + assert.deepEqual(still.phases.map((p) => p.k), ["unconfirmed", "unconfirmed"]); + assert.deepEqual(still.last(), { ...pending, note: NOT_CONFIRMED_YET }); + const ok = world({ phase: pending, getReceipt: async () => ({ status: "success" }) }); + await ok.flow.recheck(); + assert.deepEqual(ok.last(), { k: "done", hash: "0xsent", tab: "deposit", amount: 250_000_000n }); + const reverted = world({ phase: pending, getReceipt: async () => ({ status: "reverted" }) }); + await reverted.flow.recheck(); + assert.deepEqual(reverted.last(), { k: "error", message: revertedCopy("deposit") }); +}); + +test("a smart-wallet batch is one atomic, builder-tagged request; an unreadable status stays unconfirmed until the wallet's final answer", async () => { + const w = world({ batching: true, allowance: 0n, waitForCalls: timeout }); + await w.flow.deposit(); + assert.equal(w.batches.length, 1); + const req = w.batches[0] as { account: Address; calls: { to: Address }[]; capabilities: unknown; forceAtomic: boolean }; + assert.equal(req.account, A); + assert.deepEqual(req.calls.map((c) => c.to), [vault.asset, vault.address]); // approve, then deposit + assert.deepEqual(req.capabilities, { dataSuffix: { value: BUILDER_DATA_SUFFIX, optional: true } }); + assert.equal(req.forceAtomic, true); + assert.deepEqual(w.last(), { k: "unconfirmed", tab: "deposit", amount: 250_000_000n, sent: { kind: "batch", id: "0xbatch" }, checking: false, note: null }); + assert.equal(w.writes.length, 0); + const pending = w.last(); + for (const answer of [{ status: "pending" }, { status: undefined }, null]) { + const r = world({ phase: pending, callsStatus: async () => answer }); + await r.flow.recheck(); + assert.equal(r.last().k, "unconfirmed", JSON.stringify(answer)); + } + const done = world({ phase: pending, callsStatus: async () => ({ status: "success", receipts: [{ status: "success", transactionHash: "0xt" }] }) }); + await done.flow.recheck(); + assert.deepEqual(done.last(), { k: "done", hash: "0xt", tab: "deposit", amount: 250_000_000n }); + const failed = world({ phase: pending, callsStatus: async () => ({ status: "failure", receipts: [] }) }); + await failed.flow.recheck(); + assert.equal(failed.last().k, "error"); +}); + +test("every wallet prompt is bound to the account that started the deposit", async () => { + const w = world({ allowance: 0n }); + await w.flow.deposit(); + assert.equal(w.last().k, "done"); + for (const req of w.walletRequests) assert.deepEqual(req, { chainId: 8453, account: A }); + assert.deepEqual(w.writes.map((x) => [x.functionName, x.account]), [["approve", A], ["deposit", A]]); +}); + +test("a wallet switch during the first reads stops the deposit before any prompt, instead of falling back", async () => { + const w = world({ allowance: 0n, onRead: (fn, s) => { if (fn === "allowance") s.account.value = B; } }); + await w.flow.deposit(); + assert.equal(w.walletRequests.length, 0, "no wallet client was requested for the other account"); + assert.equal(w.writes.length, 0); + assert.equal(w.batches.length, 0); + assert.match((w.last() as { message: string }).message, /switched accounts/); +}); + +test("a wallet switch while the approval confirms stops before the deposit prompt", async () => { + const w = world({ allowance: 0n, afterApproval: (s) => { s.account.value = B; } }); + await w.flow.deposit(); + assert.deepEqual(w.writes.map((x) => x.functionName), ["approve"]); + assert.match((w.last() as { message: string }).message, /switched accounts/); +}); + +test("the vault's terms are read again after the approval confirms; a change stops the deposit", async () => { + const w = world({ allowance: 0n, terms: [reviewed, feeRaised] }); + await w.flow.deposit(); + assert.deepEqual(w.writes.map((x) => x.functionName), ["approve"], "no deposit prompt after the terms changed"); + assert.deepEqual(w.last(), { k: "error", message: pausedCopy("The vault now keeps 10% of the interest it earns.") }); + assert.equal(w.refetches(), 1, "the form is told to show the pause at once"); +}); + +test("terms changed before anything was sent: no prompt at all", async () => { + const w = world({ allowance: 0n, terms: [feeRaised] }); + await w.flow.deposit(); + assert.equal(w.writes.length, 0); + assert.equal(w.last().k, "error"); +}); + +test("a withdrawal whose receipt cannot be read stays unconfirmed too", async () => { + const w = world({ tab: "withdraw", receipt: timeout }); + await w.flow.withdraw(); + assert.deepEqual(w.writes.map((x) => [x.functionName, x.account]), [["redeem", A]]); + assert.deepEqual(w.last(), { k: "unconfirmed", tab: "withdraw", amount: 1_000_000_000n, sent: { kind: "tx", hash: "0xsent" }, checking: false, note: null }); +}); + +test("a card that unmounted mid-action stops before its next prompt", async () => { + const w = world({ onRead: (fn, s) => { if (fn === "allowance") s.mounted.current = false; } }); + await w.flow.deposit(); + assert.equal(w.writes.length, 0); + assert.match((w.last() as { message: string }).message, /closed/); +}); diff --git a/app/src/components/earn/earn-ui.test.ts b/app/src/components/earn/earn-ui.test.ts index 8fcc6455..0412012a 100644 --- a/app/src/components/earn/earn-ui.test.ts +++ b/app/src/components/earn/earn-ui.test.ts @@ -28,7 +28,7 @@ test("every wallet write is credited to the builder code", () => { test("approvals are exact: no unlimited allowance anywhere in Earn", () => { for (const src of allEarn) assert.doesNotMatch(src, /maxUint256|maxUint160|2n \*\* 256n/); - assert.match(hook, /functionName: "approve", args: \[vault\.address, amount\]/); + assert.match(hook, /functionName: "approve", args: \[vault\.address, value\]/); }); test("both actions lock before their first await and simulate before any signature", () => { @@ -39,18 +39,38 @@ test("both actions lock before their first await and simulate before any signatu assert.ok(lockAt < body.indexOf("await "), `${name} awaits before locking`); } const deposit = hook.slice(hook.indexOf("async function deposit()"), hook.indexOf("async function withdraw()")); - assert.ok(deposit.indexOf("termsCheck(vault, await readTerms(pub, vault))") < deposit.indexOf("sendBatch("), "terms are checked before any send"); + assert.ok(deposit.indexOf("await requireTerms(pub)") < deposit.indexOf("batching("), "terms are checked before any send"); + // and again once an approval has confirmed, right before the deposit is prepared + const afterApproval = deposit.slice(deposit.indexOf("functionName: \"approve\"")); + assert.ok(afterApproval.indexOf("await requireTerms(pub)") > 0 && afterApproval.indexOf("await requireTerms(pub)") < afterApproval.indexOf("simulateContract(")); assert.ok(deposit.indexOf("simulateContract(") < deposit.indexOf("writeContract(request)")); }); +test("every prompt is bound to the initiating account; an unreadable receipt is never a retryable failure", () => { + assert.match(hook, /getWalletClient\(config, \{ chainId, account \}\)/); + assert.match(hook, /wallet\.writeContract\(\{ account, address: vault\.asset/); + assert.doesNotMatch(hook, /getWalletClient\(config, \{ chainId \}\)/); + for (const name of ["walletFor", "batching", "sendBatch"]) { + const body = hook.slice(hook.indexOf(`function ${name}(`)); + assert.match(body.slice(0, 200), /ensureAccount\(account\)/, name); + } + // the deposit and withdrawal receipts go through settleTx, which turns a failed wait into "unconfirmed" + assert.equal((hook.match(/outcome = await settleTx\(pub, hash, /g) ?? []).length, 2); + assert.match(hook.slice(hook.indexOf("async function settleTx(")), /catch \{\s*setPhase\(unconfirmed\(tab, amount, \{ kind: "tx", hash \}\)\);/); + assert.match(hook.slice(hook.indexOf("async function sendBatch(")), /catch \{\s*setPhase\(unconfirmed\(tab, amount, \{ kind: "batch", id \}\)\);/); + assert.match(hook, /const busy = isBusy\(phase\);/); +}); + test("a deposit pause is derived from a polled read, never stored, so it cannot stick after the terms recover", () => { assert.doesNotMatch(hook, /setPaused|useState/); assert.match(hook, /useReadContracts\(\{[\s\S]*?query: \{ refetchInterval: 60_000 \}/); for (const fn of ["asset", "performanceFee", "managementFee", "receiveSharesGate", "sendSharesGate", "receiveAssetsGate", "sendAssetsGate"]) assert.match(hook, new RegExp(`functionName: "${fn}"`), fn); assert.match(panel, /const pauseReason = depositPause\(e\.liveTerms, snapshot\?\.terms \?\? null\);/); + // a failed latest poll is no live answer (wagmi keeps the last good data through a failed refetch) + assert.match(hook, /const liveTerms = liveCheck\(vault, terms\.data, terms\.isError\);/); // a failed send-time check refreshes the polled read so the form shows the pause at once - const deposit = hook.slice(hook.indexOf("async function deposit()"), hook.indexOf("async function withdraw()")); - assert.match(deposit, /if \(!check\.ok\) \{\s*void terms\.refetch\(\);[^\n]*\n\s*throw new Error\(pausedCopy\(check\.reason\)\);/); + const require = hook.slice(hook.indexOf("async function requireTerms(")); + assert.match(require, /if \(!check\.ok\) \{\s*void terms\.refetch\(\);[^\n]*\n\s*throw new Error\(pausedCopy\(check\.reason\)\);/); }); test("an upgrade-to-smart-account prompt is never triggered", () => { diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts index 5435a50a..fec7e105 100644 --- a/app/src/components/earn/useEarn.ts +++ b/app/src/components/earn/useEarn.ts @@ -1,20 +1,23 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useBalance, useConfig, useReadContract, useReadContracts, useSwitchChain } from "wagmi"; -import { getCapabilities, getPublicClient, getWalletClient, sendCalls, waitForCallsStatus } from "wagmi/actions"; -import type { Address, Hex } from "viem"; +import { getAccount, getCallsStatus, getCapabilities, getPublicClient, getWalletClient, sendCalls, waitForCallsStatus } from "wagmi/actions"; +import type { Address, Hex, PublicClient } from "viem"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { BUILDER_DATA_SUFFIX, CHAINS } from "@/lib/chainPublic"; import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { EARN_VAULT_ABI } from "@/lib/earn/abi"; -import { EARN_BATCH_CAPABILITIES, approveCall, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; +import { EARN_BATCH_CAPABILITIES, approveCall, batchHash, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; import { cleanAmountInput, depositProblem, fmtAmount, maxInput, parseAmount, withdrawPlan, withdrawProblem } from "@/lib/earn/math"; -import { pausedCopy, readTerms, termsCheck, type TermsCheck } from "@/lib/earn/terms"; +import { NOT_CONFIRMED_YET, isBusy, revertedCopy, type EarnOutcome, type EarnPhase, type EarnSent, type EarnTab } from "@/lib/earn/phase"; +import { liveCheck, pausedCopy, readTerms, termsCheck } from "@/lib/earn/terms"; import type { EarnVault } from "@/lib/earn/vaults"; import { friendlyError } from "@/lib/errors"; import { toast } from "@/components/launchpad/TxToasts"; +export type { EarnPhase, EarnTab } from "@/lib/earn/phase"; + /** * Earn's state and its two actions, for one vault and the connected wallet. * @@ -23,16 +26,16 @@ import { toast } from "@/components/launchpad/TxToasts"; * simulation before every signature, then the receipt. Every write is credited to openlaunch's builder code * (lib/earn/calls.ts): one-at-a-time wallets via `dataSuffix`, batching smart wallets via `sendCalls` with the suffix on * each call and as the `dataSuffix` capability. Approvals are for the exact amount, never unlimited. + * + * Every wallet prompt is bound to the account that started the action and preceded by a check that it is still the + * connected one. Once something is sent, only a final answer ends the action: a receipt that cannot be read leaves it + * "unconfirmed" (lib/earn/phase.ts) with a "Check again", never retryable. */ -export type EarnTab = "deposit" | "withdraw"; -export type EarnPhase = - | { k: "idle" } - | { k: "preparing" } - | { k: "approving" } - | { k: "signing" } - | { k: "sent"; hash: Hex | null } - | { k: "done"; hash: Hex | null; tab: EarnTab; amount: bigint } - | { k: "error"; message: string }; + +/** A wallet switch mid-action: stop before the next prompt, whatever the new account is. */ +const ACCOUNT_CHANGED = "Your wallet switched accounts, so this stopped before anything else was sent. Start again from the account you want to use."; +/** The card went away mid-action (the page changed): stop before the next prompt. */ +const FLOW_STOPPED = "Stopped: the Earn card closed before the next step."; /** The wallet's USDC, its vault shares and what they redeem for now. Shared by the /me card and the dialog. */ export function useEarnPosition(vault: EarnVault, address: Address | undefined) { @@ -68,6 +71,14 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { const [all, setAll] = useState(false); const [phase, setPhase] = useState({ k: "idle" }); const lock = useRef(false); + // false once the card unmounts (wallet switched, page left): an action in flight stops before its next wallet prompt + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); // The vault's terms, re-read every minute while the card is open (one multicall). The pause is derived from this on // every render (lib/earn/terms.ts depositPause), never stored, so it lifts by itself when the terms are back. const at = { address: vault.address, abi: EARN_VAULT_ABI, chainId } as const; @@ -84,13 +95,13 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { allowFailure: false, query: { refetchInterval: 60_000 }, }); - const t = terms.data; - const liveTerms: TermsCheck | null = t ? termsCheck(vault, { asset: t[0], performanceFee: t[1], managementFee: t[2], gates: [t[3], t[4], t[5], t[6]] }) : null; + // a failed latest poll means no live answer: the server's check stands instead of an older live one + const liveTerms = liveCheck(vault, terms.data, terms.isError); const decimals = vault.assetDecimals; const typed = parseAmount(text, decimals); const amount = tab === "withdraw" && all ? (position.position ?? null) : typed; - const busy = phase.k === "preparing" || phase.k === "approving" || phase.k === "signing" || phase.k === "sent"; + const busy = isBusy(phase); const onChain = walletChain === chainId; const problem = tab === "deposit" ? depositProblem({ amount, balance: position.balance }) : withdrawProblem({ amount, shares: position.shares, position: position.position }); const needsApproval = tab === "deposit" && amount !== null && position.allowance !== undefined ? position.allowance < amount : null; @@ -130,69 +141,132 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { } } - /** Whether this wallet already executes atomic batches; any doubt means the one-at-a-time path. */ + /** + * Throws unless the account that started the action is still the connected one and the card is still mounted. Runs + * before every wallet prompt, so a switch to another account mid-action never sends that account a prompt meant for + * this one (the keyed remount on a switch does not cancel an async action already running). + */ + function ensureAccount(account: Address) { + if (!mounted.current) throw new Error(FLOW_STOPPED); + const current = getAccount(config).address; + if (!current || current.toLowerCase() !== account.toLowerCase()) throw new Error(ACCOUNT_CHANGED); + } + + /** The wallet client for exactly this account (wagmi refuses if the connector no longer has it). */ + async function walletFor(account: Address) { + ensureAccount(account); + return getWalletClient(config, { chainId, account }); + } + + /** + * Whether this wallet already executes atomic batches; an unreadable answer means the one-at-a-time path. A changed + * account is not an unreadable answer: it stops the action. + */ async function batching(account: Address): Promise { + ensureAccount(account); try { return canBatch(await getCapabilities(config, { account, chainId })); } catch { + ensureAccount(account); return false; } } - /** Sends through the wallet's batch API with the builder-code capability; resolves to the last transaction hash. */ - async function sendBatch(account: Address, calls: EarnCall[]): Promise { + /** Stops a deposit when the live vault differs from the reviewed terms; the form shows the pause at once. */ + async function requireTerms(pub: Pick) { + const check = termsCheck(vault, await readTerms(pub, vault)); + if (!check.ok) { + void terms.refetch(); // the form shows the pause now, not at the next poll + throw new Error(pausedCopy(check.reason)); + } + } + + /** The state for a send whose outcome could not be read. */ + function unconfirmed(tab: EarnTab, amount: bigint, sent: EarnSent): EarnPhase { + return { k: "unconfirmed", tab, amount, sent, checking: false, note: null }; + } + + /** Waits for a sent transaction. A receipt decides; a timeout or RPC error leaves it unconfirmed, not failed. */ + async function settleTx(pub: Pick, hash: Hex, tab: EarnTab, amount: bigint): Promise { + let rc; + try { + rc = await pub.waitForTransactionReceipt({ hash }); + } catch { + setPhase(unconfirmed(tab, amount, { kind: "tx", hash })); + return { confirmed: false }; + } + if (rc.status !== "success") throw new Error(revertedCopy(tab)); + return { confirmed: true, hash }; + } + + /** Sends through the wallet's batch API with the builder-code capability, then waits for the wallet's final status. */ + async function sendBatch(account: Address, calls: EarnCall[], tab: EarnTab, amount: bigint): Promise { + ensureAccount(account); setPhase({ k: "signing" }); const { id } = await sendCalls(config, { account, chainId, calls, capabilities: EARN_BATCH_CAPABILITIES, forceAtomic: true }); setPhase({ k: "sent", hash: null }); - const result = await waitForCallsStatus(config, { id, timeout: 180_000 }); - const receipts = result.receipts ?? []; - if (result.status !== "success" || receipts.some((r) => r.status !== "success")) throw new Error("Your wallet reports the transaction failed. Check its activity before trying again."); - return receipts.at(-1)?.transactionHash ?? null; + let result; + try { + result = await waitForCallsStatus(config, { id, timeout: 180_000 }); + } catch { + setPhase(unconfirmed(tab, amount, { kind: "batch", id })); + return { confirmed: false }; + } + return { confirmed: true, hash: batchHash(result) }; } - /** Deposits the amount: fresh terms and balance checks, an exact approval only if short, simulate, send, receipt. */ + /** A confirmed action: the done state, a toast, a clean form and fresh balances. */ + function finish(tab: EarnTab, amount: bigint, hash: Hex | null) { + setPhase({ k: "done", hash, tab, amount }); + toast({ kind: "info", title: `${tab === "deposit" ? "Deposited" : "Withdrew"} ${fmtAmount(amount, decimals)} ${vault.assetSymbol}`, sub: `${tab === "deposit" ? "Into" : "From"} ${vault.name}` }); + setText(""); + setAll(false); + void position.refetch(); + } + + /** Deposits the amount: fresh terms and balance checks, an exact approval only if short, terms again, simulate, send. */ async function deposit() { if (!address || amount === null || problem || busy || lock.current) return; // Lock before the first await, including the network switch and the vault check. lock.current = true; + const account = address; + const value = amount; setPhase({ k: "preparing" }); try { if (!onChain) await switchChainAsync({ chainId }); const pub = getPublicClient(config, { chainId })!; // authoritative: read fresh at send time, whatever the polled terms said - const check = termsCheck(vault, await readTerms(pub, vault)); - if (!check.ok) { - void terms.refetch(); // the form shows the pause now, not at the next poll - throw new Error(pausedCopy(check.reason)); - } + await requireTerms(pub); const [balance, allowance] = await Promise.all([ - pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: [address] }), - pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "allowance", args: [address, vault.address] }), + pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: [account] }), + pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "allowance", args: [account, vault.address] }), ]); - if (amount > balance) throw new Error("That is more USDC than this wallet holds."); - let hash: Hex | null; - if (await batching(address)) { + if (value > balance) throw new Error("That is more USDC than this wallet holds."); + let outcome: EarnOutcome; + if (await batching(account)) { // one prompt; atomic, so a failed deposit takes the approval back with it - hash = await sendBatch(address, allowance < amount ? [approveCall(vault, amount), depositCall(vault, amount, address)] : [depositCall(vault, amount, address)]); + outcome = await sendBatch(account, allowance < value ? [approveCall(vault, value), depositCall(vault, value, account)] : [depositCall(vault, value, account)], "deposit", value); } else { - const wallet = await getWalletClient(config, { chainId }); - if (allowance < amount) { + if (allowance < value) { + const wallet = await walletFor(account); setPhase({ k: "approving" }); - const approval = await wallet.writeContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "approve", args: [vault.address, amount], dataSuffix: BUILDER_DATA_SUFFIX }); - const rc = await pub.waitForTransactionReceipt({ hash: approval }); + const approval = await wallet.writeContract({ account, address: vault.asset, abi: ERC20_MIN_ABI, functionName: "approve", args: [vault.address, value], dataSuffix: BUILDER_DATA_SUFFIX }); + // approving again is harmless (it sets the same exact allowance), so an unreadable approval may be retried + const rc = await pub.waitForTransactionReceipt({ hash: approval }).catch(() => { + throw new Error("The approval's confirmation could not be read. Nothing was deposited; try again (an approval that went through is reused)."); + }); if (rc.status !== "success") throw new Error("The approval reverted on-chain. Nothing was deposited."); + // the vault could have changed while the approval confirmed + await requireTerms(pub); } - const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "deposit", args: [amount, address], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "deposit", args: [value, account], account, dataSuffix: BUILDER_DATA_SUFFIX }); + const wallet = await walletFor(account); setPhase({ k: "signing" }); - hash = await wallet.writeContract(request); + const hash = await wallet.writeContract(request); setPhase({ k: "sent", hash }); - const rc = await pub.waitForTransactionReceipt({ hash }); - if (rc.status !== "success") throw new Error("The deposit reverted on-chain."); + outcome = await settleTx(pub, hash, "deposit", value); } - setPhase({ k: "done", hash, tab: "deposit", amount }); - toast({ kind: "info", title: `Deposited ${fmtAmount(amount, decimals)} ${vault.assetSymbol}`, sub: `Into ${vault.name}` }); - setText(""); - void position.refetch(); + if (outcome.confirmed) finish("deposit", value, outcome.hash); } catch (err) { setPhase({ k: "error", message: friendlyError(err) }); } finally { @@ -205,37 +279,60 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { const plan = withdrawPlan({ amount, shares: position.shares, position: position.position }); if (!address || !plan || problem || busy || lock.current) return; lock.current = true; + const account = address; setPhase({ k: "preparing" }); try { if (!onChain) await switchChainAsync({ chainId }); const pub = getPublicClient(config, { chainId })!; // "withdraw all" redeems the shares held at this moment, not the count from the last poll - const held = await pub.readContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "balanceOf", args: [address] }); + const held = await pub.readContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "balanceOf", args: [account] }); if (held <= 0n) throw new Error("Nothing is deposited from this wallet."); - const call = plan.fn === "redeem" ? redeemCall(vault, held, address) : withdrawCall(vault, plan.assets, address); - let hash: Hex | null; - if (await batching(address)) { - hash = await sendBatch(address, [call]); + let outcome: EarnOutcome; + if (await batching(account)) { + outcome = await sendBatch(account, [plan.fn === "redeem" ? redeemCall(vault, held, account) : withdrawCall(vault, plan.assets, account)], "withdraw", plan.assets); } else { - const wallet = await getWalletClient(config, { chainId }); + let hash: Hex; if (plan.fn === "redeem") { - const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "redeem", args: [held, address, address], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "redeem", args: [held, account, account], account, dataSuffix: BUILDER_DATA_SUFFIX }); + const wallet = await walletFor(account); setPhase({ k: "signing" }); hash = await wallet.writeContract(request); } else { - const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "withdraw", args: [plan.assets, address, address], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + const { request } = await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "withdraw", args: [plan.assets, account, account], account, dataSuffix: BUILDER_DATA_SUFFIX }); + const wallet = await walletFor(account); setPhase({ k: "signing" }); hash = await wallet.writeContract(request); } setPhase({ k: "sent", hash }); - const rc = await pub.waitForTransactionReceipt({ hash }); - if (rc.status !== "success") throw new Error("The withdrawal reverted on-chain."); + outcome = await settleTx(pub, hash, "withdraw", plan.assets); + } + if (outcome.confirmed) finish("withdraw", plan.assets, outcome.hash); + } catch (err) { + setPhase({ k: "error", message: friendlyError(err) }); + } finally { + lock.current = false; + } + } + + /** "Check again" on an unconfirmed send: only a receipt, or the wallet's final batch status, ends it. */ + async function recheck() { + if (phase.k !== "unconfirmed" || phase.checking || lock.current) return; + const p = phase; + lock.current = true; + setPhase({ ...p, checking: true, note: null }); + try { + if (p.sent.kind === "tx") { + const pub = getPublicClient(config, { chainId })!; + const rc = await pub.getTransactionReceipt({ hash: p.sent.hash }).catch(() => null); + if (!rc) setPhase({ ...p, checking: false, note: NOT_CONFIRMED_YET }); + else if (rc.status !== "success") setPhase({ k: "error", message: revertedCopy(p.tab) }); + else finish(p.tab, p.amount, p.sent.hash); + } else { + const status = await getCallsStatus(config, { id: p.sent.id }).catch(() => null); + // only "success" or "failure" is final; no answer, "pending" or anything else keeps it unconfirmed + if (status?.status !== "success" && status?.status !== "failure") setPhase({ ...p, checking: false, note: NOT_CONFIRMED_YET }); + else finish(p.tab, p.amount, batchHash(status)); } - setPhase({ k: "done", hash, tab: "withdraw", amount: plan.assets }); - toast({ kind: "info", title: `Withdrew ${plan.fn === "redeem" ? "everything" : `${fmtAmount(plan.assets, decimals)} ${vault.assetSymbol}`}`, sub: `From ${vault.name}` }); - setText(""); - setAll(false); - void position.refetch(); } catch (err) { setPhase({ k: "error", message: friendlyError(err) }); } finally { @@ -258,11 +355,12 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { amount, problem, needsApproval, - /** The live vault against the reviewed terms; null until the first read lands (or if it fails). */ + /** The live vault against the reviewed terms; null until the first read lands, or while the latest read failed. */ liveTerms, phase, busy, reset: () => { if (!busy) setPhase({ k: "idle" }); }, + recheck, balance: position.balance, shares: position.shares, position: position.position, diff --git a/app/src/lib/earn/calls.test.ts b/app/src/lib/earn/calls.test.ts index e096efe4..3b9aa6f4 100644 --- a/app/src/lib/earn/calls.test.ts +++ b/app/src/lib/earn/calls.test.ts @@ -5,7 +5,7 @@ import { concatHex, decodeFunctionData, encodeFunctionData, getAddress, maxUint2 import { BUILDER_CODE, BUILDER_DATA_SUFFIX } from "../chainPublic.ts"; import { ERC20_MIN_ABI } from "../launchpad/abi.ts"; import { EARN_VAULT_ABI } from "./abi.ts"; -import { EARN_BATCH_CAPABILITIES, approveCall, canBatch, depositCall, redeemCall, withdrawCall } from "./calls.ts"; +import { EARN_BATCH_CAPABILITIES, approveCall, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "./calls.ts"; import { EARN_VAULTS } from "./vaults.ts"; const vault = EARN_VAULTS.base!; @@ -48,6 +48,13 @@ test("a batching wallet is asked to tag its own operation too, and may decline", assert.deepEqual(EARN_BATCH_CAPABILITIES, { dataSuffix: { value: BUILDER_DATA_SUFFIX, optional: true } }); }); +test("a batch counts as done only when the wallet reports success for every call", () => { + assert.equal(batchHash({ status: "success", receipts: [{ status: "success", transactionHash: "0x01" }, { status: "success", transactionHash: "0x02" }] }), "0x02"); + assert.equal(batchHash({ status: "success", receipts: [] }), null); + assert.throws(() => batchHash({ status: "failure", receipts: [] }), /failed/); + assert.throws(() => batchHash({ status: "success", receipts: [{ status: "reverted", transactionHash: "0x01" }] }), /failed/); +}); + test("only wallets that already batch take the batch path; an EIP-7702 upgrade prompt is never triggered", () => { assert.equal(canBatch({ atomic: { status: "supported" } }), true); assert.equal(canBatch({ atomicBatch: { supported: true } }), true); diff --git a/app/src/lib/earn/calls.ts b/app/src/lib/earn/calls.ts index 5d65a4ac..2b6d16fe 100644 --- a/app/src/lib/earn/calls.ts +++ b/app/src/lib/earn/calls.ts @@ -38,6 +38,16 @@ export function redeemCall(vault: EarnVault, shares: bigint, owner: Address): Ea return { to: vault.address, data: tagged(encodeFunctionData({ abi: EARN_VAULT_ABI, functionName: "redeem", args: [shares, owner, owner] })) }; } +/** + * A batch's final status as the transaction hash to show (the last receipt's; null when the wallet reports none). + * Anything but success with every receipt successful throws: that is a final failure, after which sending again is safe. + */ +export function batchHash(result: { status?: string; receipts?: readonly { status?: string; transactionHash?: Hex }[] }): Hex | null { + const receipts = result.receipts ?? []; + if (result.status !== "success" || receipts.some((r) => r.status !== "success")) throw new Error("Your wallet reports the transaction failed. Nothing moved; check its activity before trying again."); + return receipts.at(-1)?.transactionHash ?? null; +} + /** The EIP-5792 capability that asks a batching wallet to tag its own operation with the builder code. */ export const EARN_BATCH_CAPABILITIES = { dataSuffix: { value: BUILDER_DATA_SUFFIX, optional: true } } as const; diff --git a/app/src/lib/earn/phase.test.ts b/app/src/lib/earn/phase.test.ts new file mode 100644 index 00000000..5d4655b6 --- /dev/null +++ b/app/src/lib/earn/phase.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isBusy, revertedCopy, type EarnPhase } from "./phase.ts"; + +test("a send whose outcome is unknown keeps the form busy; only final states free it", () => { + const busy: EarnPhase[] = [ + { k: "preparing" }, { k: "approving" }, { k: "signing" }, { k: "sent", hash: null }, + { k: "unconfirmed", tab: "deposit", amount: 1n, sent: { kind: "tx", hash: "0x01" }, checking: false, note: null }, + ]; + for (const p of busy) assert.equal(isBusy(p), true, p.k); + for (const p of [{ k: "idle" }, { k: "error", message: "x" }, { k: "done", hash: null, tab: "deposit", amount: 1n }] as EarnPhase[]) assert.equal(isBusy(p), false, p.k); +}); + +test("a reverted receipt says nothing moved", () => { + assert.equal(revertedCopy("deposit"), "The deposit reverted on-chain. Nothing moved; network gas was still charged."); + assert.match(revertedCopy("withdraw"), /^The withdrawal reverted/); +}); diff --git a/app/src/lib/earn/phase.ts b/app/src/lib/earn/phase.ts new file mode 100644 index 00000000..3778a474 --- /dev/null +++ b/app/src/lib/earn/phase.ts @@ -0,0 +1,36 @@ +import type { Hex } from "viem"; + +/** + * Where an Earn action stands (pure; node --test loads it directly). + * + * "unconfirmed" is the state after the wallet has sent something (a transaction hash, or a batch id) but its outcome + * could not be read: a receipt timeout or an RPC error is not evidence that it failed. The form stays locked, the + * amount is kept out of reach, and only a final answer (a receipt, or the wallet's final batch status) ends it. + */ +export type EarnTab = "deposit" | "withdraw"; +export type EarnSent = { kind: "tx"; hash: Hex } | { kind: "batch"; id: string }; +export type EarnPhase = + | { k: "idle" } + | { k: "preparing" } + | { k: "approving" } + | { k: "signing" } + | { k: "sent"; hash: Hex | null } + | { k: "unconfirmed"; tab: EarnTab; amount: bigint; sent: EarnSent; checking: boolean; note: string | null } + | { k: "done"; hash: Hex | null; tab: EarnTab; amount: bigint } + | { k: "error"; message: string }; + +/** Whether a new deposit or withdrawal must wait: anything in flight, including a send whose outcome is not known. */ +export function isBusy(phase: EarnPhase): boolean { + return phase.k === "preparing" || phase.k === "approving" || phase.k === "signing" || phase.k === "sent" || phase.k === "unconfirmed"; +} + +/** What an action's last step came to: confirmed with its hash (null when a batch reports none), or not known yet. */ +export type EarnOutcome = { confirmed: true; hash: Hex | null } | { confirmed: false }; + +/** "Check again" found no final answer yet. */ +export const NOT_CONFIRMED_YET = "Still no confirmation. It can take a while when the network is busy; check again in a moment, or look at your wallet's activity."; + +/** A final, reverted receipt: the one failure after sending that allows sending again. */ +export function revertedCopy(tab: EarnTab): string { + return `The ${tab === "deposit" ? "deposit" : "withdrawal"} reverted on-chain. Nothing moved; network gas was still charged.`; +} diff --git a/app/src/lib/earn/terms.test.ts b/app/src/lib/earn/terms.test.ts index 64bd621a..5a9073a3 100644 --- a/app/src/lib/earn/terms.test.ts +++ b/app/src/lib/earn/terms.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { zeroAddress } from "viem"; -import { depositPause, pausedCopy, termsCheck, type LiveTerms } from "./terms.ts"; +import { depositPause, liveCheck, pausedCopy, termsCheck, type LiveTerms } from "./terms.ts"; import { EARN_VAULTS } from "./vaults.ts"; const vault = EARN_VAULTS.base!; @@ -39,6 +39,20 @@ test("the pause follows the freshest read, so it lifts once the vault is back to assert.equal(depositPause(null, null), null); }); +test("a failed latest poll gives no live answer, even though wagmi keeps the last good data", () => { + const z = zeroAddress; + const good = [vault.asset, 0n, 0n, z, z, z, z] as const; + const feeRaised = [vault.asset, 100_000_000_000_000_000n, 0n, z, z, z, z] as const; + assert.equal(liveCheck(vault, undefined, false), null); + assert.deepEqual(liveCheck(vault, good, false), { ok: true }); + assert.equal(liveCheck(vault, good, true), null); + assert.equal(liveCheck(vault, feeRaised, false)?.ok, false); + // so the server's check decides while the live read is failing, in both directions + const changed = { ok: false as const, reason: "The vault now restricts who may use it." }; + assert.equal(depositPause(liveCheck(vault, good, true), changed), changed.reason); + assert.equal(depositPause(liveCheck(vault, feeRaised, true), { ok: true }), null); +}); + test("the paused notice says withdrawals stay open", () => { assert.match(pausedCopy("The vault now restricts who may use it."), /Withdrawals stay open\.$/); }); diff --git a/app/src/lib/earn/terms.ts b/app/src/lib/earn/terms.ts index 59a6ed96..2d1edbd6 100644 --- a/app/src/lib/earn/terms.ts +++ b/app/src/lib/earn/terms.ts @@ -33,6 +33,19 @@ export async function readTerms(client: Pick, vaul return { asset, performanceFee, managementFee, gates }; } +/** The seven polled reads, in the order the card asks for them. */ +export type PolledTerms = readonly [Address, bigint, bigint, Address, Address, Address, Address]; + +/** + * The browser's polled read as a terms check, or null when there is none to trust: not read yet, or the latest poll + * failed. A failed refetch keeps the last good data around (wagmi documents `data` as the last success), and that older + * read must not outrank the server's check. + */ +export function liveCheck(vault: EarnVault, data: PolledTerms | undefined, failed: boolean): TermsCheck | null { + if (!data || failed) return null; + return termsCheck(vault, { asset: data[0], performanceFee: data[1], managementFee: data[2], gates: [data[3], data[4], data[5], data[6]] }); +} + /** * Why deposits are paused right now, or null. The browser's own read of the vault (re-read every minute while the card * is open) wins over the server's figures, which can be up to 10 minutes old; until it arrives the server's stand. diff --git a/docs/earn.md b/docs/earn.md index f602a8f2..4d15138c 100644 --- a/docs/earn.md +++ b/docs/earn.md @@ -24,7 +24,10 @@ Base's Dashboard reports lending TVL per builder code. Check a real transaction ## Safety rules - **Exact approvals only.** No unlimited allowance exists anywhere in the feature (enforced by `components/earn/earn-ui.test.ts`). An unspent exact approval remains on-chain until used or revoked. -- **Terms tripwire.** A vault's owner and curator can change fees and gates, some with no delay. Before every deposit the browser reads the vault's asset, performance fee, management fee and four gates. If any differ from the reviewed terms (a fee above 0, a gate set, a different asset), the deposit is refused with the reason. The form also shows "Deposits paused" before anyone tries: the open card re-reads the terms every minute (the server's 10-minute figures stand until that first read lands). The pause is derived from those reads, never stored, so it lifts by itself once the vault is back to the reviewed terms. **Withdrawals never depend on it.** +- **Terms tripwire.** A vault's owner and curator can change fees and gates, some with no delay. Before every deposit the browser reads the vault's asset, performance fee, management fee and four gates. If any differ from the reviewed terms (a fee above 0, a gate set, a different asset), the deposit is refused with the reason. The form also shows "Deposits paused" before anyone tries: the open card re-reads the terms every minute (the server's 10-minute figures stand until that first read lands, and whenever the latest read failed). The pause is derived from those reads, never stored, so it lifts by itself once the vault is back to the reviewed terms. **Withdrawals never depend on it.** +- **Sent means sent.** Once the wallet returns a transaction hash (or a batch id), only a final answer ends the action: a successful receipt (done) or a reverted one (an error, after which sending again is safe). A receipt timeout or RPC error leaves it **unconfirmed**: the form stays locked with the amount out of reach, the transaction link is shown, and "Check again" asks the chain (or the wallet, for a batch) once more. Closing the sheet keeps this; reloading the page forgets it, so the copy says to look at the wallet's activity before sending again. An approval whose receipt cannot be read is the one exception that may be retried, because approving again sets the same exact allowance. +- **One account per action.** Every wallet prompt is bound to the account that started the action (`getWalletClient({ account })`, `account` on each write) and preceded by a check that it is still the connected account. A switch mid-action stops it before the next prompt, rather than falling back to another path; so does leaving the page. +- **Terms at every step.** The terms are read again after an approval confirms, right before the deposit is prepared, closing the approval window. They can still change after the deposit is signed; nothing here can prevent that. - **Liquidity.** The vault pays withdrawals from what its markets have free. A withdrawal larger than that reverts in simulation and the panel says so; nothing is sent. - **Copy.** The rate is labelled "Last 30 days · Annualised. Variable." The risk line is always visible: deposits can lose money; openlaunch never holds the funds, cannot move them and cannot guarantee a withdrawal; the vault is run by Steakhouse Financial, not openlaunch. No Base or Coinbase marks, no yield promises. @@ -38,7 +41,9 @@ Base's Dashboard reports lending TVL per builder code. Check a real transaction From `app/`: `npm test`, `npm run lint`, `npm run typecheck`, `npm run build`. Set `EARN=on` in `.env.local` to see the card locally. -`/ui-review-earn` is a development-only fixture of the card and every panel state (disconnected, first deposit with approval, approved deposit, over balance, wrong network, approving, confirming, done, withdraw all, withdraw part, liquidity error, paused terms, rate unavailable). `?scene=` shows one. The route returns 404 outside development. +`/ui-review-earn` is a development-only fixture of the card and every panel state (disconnected, first deposit with approval, approved deposit, over balance, wrong network, approving, confirming, unconfirmed transaction and batch, done, withdraw all, withdraw part, liquidity error, paused terms, rate unavailable). + +`components/earn/earn-flow.test.ts` runs the real `deposit`, `withdraw` and `recheck` code from `useEarn.ts` against inert wallets and RPCs: a receipt timeout leaves the action unconfirmed and a second press sends nothing; a wallet switch at either step stops before the next prompt; a terms change while the approval confirms stops the deposit; a batch is one atomic, builder-tagged request and only the wallet's final status ends it. `?scene=` shows one. The route returns 404 outside development. The opt-in live read test checks the pinned vault on Base mainnet (factory proof, asset, fees, gates, a deposit preview, the snapshot). It sends nothing: From b258b353630dc9a906437e07592f8488f5a5cf76 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 19:35:15 +0800 Subject: [PATCH 5/9] fix(earn): simulate a smart wallet's whole batch before its one prompt CodeRabbit (full review): the batch path called sendCalls with no simulation, although every other send is simulated first, and EIP-5792 does not require the wallet to simulate. Simulating the deposit on its own would fail for the wrong reason, since its approval only exists inside the batch. The [approve, deposit] batch is now simulated the way it will run: the approval, then the deposit with the approval's only effect (the allowance) applied as a state override on USDC (Circle FiatToken v2.2, `allowed` at slot 10; the opt-in live test proves the override lands on Base). The override is trusted only after a read shows it took; otherwise the wallet gets the one-at-a-time path, where each call is simulated on its own. A deposit that reverts with the allowance in place stops before any prompt. A withdrawal batch is one call and is simulated first. Keeping the batch (rather than always using the one-at-a-time path when an approval is needed, as suggested) keeps one prompt and the builder-code capability for smart-wallet deposits: approvals are exact, so nearly every deposit needs one. --- app/src/components/earn/earn-flow.test.ts | 63 ++++++++++++++++++++--- app/src/components/earn/earn-ui.test.ts | 16 +++++- app/src/components/earn/useEarn.ts | 35 +++++++++++-- app/src/lib/earn/calls.test.ts | 12 ++++- app/src/lib/earn/calls.ts | 18 ++++++- app/src/lib/earn/vault.live.test.ts | 5 ++ app/src/lib/earn/vaults.ts | 8 +++ docs/earn.md | 4 +- 8 files changed, 144 insertions(+), 17 deletions(-) diff --git a/app/src/components/earn/earn-flow.test.ts b/app/src/components/earn/earn-flow.test.ts index b25ebd5b..5db9e357 100644 --- a/app/src/components/earn/earn-flow.test.ts +++ b/app/src/components/earn/earn-flow.test.ts @@ -6,7 +6,7 @@ import { zeroAddress, type Address } from "viem"; import { BUILDER_DATA_SUFFIX } from "@/lib/chainPublic"; import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { EARN_VAULT_ABI } from "@/lib/earn/abi"; -import { EARN_BATCH_CAPABILITIES, approveCall, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "@/lib/earn/calls"; +import { EARN_BATCH_CAPABILITIES, approveCall, approvedState, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "@/lib/earn/calls"; import { fmtAmount, withdrawPlan } from "@/lib/earn/math"; import { NOT_CONFIRMED_YET, isBusy, revertedCopy, type EarnPhase } from "@/lib/earn/phase"; import { pausedCopy, termsCheck, type LiveTerms } from "@/lib/earn/terms"; @@ -16,7 +16,7 @@ import { EARN_VAULTS } from "@/lib/earn/vaults"; * Runs the real deposit / withdraw / recheck code from useEarn.ts (extracted from the source, like * transaction-safety.test.ts) against inert wallets and RPCs: no real wallet, chain or transaction. */ -const FLOW = ["ensureAccount", "walletFor", "batching", "requireTerms", "unconfirmed", "settleTx", "sendBatch", "finish", "deposit", "withdraw", "recheck"] as const; +const FLOW = ["ensureAccount", "walletFor", "batching", "requireTerms", "preflightBatch", "unconfirmed", "settleTx", "sendBatch", "finish", "deposit", "withdraw", "recheck"] as const; type Flow = Record<(typeof FLOW)[number], (...args: unknown[]) => Promise>; function load(bindings: Record): Flow { @@ -59,8 +59,13 @@ function world(o: { callsStatus?: () => Promise; waitForCalls?: () => Promise; tab?: "deposit" | "withdraw"; + /** false: the node ignores the allowance override (as if the token's storage moved) */ + overrideTakes?: boolean; + /** which simulations revert, by function name and whether the allowance override was applied */ + reverts?: (fn: string, withOverride: boolean) => boolean; } = {}) { const phases: EarnPhase[] = []; + const events: string[] = []; const writes: { functionName?: string; account?: unknown }[] = []; const walletRequests: unknown[] = []; const batches: unknown[] = []; @@ -70,13 +75,18 @@ function world(o: { const termsQueue = [...(o.terms ?? [])]; let refetches = 0; const pub = { - readContract: async ({ address, functionName }: { address: string; functionName: string }) => { + readContract: async ({ address, functionName, stateOverride }: { address: string; functionName: string; stateOverride?: { stateDiff: { value: string }[] }[] }) => { + if (stateOverride) return o.overrideTakes === false ? 0n : BigInt(stateOverride[0].stateDiff[0].value); o.onRead?.(functionName, { account, mounted }); if (functionName === "allowance") return o.allowance ?? 10_000_000_000n; if (functionName === "balanceOf") return address === vault.asset ? 10_000_000_000n : 957_000_000_000_000_000_000n; throw new Error(`unexpected read ${functionName}`); }, - simulateContract: async (args: { functionName: string }) => ({ request: { ...args } }), + simulateContract: async (args: { functionName: string; stateOverride?: unknown }) => { + events.push(`simulate:${args.functionName}${args.stateOverride ? "+override" : ""}`); + if (o.reverts?.(args.functionName, Boolean(args.stateOverride))) throw new Error(`${args.functionName} would revert`); + return { request: { ...args } }; + }, waitForTransactionReceipt: async ({ hash }: { hash: string }) => { if (hash === "0xapprove") { o.afterApproval?.({ account }); @@ -106,18 +116,18 @@ function world(o: { if (!o.batching) throw new Error("wallet_getCapabilities is not supported"); return { atomic: { status: "supported" } }; }, - sendCalls: async (_config: unknown, req: unknown) => { batches.push(req); return { id: "0xbatch" }; }, + sendCalls: async (_config: unknown, req: unknown) => { events.push("sendCalls"); batches.push(req); return { id: "0xbatch" }; }, waitForCallsStatus: () => (o.waitForCalls ?? (async () => ({ status: "success", receipts: [{ status: "success", transactionHash: "0xt" }] })))(), getCallsStatus: () => (o.callsStatus ?? (async () => ({ status: "pending" })))(), readTerms: async () => termsQueue.shift() ?? reviewed, - termsCheck, pausedCopy, approveCall, depositCall, redeemCall, withdrawCall, batchHash, canBatch, withdrawPlan, fmtAmount, + termsCheck, pausedCopy, approveCall, approvedState, depositCall, redeemCall, withdrawCall, batchHash, canBatch, withdrawPlan, fmtAmount, EARN_BATCH_CAPABILITIES, ERC20_MIN_ABI, EARN_VAULT_ABI, BUILDER_DATA_SUFFIX, NOT_CONFIRMED_YET, revertedCopy, setPhase: (p: EarnPhase) => phases.push(p), setText: () => {}, setAll: () => {}, toast: () => {}, friendlyError: (e: Error) => e.message, }; if (o.tab === "withdraw") Object.assign(bindings, { amount: 1_000_000_000n }); const flow = load(bindings); - return { flow, phases, writes, walletRequests, batches, account, mounted, lock, refetches: () => refetches, last: () => phases.at(-1)!, rebind: (extra: Record) => load({ ...bindings, ...extra }) }; + return { flow, phases, events, writes, walletRequests, batches, account, mounted, lock, refetches: () => refetches, last: () => phases.at(-1)!, rebind: (extra: Record) => load({ ...bindings, ...extra }) }; } test("a deposit whose receipt cannot be read stays unconfirmed, and the form cannot send it again", async () => { @@ -156,6 +166,8 @@ test("Check again: no receipt keeps it unconfirmed with a note; a receipt settle test("a smart-wallet batch is one atomic, builder-tagged request; an unreadable status stays unconfirmed until the wallet's final answer", async () => { const w = world({ batching: true, allowance: 0n, waitForCalls: timeout }); await w.flow.deposit(); + // the whole batch simulated before the one prompt: the approval, then the deposit with the allowance in place + assert.deepEqual(w.events, ["simulate:approve", "simulate:deposit+override", "sendCalls"]); assert.equal(w.batches.length, 1); const req = w.batches[0] as { account: Address; calls: { to: Address }[]; capabilities: unknown; forceAtomic: boolean }; assert.equal(req.account, A); @@ -178,6 +190,43 @@ test("a smart-wallet batch is one atomic, builder-tagged request; an unreadable assert.equal(failed.last().k, "error"); }); +test("a batch deposit that would revert, with the allowance in place, stops before the wallet's prompt", async () => { + const w = world({ batching: true, allowance: 0n, reverts: (fn, withOverride) => fn === "deposit" && withOverride }); + await w.flow.deposit(); + assert.deepEqual(w.events, ["simulate:approve", "simulate:deposit+override"]); + assert.equal(w.batches.length, 0); + assert.equal(w.writes.length, 0); + assert.deepEqual(w.last(), { k: "error", message: "deposit would revert" }); +}); + +test("when the allowance override does not take, a batching wallet gets the simulated one-at-a-time path", async () => { + const w = world({ batching: true, allowance: 0n, overrideTakes: false }); + await w.flow.deposit(); + assert.equal(w.batches.length, 0, "no batch sent on an unverified simulation"); + assert.deepEqual(w.writes.map((x) => x.functionName), ["approve", "deposit"]); + assert.deepEqual(w.events, ["simulate:approve", "simulate:deposit"]); // the batch's approval check, then the real deposit simulation after the approval + assert.equal(w.last().k, "done"); +}); + +test("an approved batching wallet's single deposit is simulated before its prompt", async () => { + const w = world({ batching: true }); + await w.flow.deposit(); + assert.deepEqual(w.events, ["simulate:deposit", "sendCalls"]); + assert.equal((w.batches[0] as { calls: unknown[] }).calls.length, 1); + assert.equal(w.last().k, "done"); +}); + +test("a batching wallet's withdrawal is simulated before its prompt, and a revert sends nothing", async () => { + const ok = world({ tab: "withdraw", batching: true }); + await ok.flow.withdraw(); + assert.deepEqual(ok.events, ["simulate:redeem", "sendCalls"]); + const bad = world({ tab: "withdraw", batching: true, reverts: (fn) => fn === "redeem" }); + await bad.flow.withdraw(); + assert.deepEqual(bad.events, ["simulate:redeem"]); + assert.equal(bad.batches.length, 0); + assert.equal(bad.last().k, "error"); +}); + test("every wallet prompt is bound to the account that started the deposit", async () => { const w = world({ allowance: 0n }); await w.flow.deposit(); diff --git a/app/src/components/earn/earn-ui.test.ts b/app/src/components/earn/earn-ui.test.ts index 0412012a..95a2540f 100644 --- a/app/src/components/earn/earn-ui.test.ts +++ b/app/src/components/earn/earn-ui.test.ts @@ -18,7 +18,9 @@ test("every wallet write is credited to the builder code", () => { assert.ok(direct.length >= 1); for (const w of direct) assert.match(w, /dataSuffix: BUILDER_DATA_SUFFIX/, w); const simulated = hook.match(/simulateContract\(\{[^}]*\}/g) ?? []; - assert.equal(simulated.length, 3); // deposit, redeem, withdraw + // batch preflight: deposit, approve, deposit with the allowance override; one-at-a-time deposit; redeem and + // withdraw on each path + assert.equal(simulated.length, 8); for (const s of simulated) assert.match(s, /dataSuffix: BUILDER_DATA_SUFFIX/, s); // a prepared request is only ever sent as simulated (the suffix travels inside it) assert.equal((hook.match(/writeContract\(request\)/g) ?? []).length, 3); @@ -73,6 +75,18 @@ test("a deposit pause is derived from a polled read, never stored, so it cannot assert.match(require, /if \(!check\.ok\) \{\s*void terms\.refetch\(\);[^\n]*\n\s*throw new Error\(pausedCopy\(check\.reason\)\);/); }); +test("a batch is simulated before its one prompt, like every other send", () => { + const deposit = hook.slice(hook.indexOf("async function deposit()"), hook.indexOf("async function withdraw()")); + assert.match(deposit, /if \(\(await batching\(account\)\) && \(await preflightBatch\(pub, account, value, short\)\)\) \{\s*(?:\/\/[^\n]*\n\s*)*outcome = await sendBatch\(/); + const withdraw = hook.slice(hook.indexOf("async function withdraw()"), hook.indexOf("async function recheck()")); + const batchBranch = withdraw.slice(withdraw.indexOf("if (await batching(account)) {")); + assert.ok(batchBranch.indexOf("simulateContract(") < batchBranch.indexOf("sendBatch(")); + // the override is trusted only after it is seen to take + const preflight = hook.slice(hook.indexOf("async function preflightBatch(")); + assert.match(preflight, /if \(seen !== value\) return false;/); + assert.ok(preflight.indexOf("if (seen !== value) return false;") < preflight.indexOf("stateOverride });")); +}); + test("an upgrade-to-smart-account prompt is never triggered", () => { assert.match(calls, /c\.atomic\?\.status === "supported"/); assert.doesNotMatch(calls, /status === "ready"/); diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts index fec7e105..f44a2ad2 100644 --- a/app/src/components/earn/useEarn.ts +++ b/app/src/components/earn/useEarn.ts @@ -8,7 +8,7 @@ import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { BUILDER_DATA_SUFFIX, CHAINS } from "@/lib/chainPublic"; import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { EARN_VAULT_ABI } from "@/lib/earn/abi"; -import { EARN_BATCH_CAPABILITIES, approveCall, batchHash, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; +import { EARN_BATCH_CAPABILITIES, approveCall, approvedState, batchHash, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; import { cleanAmountInput, depositProblem, fmtAmount, maxInput, parseAmount, withdrawPlan, withdrawProblem } from "@/lib/earn/math"; import { NOT_CONFIRMED_YET, isBusy, revertedCopy, type EarnOutcome, type EarnPhase, type EarnSent, type EarnTab } from "@/lib/earn/phase"; import { liveCheck, pausedCopy, readTerms, termsCheck } from "@/lib/earn/terms"; @@ -181,6 +181,26 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { } } + /** + * Simulates the whole deposit batch before the wallet's one prompt, the way the batch will run: the approval, then + * the deposit with that approval's only effect (the allowance) applied as a state override on the asset. Returns + * false when the override does not take (the asset's storage is not what was reviewed): the caller then takes the + * one-at-a-time path, where each call is simulated on its own. A deposit that reverts with the allowance in place + * throws here, before any prompt. + */ + async function preflightBatch(pub: Pick, account: Address, value: bigint, withApproval: boolean): Promise { + if (!withApproval) { + await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "deposit", args: [value, account], account, dataSuffix: BUILDER_DATA_SUFFIX }); + return true; + } + await pub.simulateContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "approve", args: [vault.address, value], account, dataSuffix: BUILDER_DATA_SUFFIX }); + const stateOverride = approvedState(vault, account, value); + const seen = await pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "allowance", args: [account, vault.address], stateOverride }).catch(() => null); + if (seen !== value) return false; + await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "deposit", args: [value, account], account, dataSuffix: BUILDER_DATA_SUFFIX, stateOverride }); + return true; + } + /** The state for a send whose outcome could not be read. */ function unconfirmed(tab: EarnTab, amount: bigint, sent: EarnSent): EarnPhase { return { k: "unconfirmed", tab, amount, sent, checking: false, note: null }; @@ -242,12 +262,14 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { pub.readContract({ address: vault.asset, abi: ERC20_MIN_ABI, functionName: "allowance", args: [account, vault.address] }), ]); if (value > balance) throw new Error("That is more USDC than this wallet holds."); + const short = allowance < value; let outcome: EarnOutcome; - if (await batching(account)) { - // one prompt; atomic, so a failed deposit takes the approval back with it - outcome = await sendBatch(account, allowance < value ? [approveCall(vault, value), depositCall(vault, value, account)] : [depositCall(vault, value, account)], "deposit", value); + // one prompt for a wallet that batches, once the whole batch has simulated; atomic, so a failed deposit takes the + // approval back with it + if ((await batching(account)) && (await preflightBatch(pub, account, value, short))) { + outcome = await sendBatch(account, short ? [approveCall(vault, value), depositCall(vault, value, account)] : [depositCall(vault, value, account)], "deposit", value); } else { - if (allowance < value) { + if (short) { const wallet = await walletFor(account); setPhase({ k: "approving" }); const approval = await wallet.writeContract({ account, address: vault.asset, abi: ERC20_MIN_ABI, functionName: "approve", args: [vault.address, value], dataSuffix: BUILDER_DATA_SUFFIX }); @@ -289,6 +311,9 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { if (held <= 0n) throw new Error("Nothing is deposited from this wallet."); let outcome: EarnOutcome; if (await batching(account)) { + // the one call, simulated before the one prompt + if (plan.fn === "redeem") await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "redeem", args: [held, account, account], account, dataSuffix: BUILDER_DATA_SUFFIX }); + else await pub.simulateContract({ address: vault.address, abi: EARN_VAULT_ABI, functionName: "withdraw", args: [plan.assets, account, account], account, dataSuffix: BUILDER_DATA_SUFFIX }); outcome = await sendBatch(account, [plan.fn === "redeem" ? redeemCall(vault, held, account) : withdrawCall(vault, plan.assets, account)], "withdraw", plan.assets); } else { let hash: Hex; diff --git a/app/src/lib/earn/calls.test.ts b/app/src/lib/earn/calls.test.ts index 3b9aa6f4..75767f71 100644 --- a/app/src/lib/earn/calls.test.ts +++ b/app/src/lib/earn/calls.test.ts @@ -5,7 +5,7 @@ import { concatHex, decodeFunctionData, encodeFunctionData, getAddress, maxUint2 import { BUILDER_CODE, BUILDER_DATA_SUFFIX } from "../chainPublic.ts"; import { ERC20_MIN_ABI } from "../launchpad/abi.ts"; import { EARN_VAULT_ABI } from "./abi.ts"; -import { EARN_BATCH_CAPABILITIES, approveCall, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "./calls.ts"; +import { EARN_BATCH_CAPABILITIES, allowanceSlot, approveCall, approvedState, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "./calls.ts"; import { EARN_VAULTS } from "./vaults.ts"; const vault = EARN_VAULTS.base!; @@ -48,6 +48,16 @@ test("a batching wallet is asked to tag its own operation too, and may decline", assert.deepEqual(EARN_BATCH_CAPABILITIES, { dataSuffix: { value: BUILDER_DATA_SUFFIX, optional: true } }); }); +test("the allowance override targets allowed[owner][vault] in USDC's slot-10 mapping", () => { + // vector from `cast index address $(cast index address 0x…a1 10)` + assert.equal(allowanceSlot("0x00000000000000000000000000000000000000A1", vault.address, 10n), "0x225d826c47ad7eee9e6a5f77df704b43c0a9c35932432324101796966f514eec"); + assert.equal(vault.assetAllowanceSlot, 10n); + // case never matters (and a mixed-case address with an off checksum does not throw) + assert.equal(allowanceSlot("0x00000000000000000000000000000000000000a1", vault.address.toLowerCase() as `0x${string}`, 10n), allowanceSlot("0x00000000000000000000000000000000000000A1", vault.address, 10n)); + const o = approvedState(vault, "0x00000000000000000000000000000000000000A1", 250_000_000n); + assert.deepEqual(o, [{ address: vault.asset, stateDiff: [{ slot: "0x225d826c47ad7eee9e6a5f77df704b43c0a9c35932432324101796966f514eec", value: `0x${(250_000_000).toString(16).padStart(64, "0")}` }] }]); +}); + test("a batch counts as done only when the wallet reports success for every call", () => { assert.equal(batchHash({ status: "success", receipts: [{ status: "success", transactionHash: "0x01" }, { status: "success", transactionHash: "0x02" }] }), "0x02"); assert.equal(batchHash({ status: "success", receipts: [] }), null); diff --git a/app/src/lib/earn/calls.ts b/app/src/lib/earn/calls.ts index 2b6d16fe..ab4e721a 100644 --- a/app/src/lib/earn/calls.ts +++ b/app/src/lib/earn/calls.ts @@ -1,4 +1,4 @@ -import { concatHex, encodeFunctionData, type Address, type Hex } from "viem"; +import { concatHex, encodeAbiParameters, encodeFunctionData, keccak256, numberToHex, type Address, type Hex, type StateOverride } from "viem"; import { ERC20_MIN_ABI } from "../launchpad/abi.ts"; import { BUILDER_DATA_SUFFIX } from "../chainPublic.ts"; import { EARN_VAULT_ABI } from "./abi.ts"; @@ -38,6 +38,22 @@ export function redeemCall(vault: EarnVault, shares: bigint, owner: Address): Ea return { to: vault.address, data: tagged(encodeFunctionData({ abi: EARN_VAULT_ABI, functionName: "redeem", args: [shares, owner, owner] })) }; } +/** The storage slot of `allowed[owner][spender]` in a Solidity mapping-of-mappings at `mappingSlot`. */ +export function allowanceSlot(owner: Address, spender: Address, mappingSlot: bigint): Hex { + // lowercased: the encoding ignores case, and viem rejects a mixed-case address whose checksum is off + const inner = keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [owner.toLowerCase() as Address, mappingSlot])); + return keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes32" }], [spender.toLowerCase() as Address, inner])); +} + +/** + * The approval's only effect, as a state override: the vault allowed exactly `amount` of the owner's asset. With it a + * simulation runs the deposit as the batch will, right after its approval, so the whole [approve, deposit] batch is + * simulated before the wallet's one prompt. + */ +export function approvedState(vault: EarnVault, owner: Address, amount: bigint): StateOverride { + return [{ address: vault.asset, stateDiff: [{ slot: allowanceSlot(owner, vault.address, vault.assetAllowanceSlot), value: numberToHex(amount, { size: 32 }) }] }]; +} + /** * A batch's final status as the transaction hash to show (the last receipt's; null when the wallet reports none). * Anything but success with every receipt successful throws: that is a final failure, after which sending again is safe. diff --git a/app/src/lib/earn/vault.live.test.ts b/app/src/lib/earn/vault.live.test.ts index fbcb4722..61d80bb7 100644 --- a/app/src/lib/earn/vault.live.test.ts +++ b/app/src/lib/earn/vault.live.test.ts @@ -7,6 +7,8 @@ import { readSnapshot } from "./server.ts"; import { readTerms, termsCheck } from "./terms.ts"; import { EARN_VAULTS, VAULT_V2_FACTORY_BASE } from "./vaults.ts"; import { fmtRate } from "./math.ts"; +import { approvedState } from "./calls.ts"; +import { ERC20_MIN_ABI } from "../launchpad/abi.ts"; // Explicit opt-in (RUN_EARN_LIVE_TESTS=1). Reads Base mainnet only: no wallet, no signature, no transaction. // It is the check to run before relisting or after any news about the vault. @@ -18,6 +20,9 @@ test("read-only live: the pinned Base vault is still the reviewed Morpho Vault V assert.equal(await client.readContract({ address: VAULT_V2_FACTORY_BASE, abi: VAULT_V2_FACTORY_ABI, functionName: "isVaultV2", args: [v.address] }), true); assert.deepEqual(termsCheck(v, await readTerms(client, v)), { ok: true }); assert.ok((await client.readContract({ address: v.address, abi: EARN_VAULT_ABI, functionName: "previewDeposit", args: [1_000_000n] })) > 0n); + // the batch preflight's allowance override really lands in USDC's allowance mapping + const owner = "0x00000000000000000000000000000000000e4321"; + assert.equal(await client.readContract({ address: v.asset, abi: ERC20_MIN_ABI, functionName: "allowance", args: [owner, v.address], stateOverride: approvedState(v, owner, 123_456n) }), 123_456n); const s = await readSnapshot(v, client); t.diagnostic(JSON.stringify({ rate: fmtRate(s.rate), from: s.rateFrom, to: s.rateTo, totalAssets: s.totalAssets })); assert.deepEqual(s.terms, { ok: true }); diff --git a/app/src/lib/earn/vaults.ts b/app/src/lib/earn/vaults.ts index 28f477cd..a5fad6c4 100644 --- a/app/src/lib/earn/vaults.ts +++ b/app/src/lib/earn/vaults.ts @@ -19,6 +19,12 @@ export type EarnVault = { asset: Address; assetSymbol: "USDC"; assetDecimals: 6; + /** + * The storage slot of the asset's `allowed[owner][spender]` mapping. Used only to simulate a smart wallet's + * [approve, deposit] batch before its one prompt (lib/earn/calls.ts approvedState). The app checks the override took + * effect before trusting the simulation, so a wrong slot falls back to the one-at-a-time path instead of misleading. + */ + assetAllowanceSlot: bigint; shareDecimals: 18; /** The vault's own on-chain name. */ name: string; @@ -42,6 +48,8 @@ const BASE_VAULT: EarnVault = { asset: BASE_USDC, assetSymbol: "USDC", assetDecimals: 6, + // Circle FiatToken v2.2: `allowed` is mapping slot 10 (verified on a Base fork and by the opt-in live test) + assetAllowanceSlot: 10n, shareDecimals: 18, name: "Steakhouse Prime USDC", curator: "Steakhouse Financial", diff --git a/docs/earn.md b/docs/earn.md index 4d15138c..429a44eb 100644 --- a/docs/earn.md +++ b/docs/earn.md @@ -10,7 +10,7 @@ The feature is **off unless the runtime secret `EARN=on` is set**. Off, `GET /ap - **Calls** (`app/src/lib/earn/calls.ts`): plain ERC-4626 on the vault. `deposit(assets, wallet)`, `withdraw(assets, wallet, wallet)` for part, `redeem(allShares, wallet, wallet)` for everything (so no dust share is left). The approval is `approve(vault, exactAmount)` on USDC. No SDK, no router, no bundler. - **Reads**: the wallet's USDC, allowance, shares and what they redeem for are read in the browser through the same-origin `/api/rpc` proxy. The vault's public figures (rate, size, fees, terms) come from `GET /api/earn`, which reads the chain on the server, once per 10 minutes. No third-party API: the CSP and `SECURITY.md` are unchanged. - **Rate**: what one share actually gained over about the last 30 days, annualised (simple, not compounded), from `convertToAssets` at two blocks and their real timestamps. It is net of the vault's fees and excludes any external rewards. It needs a node that serves historical state; without one the rate shows as unavailable and everything else still works. -- **Writes** follow the trade panel: a synchronous lock before the first await, a network switch if needed, a simulation before every signature, then the receipt. One-at-a-time wallets: approve (only when the allowance is short), then deposit. Smart wallets that already execute atomic batches (`wallet_getCapabilities` → `atomic.status: "supported"`): one `wallet_sendCalls` with approve + deposit, atomic, so a failed deposit takes the approval back with it. A wallet that would first need an EIP-7702 upgrade (`"ready"`) takes the one-at-a-time path. +- **Writes** follow the trade panel: a synchronous lock before the first await, a network switch if needed, a simulation before every signature, then the receipt. One-at-a-time wallets: approve (only when the allowance is short), then deposit. Smart wallets that already execute atomic batches (`wallet_getCapabilities` → `atomic.status: "supported"`): one `wallet_sendCalls` with approve + deposit, atomic, so a failed deposit takes the approval back with it. The batch is simulated before its one prompt too: the approval, then the deposit with the approval's only effect (the allowance) applied as a state override on USDC (`allowed` mapping, slot 10, `assetAllowanceSlot`). The override is trusted only after a read shows it took; otherwise the wallet gets the one-at-a-time path, where each call is simulated on its own. A deposit that reverts with the allowance in place stops before any prompt. A withdrawal batch is one call, simulated first. A wallet that would first need an EIP-7702 upgrade (`"ready"`) takes the one-at-a-time path. ## Builder code @@ -35,7 +35,7 @@ Base's Dashboard reports lending TVL per builder code. Check a real transaction - Base only, one vault, USDC only. No borrowing, no "earned so far" figure (it needs deposit history the app does not index), no country restrictions (the switch is global). - The vault is a third party. Its availability, liquidity, rate and solvency are not guaranteed by openlaunch. -- The batch path for smart wallets is covered by unit tests of the exact calls and capability, not yet by a real wallet. +- The batch path for smart wallets is covered by unit and flow tests (exact calls, capability, preflight) and the live test proves the allowance override lands in Base USDC's storage, but it has not yet been run with a real wallet. ## Development and verification From 959032c107d6cf71b98093f48bf8882adf2e47fa Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 19:56:49 +0800 Subject: [PATCH 6/9] fix(earn): read the EARN switch fresh on every ask, so switching off reaches open tabs CodeRabbit (full review): loadEarnInfo kept a successful answer for the whole browser session, so after EARN was switched off an open tab kept showing the /me card and the command-palette entry until a full reload, which blunted the kill switch. It now shares only the request in flight and forgets the answer once it settles: every /me visit or palette open reads the switch as it is now. A failed request still reads as off. Deliberately no periodic re-poll: a network blip would then hide the card in the middle of a transaction. --- app/src/components/earn/earnInfo.test.ts | 44 ++++++++++++++++++++++++ app/src/components/earn/earnInfo.ts | 13 +++---- docs/earn.md | 2 +- 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 app/src/components/earn/earnInfo.test.ts diff --git a/app/src/components/earn/earnInfo.test.ts b/app/src/components/earn/earnInfo.test.ts new file mode 100644 index 00000000..84246ef0 --- /dev/null +++ b/app/src/components/earn/earnInfo.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { loadEarnInfo } from "./earnInfo.ts"; + +// loadEarnInfo is plain fetch logic; the global fetch is swapped for a counter so no request leaves the test. +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function serve(answers: (() => Promise)[]) { + let calls = 0; + globalThis.fetch = (async () => { + const answer = answers[Math.min(calls, answers.length - 1)]; + calls++; + return answer(); + }) as typeof fetch; + return () => calls; +} +const json = (body: unknown) => async () => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + +test("callers asking at the same time share one request", async () => { + const calls = serve([json({ enabled: true, vaults: [] })]); + const [a, b] = await Promise.all([loadEarnInfo(), loadEarnInfo()]); + assert.equal(calls(), 1); + assert.deepEqual(a, { enabled: true, vaults: [] }); + assert.deepEqual(b, a); +}); + +test("a settled answer is not remembered: switching EARN off shows on the next ask, without a reload", async () => { + const calls = serve([json({ enabled: true, vaults: [] }), json({ enabled: false })]); + assert.deepEqual(await loadEarnInfo(), { enabled: true, vaults: [] }); + assert.deepEqual(await loadEarnInfo(), { enabled: false }); + assert.equal(calls(), 2); +}); + +test("a failed request reads as off, and the next ask tries again", async () => { + const calls = serve([async () => new Response("down", { status: 502 }), json({ enabled: true, vaults: [] })]); + assert.deepEqual(await loadEarnInfo(), { enabled: false }); + assert.deepEqual(await loadEarnInfo(), { enabled: true, vaults: [] }); + assert.equal(calls(), 2); + serve([async () => { throw new TypeError("network down"); }]); + assert.deepEqual(await loadEarnInfo(), { enabled: false }); +}); diff --git a/app/src/components/earn/earnInfo.ts b/app/src/components/earn/earnInfo.ts index d3b0a8e2..b5c83028 100644 --- a/app/src/components/earn/earnInfo.ts +++ b/app/src/components/earn/earnInfo.ts @@ -3,24 +3,25 @@ import { useEffect, useState } from "react"; import type { EarnInfo } from "@/lib/earn/info"; -/** The one request per page load, shared by every caller. */ +/** The request in flight, shared by every caller that asks before it settles. */ let pending: Promise | null = null; /** - * GET /api/earn, once per page load and shared by everything that asks (the /me card, the ⌘K list). A failed request - * reads as "off" and is not remembered, so the next caller tries again. + * GET /api/earn, shared while in flight (the /me card and the ⌘K list asking at once make one request). A settled + * answer is not remembered: every later caller reads the switch as it is now, so turning EARN off takes effect on the + * next /me visit or ⌘K open, without a reload. A failed request reads as "off". */ export function loadEarnInfo(): Promise { pending ??= fetch("/api/earn", { cache: "no-store" }) .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(String(r.status))))) - .catch((): EarnInfo => { + .catch((): EarnInfo => ({ enabled: false })) + .finally(() => { pending = null; - return { enabled: false }; }); return pending; } -/** The answer, or null until it arrives. `active` false skips the request (the ⌘K list asks only once opened). */ +/** The answer, or null until it arrives; asked again on each mount or (re)activation. `active` false skips it (the ⌘K list asks when opened). */ export function useEarnInfo(active = true): EarnInfo | null { const [info, setInfo] = useState(null); useEffect(() => { diff --git a/docs/earn.md b/docs/earn.md index 429a44eb..8586b899 100644 --- a/docs/earn.md +++ b/docs/earn.md @@ -2,7 +2,7 @@ A card on the **/me** dashboard lets a wallet put its own USDC into one reviewed lending vault on Base and take it out again. The vault is **Steakhouse Prime USDC**, a Morpho Vault V2 run by Steakhouse Financial (`0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9`). Funds go from the wallet straight into the vault and back. Openlaunch adds no contract, holds no key, takes no fee and never has custody. -The feature is **off unless the runtime secret `EARN=on` is set**. Off, `GET /api/earn` answers `{ "enabled": false }` and the card and the ⌘K entry do not appear. `fly secrets set EARN=on` turns it on (the app restarts, no rebuild); unsetting it or any other value is the kill switch. +The feature is **off unless the runtime secret `EARN=on` is set**. Off, `GET /api/earn` answers `{ "enabled": false }` and the card and the ⌘K entry do not appear. `fly secrets set EARN=on` turns it on (the app restarts, no rebuild); unsetting it or any other value is the kill switch. The browser does not remember the answer: open tabs pick up a switch-off on their next /me visit or ⌘K open, without a reload (a /me page left open keeps its card until then). It cannot stop anyone using the vault directly; it removes openlaunch's door to it. ## How it works From ba25d02ac1808bdcfa32636e634dd8e4bfcb4eb9 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 20:05:49 +0800 Subject: [PATCH 7/9] fix(earn): a reopened command palette shows nothing from its last open CodeRabbit (outside-diff comment on 959032c): useEarnInfo kept its answer while inactive, so reopening the always-mounted palette could show the previous open's Earn entry (say, one since switched off) until the fresh request returned. Deactivating now forgets the answer (reset in the effect cleanup, which the lint rules allow, and nothing returned while inactive). The test runs the real hook source with a stand-in for React's render and effect order; it fails against the previous hook. --- app/src/components/earn/earnInfo.test.ts | 55 ++++++++++++++++++++++++ app/src/components/earn/earnInfo.ts | 9 +++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/app/src/components/earn/earnInfo.test.ts b/app/src/components/earn/earnInfo.test.ts index 84246ef0..41bf7c2d 100644 --- a/app/src/components/earn/earnInfo.test.ts +++ b/app/src/components/earn/earnInfo.test.ts @@ -1,7 +1,62 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { afterEach, test } from "node:test"; +import ts from "typescript"; import { loadEarnInfo } from "./earnInfo.ts"; +/** + * The real useEarnInfo source, run with a one-state, one-effect stand-in for React that keeps React's order: an effect + * re-runs after a render whose deps changed, the previous cleanup first. Enough to see what each render returns. + */ +function hookHarness(load: () => Promise) { + const source = readFileSync(new URL("./earnInfo.ts", import.meta.url), "utf8"); + const ast = ts.createSourceFile("earnInfo.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + let decl = ""; + const visit = (n: ts.Node) => { + if (ts.isFunctionDeclaration(n) && n.name?.text === "useEarnInfo") decl = n.getText(ast).replace(/^export\s+/, ""); + ts.forEachChild(n, visit); + }; + visit(ast); + assert.ok(decl, "earnInfo.ts no longer has useEarnInfo"); + const { outputText } = ts.transpileModule(`(${decl})`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }); + let state: unknown = null; + let effect: { deps: unknown[]; cleanup?: () => void } | null = null; + let queued: (() => void | (() => void)) | null = null; + let queuedDeps: unknown[] = []; + const useState = (init: unknown) => [state ?? init, (v: unknown) => { state = v; }]; + const useEffect = (fn: () => void | (() => void), deps: unknown[]) => { + if (!effect || deps.some((d, i) => d !== effect!.deps[i])) { + queued = fn; + queuedDeps = deps; + } + }; + const hook = new Function("useState", "useEffect", "loadEarnInfo", `return ${outputText}`)(useState, useEffect, load) as (active: boolean) => unknown; + /** One render, then (like React) its effect commit; returns what the render returned. */ + return (active: boolean) => { + const out = hook(active); + if (queued) { + effect?.cleanup?.(); + const cleanup = queued(); + effect = { deps: queuedDeps, cleanup: typeof cleanup === "function" ? cleanup : undefined }; + queued = null; + } + return out; + }; +} + +test("a reopened palette shows nothing from its last open until the fresh answer lands", async () => { + let answer: unknown = { enabled: true, vaults: [] }; + const render = hookHarness(() => Promise.resolve(answer)); + assert.equal(render(true), null); // first open: nothing until the answer + await Promise.resolve(); + assert.deepEqual(render(true), { enabled: true, vaults: [] }); + assert.equal(render(false), null); // closed + answer = { enabled: false }; // Earn switched off while the palette was closed + assert.equal(render(true), null, "the previous open's Earn answer must not show again"); + await Promise.resolve(); + assert.deepEqual(render(true), { enabled: false }); +}); + // loadEarnInfo is plain fetch logic; the global fetch is swapped for a counter so no request leaves the test. const realFetch = globalThis.fetch; afterEach(() => { diff --git a/app/src/components/earn/earnInfo.ts b/app/src/components/earn/earnInfo.ts index b5c83028..f78bda35 100644 --- a/app/src/components/earn/earnInfo.ts +++ b/app/src/components/earn/earnInfo.ts @@ -21,7 +21,11 @@ export function loadEarnInfo(): Promise { return pending; } -/** The answer, or null until it arrives; asked again on each mount or (re)activation. `active` false skips it (the ⌘K list asks when opened). */ +/** + * The answer, or null until it arrives; asked again on each mount or (re)activation. `active` false skips it (the ⌘K + * list asks when opened). Deactivating forgets the answer, so a reopened list shows nothing from the last open (say, + * an Earn entry since switched off) until the fresh answer lands. + */ export function useEarnInfo(active = true): EarnInfo | null { const [info, setInfo] = useState(null); useEffect(() => { @@ -32,7 +36,8 @@ export function useEarnInfo(active = true): EarnInfo | null { }); return () => { alive = false; + setInfo(null); }; }, [active]); - return info; + return active ? info : null; } From 14f84084916b6f8a5cebd45eca8e3c9c08152cbc Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 21:56:05 +0800 Subject: [PATCH 8/9] refactor(earn): make "Check again" on a failed batch an explicit error branch CodeRabbit (full review on ba25d02) read recheck() as calling finish() for a batch the wallet reports as failed. It did not: finish(..., batchHash(status)) evaluates batchHash first, which throws for anything but success, so the catch set the error phase. The stricter test added here passes against the previous code too. But a throw inside an argument is easy to misread, so the three outcomes are now explicit branches: success finishes, failure is an error with the wallet's message (BATCH_FAILED), anything else stays unconfirmed. The message stays "your wallet reports it failed", not "reverted on-chain": a batch can fail before it reaches the chain. The test now also proves a failed batch never reaches "done" or shows a success toast, and that a "success" with a reverted receipt is an error. --- app/src/components/earn/earn-flow.test.ts | 20 +++++++++++++++----- app/src/components/earn/useEarn.ts | 10 ++++++---- app/src/lib/earn/calls.ts | 8 +++++++- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/app/src/components/earn/earn-flow.test.ts b/app/src/components/earn/earn-flow.test.ts index 5db9e357..694d6300 100644 --- a/app/src/components/earn/earn-flow.test.ts +++ b/app/src/components/earn/earn-flow.test.ts @@ -6,7 +6,7 @@ import { zeroAddress, type Address } from "viem"; import { BUILDER_DATA_SUFFIX } from "@/lib/chainPublic"; import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { EARN_VAULT_ABI } from "@/lib/earn/abi"; -import { EARN_BATCH_CAPABILITIES, approveCall, approvedState, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "@/lib/earn/calls"; +import { BATCH_FAILED, EARN_BATCH_CAPABILITIES, approveCall, approvedState, batchHash, canBatch, depositCall, redeemCall, withdrawCall } from "@/lib/earn/calls"; import { fmtAmount, withdrawPlan } from "@/lib/earn/math"; import { NOT_CONFIRMED_YET, isBusy, revertedCopy, type EarnPhase } from "@/lib/earn/phase"; import { pausedCopy, termsCheck, type LiveTerms } from "@/lib/earn/terms"; @@ -74,6 +74,7 @@ function world(o: { const lock = { current: false }; const termsQueue = [...(o.terms ?? [])]; let refetches = 0; + let toasts = 0; const pub = { readContract: async ({ address, functionName, stateOverride }: { address: string; functionName: string; stateOverride?: { stateDiff: { value: string }[] }[] }) => { if (stateOverride) return o.overrideTakes === false ? 0n : BigInt(stateOverride[0].stateDiff[0].value); @@ -121,13 +122,13 @@ function world(o: { getCallsStatus: () => (o.callsStatus ?? (async () => ({ status: "pending" })))(), readTerms: async () => termsQueue.shift() ?? reviewed, termsCheck, pausedCopy, approveCall, approvedState, depositCall, redeemCall, withdrawCall, batchHash, canBatch, withdrawPlan, fmtAmount, - EARN_BATCH_CAPABILITIES, ERC20_MIN_ABI, EARN_VAULT_ABI, BUILDER_DATA_SUFFIX, NOT_CONFIRMED_YET, revertedCopy, - setPhase: (p: EarnPhase) => phases.push(p), setText: () => {}, setAll: () => {}, toast: () => {}, + BATCH_FAILED, EARN_BATCH_CAPABILITIES, ERC20_MIN_ABI, EARN_VAULT_ABI, BUILDER_DATA_SUFFIX, NOT_CONFIRMED_YET, revertedCopy, + setPhase: (p: EarnPhase) => phases.push(p), setText: () => {}, setAll: () => {}, toast: () => { toasts++; }, friendlyError: (e: Error) => e.message, }; if (o.tab === "withdraw") Object.assign(bindings, { amount: 1_000_000_000n }); const flow = load(bindings); - return { flow, phases, events, writes, walletRequests, batches, account, mounted, lock, refetches: () => refetches, last: () => phases.at(-1)!, rebind: (extra: Record) => load({ ...bindings, ...extra }) }; + return { flow, phases, events, writes, walletRequests, batches, account, mounted, lock, refetches: () => refetches, toasts: () => toasts, last: () => phases.at(-1)!, rebind: (extra: Record) => load({ ...bindings, ...extra }) }; } test("a deposit whose receipt cannot be read stays unconfirmed, and the form cannot send it again", async () => { @@ -185,9 +186,18 @@ test("a smart-wallet batch is one atomic, builder-tagged request; an unreadable const done = world({ phase: pending, callsStatus: async () => ({ status: "success", receipts: [{ status: "success", transactionHash: "0xt" }] }) }); await done.flow.recheck(); assert.deepEqual(done.last(), { k: "done", hash: "0xt", tab: "deposit", amount: 250_000_000n }); + assert.equal(done.toasts(), 1); + // a batch the wallet reports as failed is an error: never "done", no success toast, nothing marked as deposited const failed = world({ phase: pending, callsStatus: async () => ({ status: "failure", receipts: [] }) }); await failed.flow.recheck(); - assert.equal(failed.last().k, "error"); + assert.deepEqual(failed.last(), { k: "error", message: BATCH_FAILED }); + assert.ok(!failed.phases.some((p) => p.k === "done")); + assert.equal(failed.toasts(), 0); + // so is a "success" whose receipts include a revert + const partial = world({ phase: pending, callsStatus: async () => ({ status: "success", receipts: [{ status: "reverted", transactionHash: "0xt" }] }) }); + await partial.flow.recheck(); + assert.deepEqual(partial.last(), { k: "error", message: BATCH_FAILED }); + assert.equal(partial.toasts(), 0); }); test("a batch deposit that would revert, with the allowance in place, stops before the wallet's prompt", async () => { diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts index f44a2ad2..1f503076 100644 --- a/app/src/components/earn/useEarn.ts +++ b/app/src/components/earn/useEarn.ts @@ -8,7 +8,7 @@ import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { BUILDER_DATA_SUFFIX, CHAINS } from "@/lib/chainPublic"; import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { EARN_VAULT_ABI } from "@/lib/earn/abi"; -import { EARN_BATCH_CAPABILITIES, approveCall, approvedState, batchHash, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; +import { BATCH_FAILED, EARN_BATCH_CAPABILITIES, approveCall, approvedState, batchHash, canBatch, depositCall, redeemCall, withdrawCall, type EarnCall } from "@/lib/earn/calls"; import { cleanAmountInput, depositProblem, fmtAmount, maxInput, parseAmount, withdrawPlan, withdrawProblem } from "@/lib/earn/math"; import { NOT_CONFIRMED_YET, isBusy, revertedCopy, type EarnOutcome, type EarnPhase, type EarnSent, type EarnTab } from "@/lib/earn/phase"; import { liveCheck, pausedCopy, readTerms, termsCheck } from "@/lib/earn/terms"; @@ -354,9 +354,11 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { else finish(p.tab, p.amount, p.sent.hash); } else { const status = await getCallsStatus(config, { id: p.sent.id }).catch(() => null); - // only "success" or "failure" is final; no answer, "pending" or anything else keeps it unconfirmed - if (status?.status !== "success" && status?.status !== "failure") setPhase({ ...p, checking: false, note: NOT_CONFIRMED_YET }); - else finish(p.tab, p.amount, batchHash(status)); + // only "success" or "failure" is final; no answer, "pending" or anything else keeps it unconfirmed. + // A success whose receipts include a revert makes batchHash throw: an error too, never done. + if (status?.status === "success") finish(p.tab, p.amount, batchHash(status)); + else if (status?.status === "failure") setPhase({ k: "error", message: BATCH_FAILED }); + else setPhase({ ...p, checking: false, note: NOT_CONFIRMED_YET }); } } catch (err) { setPhase({ k: "error", message: friendlyError(err) }); diff --git a/app/src/lib/earn/calls.ts b/app/src/lib/earn/calls.ts index ab4e721a..9c5ed657 100644 --- a/app/src/lib/earn/calls.ts +++ b/app/src/lib/earn/calls.ts @@ -60,10 +60,16 @@ export function approvedState(vault: EarnVault, owner: Address, amount: bigint): */ export function batchHash(result: { status?: string; receipts?: readonly { status?: string; transactionHash?: Hex }[] }): Hex | null { const receipts = result.receipts ?? []; - if (result.status !== "success" || receipts.some((r) => r.status !== "success")) throw new Error("Your wallet reports the transaction failed. Nothing moved; check its activity before trying again."); + if (result.status !== "success" || receipts.some((r) => r.status !== "success")) throw new Error(BATCH_FAILED); return receipts.at(-1)?.transactionHash ?? null; } +/** + * A batch the wallet reports as failed. Not "reverted on-chain": a batch can fail before it reaches the chain, so + * whether gas was charged is not known. Atomic, so nothing in it took effect. + */ +export const BATCH_FAILED = "Your wallet reports the transaction failed. Nothing moved; check its activity before trying again."; + /** The EIP-5792 capability that asks a batching wallet to tag its own operation with the builder code. */ export const EARN_BATCH_CAPABILITIES = { dataSuffix: { value: BUILDER_DATA_SUFFIX, optional: true } } as const; From 47bdf5e07a530770a0797181f884f51bd1a7e948 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 14 Oct 2026 22:15:13 +0800 Subject: [PATCH 9/9] fix(earn): no network-switch offer while a send is in flight or unsettled CodeRabbit (full review on 14f8408): if the wallet changed network while a send was confirming, the button became an enabled "Switch to Base", and switchToVaultChain had no busy check. A switch rejected late could replace the "sent, not confirmed" state with an error, unlocking the form while the deposit might still go through. - The button shows the send's progress while busy, and only offers the switch (clickable) when nothing is in flight. - switchToVaultChain returns at once while busy or locked. - Its error is set through an updater that leaves a busy state alone, so a rejection that lands late never replaces "unconfirmed". Both new flow tests fail against the previous hook. --- app/src/components/earn/EarnPanel.tsx | 5 +-- app/src/components/earn/earn-flow.test.ts | 40 ++++++++++++++++++++--- app/src/components/earn/earn-ui.test.ts | 7 ++++ app/src/components/earn/useEarn.ts | 9 +++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/app/src/components/earn/EarnPanel.tsx b/app/src/components/earn/EarnPanel.tsx index bdf442a1..d71971c3 100644 --- a/app/src/components/earn/EarnPanel.tsx +++ b/app/src/components/earn/EarnPanel.tsx @@ -73,18 +73,19 @@ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: Earn ); } + // a send in flight shows its progress even if the wallet changed network meanwhile: no switch offer until it settles const label = !e.isConnected ? "Connect wallet" - : !e.onChain ? (e.switching ? "Confirm the network switch…" : `Switch to ${network}`) : e.phase.k === "preparing" ? "Checking the vault…" : e.phase.k === "approving" ? `Allow exactly ${e.amount !== null ? amt(e.amount) : ""} ${sym} in your wallet…` : e.phase.k === "signing" ? "Confirm in your wallet…" : e.phase.k === "sent" ? `Confirming on ${network}…` + : !e.onChain ? (e.switching ? "Confirm the network switch…" : `Switch to ${network}`) : deposit && pauseReason ? "Deposits paused" : e.problem ? e.problem : deposit ? (e.needsApproval ? `Allow and deposit ${amt(e.amount!)} ${sym}` : `Deposit ${amt(e.amount!)} ${sym}`) : everything ? "Withdraw everything" : `Withdraw ${amt(e.amount!)} ${sym}`; const ready = e.isConnected && e.onChain && !e.busy && !e.problem && !(deposit && pauseReason); - const clickable = !e.isConnected || (!e.onChain && !e.switching) || ready; + const clickable = !e.isConnected || (!e.onChain && !e.switching && !e.busy) || ready; const onClick = !e.isConnected ? onConnect : !e.onChain ? e.switchToVaultChain : e.submit; return ( diff --git a/app/src/components/earn/earn-flow.test.ts b/app/src/components/earn/earn-flow.test.ts index 694d6300..f6b864c3 100644 --- a/app/src/components/earn/earn-flow.test.ts +++ b/app/src/components/earn/earn-flow.test.ts @@ -16,7 +16,7 @@ import { EARN_VAULTS } from "@/lib/earn/vaults"; * Runs the real deposit / withdraw / recheck code from useEarn.ts (extracted from the source, like * transaction-safety.test.ts) against inert wallets and RPCs: no real wallet, chain or transaction. */ -const FLOW = ["ensureAccount", "walletFor", "batching", "requireTerms", "preflightBatch", "unconfirmed", "settleTx", "sendBatch", "finish", "deposit", "withdraw", "recheck"] as const; +const FLOW = ["ensureAccount", "walletFor", "batching", "requireTerms", "preflightBatch", "unconfirmed", "settleTx", "sendBatch", "finish", "deposit", "withdraw", "recheck", "switchToVaultChain"] as const; type Flow = Record<(typeof FLOW)[number], (...args: unknown[]) => Promise>; function load(bindings: Record): Flow { @@ -63,6 +63,8 @@ function world(o: { overrideTakes?: boolean; /** which simulations revert, by function name and whether the allowance override was applied */ reverts?: (fn: string, withOverride: boolean) => boolean; + /** the wallet's answer to a network switch */ + switchChain?: () => Promise; } = {}) { const phases: EarnPhase[] = []; const events: string[] = []; @@ -75,6 +77,7 @@ function world(o: { const termsQueue = [...(o.terms ?? [])]; let refetches = 0; let toasts = 0; + let switches = 0; const pub = { readContract: async ({ address, functionName, stateOverride }: { address: string; functionName: string; stateOverride?: { stateDiff: { value: string }[] }[] }) => { if (stateOverride) return o.overrideTakes === false ? 0n : BigInt(stateOverride[0].stateDiff[0].value); @@ -108,7 +111,8 @@ function world(o: { address: A, amount: 250_000_000n, phase: o.phase ?? { k: "idle" }, position: { shares: 957_000_000_000_000_000_000n, position: 1_000_000_000n, refetch: async () => {} }, terms: { refetch: async () => { refetches++; } }, - switchChainAsync: async () => {}, + switchChainAsync: () => { switches++; return (o.switchChain ?? (async () => {}))(); }, + isBusy, getPublicClient: () => pub, getAccount: () => ({ address: account.value }), getWalletClient: async (_config: unknown, opts: unknown) => { walletRequests.push(opts); return wallet; }, @@ -123,12 +127,13 @@ function world(o: { readTerms: async () => termsQueue.shift() ?? reviewed, termsCheck, pausedCopy, approveCall, approvedState, depositCall, redeemCall, withdrawCall, batchHash, canBatch, withdrawPlan, fmtAmount, BATCH_FAILED, EARN_BATCH_CAPABILITIES, ERC20_MIN_ABI, EARN_VAULT_ABI, BUILDER_DATA_SUFFIX, NOT_CONFIRMED_YET, revertedCopy, - setPhase: (p: EarnPhase) => phases.push(p), setText: () => {}, setAll: () => {}, toast: () => { toasts++; }, + // like React's setter: a value, or an updater given the current phase + setPhase: (p: EarnPhase | ((cur: EarnPhase) => EarnPhase)) => phases.push(typeof p === "function" ? p(phases.at(-1) ?? { k: "idle" }) : p), setText: () => {}, setAll: () => {}, toast: () => { toasts++; }, friendlyError: (e: Error) => e.message, }; if (o.tab === "withdraw") Object.assign(bindings, { amount: 1_000_000_000n }); const flow = load(bindings); - return { flow, phases, events, writes, walletRequests, batches, account, mounted, lock, refetches: () => refetches, toasts: () => toasts, last: () => phases.at(-1)!, rebind: (extra: Record) => load({ ...bindings, ...extra }) }; + return { flow, phases, events, writes, walletRequests, batches, account, mounted, lock, refetches: () => refetches, toasts: () => toasts, switches: () => switches, last: () => phases.at(-1)!, rebind: (extra: Record) => load({ ...bindings, ...extra }) }; } test("a deposit whose receipt cannot be read stays unconfirmed, and the form cannot send it again", async () => { @@ -283,6 +288,33 @@ test("a withdrawal whose receipt cannot be read stays unconfirmed too", async () assert.deepEqual(w.last(), { k: "unconfirmed", tab: "withdraw", amount: 1_000_000_000n, sent: { kind: "tx", hash: "0xsent" }, checking: false, note: null }); }); +test("the network switch is refused while a send is in flight or unsettled", async () => { + const w = world(); + await w.rebind({ busy: true }).switchToVaultChain(); + assert.equal(w.switches(), 0); + w.lock.current = true; + await w.flow.switchToVaultChain(); + assert.equal(w.switches(), 0); + w.lock.current = false; + await w.flow.switchToVaultChain(); + assert.equal(w.switches(), 1); +}); + +test("a switch rejected after a send became unconfirmed leaves it unconfirmed; when idle it shows the error", async () => { + let reject!: (e: Error) => void; + const w = world({ switchChain: () => new Promise((_, r) => { reject = r; }) }); + const pending = w.flow.switchToVaultChain(); + // meanwhile a send was made and its confirmation could not be read + const unconfirmed: EarnPhase = { k: "unconfirmed", tab: "deposit", amount: 250_000_000n, sent: { kind: "tx", hash: "0xsent" }, checking: false, note: null }; + w.phases.push(unconfirmed); + reject(new Error("User rejected the request.")); + await pending; + assert.deepEqual(w.last(), unconfirmed, "the 'do not send again' state survives a late rejection"); + const idle = world({ switchChain: async () => { throw new Error("User rejected the request."); } }); + await idle.flow.switchToVaultChain(); + assert.deepEqual(idle.last(), { k: "error", message: "User rejected the request." }); +}); + test("a card that unmounted mid-action stops before its next prompt", async () => { const w = world({ onRead: (fn, s) => { if (fn === "allowance") s.mounted.current = false; } }); await w.flow.deposit(); diff --git a/app/src/components/earn/earn-ui.test.ts b/app/src/components/earn/earn-ui.test.ts index 95a2540f..8d8be58c 100644 --- a/app/src/components/earn/earn-ui.test.ts +++ b/app/src/components/earn/earn-ui.test.ts @@ -87,6 +87,13 @@ test("a batch is simulated before its one prompt, like every other send", () => assert.ok(preflight.indexOf("if (seen !== value) return false;") < preflight.indexOf("stateOverride });")); }); +test("while a send is in flight the button shows its progress, never a network-switch offer", () => { + assert.match(panel, /const clickable = !e\.isConnected \|\| \(!e\.onChain && !e\.switching && !e\.busy\) \|\| ready;/); + const label = panel.slice(panel.indexOf("const label = "), panel.indexOf("const ready = ")); + assert.ok(label.indexOf('e.phase.k === "sent"') < label.indexOf("!e.onChain ?"), "progress labels come before the switch offer"); + assert.match(hook.slice(hook.indexOf("async function switchToVaultChain(")), /^async function switchToVaultChain\(\) \{\s*if \(busy \|\| lock\.current\) return;/); +}); + test("an upgrade-to-smart-account prompt is never triggered", () => { assert.match(calls, /c\.atomic\?\.status === "supported"/); assert.doesNotMatch(calls, /status === "ready"/); diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts index 1f503076..6ecfbb3f 100644 --- a/app/src/components/earn/useEarn.ts +++ b/app/src/components/earn/useEarn.ts @@ -132,12 +132,17 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { settle(); } - /** The explicit "Switch to Base" step, before the form offers to send anything. */ + /** + * The explicit "Switch to Base" step, before the form offers to send anything. Never while a send is in flight or + * unsettled, and a rejection that lands late can never replace such a state: it would unlock the form while a + * deposit may still go through. + */ async function switchToVaultChain() { + if (busy || lock.current) return; try { await switchChainAsync({ chainId }); } catch (err) { - setPhase({ k: "error", message: friendlyError(err) }); + setPhase((current) => (isBusy(current) ? current : { k: "error", message: friendlyError(err) })); } }