DocsComponents
Routes
The preset ways to earn on USDC as cards — each with its own mark, the rate, a yearly estimate for the amount, a risk meter and points. A 2-column grid when its container has room, a swipeable row when it does not (container queries). Controlled: you hold the selected id.
"use client";
// Mercato kit — preset ways to earn on USDC, in plain language, as cards. https://mercato.sh/developers
// `layout="grid"` (default): cards in a grid when there's room, a swipeable row when there isn't.
// `layout="list"`: compact rows in one column — for a side column next to a bigger summary (the Earn block). The rows
// share out the height they're given, so the list can run the full height of its column.
import { CalendarClock, CircleDollarSign, Rocket, Sparkles, Waves, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { RISK_LABEL, fmtPct, fmtUsd, type MercatoRoute } from "@/lib/mercato";
/** Each preset's mark and tint. Unknown ids (your own routes) get a coin in your primary colour. */
const LOOK: Record<string, { icon: LucideIcon; glow: string; ink: string }> = {
flexible: { icon: Waves, glow: "from-sky-500/25", ink: "text-sky-500" },
fixed: { icon: CalendarClock, glow: "from-violet-500/25", ink: "text-violet-500" },
points: { icon: Sparkles, glow: "from-amber-500/25", ink: "text-amber-500" },
boosted: { icon: Rocket, glow: "from-rose-500/25", ink: "text-rose-500" },
};
export const routeLook = (id: string) => LOOK[id] ?? { icon: CircleDollarSign, glow: "from-primary/25", ink: "text-primary" };
const RISK_INK = { 1: "bg-emerald-500", 2: "bg-amber-500", 3: "bg-rose-500" } as const;
export function MercatoRoutes({ routes, value, onValueChange, amount = 0, layout = "grid", className }: {
routes: MercatoRoute[]; value?: string; onValueChange?: (id: string) => void; amount?: number; layout?: "grid" | "list"; className?: string;
}) {
if (layout === "list") return (
<div role="radiogroup" aria-label="How to earn" className={cn("flex flex-col gap-2", className)}>
{routes.map((r) => {
const on = r.id === value;
const { icon: Icon, glow, ink } = routeLook(r.id);
return (
<button key={r.id} type="button" role="radio" aria-checked={on} onClick={() => onValueChange?.(r.id)}
className={cn("group relative flex min-h-[120px] shrink-0 grow flex-col justify-between gap-3 overflow-hidden rounded-xl border p-4 text-left transition duration-200",
on ? "border-primary bg-primary/[0.06] ring-1 ring-primary" : "border-border bg-card hover:border-foreground/25")}>
<span aria-hidden className={cn("pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b to-transparent transition-opacity", glow, on ? "opacity-100" : "opacity-40 group-hover:opacity-80")} />
<span className="relative flex items-start gap-3">
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-background/80", ink)}><Icon className="size-[18px]" /></span>
<span className="min-w-0 flex-1">
<span className="block font-medium text-card-foreground">{r.title}</span>
<span className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{r.summary}</span>
</span>
<span className="flex shrink-0 flex-col items-end gap-1.5">
<span className="mercato-figure text-2xl leading-none tabular-nums text-card-foreground">{fmtPct(r.rate, 1)}</span>
{!r.available && <span className="rounded-full border border-dashed border-border px-1.5 text-[10px] text-muted-foreground">at launch</span>}
</span>
</span>
<span className="relative flex items-center justify-between gap-2 border-t border-border/70 pt-2.5 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5" title={r.riskNote}>
{[1, 2, 3].map((i) => <span key={i} className={cn("h-1 w-3 rounded-full", i <= r.risk ? RISK_INK[r.risk] : "bg-secondary")} />)}
<span className="ml-0.5">{RISK_LABEL[r.risk]}</span>
</span>
<span className="rounded-full bg-secondary px-2 py-0.5 text-secondary-foreground"><span className="mercato-figure tabular-nums">{r.pointsPerUsdDay}</span> pts/$/day</span>
</span>
</button>
);
})}
</div>
);
return (
<div className={cn("@container", className)}>
<div role="radiogroup" aria-label="How to earn"
className="-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-2 [scrollbar-width:none] @lg:mx-0 @lg:grid @lg:grid-cols-2 @lg:overflow-visible @lg:px-0 @lg:pb-0">
{routes.map((r) => {
const on = r.id === value;
const { icon: Icon, glow, ink } = routeLook(r.id);
return (
<button key={r.id} type="button" role="radio" aria-checked={on} onClick={() => onValueChange?.(r.id)}
className={cn("group relative flex w-[80%] shrink-0 snap-start flex-col gap-4 overflow-hidden rounded-2xl border p-4 text-left transition duration-200 @lg:w-auto",
on ? "border-primary bg-primary/[0.06] ring-1 ring-primary" : "border-border bg-card hover:-translate-y-0.5 hover:border-foreground/25")}>
<span aria-hidden className={cn("pointer-events-none absolute inset-x-0 top-0 h-28 bg-gradient-to-b to-transparent transition-opacity duration-300", glow, on ? "opacity-100" : "opacity-50 group-hover:opacity-90")} />
<span className="relative flex items-start justify-between gap-3">
<span className={cn("grid size-10 place-items-center rounded-xl border border-border bg-background/80", ink)}><Icon className="size-5" /></span>
<span className={cn("grid size-5 place-items-center rounded-full border transition", on ? "border-primary bg-primary" : "border-muted-foreground/40")}>
{on && <svg viewBox="0 0 12 12" className="size-3 text-primary-foreground"><path d="M2.5 6.2 5 8.5l4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
</span>
<span className="relative">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium text-card-foreground">{r.title}</span>
{!r.available && <span className="rounded-full border border-dashed border-border px-2 text-[11px] text-muted-foreground">at launch</span>}
</span>
<span className="mt-1 block text-sm text-muted-foreground">{r.summary}</span>
</span>
<span className="relative mt-auto">
<span className="mercato-figure block text-3xl leading-none tabular-nums text-card-foreground">{fmtPct(r.rate, 1)}</span>
<span className="mt-1.5 block text-xs text-muted-foreground">
{amount > 0 && r.rate != null ? <>≈ <span className="mercato-figure tabular-nums">{fmtUsd(amount * r.rate)}</span> a year · {r.rateNote}</> : r.rateNote}
</span>
</span>
<span className="relative flex flex-wrap items-center justify-between gap-2 border-t border-border pt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5" title={r.riskNote}>
{[1, 2, 3].map((i) => <span key={i} className={cn("h-1.5 w-4 rounded-full", i <= r.risk ? RISK_INK[r.risk] : "bg-secondary")} />)}
{RISK_LABEL[r.risk]}
</span>
<span className="rounded-full bg-secondary px-2 py-0.5 text-secondary-foreground">{r.pointsPerUsdDay} pts/$/day</span>
</span>
</button>
);
})}
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add https://mercato.sh/r/mercato-routes.jsonInstall the dependencies
npm install lucide-react wagmi viem @tanstack/react-queryCopy and paste the following code into your project
lib/mercato.ts "use client"; // Mercato kit — data, wallet reads and transactions shared by every Mercato component. // Installed with the shadcn CLI from https://mercato.sh/r. It's your code now: edit freely. // A note for whoever reads the source: code OPEN-SOURCE-27 — redeem it at mercato.sh/points#achievements. import { createContext, createElement, useContext, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAccount, useReadContracts, useWriteContract, usePublicClient } from "wagmi"; import { erc20Abi, erc4626Abi, formatUnits, parseAbi, parseUnits } from "viem"; import { BUNDLER3, GENERAL_ADAPTER_1, MORPHO_ABI, MORPHO_BLUE, buildLoop, buildRoute, buildUnloop, buildUnroute, sharePriceE27, type MarketParams } from "@/lib/mercato-loop"; export const MERCATO_API = "https://mercato.sh/api/earn"; export type MercatoRoute = { id: string; /** Plain-language name and one-line explanation, written for people who don't use DeFi. */ title: string; summary: string; /** 1 lower · 2 medium · 3 higher — relative to the other routes, never "safe". */ risk: 1 | 2 | 3; riskNote: string; /** Annual rate as a fraction (0.06 = 6%). Variable unless the route says otherwise. */ rate: number | null; rateNote: string; pointsPerUsdDay: number; /** "supply" happens in place (USDC → mcUSDC); "loop" opens a looped position in place (MercatoLoop); * "link" continues on the venue's own app. */ action: { kind: "supply" } | { kind: "loop" } | { kind: "link"; href: string; venue: string }; /** False while the route's market doesn't exist yet. */ available: boolean; }; /** A token people can supply with. USDC goes straight in; others are swapped to USDC first (e.g. USDT on Curve). */ export type MercatoInput = { symbol: string; address: `0x${string}`; decimals: number; swap?: { venue: string; pool: `0x${string}`; i: number; j: number; maxSlippage: number }; }; export type MercatoData = { vault: { address: `0x${string}`; asset: `0x${string}`; chainId: number; sharePrice: number; rate: number | null; rateNote: string; withdrawableUsd: number; supplyOpen: boolean; inputs: MercatoInput[]; }; routes: MercatoRoute[]; /** Looping on the mcUSDC / USDC market: supply mcUSDC as collateral, borrow USDC, supply it again — in one * transaction through Morpho's Bundler3. Absent where the API doesn't offer it. */ loop?: MercatoLoopData; /** Collateral routes — put in a token you hold (AVT), borrow USDC against it, hold the USDC as mcUSDC. */ collateralRoutes?: MercatoCollateralRouteData[]; updatedAt: string; }; export type MercatoLoopData = { available: boolean; marketId: `0x${string}`; /** The market's immutable parameters; `lltv` as a decimal string (1e18 = 100%). */ market: { loanToken: `0x${string}`; collateralToken: `0x${string}`; oracle: `0x${string}`; irm: `0x${string}`; lltv: string }; lltv: number; /** The highest LTV offered when opening, below `lltv` for a safety margin. */ maxLtv: number; /** Leverage presets (2 = twice your deposit earning). */ presets: number[]; borrowRate: number | null; /** What lenders of USDC into the market earn, and how much of it is borrowed. */ supplyRate?: number | null; utilization?: number | null; /** USDC the market can still lend. */ liquidityUsd: number; }; export type MercatoCollateralRouteData = { id: string; /** "AVT → mcUSDC": what you put in → what you hold. */ label: string; available: boolean; collateral: { symbol: string; address: `0x${string}`; decimals: number; logo?: string | null }; /** Display price in USD and where it is from; the market itself prices the collateral with its oracle. */ priceUsd: number | null; priceSource: string; /** Null until the market exists. */ marketId: `0x${string}` | null; market: MercatoLoopData["market"] | null; lltv: number; /** LTVs offered when opening (0.2 = borrow 20% of the collateral's value). */ ltvPresets: number[]; borrowRate: number | null; /** Rewards paid on the borrowed dollars (e.g. AVT from Aventus nodes), shown on their own line. */ rewards: { symbol: string; apr: number; paidBy: string } | null; liquidityUsd: number; }; const ApiContext = createContext(MERCATO_API); /** Optional: set the API once for every Mercato component below it (e.g. your own proxy). */ export function MercatoProvider({ api = MERCATO_API, children }: { api?: string; children: ReactNode }) { return createElement(ApiContext.Provider, { value: api }, children); } /** Live vault figures and the preset routes. `api` overrides the provider for one component. */ export function useMercato(api?: string) { const fromProvider = useContext(ApiContext); const url = api ?? fromProvider; return useQuery({ queryKey: ["mercato", url], queryFn: async (): Promise<MercatoData> => { const r = await fetch(url); if (!r.ok) throw new Error(`Mercato API ${r.status}`); return r.json(); }, staleTime: 60_000, }); } const curveAbi = parseAbi([ "function get_dy(int128 i, int128 j, uint256 dx) view returns (uint256)", "function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy)", ]); /** USDT-style approve: no return value, and a non-zero allowance must be reset to zero first. */ const approveAbi = parseAbi(["function approve(address spender, uint256 amount)"]); // BigInt(…) rather than ZERO literals, so the file compiles in projects targeting ES2017 (create-next-app's default). const ZERO = BigInt(0); /** The connected wallet: balance of each input token, its mcUSDC, and what that mcUSDC is worth in USDC. */ export function useMercatoPosition(vault?: MercatoData["vault"]) { const { address } = useAccount(); const on = !!address && !!vault; const inputs = vault?.inputs ?? []; const { data, refetch } = useReadContracts({ allowFailure: true, contracts: on ? [ { address: vault.address, abi: erc20Abi, functionName: "balanceOf", args: [address] } as const, // mcUSDC is an ERC-20 too ...inputs.map((t) => ({ address: t.address, abi: erc20Abi, functionName: "balanceOf", args: [address] }) as const), ...inputs.map((t) => ({ address: t.address, abi: erc20Abi, functionName: "allowance", args: [address, t.swap?.pool ?? vault.address] }) as const), ] : [], }); const shares = data?.[0]?.result as bigint | undefined; const balances = Object.fromEntries(inputs.map((t, k) => [t.symbol, data?.[1 + k]?.result as bigint | undefined])); const allowances = Object.fromEntries(inputs.map((t, k) => [t.symbol, data?.[1 + inputs.length + k]?.result as bigint | undefined])); const { data: value } = useReadContracts({ allowFailure: true, contracts: on && shares != null ? [{ address: vault.address, abi: erc4626Abi, functionName: "convertToAssets", args: [shares] }] : [], }); const assets = value?.[0]?.result as bigint | undefined; const tokenBalance = (symbol: string) => { const t = inputs.find((x) => x.symbol === symbol), b = balances[symbol]; return t && b != null ? Number(formatUnits(b, t.decimals)) : null; }; return { address, shares, /** What the wallet's mcUSDC is worth now, in USDC. */ balance: assets != null ? Number(formatUnits(assets, 6)) : shares === ZERO ? 0 : null, usdc: tokenBalance("USDC"), tokenBalance, allowances, refetch, }; } /** How much USDC an amount of `symbol` becomes before it is supplied — 1:1 for USDC, the pool's own quote otherwise. */ export function useMercatoQuote(vault: MercatoData["vault"] | undefined, symbol: string, amount: string) { const t = vault?.inputs.find((x) => x.symbol === symbol); let dx = ZERO; try { dx = t ? parseUnits(amount || "0", t.decimals) : ZERO; } catch { /* not a number yet */ } const { data } = useReadContracts({ allowFailure: true, contracts: t?.swap && dx > ZERO ? [{ address: t.swap.pool, abi: curveAbi, functionName: "get_dy", args: [BigInt(t.swap.i), BigInt(t.swap.j), dx] }] : [], }); if (!t) return { usdc: null, input: undefined }; if (!t.swap) return { usdc: Number(amount) || 0, input: t }; const dy = data?.[0]?.result as bigint | undefined; return { usdc: dy != null ? Number(formatUnits(dy, 6)) : null, input: t }; } /** What the wallet has earned in Mercato so far, in USDC (Morpho's own figure, via the Mercato API). */ export function useMercatoEarned(api?: string) { const fromProvider = useContext(ApiContext); const url = api ?? fromProvider; const { address } = useAccount(); return useQuery({ queryKey: ["mercato-position", url, address], enabled: !!address, queryFn: async (): Promise<{ balance: number; earned: number } | null> => { const r = await fetch(`${url}/position?address=${address}`); if (!r.ok) throw new Error(`Mercato API ${r.status}`); return r.json(); }, staleTime: 60_000, }); } /** Supply (swapping to USDC first if needed) and withdraw. Users sign every step in their own wallet. */ export function useMercatoActions(vault?: MercatoData["vault"]) { const pos = useMercatoPosition(vault); const client = usePublicClient(); const { writeContractAsync } = useWriteContract(); /** What is happening now, for the button ("Swapping USDT…"); null when idle. */ const [step, setStep] = useState<string | null>(null); const wait = async (hash: `0x${string}`) => { const r = await client?.waitForTransactionReceipt({ hash }); if (r?.status === "reverted") throw new Error("Transaction failed"); }; async function approve(token: `0x${string}`, spender: `0x${string}`, amount: bigint, current: bigint | undefined) { if ((current ?? ZERO) >= amount) return; setStep(`Approving ${vault?.inputs.find((x) => x.address === token)?.symbol ?? "token"}…`); if ((current ?? ZERO) > ZERO) await wait(await writeContractAsync({ address: token, abi: approveAbi, functionName: "approve", args: [spender, ZERO] })); await wait(await writeContractAsync({ address: token, abi: approveAbi, functionName: "approve", args: [spender, amount] })); } /** Supply `amount` of `symbol` (default USDC). Other tokens are swapped to USDC on the input's pool, then the USDC received is supplied. */ async function supply(amount: string, symbol = "USDC") { const t = vault?.inputs.find((x) => x.symbol === symbol); const usdc = vault?.inputs.find((x) => x.symbol === "USDC"); if (!vault || !usdc || !pos.address || !t || !client) return; let units = parseUnits(amount || "0", t.decimals); if (units === ZERO) return; setStep("Preparing…"); try { if (t.swap) { const { pool, i, j, venue, maxSlippage } = t.swap; const quote = await client.readContract({ address: pool, abi: curveAbi, functionName: "get_dy", args: [BigInt(i), BigInt(j), units] }); const minOut = (quote * BigInt(Math.round((1 - maxSlippage) * 10_000))) / BigInt(10_000); await approve(t.address, pool, units, pos.allowances[symbol]); const before = await client.readContract({ address: usdc.address, abi: erc20Abi, functionName: "balanceOf", args: [pos.address] }); setStep(`Swapping ${symbol} to USDC on ${venue}…`); await wait(await writeContractAsync({ address: pool, abi: curveAbi, functionName: "exchange", args: [BigInt(i), BigInt(j), units, minOut] })); const after = await client.readContract({ address: usdc.address, abi: erc20Abi, functionName: "balanceOf", args: [pos.address] }); units = after - before; // supply exactly what the swap returned const allowance = await client.readContract({ address: usdc.address, abi: erc20Abi, functionName: "allowance", args: [pos.address, vault.address] }); await approve(usdc.address, vault.address, units, allowance); } else { await approve(t.address, vault.address, units, pos.allowances[symbol]); } setStep("Supplying…"); await wait(await writeContractAsync({ address: vault.address, abi: erc4626Abi, functionName: "deposit", args: [units, pos.address] })); } finally { setStep(null); pos.refetch(); } } /** Withdraw an amount of USDC, or everything (redeems every share). */ async function withdraw(amountUsdc: string | "all") { if (!vault || !pos.address) return; setStep("Withdrawing…"); try { await wait(amountUsdc === "all" ? await writeContractAsync({ address: vault.address, abi: erc4626Abi, functionName: "redeem", args: [pos.shares ?? ZERO, pos.address, pos.address] }) : await writeContractAsync({ address: vault.address, abi: erc4626Abi, functionName: "withdraw", args: [parseUnits(amountUsdc || "0", 6), pos.address, pos.address] })); } finally { setStep(null); pos.refetch(); } } return { ...pos, supply, withdraw, step, isPending: step != null }; } const parseMarket = (m: MercatoLoopData["market"]): MarketParams => ({ ...m, lltv: BigInt(m.lltv) }); /** Net rate of a loop: the vault rate on everything you hold, minus the borrow rate on what you borrowed. */ export const loopRate = (vaultRate: number | null | undefined, borrowRate: number | null | undefined, leverage: number) => vaultRate == null || borrowRate == null ? null : vaultRate * leverage - borrowRate * (leverage - 1); /** How far mcUSDC's price can fall before a loop at this leverage can be liquidated. */ export const loopCushion = (leverage: number, lltv: number) => 1 - (1 - 1 / leverage) / lltv; /** The wallet's looped position, and one-transaction loop / close. Users sign in their own wallet: once to approve * USDC and once to let Morpho's adapter act for them (both only the first time), then the loop itself. */ export function useMercatoLoop(data?: MercatoData) { const { address } = useAccount(); const client = usePublicClient(); const { writeContractAsync } = useWriteContract(); const [step, setStep] = useState<string | null>(null); const loop = data?.loop, vault = data?.vault; const id = loop?.marketId; const reads = useReadContracts({ allowFailure: true, contracts: loop && vault && address ? [ { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "position", args: [id!, address] }, { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "market", args: [id!] }, { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "isAuthorized", args: [address, GENERAL_ADAPTER_1] }, { address: vault.asset, abi: erc20Abi, functionName: "allowance", args: [address, GENERAL_ADAPTER_1] }, { address: loop.market.collateralToken, abi: erc20Abi, functionName: "decimals" }, { address: vault.asset, abi: erc20Abi, functionName: "balanceOf", args: [address] }, ] : [], query: { enabled: !!(loop && vault && address), refetchInterval: 30_000 }, }); const r = reads.data ?? []; const pos = r[0]?.result as readonly [bigint, bigint, bigint] | undefined; const mkt = r[1]?.result as readonly [bigint, bigint, bigint, bigint, bigint, bigint] | undefined; const authorized = r[2]?.result as boolean | undefined; const allowance = r[3]?.result as bigint | undefined; const shareDecimals = (r[4]?.result as number | undefined) ?? 18; const usdc = r[5]?.result as bigint | undefined; const debtUnits = pos && mkt && mkt[3] > ZERO ? (pos[1] * mkt[2] + mkt[3] - BigInt(1)) / mkt[3] : ZERO; const collateralUsd = pos && vault ? Number(formatUnits(pos[2], shareDecimals)) * vault.sharePrice : 0; const debtUsd = Number(formatUnits(debtUnits, 6)); const equity = collateralUsd - debtUsd; const position = collateralUsd > 0 ? { collateralUsd, debtUsd, equityUsd: equity, ltv: debtUsd / collateralUsd, leverage: equity > 0 ? collateralUsd / equity : null } : null; const wait = async (hash: `0x${string}`) => { const rc = await client?.waitForTransactionReceipt({ hash }); if (rc?.status === "reverted") throw new Error("Transaction failed"); }; async function sharePrice() { const one = await client!.readContract({ address: loop!.market.collateralToken, abi: erc4626Abi, functionName: "convertToAssets", args: [BigInt(10) ** BigInt(shareDecimals)] }); return sharePriceE27(one, shareDecimals); } async function authorize() { if (authorized) return; setStep("Allowing Morpho's adapter to borrow for you…"); await wait(await writeContractAsync({ address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "setAuthorization", args: [GENERAL_ADAPTER_1, true] })); } /** Put `amount` USDC in at `leverage` (2 = twice your deposit earns the vault rate). */ async function open(amount: string, leverage: number) { if (!loop || !vault || !address || !client) return; const deposit = parseUnits(amount || "0", 6); if (deposit === ZERO) return; const borrow = (deposit * BigInt(Math.round((leverage - 1) * 10_000))) / BigInt(10_000); try { if ((allowance ?? ZERO) < deposit) { setStep("Approving USDC…"); await wait(await writeContractAsync({ address: vault.asset, abi: approveAbi, functionName: "approve", args: [GENERAL_ADAPTER_1, deposit] })); } await authorize(); setStep("Looping…"); await wait(await writeContractAsync(buildLoop({ user: address, vault: loop.market.collateralToken, market: parseMarket(loop.market), depositAssets: deposit, borrowAssets: borrow, sharePriceE27: await sharePrice() }))); } finally { setStep(null); reads.refetch(); } } /** Close the whole loop: repay the debt, take the collateral back and receive the rest as USDC. Needs available * liquidity in the vault for the redeem. */ async function close() { if (!loop || !address || !client || debtUnits === ZERO) return; try { await authorize(); setStep("Closing…"); const minPrice = ((await sharePrice()) * BigInt(9_990)) / BigInt(10_000); await wait(await writeContractAsync(buildUnloop({ user: address, vault: loop.market.collateralToken, market: parseMarket(loop.market), debtAssets: (debtUnits * BigInt(10_010)) / BigInt(10_000) + BigInt(1), minSharePriceE27: minPrice }))); } finally { setStep(null); reads.refetch(); } } return { address, position, walletUsdc: usdc == null ? null : Number(formatUnits(usdc, 6)), open, close, step, isPending: step != null, bundler: BUNDLER3 }; } const MP = "(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)"; const LEND_ABI = parseAbi([ `function supply(${MP} marketParams, uint256 assets, uint256 shares, address onBehalf, bytes data) returns (uint256, uint256)`, `function withdraw(${MP} marketParams, uint256 assets, uint256 shares, address onBehalf, address receiver) returns (uint256, uint256)`, ]); /** Lend USDC in the mcUSDC / USDC market — to anyone borrowing against mcUSDC — and take it back. Users sign in their * own wallet: approve USDC to Morpho once, then supply. Withdrawing takes what isn't borrowed at the time. */ export function useMercatoLend(data?: MercatoData) { const { address } = useAccount(); const client = usePublicClient(); const { writeContractAsync } = useWriteContract(); const [step, setStep] = useState<string | null>(null); const loop = data?.loop; const usdc = loop?.market.loanToken; const reads = useReadContracts({ allowFailure: true, contracts: loop && usdc && address ? [ { address: usdc, abi: erc20Abi, functionName: "balanceOf", args: [address] }, { address: usdc, abi: erc20Abi, functionName: "allowance", args: [address, MORPHO_BLUE] }, { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "position", args: [loop.marketId, address] }, { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "market", args: [loop.marketId] }, ] : [], query: { enabled: !!(loop && address), refetchInterval: 30_000 }, }); const r = reads.data ?? []; const wallet = r[0]?.result as bigint | undefined; const allowance = r[1]?.result as bigint | undefined; const pos = r[2]?.result as readonly [bigint, bigint, bigint] | undefined; const mkt = r[3]?.result as readonly bigint[] | undefined; const supplied = pos && mkt && mkt[1]! > ZERO ? (pos[0] * mkt[0]!) / mkt[1]! : undefined; const wait = async (hash: `0x${string}`) => { const rc = await client?.waitForTransactionReceipt({ hash }); if (rc?.status === "reverted") throw new Error("Transaction failed"); }; const run = async (f: () => Promise<void>) => { try { await f(); } finally { setStep(null); reads.refetch(); } }; const lend = (amount: string) => run(async () => { if (!loop || !usdc || !address) return; const units = parseUnits(amount || "0", 6); if (units === ZERO) return; if ((allowance ?? ZERO) < units) { setStep("Approving USDC…"); await wait(await writeContractAsync({ address: usdc, abi: erc20Abi, functionName: "approve", args: [MORPHO_BLUE, units] })); } setStep("Lending…"); await wait(await writeContractAsync({ address: MORPHO_BLUE, abi: LEND_ABI, functionName: "supply", args: [parseMarket(loop.market), units, ZERO, address, "0x"] })); }); const withdrawAll = () => run(async () => { if (!loop || !address || !pos || pos[0] === ZERO) return; setStep("Withdrawing…"); await wait(await writeContractAsync({ address: MORPHO_BLUE, abi: LEND_ABI, functionName: "withdraw", args: [parseMarket(loop.market), ZERO, pos[0], address, address] })); }); return { address, step, isPending: step != null, walletUsdc: wallet == null ? undefined : Number(formatUnits(wallet, 6)), lentUsd: supplied == null ? undefined : Number(formatUnits(supplied, 6)), lend, withdrawAll, }; } const ORACLE_ABI = parseAbi(["function price() view returns (uint256)"]); /** Net rate on the borrowed dollars of a collateral route: the vault rate − the borrow rate + rewards. */ export const routeRate = (vaultRate: number | null | undefined, borrowRate: number | null | undefined, rewardsApr = 0) => vaultRate == null || borrowRate == null ? null : vaultRate - borrowRate + rewardsApr; /** The wallet's position on a collateral route, and one-transaction open / close (Bundler3, like the loop). The * collateral is priced with the market's own oracle, so the LTV chosen is the LTV Morpho sees. */ export function useMercatoCollateralRoute(data: MercatoData | undefined, id: string) { const { address } = useAccount(); const client = usePublicClient(); const { writeContractAsync } = useWriteContract(); const [step, setStep] = useState<string | null>(null); const route = data?.collateralRoutes?.find((r) => r.id === id); const vault = data?.vault; const live = !!(route?.marketId && route.market && vault && address); const reads = useReadContracts({ allowFailure: true, contracts: live ? [ { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "position", args: [route!.marketId!, address!] }, { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "market", args: [route!.marketId!] }, { address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "isAuthorized", args: [address!, GENERAL_ADAPTER_1] }, { address: route!.collateral.address, abi: erc20Abi, functionName: "allowance", args: [address!, GENERAL_ADAPTER_1] }, { address: route!.market!.oracle, abi: ORACLE_ABI, functionName: "price" }, ] : [], query: { enabled: live, refetchInterval: 30_000 }, }); // The collateral balance is useful before the market exists too (to show what you could put in). const bal = useReadContracts({ allowFailure: true, contracts: route && address ? [{ address: route.collateral.address, abi: erc20Abi, functionName: "balanceOf", args: [address] }] : [], query: { enabled: !!(route && address), refetchInterval: 30_000 }, }); const r = reads.data ?? []; const pos = r[0]?.result as readonly [bigint, bigint, bigint] | undefined; const mkt = r[1]?.result as readonly [bigint, bigint, bigint, bigint, bigint, bigint] | undefined; const authorized = r[2]?.result as boolean | undefined; const allowance = r[3]?.result as bigint | undefined; const oraclePrice = r[4]?.result as bigint | undefined; // 1e36 × USDC units per collateral unit const walletRaw = bal.data?.[0]?.result as bigint | undefined; const dec = route?.collateral.decimals ?? 18; const debtUnits = pos && mkt && mkt[3] > ZERO ? (pos[1] * mkt[2] + mkt[3] - BigInt(1)) / mkt[3] : ZERO; const E36 = BigInt(10) ** BigInt(36); const collateralUsd = pos && oraclePrice ? Number(formatUnits((pos[2] * oraclePrice) / E36, 6)) : 0; const debtUsd = Number(formatUnits(debtUnits, 6)); const collateralAmount = pos ? Number(formatUnits(pos[2], dec)) : 0; const position = collateralAmount > 0 ? { collateral: collateralAmount, collateralUsd, debtUsd, ltv: collateralUsd > 0 ? debtUsd / collateralUsd : null, /** The collateral price at which the position can be liquidated. */ liquidationPriceUsd: route && collateralAmount > 0 ? debtUsd / (collateralAmount * route.lltv) : null, } : null; const wait = async (hash: `0x${string}`) => { const rc = await client?.waitForTransactionReceipt({ hash }); if (rc?.status === "reverted") throw new Error("Transaction failed"); }; const sharePrice = async () => sharePriceE27(await client!.readContract({ address: vault!.address, abi: erc4626Abi, functionName: "convertToAssets", args: [BigInt(10) ** BigInt(18)] }), 18); async function authorize() { if (authorized) return; setStep("Allowing Morpho's adapter to borrow for you…"); await wait(await writeContractAsync({ address: MORPHO_BLUE, abi: MORPHO_ABI, functionName: "setAuthorization", args: [GENERAL_ADAPTER_1, true] })); } /** Put `amount` of the collateral in and borrow `ltv` of its value (at the oracle's price), held as mcUSDC. */ async function open(amount: string, ltv: number) { if (!route?.market || !vault || !address || !client || !oraclePrice) return; const collateral = parseUnits(amount || "0", dec); if (collateral === ZERO) return; const borrow = (((collateral * oraclePrice) / E36) * BigInt(Math.round(ltv * 10_000))) / BigInt(10_000); try { if ((allowance ?? ZERO) < collateral) { setStep(`Approving ${route.collateral.symbol}…`); await wait(await writeContractAsync({ address: route.collateral.address, abi: approveAbi, functionName: "approve", args: [GENERAL_ADAPTER_1, collateral] })); } await authorize(); setStep("Borrowing and supplying…"); await wait(await writeContractAsync(buildRoute({ user: address, vault: vault.address, market: parseMarket(route.market), collateralAssets: collateral, borrowAssets: borrow, sharePriceE27: await sharePrice() }))); } finally { setStep(null); reads.refetch(); bal.refetch(); } } /** Close the whole route: redeem enough of the wallet's mcUSDC to repay the debt (topping up from the wallet's USDC * if the mcUSDC is short), and take all the collateral back. Needs available liquidity in the vault. */ async function close() { if (!route?.market || !vault || !address || !client || debtUnits === ZERO) return; try { const price = await sharePrice(); const [mcBal, usdcBal] = await Promise.all([ client.readContract({ address: vault.address, abi: erc20Abi, functionName: "balanceOf", args: [address] }), client.readContract({ address: vault.asset, abi: erc20Abi, functionName: "balanceOf", args: [address] }), ]); const debtBuffered = (debtUnits * BigInt(10_010)) / BigInt(10_000) + BigInt(1); const sharesNeeded = (debtBuffered * BigInt(10) ** BigInt(27)) / price + BigInt(1); const shares = sharesNeeded < mcBal ? sharesNeeded : mcBal; const covered = (shares * price) / BigInt(10) ** BigInt(27); const topUp = covered >= debtBuffered ? ZERO : debtBuffered - covered; if (topUp > usdcBal) throw new Error("Not enough mcUSDC and USDC in the wallet to repay"); setStep("Approving mcUSDC…"); await wait(await writeContractAsync({ address: vault.address, abi: approveAbi, functionName: "approve", args: [GENERAL_ADAPTER_1, shares] })); if (topUp > ZERO) { setStep("Approving USDC…"); await wait(await writeContractAsync({ address: vault.asset, abi: approveAbi, functionName: "approve", args: [GENERAL_ADAPTER_1, topUp] })); } await authorize(); setStep("Closing…"); await wait(await writeContractAsync(buildUnroute({ user: address, vault: vault.address, market: parseMarket(route.market), shares, minSharePriceE27: (price * BigInt(9_990)) / BigInt(10_000), topUpAssets: topUp > ZERO ? topUp : undefined }))); } finally { setStep(null); reads.refetch(); bal.refetch(); } } return { route, address, position, open, close, step, isPending: step != null, walletCollateral: walletRaw == null ? null : Number(formatUnits(walletRaw, dec)), }; } export const fmtPct = (x: number | null | undefined, dp = 2) => (x == null || !Number.isFinite(x) ? "—" : `${(x * 100).toFixed(dp)}%`); export const fmtUsd = (x: number | null | undefined, dp = 0) => x == null || !Number.isFinite(x) ? "—" : `$${x.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })}`; export const RISK_LABEL = { 1: "Lower risk", 2: "Medium risk", 3: "Higher risk" } as const; /** The one line every Mercato surface carries (docs/positioning.md copy rules). */ export const MERCATO_DISCLOSURE = "Rates are variable and measured, never promised. Withdraw from available liquidity. mcUSDC is your share of Mercato USDC, a vault on Morpho. You can lose money.";lib/mercato-loop.ts import { encodeAbiParameters, encodeFunctionData, keccak256, parseAbi, type Address, type Hex } from "viem"; // Mercato kit — one-click loop (looping) on mcUSDC / USDC. https://mercato.sh/developers /* One-click loop on a Morpho market whose collateral is an ERC-4626 share of the loan asset (mcUSDC / USDC). Built on Morpho's Bundler3 + GeneralAdapter1 (morpho-org/bundler3): a flash loan of USDC, deposited into the vault with the user's own USDC, the shares supplied as collateral, and USDC borrowed to repay the flash loan — one transaction, no swap, so no price slippage. Bundler3 is marked deprecated in favour of Morpho's "Bundles", which have no flash loan or leverage function; Bundler3 stays deployed and immutable (docs/addresses.md). */ /** Source: docs.morpho.org/get-started/resources/addresses (Bundler3 section, Ethereum), read 2026-10-07; * GeneralAdapter1.BUNDLER3() checked on-chain. */ export const BUNDLER3 = "0x6566194141eefa99Af43Bb5Aa71460Ca2Dc90245" as const; export const GENERAL_ADAPTER_1 = "0x4A6c312ec70E8747a587EE860a0353cd42Be0aE0" as const; export const MORPHO_BLUE = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" as const; export type MarketParams = { loanToken: Address; collateralToken: Address; oracle: Address; irm: Address; lltv: bigint }; const MP = "(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)"; export const BUNDLER3_ABI = parseAbi(["struct Call { address to; bytes data; uint256 value; bool skipRevert; bytes32 callbackHash; }", "function multicall(Call[] bundle) payable"]); export const ADAPTER_ABI = parseAbi([ "function erc20TransferFrom(address token, address receiver, uint256 amount)", "function erc20Transfer(address token, address receiver, uint256 amount)", "function erc4626Deposit(address vault, uint256 assets, uint256 maxSharePriceE27, address receiver)", "function erc4626Redeem(address vault, uint256 shares, uint256 minSharePriceE27, address receiver, address owner)", "function morphoFlashLoan(address token, uint256 assets, bytes data)", `function morphoSupplyCollateral(${MP} marketParams, uint256 assets, address onBehalf, bytes data)`, `function morphoBorrow(${MP} marketParams, uint256 assets, uint256 shares, uint256 minSharePriceE27, address receiver)`, `function morphoRepay(${MP} marketParams, uint256 assets, uint256 shares, uint256 maxSharePriceE27, address onBehalf, bytes data)`, `function morphoWithdrawCollateral(${MP} marketParams, uint256 assets, address receiver)`, ]); export const MORPHO_ABI = parseAbi([ "function isAuthorized(address authorizer, address authorized) view returns (bool)", "function setAuthorization(address authorized, bool newIsAuthorized)", "function position(bytes32 id, address user) view returns (uint256 supplyShares, uint128 borrowShares, uint128 collateral)", "function market(bytes32 id) view returns (uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee)", ]); type Call = { to: Address; data: Hex; value: bigint; skipRevert: boolean; callbackHash: Hex }; const ZERO32 = `0x${"0".repeat(64)}` as Hex; const MAX = BigInt(2) ** BigInt(256) - BigInt(1); const E27 = BigInt(10) ** BigInt(27); const BPS = BigInt(10_000); const call = (data: Hex, callbackHash: Hex = ZERO32): Call => ({ to: GENERAL_ADAPTER_1, data, value: BigInt(0), skipRevert: false, callbackHash }); const fn = (functionName: any, args: readonly unknown[]) => encodeFunctionData({ abi: ADAPTER_ABI, functionName, args } as any); /** What GeneralAdapter1 passes to Bundler3.reenter in the flash-loan callback; Bundler3 checks keccak256 of it. */ const encodeCalls = (inner: Call[]): Hex => encodeAbiParameters( [{ type: "tuple[]", components: [{ name: "to", type: "address" }, { name: "data", type: "bytes" }, { name: "value", type: "uint256" }, { name: "skipRevert", type: "bool" }, { name: "callbackHash", type: "bytes32" }] }], [inner]); /** Leverage numbers for a target LTV: put in `deposit`, end with `deposit / (1 − ltv)` of collateral and borrow the rest. */ export function loopPlan(deposit: number, targetLtv: number, lltv: number, vaultApy: number, borrowApy: number) { const leverage = 1 / (1 - targetLtv); const collateral = deposit * leverage, borrow = collateral - deposit; return { leverage, collateral, borrow, netApy: vaultApy * leverage - borrowApy * (leverage - 1), /** How far the share price can fall before the position can be liquidated. */ dropToLiquidation: 1 - targetLtv / lltv, }; } /** The highest target LTV the app offers: 90% of LLTV (the same margin as `loopPresets` in the SDK). */ export const maxTargetLtv = (lltv: number) => lltv * 0.9; /** Bundler3.multicall args to open (or add to) a loop. `sharePriceE27` = assets per share × 1e27 (convertToAssets); * the deposit reverts if the vault's price is more than `slippageBps` above it. The user must first approve * `depositAssets` USDC to GeneralAdapter1 and authorize it on Morpho (`loopPrerequisites`). */ export function buildLoop(p: { user: Address; vault: Address; market: MarketParams; depositAssets: bigint; borrowAssets: bigint; sharePriceE27: bigint; slippageBps?: number }) { const slip = BigInt(p.slippageBps ?? 10); const inner: Call[] = [ call(fn("erc4626Deposit", [p.vault, MAX, (p.sharePriceE27 * (BPS + slip)) / BPS, GENERAL_ADAPTER_1])), call(fn("morphoSupplyCollateral", [p.market, MAX, p.user, "0x"])), call(fn("morphoBorrow", [p.market, p.borrowAssets, BigInt(0), BigInt(0), GENERAL_ADAPTER_1])), ]; const data = encodeCalls(inner); const bundle: Call[] = [ call(fn("erc20TransferFrom", [p.market.loanToken, GENERAL_ADAPTER_1, p.depositAssets])), call(fn("morphoFlashLoan", [p.market.loanToken, p.borrowAssets, data]), keccak256(data)), ]; return { address: BUNDLER3, abi: BUNDLER3_ABI, functionName: "multicall" as const, args: [bundle] as const }; } /** Bundler3.multicall args to close a loop entirely: flash-borrow the debt, repay it, take the collateral back, * redeem it in the vault and send what is left to the user as USDC. Needs the vault to have available liquidity * for the redeem; `debtAssets` should include a small buffer over the current debt (the unused part comes back). */ export function buildUnloop(p: { user: Address; vault: Address; market: MarketParams; debtAssets: bigint; minSharePriceE27: bigint }) { const inner: Call[] = [ call(fn("morphoRepay", [p.market, BigInt(0), MAX, MAX, p.user, "0x"])), call(fn("morphoWithdrawCollateral", [p.market, MAX, GENERAL_ADAPTER_1])), call(fn("erc4626Redeem", [p.vault, MAX, p.minSharePriceE27, GENERAL_ADAPTER_1, GENERAL_ADAPTER_1])), ]; const data = encodeCalls(inner); const bundle: Call[] = [ call(fn("morphoFlashLoan", [p.market.loanToken, p.debtAssets, data]), keccak256(data)), call(fn("erc20Transfer", [p.market.loanToken, p.user, MAX])), ]; return { address: BUNDLER3, abi: BUNDLER3_ABI, functionName: "multicall" as const, args: [bundle] as const }; } /** Share price in the units GeneralAdapter1 checks: assets per share, scaled by 1e27. */ export const sharePriceE27 = (assetsPerOneShare: bigint, shareDecimals: number) => (assetsPerOneShare * E27) / BigInt(10) ** BigInt(shareDecimals); /* ---- Collateral routes: `AVT → mcUSDC` (docs/avt.md §2.2), later `cbBTC → mcUSDC` (markets-roadmap row 8) ---- Supply the user's collateral to a Morpho market that lends USDC, borrow USDC, deposit it into the vault for the user — one transaction, no flash loan, no swap. The user keeps the collateral exposure and holds mcUSDC; the net rate on the borrowed dollars is the vault rate − the borrow rate (+ any rewards). */ /** Numbers for a route at a target LTV: `collateralUsd` of collateral, borrow `targetLtv` of it. */ export function routePlan(collateralUsd: number, targetLtv: number, lltv: number, vaultApy: number, borrowApy: number, rewardsApr = 0) { const borrow = collateralUsd * targetLtv; const spread = vaultApy - borrowApy; return { borrow, /** Net rate on the borrowed dollars, and the same in dollars a year as a share of the collateral. */ netOnBorrowed: spread + rewardsApr, netOnCollateral: (spread + rewardsApr) * targetLtv, /** How far the collateral's price can fall before the position can be liquidated. */ dropToLiquidation: 1 - targetLtv / lltv, }; } /** Bundler3.multicall args to open a route. Prerequisites: approve `collateralAssets` of the collateral token to * GeneralAdapter1 and authorize it on Morpho (`loopPrerequisites`). The deposit reverts if the vault's share price is * more than `slippageBps` above `sharePriceE27`. */ export function buildRoute(p: { user: Address; vault: Address; market: MarketParams; collateralAssets: bigint; borrowAssets: bigint; sharePriceE27: bigint; slippageBps?: number }) { const slip = BigInt(p.slippageBps ?? 10); const bundle: Call[] = [ call(fn("erc20TransferFrom", [p.market.collateralToken, GENERAL_ADAPTER_1, p.collateralAssets])), call(fn("morphoSupplyCollateral", [p.market, MAX, p.user, "0x"])), call(fn("morphoBorrow", [p.market, p.borrowAssets, BigInt(0), BigInt(0), GENERAL_ADAPTER_1])), call(fn("erc4626Deposit", [p.vault, MAX, (p.sharePriceE27 * (BPS + slip)) / BPS, p.user])), ]; return { address: BUNDLER3, abi: BUNDLER3_ABI, functionName: "multicall" as const, args: [bundle] as const }; } /** Bundler3.multicall args to close a route entirely: redeem `shares` of the user's mcUSDC, repay the whole debt, * return all the collateral and any USDC left over. If the redeemed USDC is short of the debt (the borrow rate ran * above the vault rate), `topUpAssets` of the user's USDC covers it; the unused part comes back. Prerequisites: * approve `shares` mcUSDC (and `topUpAssets` USDC) to GeneralAdapter1; it is already authorized on Morpho. Needs * the vault to have available liquidity for the redeem. */ export function buildUnroute(p: { user: Address; vault: Address; market: MarketParams; shares: bigint; minSharePriceE27: bigint; topUpAssets?: bigint }) { const bundle: Call[] = [ call(fn("erc20TransferFrom", [p.vault, GENERAL_ADAPTER_1, p.shares])), call(fn("erc4626Redeem", [p.vault, MAX, p.minSharePriceE27, GENERAL_ADAPTER_1, GENERAL_ADAPTER_1])), ...(p.topUpAssets ? [call(fn("erc20TransferFrom", [p.market.loanToken, GENERAL_ADAPTER_1, p.topUpAssets]))] : []), call(fn("morphoRepay", [p.market, BigInt(0), MAX, MAX, p.user, "0x"])), call(fn("morphoWithdrawCollateral", [p.market, MAX, p.user])), call(fn("erc20Transfer", [p.market.loanToken, p.user, MAX])), ]; return { address: BUNDLER3, abi: BUNDLER3_ABI, functionName: "multicall" as const, args: [bundle] as const }; }components/mercato-routes.tsx "use client"; // Mercato kit — preset ways to earn on USDC, in plain language, as cards. https://mercato.sh/developers // `layout="grid"` (default): cards in a grid when there's room, a swipeable row when there isn't. // `layout="list"`: compact rows in one column — for a side column next to a bigger summary (the Earn block). The rows // share out the height they're given, so the list can run the full height of its column. import { CalendarClock, CircleDollarSign, Rocket, Sparkles, Waves, type LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { RISK_LABEL, fmtPct, fmtUsd, type MercatoRoute } from "@/lib/mercato"; /** Each preset's mark and tint. Unknown ids (your own routes) get a coin in your primary colour. */ const LOOK: Record<string, { icon: LucideIcon; glow: string; ink: string }> = { flexible: { icon: Waves, glow: "from-sky-500/25", ink: "text-sky-500" }, fixed: { icon: CalendarClock, glow: "from-violet-500/25", ink: "text-violet-500" }, points: { icon: Sparkles, glow: "from-amber-500/25", ink: "text-amber-500" }, boosted: { icon: Rocket, glow: "from-rose-500/25", ink: "text-rose-500" }, }; export const routeLook = (id: string) => LOOK[id] ?? { icon: CircleDollarSign, glow: "from-primary/25", ink: "text-primary" }; const RISK_INK = { 1: "bg-emerald-500", 2: "bg-amber-500", 3: "bg-rose-500" } as const; export function MercatoRoutes({ routes, value, onValueChange, amount = 0, layout = "grid", className }: { routes: MercatoRoute[]; value?: string; onValueChange?: (id: string) => void; amount?: number; layout?: "grid" | "list"; className?: string; }) { if (layout === "list") return ( <div role="radiogroup" aria-label="How to earn" className={cn("flex flex-col gap-2", className)}> {routes.map((r) => { const on = r.id === value; const { icon: Icon, glow, ink } = routeLook(r.id); return ( <button key={r.id} type="button" role="radio" aria-checked={on} onClick={() => onValueChange?.(r.id)} className={cn("group relative flex min-h-[120px] shrink-0 grow flex-col justify-between gap-3 overflow-hidden rounded-xl border p-4 text-left transition duration-200", on ? "border-primary bg-primary/[0.06] ring-1 ring-primary" : "border-border bg-card hover:border-foreground/25")}> <span aria-hidden className={cn("pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b to-transparent transition-opacity", glow, on ? "opacity-100" : "opacity-40 group-hover:opacity-80")} /> <span className="relative flex items-start gap-3"> <span className={cn("grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-background/80", ink)}><Icon className="size-[18px]" /></span> <span className="min-w-0 flex-1"> <span className="block font-medium text-card-foreground">{r.title}</span> <span className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{r.summary}</span> </span> <span className="flex shrink-0 flex-col items-end gap-1.5"> <span className="mercato-figure text-2xl leading-none tabular-nums text-card-foreground">{fmtPct(r.rate, 1)}</span> {!r.available && <span className="rounded-full border border-dashed border-border px-1.5 text-[10px] text-muted-foreground">at launch</span>} </span> </span> <span className="relative flex items-center justify-between gap-2 border-t border-border/70 pt-2.5 text-[11px] text-muted-foreground"> <span className="flex items-center gap-1.5" title={r.riskNote}> {[1, 2, 3].map((i) => <span key={i} className={cn("h-1 w-3 rounded-full", i <= r.risk ? RISK_INK[r.risk] : "bg-secondary")} />)} <span className="ml-0.5">{RISK_LABEL[r.risk]}</span> </span> <span className="rounded-full bg-secondary px-2 py-0.5 text-secondary-foreground"><span className="mercato-figure tabular-nums">{r.pointsPerUsdDay}</span> pts/$/day</span> </span> </button> ); })} </div> ); return ( <div className={cn("@container", className)}> <div role="radiogroup" aria-label="How to earn" className="-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-2 [scrollbar-width:none] @lg:mx-0 @lg:grid @lg:grid-cols-2 @lg:overflow-visible @lg:px-0 @lg:pb-0"> {routes.map((r) => { const on = r.id === value; const { icon: Icon, glow, ink } = routeLook(r.id); return ( <button key={r.id} type="button" role="radio" aria-checked={on} onClick={() => onValueChange?.(r.id)} className={cn("group relative flex w-[80%] shrink-0 snap-start flex-col gap-4 overflow-hidden rounded-2xl border p-4 text-left transition duration-200 @lg:w-auto", on ? "border-primary bg-primary/[0.06] ring-1 ring-primary" : "border-border bg-card hover:-translate-y-0.5 hover:border-foreground/25")}> <span aria-hidden className={cn("pointer-events-none absolute inset-x-0 top-0 h-28 bg-gradient-to-b to-transparent transition-opacity duration-300", glow, on ? "opacity-100" : "opacity-50 group-hover:opacity-90")} /> <span className="relative flex items-start justify-between gap-3"> <span className={cn("grid size-10 place-items-center rounded-xl border border-border bg-background/80", ink)}><Icon className="size-5" /></span> <span className={cn("grid size-5 place-items-center rounded-full border transition", on ? "border-primary bg-primary" : "border-muted-foreground/40")}> {on && <svg viewBox="0 0 12 12" className="size-3 text-primary-foreground"><path d="M2.5 6.2 5 8.5l4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>} </span> </span> <span className="relative"> <span className="flex flex-wrap items-center gap-2"> <span className="font-medium text-card-foreground">{r.title}</span> {!r.available && <span className="rounded-full border border-dashed border-border px-2 text-[11px] text-muted-foreground">at launch</span>} </span> <span className="mt-1 block text-sm text-muted-foreground">{r.summary}</span> </span> <span className="relative mt-auto"> <span className="mercato-figure block text-3xl leading-none tabular-nums text-card-foreground">{fmtPct(r.rate, 1)}</span> <span className="mt-1.5 block text-xs text-muted-foreground"> {amount > 0 && r.rate != null ? <>≈ <span className="mercato-figure tabular-nums">{fmtUsd(amount * r.rate)}</span> a year · {r.rateNote}</> : r.rateNote} </span> </span> <span className="relative flex flex-wrap items-center justify-between gap-2 border-t border-border pt-3 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5" title={r.riskNote}> {[1, 2, 3].map((i) => <span key={i} className={cn("h-1.5 w-4 rounded-full", i <= r.risk ? RISK_INK[r.risk] : "bg-secondary")} />)} {RISK_LABEL[r.risk]} </span> <span className="rounded-full bg-secondary px-2 py-0.5 text-secondary-foreground">{r.pointsPerUsdDay} pts/$/day</span> </span> </button> ); })} </div> </div> ); }Update the import paths to match your project setup
The files import
@/lib/utils(shadcn'scn),@/lib/mercatoand@/components/….
Usage
import { useState } from "react"
import { MercatoRoutes } from "@/components/mercato-routes"
import { useMercato } from "@/lib/mercato"
export function Pick() {
const { data } = useMercato()
const [id, setId] = useState("flexible")
return <MercatoRoutes routes={data?.routes ?? []} value={id} onValueChange={setId} amount={1000} />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
routes | MercatoRoute[] | — | From useMercato().data.routes. |
value | string | — | Selected route id. |
onValueChange | (id: string) => void | — | Called when a route is picked. |
amount | number | 0 | USDC amount used for the per-year estimate. |
layout | "grid" | "list" | "grid" | Cards in a grid, or compact rows in one column (what the Earn block uses beside its plan). |
className | string | — | Classes on the outer element. |