Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Copy to .env.local for local dev. Never commit real values.

# Public site URL (used in metadata, image URLs, share cards)
Expand Down Expand Up @@ -49,6 +49,9 @@
# 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=
24 changes: 24 additions & 0 deletions app/src/app/api/earn/earn-route.test.ts
Original file line number Diff line number Diff line change
@@ -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"/);
});
20 changes: 20 additions & 0 deletions app/src/app/api/earn/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
91 changes: 91 additions & 0 deletions app/src/app/ui-review-earn/EarnReview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"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,
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 () => {}, recheck: async () => {},
};
const typed = (text: string, amount: bigint): Partial<EarnView> => ({ text, amount, problem: null });
const hash = `0x${"ab".repeat(32)}` as const;
const deposited: Partial<EarnView> = { shares: 478_838_632_555_005_049_702n, position: 500_000_000n };

const scenes: { id: string; title: string; view: Partial<EarnView>; 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: "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) } },
{ 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), 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 },
];

/** The sheet drawn in place (no modal), with one scene's synthetic state. */
function Panel({ view, snap }: { view: EarnView; snap: EarnVaultSnapshot | null }) {
return (
<div className={`${styles.panel} ${styles.static}`}>
<EarnShell vault={vault} title={<h2 className={styles.title}>Earn on USDC</h2>} close={<button type="button" className={styles.close} aria-label="Close"><X size={19} aria-hidden /></button>}
description={<p className={styles.description}>Lend your USDC through a Morpho vault on Base. Take it out whenever the vault has it free.</p>}>
<EarnPanel view={view} snapshot={snap} onConnect={noop} />
</EarnShell>
</div>
);
}

/** Every scene, or one with ?scene=<id> (?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");
// ?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 <main className="mx-auto max-w-3xl p-6"><EarnCardView vault={vault} snapshot={snapshot} balance={1_250_500_000n} position={500_000_000n} onOpen={noop} /><EarnCardView vault={vault} snapshot={null} balance={0n} position={0n} onOpen={noop} /></main>;
const shown = only ? scenes.filter((s) => s.id === only) : scenes;
return (
<main className="mx-auto max-w-[1500px] p-6">
{!only ? (
<section className="mb-8 max-w-3xl">
<h1 className="mb-3 text-lg font-semibold">/me card</h1>
<EarnCardView vault={vault} snapshot={snapshot} balance={1_250_500_000n} position={500_000_000n} onOpen={noop} />
<EarnCardView vault={vault} snapshot={null} balance={0n} position={0n} onOpen={noop} />
</section>
) : null}
<div className="grid gap-8" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(min(460px, 100%), 1fr))" }}>
{shown.map((s) => (
<section key={s.id} aria-label={s.title}>
<h2 className="mb-2 text-sm font-semibold text-muted">{s.title} <code className="font-mono text-xs">?scene={s.id}</code></h2>
<Panel view={{ ...base, ...s.view }} snap={s.snap === undefined ? snapshot : s.snap} />
</section>
))}
</div>
</main>
);
}
11 changes: 11 additions & 0 deletions app/src/app/ui-review-earn/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <Suspense><EarnReview /></Suspense>;
}
5 changes: 4 additions & 1 deletion app/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof paletteTokenFrom>[0][] };

Expand Down Expand Up @@ -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 = () => {
Expand Down
89 changes: 89 additions & 0 deletions app/src/components/earn/EarnCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"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 <EarnCardLive key={address.toLowerCase()} vault={vault} snapshot={entry.snapshot} />;
}

/** 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
const [activated, setActivated] = useState(false);
const [open, setOpen] = useState(false);
const section = useRef<HTMLElement>(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" });
}, []);
/** 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) {
earn.reset();
earn.setTab(tab);
}
setActivated(true);
setOpen(true);
}
return (
<>
<EarnCardView ref={section} vault={vault} snapshot={snapshot} balance={earn.balance} position={earn.position} onOpen={show} />
{activated ? <EarnDialog open={open} onOpenChange={setOpen} earn={earn} snapshot={snapshot} /> : 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<HTMLElement>; 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) => (
<div className="min-w-0 rounded-xl border border-line bg-paper px-4 py-3">
<p className="text-xs text-muted">{label}</p>
<p className="mt-1 truncate font-mono text-xl font-bold text-ink tnum">{value}</p>
<p className="mt-1 truncate text-[11px] text-muted">{hint}</p>
</div>
);
return (
<section ref={ref} id="earn" aria-labelledby="earn-card" className="mb-4 scroll-mt-24 rounded-2xl border border-line bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 id="earn-card" className="inline-flex items-center gap-2 text-sm font-semibold text-ink"><Landmark size={15} className="text-brand" aria-hidden="true" />Earn on idle {sym}</h2>
<p className="text-xs text-muted">{CHAIN_LABELS[vault.chain]} · {vault.name}</p>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
{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")}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button type="button" className={btn.primarySm} onClick={() => onOpen("deposit")} aria-haspopup="dialog"><ArrowDownToLine size={14} aria-hidden="true" />Deposit</button>
<button type="button" className={btn.secondarySm} onClick={() => onOpen("withdraw")} disabled={!position} aria-haspopup="dialog"><ArrowUpFromLine size={14} aria-hidden="true" />Withdraw</button>
<p className="ml-auto text-[11px] text-muted">Run by {vault.curator} on {vault.protocol}. Can lose money. No openlaunch fee.</p>
</div>
</section>
);
}
Loading
Loading