Launch a token
Pick a chain, fill this in, sign once, done. No platform fee. You only pay gas.
-diff --git a/README.md b/README.md index a10be2dc..66383a67 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ Creators choose a 0 / 1 / 3 % trading fee that is pushed **in full** to benefici - **Rug-proof liquidity.** The position NFT lives in a locker with no owner, no pause, no upgrade path and no transfer function. Proven with an adversarial fork suite against the live deployment. - **Two chains, one set of addresses.** Base and Robinhood Chain, same contracts, same rules. -- **Any quote asset.** ETH, USDG, or a **tokenized stock**: Coinbase's B20 stocks on Base +- **Any quote asset.** ETH, USDG, **GITLAWB** ([Gitlawb](https://gitlawb.com)'s token, on Base and bridged 1:1 to + Robinhood Chain; priced from its Base Uniswap v4 WETH pool; fees paid or burned in GITLAWB), or a **tokenized stock**: Coinbase's B20 stocks on Base (NVDAc, AAPLc, TSLAc, …, priced from Chainlink on-chain feeds) and Robinhood Stock Tokens on Robinhood Chain. - **Trade in-page.** Buys and sells go straight to the Uniswap v4 pool through the Universal Router; the site never touches funds. diff --git a/app/public/gitlawb-mark.png b/app/public/gitlawb-mark.png new file mode 100644 index 00000000..3277a571 Binary files /dev/null and b/app/public/gitlawb-mark.png differ diff --git a/app/scripts/smoke.sh b/app/scripts/smoke.sh index a6009309..b9d0ecd1 100755 --- a/app/scripts/smoke.sh +++ b/app/scripts/smoke.sh @@ -4,7 +4,7 @@ set -u B="${1:-https://openlaunch.lol}" fail=0 check() { code=$(curl -s -o /dev/null -w '%{http_code}' "$B$1"); ok=0; case "$1" in /api/launch/meta/*) { [ "$code" = 200 ] || [ "$code" = 404 ]; } && ok=1;; /t/0x*) { [ "$code" = 307 ] || [ "$code" = 308 ]; } && ok=1;; *) [ "$code" = 200 ] && ok=1;; esac; [ $ok = 1 ] && echo " ok $code $1" || { echo " FAIL $code $1"; fail=1; }; } -for p in / /me /feed "/api/posts?feed=1" "/api/quotes?chain=robinhood&q=AAPL" "/?chain=robinhood" /launch "/launch?chain=robinhood" /rules /agents /llms.txt /api/health "/api/launch/live?sort=trending&window=1h" "/api/launch/live?sort=new&chain=robinhood" /api/launch/feed /api/presence; do check "$p"; done +for p in / /me /feed "/api/posts?feed=1" "/api/quotes?chain=robinhood&q=AAPL" "/api/quotes?chain=base" "/?filter=gitlawb" /gitlawb-mark.png "/?chain=robinhood" /launch "/launch?chain=robinhood" /rules /agents /llms.txt /api/health "/api/launch/live?sort=trending&window=1h" "/api/launch/live?sort=new&chain=robinhood" /api/launch/feed /api/presence; do check "$p"; done for c in base robinhood; do T=$(curl -s "$B/api/launch/list?chain=$c&limit=1" | python3 -c 'import json,sys; l=json.load(sys.stdin)["launches"]; print(l[0]["token"] if l else "")') if [ -n "$T" ]; then check "/t/$c/$T"; check "/t/$c/$T/opengraph-image"; check "/api/launch/meta/$T?chain=$c"; check "/t/$T"; else echo " skip no launches on $c"; fi diff --git a/app/src/app/agents/page.tsx b/app/src/app/agents/page.tsx index 930470c2..79a47b61 100644 --- a/app/src/app/agents/page.tsx +++ b/app/src/app/agents/page.tsx @@ -73,7 +73,7 @@ export default function AgentsPage() {
quote0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 and call findSalt(...) first so the token address sorts above the quote.quote0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3 on Base or 0xd1b0d44E4f6ed940fcC7A9F59Bf30Daf62cCFe3D on Robinhood Chain; for USDG on Robinhood Chain, 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168. For any ERC-20 quote call findSalt(...) first so the token address sorts above the quote.supply0 means the default 1B supply.startTick200; 184200 ≈ 10 ETH FDV for 1B supply.lpFee0, 10000 = 1%, or 30000 = 3%.Standard pools. Direct contract calls.
Pools are plain Uniswap v4, with tick spacing 200 and no hook. The quote can be ETH, USDG or a supported issuer-registry stock token. Get the actual pool key with poolKeyOf(token) on the factory. Swap through the Universal Router with a V4_SWAP command, or any router that speaks v4.
Pools are plain Uniswap v4, with tick spacing 200 and no hook. The quote can be ETH, GITLAWB (both chains), USDG (Robinhood Chain) or a supported issuer-registry stock token. Get the actual pool key with poolKeyOf(token) on the factory. Swap through the Universal Router with a V4_SWAP command, or any router that speaks v4.
Anyone may call collect(tokenId) on that chain's locker to pay out accrued fees. Base locker: {locker}. Use the matching chain address above.
Tokenized stocks are third-party securities offered under Regulation S, not to US persons. The site recognizes them from issuer registries, not on-chain names.
diff --git a/app/src/app/api/quotes/route.ts b/app/src/app/api/quotes/route.ts index 2ede9816..0adda648 100644 --- a/app/src/app/api/quotes/route.ts +++ b/app/src/app/api/quotes/route.ts @@ -5,12 +5,13 @@ import { ensureRegistry, stockList, stockPrices } from "@/lib/launchpad/stocksSe import { searchStocks } from "@/lib/launchpad/stocks"; import { searchBaseStocks, stockTileSvg } from "@/lib/launchpad/baseStocks"; import { memo } from "@/lib/launchpad/memo"; +import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer"; export const dynamic = "force-dynamic"; /** * GET /api/quotes?chain=Pick a chain, fill this in, sign once, done. No platform fee. You only pay gas.
-Price follows a single-sided Uniswap v4 curve: the first buyers get the most tokens per unit of the quote asset, and every buy moves the price up. Sells move it down.
There is no anti-snipe mechanism. Bots can buy in the first block like anyone else.
Nothing on-chain can be edited after launch: not the name, not the fee, not the beneficiaries. Metadata (image, description, links) is stored by this site and can change; the token itself cannot.
GITLAWB is Gitlawb's token on Base, bridged 1:1 to Robinhood Chain over LayerZero (one supply, two chains): an ordinary ERC-20 with no transfer restrictions and no issuer switch. Pick it as the quote and buyers pay in GITLAWB; any trading fee is paid in GITLAWB to the beneficiaries named at launch (by their shares), and a launch that names none burns GITLAWB on every trade.
Dollar figures for these pools come from the Uniswap v4 WETH/GITLAWB pool price on Base, cross-checked against a 30-minute on-chain average, and ETH/USD, on both chains. They are for display and sorting only; nothing on-chain depends on them.
Stock-quoted pools use tokenized stocks as the quote asset: Coinbase tokenized stocks (B20) on Base, recognised here only from Base's official list and priced from Chainlink's on-chain feeds; Robinhood Stock Tokens on Robinhood Chain, recognised only from Robinhood's own registry.
Both are securities issued by third parties under Regulation S and are not offered to US persons. Coinbase's also exclude the UK, Canada, Australia, Singapore and Switzerland. Their issuers can pause transfers or freeze wallets in restricted jurisdictions. Holding or trading them is subject to the issuer's terms, not ours.
Paired with {stockQuote.name} ({stockQuote.symbol}), a third-party tokenized stock. These securities are not offered to US persons. The quote asset is identified from the issuer registry, not its token name.
: null} + {quote.key === "gitlawb" ?Paired with GITLAWB, Gitlawb's token{chain === "robinhood" ? " (bridged 1:1 from Base over LayerZero; one supply, two chains)" : " on Base"}. {mode === "free" ? "This pool has no trading fee." : mode === "burn" ? "Every trading fee on this pool is burned as GITLAWB." : "Trading fees on this pool are paid out in GITLAWB."} USD figures use the Uniswap v4 WETH/GITLAWB pool price on Base.
: null} } />= { eth: ["0.01", "0.05", "0.1", "0.25"], usdg: ["25", "100", "250"], stock: [] }; +const BUY_PRESETS: Record= { eth: ["0.01", "0.05", "0.1", "0.25"], usdg: ["25", "100", "250"], gitlawb: ["500000", "1000000", "5000000"], stock: [] }; function randomSalt(): Hex { const b = new Uint8Array(32); @@ -121,7 +123,7 @@ async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, a return received > 0n ? { hash: h, out: received, exact: true } : { hash: h, out, exact: false }; } -export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: number | null; initialChain?: ChainKey }) { +export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "base" }: { ethUsd: number | null; gitlawbUsd?: number | null; initialChain?: ChainKey }) { const router = useRouter(); const [chain, setChain] = useState(initialChain); const cfg = launchpad(chain); @@ -131,7 +133,9 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: const [stock, setStock] = useState (null); const [stockQ, setStockQ] = useState(""); const [stockHits, setStockHits] = useState([]); - const quote: Quote = quoteKey === "stock" && stock ? stock : (cfg.quotes.find((q) => q.key === quoteKey) ?? cfg.quotes[0]); + // GITLAWB's USD is live: the page fetched it server-side (same as ethUsd); the static config carries null + const staticQuote: Quote = quoteKey === "stock" && stock ? stock : (cfg.quotes.find((q) => q.key === quoteKey) ?? cfg.quotes[0]); + const quote: Quote = staticQuote.key === "gitlawb" ? { ...staticQuote, usd: gitlawbUsd } : staticQuote; const quoteUsd = quoteUsdOf(quote, ethUsd); // stock search (per-chain registry via our server; only registry addresses are ever offered) useEffect(() => { @@ -176,12 +180,12 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: const metaKeyRef = useRef(null); const [phase, setPhase] = useState ({ k: "idle" }); - const presets = quote.key === "stock" ? stockMcapPresets(quote.usd ?? 0) : MCAP_PRESETS[quote.key]; + const presets = quote.key === "stock" ? stockMcapPresets(quote.usd ?? 0) : quote.key === "gitlawb" ? gitlawbMcapPresets(quote.usd) : MCAP_PRESETS[quote.key]; const mcap = customMcap.trim() ? Number(customMcap) : (mcapPick ?? presets[2] ?? 0); const startTick = mcap > 0 && Number.isFinite(mcap) ? startTickForFdv(mcap, quote.decimals) : null; const fdvPreview = startTick !== null ? fdvForStartTick(startTick, quote.decimals) : null; const tokensPerEth = startTick !== null ? tickToTokensPerQuote(startTick, quote.decimals) : null; - const fmtMcap = (v: number) => (quote.decimals <= 6 ? `${v.toLocaleString("en-US", { maximumFractionDigits: 0 })} ${quote.symbol}` : `${v.toFixed(quote.key === "stock" ? 3 : 2)} ${quote.symbol}`); + const fmtMcap = (v: number) => (quote.decimals <= 6 ? `${v.toLocaleString("en-US", { maximumFractionDigits: 0 })} ${quote.symbol}` : quote.key === "stock" ? `${v.toFixed(3)} ${quote.symbol}` : `${fmtQuoteUnits(v, quote.decimals)} ${quote.symbol}`); const symbolClean = symbol.trim().toUpperCase(); const initialBuyRaw = parseBuyAmount(initialBuy, quote.decimals); @@ -190,6 +194,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: if (!/^[A-Z0-9]{1,10}$/.test(symbolClean)) errors.push("Symbol: 1–10 letters or digits."); if (startTick === null) errors.push("Starting market cap must be a positive number."); if (quoteKey === "stock" && !stock) errors.push("Pick a stock to price the token in."); + if (quoteKey === "gitlawb" && quote.usd === null && !customMcap.trim() && startTick === null) errors.push("GITLAWB price unavailable right now: enter a custom starting market cap in GITLAWB, or reload."); if (image && !/^https:\/\//.test(image.trim())) errors.push("Image must be an https URL."); if (website && !/^https:\/\//.test(website.trim())) errors.push("Website must be an https URL."); if (feePips > 0 && beneficiary === "custom" && !isAddress(customAddr.trim())) errors.push("Beneficiary: enter a valid address."); @@ -357,12 +362,12 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: aria-pressed={active} > {CHAIN_LABELS[k]}-{k === "base" ? "Priced in ETH or a Coinbase tokenized stock. Gas ≈ cents." : ok ? "Priced in USDG (dollars), ETH or a Robinhood Stock Token. Gas ≈ cents." : "Coming soon."}+{k === "base" ? "Priced in ETH, GITLAWB or a Coinbase tokenized stock. Gas ≈ cents." : ok ? "Priced in USDG (dollars), ETH, GITLAWB or a Robinhood Stock Token. Gas ≈ cents." : "Coming soon."}); })}
+ Your token carries the
Opens at {fmtMcap(fdvPreview)} - {previewMcapUsd && quote.key !== "usdg" ? ≈ {previewMcapUsd} : null} fully diluted. The first {quote.symbol} buys about{" "} - {fmtCompact(tokensPerEth, 0)} tokens, then the price climbs along the curve. + {previewMcapUsd && quote.key !== "usdg" ? ≈ {previewMcapUsd} : null} fully diluted. The first {quote.key === "gitlawb" ? "1M " : ""}{quote.symbol} buys about{" "} + {fmtCompact(tokensPerEth * (quote.key === "gitlawb" ? 1e6 : 1), 0)} tokens, then the price climbs along the curve.
) : null} @@ -614,7 +641,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: onClick={() => setInitialBuy(active ? "" : v)} className={`h-11 px-4 rounded-xl border font-mono text-sm font-bold tnum ${active ? "bg-ink text-inverse border-ink" : "bg-card text-ink border-line-strong hover:border-ink/40"}`} > - {v} {quote.symbol} + {fmtQuoteUnits(Number(v), quote.decimals)} {quote.symbol} ); })} @@ -630,7 +657,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: {quote.symbol} {initialBuyRaw && buyBalance !== undefined ? ( - balance {fmtCompact(units(buyBalance, quote.decimals), quote.decimals <= 8 ? 2 : 4)} + balance {fmtQuoteUnits(units(buyBalance, quote.decimals), quote.decimals)} ) : null} {buyPreview ? ( @@ -687,7 +714,10 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:{description.trim()}
: null}= { eth: [1, 5, 10, 25], usdg: [5_000, 10_000, 25_000, 100_000], stock: [] /* derived from the live price */ }; +export const MCAP_PRESETS: Record= { eth: [1, 5, 10, 25], usdg: [5_000, 10_000, 25_000, 100_000], gitlawb: [] /* derived from the live price */, stock: [] /* derived from the live price */ }; /** Buy amount presets per quote. */ -export const BUY_PRESETS: Record= { eth: ["0.01", "0.05", "0.1", "0.5"], usdg: ["5", "25", "100", "500"], stock: ["0.1", "0.5", "1", "5"] }; +export const BUY_PRESETS: Record= { eth: ["0.01", "0.05", "0.1", "0.5"], usdg: ["5", "25", "100", "500"], gitlawb: ["100000", "500000", "1000000", "5000000"], stock: ["0.1", "0.5", "1", "5"] }; /** Browser RPC: dev override per chain, else our same-origin proxy (→ Alchemy/public, key stays server-side). */ export function browserRpc(key: ChainKey): string { diff --git a/app/src/lib/launchpad/creator.test.ts b/app/src/lib/launchpad/creator.test.ts index 37505211..eb763109 100644 --- a/app/src/lib/launchpad/creator.test.ts +++ b/app/src/lib/launchpad/creator.test.ts @@ -58,13 +58,14 @@ test("isNonce accepts 32 hex chars only", () => { test("share card shaping", () => { const now = Date.parse("2026-09-06T12:00:00Z"); - const c = shapeCard({ name: "Dollar Dog", symbol: "DDOG", chain: "robinhood", fdv_usd: 12345, fdv_quote: 12345, quote_symbol: "USDG", change_from_launch: 0.234, lp_fee: 10000, recipients: [{ payout: DEAD, bps: 10000 }], block_time: "2026-09-06T09:30:00Z" }, now); + const c = shapeCard({ name: "Dollar Dog", symbol: "DDOG", chain: "robinhood", fdv_usd: 12345, fdv_quote: 12345, quote_key: "usdg", quote_symbol: "USDG", change_from_launch: 0.234, lp_fee: 10000, recipients: [{ payout: DEAD, bps: 10000 }], block_time: "2026-09-06T09:30:00Z" }, now); assert.deepEqual(c, { title: "Dollar Dog", symbol: "DDOG", chainLabel: "Robinhood Chain", mcap: "$12.3K", change: "+23%", up: true, fee: "1% fee, burned", age: "2h old", quote: null }); - assert.deepEqual(shapeCard({ name: "Base Stock Test", symbol: "BSTK", chain: "base", fdv_usd: 25206, fdv_quote: 109.6, quote_symbol: "NVDAc", change_from_launch: 0, lp_fee: 10000, recipients: [{ payout: "0x00000000000000000000000000000000000c0ffe", bps: 10000 }], block_time: "2026-09-06T09:30:00Z" }, now).quote, { symbol: "NVDAc", ticker: "NVDA" }, "stock quotes get a ticker tile on the card"); + assert.deepEqual(shapeCard({ name: "Base Stock Test", symbol: "BSTK", chain: "base", fdv_usd: 25206, fdv_quote: 109.6, quote_key: "stock", quote_symbol: "NVDAc", change_from_launch: 0, lp_fee: 10000, recipients: [{ payout: "0x00000000000000000000000000000000000c0ffe", bps: 10000 }], block_time: "2026-09-06T09:30:00Z" }, now).quote, { symbol: "NVDAc", ticker: "NVDA", kind: "stock" }, "stock quotes get a ticker tile on the card"); + assert.deepEqual(shapeCard({ name: "Lawb Fan", symbol: "LAWB", chain: "base", fdv_usd: 9800, fdv_quote: 580_000_000, quote_key: "gitlawb", quote_symbol: "GITLAWB", change_from_launch: 0, lp_fee: 10000, recipients: [], block_time: "2026-09-06T09:30:00Z" }, now).quote, { symbol: "GITLAWB", ticker: "GL", kind: "gitlawb" }, "GITLAWB quotes get the Gitlawb mark"); assert.equal(feeLabel(0, []), "0% fee"); assert.equal(feeLabel(30000, [{ payout: "0x1", bps: 10000 }]), "3% fee → beneficiary"); assert.equal(ageLabel("2026-09-06T11:59:30Z", now), "1m old"); - assert.equal(shapeCard({ name: "X", symbol: "X", chain: "base", fdv_usd: null, fdv_quote: 2.5, quote_symbol: "ETH", change_from_launch: -0.5, lp_fee: 0, recipients: [], block_time: "2026-09-01T00:00:00Z" }, now).mcap, "2.50 ETH"); + assert.equal(shapeCard({ name: "X", symbol: "X", chain: "base", fdv_usd: null, fdv_quote: 2.5, quote_key: "eth", quote_symbol: "ETH", change_from_launch: -0.5, lp_fee: 0, recipients: [], block_time: "2026-09-01T00:00:00Z" }, now).mcap, "2.50 ETH"); }); test("clampSocial: ≤125 chars on a word boundary with an ellipsis; short text untouched; whitespace collapsed", () => { diff --git a/app/src/lib/launchpad/gitlawb.test.ts b/app/src/lib/launchpad/gitlawb.test.ts new file mode 100644 index 00000000..54a9829a --- /dev/null +++ b/app/src/lib/launchpad/gitlawb.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { GITLAWB_ADDRESS, GITLAWB_ADDRESS_ROBINHOOD, GITLAWB_ADDRESSES, GITLAWB_LOGO_PATH, GITLAWB_V3_POOL, gitlawbUsdFromTick, reconcileGitlawbUsd, twapTick, GITLAWB_POOL_ID, GITLAWB_POOL_KEY, gitlawbMcapPresets, gitlawbUsdFromSqrtPrice, isGitlawbAddress } from "./gitlawb.ts"; + +test("the derived pool id is the on-chain WETH/GITLAWB pool (Initialize at Base block 43,202,530)", () => { + const k = GITLAWB_POOL_KEY; + assert.equal(GITLAWB_POOL_ID, "0xec33256bf1ded407a57fd3c1965e7556e42ac14db09bc4e6fef57d5e2eb0b0b9"); + assert.equal(k.fee, 0x800000, "dynamic-fee flag"); + assert.ok(BigInt(k.currency0) < BigInt(k.currency1), "WETH sorts below GITLAWB → GITLAWB is currency1"); + assert.equal(GITLAWB_ADDRESS, GITLAWB_ADDRESS.toLowerCase(), "stored lowercase (DB quote column is lowercase)"); +}); + +test("gitlawbUsdFromSqrtPrice: live reading ≈ $1.68e-5 at tick 188117 / ETH $2,476; rejects garbage", () => { + // sqrtPriceX96 read from StateView on 2026-09-08 (tick 188117 → ~1.477e8 GITLAWB per ETH) + const sqrt = 0x2f799c757751a02dcfbdb3537a4an; + const usd = gitlawbUsdFromSqrtPrice(sqrt, 2476); + assert.ok(usd !== null); + assert.ok(Math.abs(usd - 1.676e-5) / 1.676e-5 < 0.01, `≈ $1.676e-5, got ${usd}`); + assert.equal(gitlawbUsdFromSqrtPrice(sqrt, null), null, "no ETH price → no USD"); + assert.equal(gitlawbUsdFromSqrtPrice(0n, 2476), null, "zero price"); + assert.equal(gitlawbUsdFromSqrtPrice(-1n, 2476), null, "negative"); + assert.equal(gitlawbUsdFromSqrtPrice(2n ** 96n, 2476), 2476, "1 GITLAWB = 1 ETH is still a reading; the TWAP guard decides whether it is published"); +}); + +test("presets convert dollar targets into GITLAWB units; empty without a price", () => { + assert.deepEqual(gitlawbMcapPresets(null), []); + assert.deepEqual(gitlawbMcapPresets(0), []); + const p = gitlawbMcapPresets(0.00002); + assert.deepEqual(p, [250_000_000, 500_000_000, 1_250_000_000, 5_000_000_000], "same $5K/$10K/$25K/$100K ladder as stocks, in whole GITLAWB"); +}); + +test("logo asset ships with the app; address check is case-insensitive", () => { + assert.ok(GITLAWB_LOGO_PATH.startsWith("/"), "same-origin path"); + assert.ok(existsSync(new URL(`../../../public${GITLAWB_LOGO_PATH}`, import.meta.url)), "public/gitlawb-mark.png exists"); + assert.ok(isGitlawbAddress("0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3")); + assert.ok(!isGitlawbAddress("0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa4")); + assert.ok(isGitlawbAddress("0xd1b0d44E4f6ed940fcC7A9F59Bf30Daf62cCFe3D", "robinhood"), "the Robinhood OFT (community repo contracts.ts)"); + assert.ok(!isGitlawbAddress("0xd1b0d44E4f6ed940fcC7A9F59Bf30Daf62cCFe3D", "base"), "the Robinhood address is not GITLAWB on Base"); + assert.equal(GITLAWB_ADDRESSES.robinhood, GITLAWB_ADDRESS_ROBINHOOD); + assert.equal(GITLAWB_ADDRESS_ROBINHOOD, GITLAWB_ADDRESS_ROBINHOOD.toLowerCase()); +}); + +test("v3 TWAP cross-check: tick from cumulatives, USD from tick, and spot/TWAP reconciliation", () => { + // observe([1800, 0]) on the v3 WETH/GITLAWB pool, 2026-09-08: avg tick 188,260 (v4 spot tick was 188,117 → 1.4% apart) + const t = twapTick(1897758471224n, 1898097340718n, 1800); + assert.equal(t, 188260); + assert.equal(twapTick(-10n, -25n, 10), -2, "negative deltas floor toward -inf like Uniswap"); + assert.equal(twapTick(0n, 20n, 10), 2); + const twap = gitlawbUsdFromTick(t, 2476); + assert.ok(twap !== null && Math.abs(twap - 1.652e-5) / 1.652e-5 < 0.01, `≈ $1.65e-5, got ${twap}`); + assert.equal(gitlawbUsdFromTick(t, null), null); + assert.equal(GITLAWB_V3_POOL, GITLAWB_V3_POOL.toLowerCase()); + + assert.deepEqual(reconcileGitlawbUsd(1.0e-5, 1.1e-5), { usd: 1.0e-5, source: "spot", deviation: Math.abs(1.0e-5 - 1.1e-5) / 1.1e-5 }, "within 25%: spot"); + assert.deepEqual(reconcileGitlawbUsd(5e-5, 1e-5), { usd: 1e-5, source: "twap", deviation: 4 }, "spot pushed 5×: TWAP wins"); + assert.deepEqual(reconcileGitlawbUsd(null, 1e-5), { usd: 1e-5, source: "twap", deviation: null }); + assert.deepEqual(reconcileGitlawbUsd(1e-5, null), { usd: 1e-5, source: "spot", deviation: null }); + assert.deepEqual(reconcileGitlawbUsd(null, null), { usd: null, source: null, deviation: null }); +}); diff --git a/app/src/lib/launchpad/gitlawb.ts b/app/src/lib/launchpad/gitlawb.ts new file mode 100644 index 00000000..47cfeaff --- /dev/null +++ b/app/src/lib/launchpad/gitlawb.ts @@ -0,0 +1,113 @@ +/** + * GITLAWB as a quote asset on Base and Robinhood Chain — pure logic (node --test loads this directly). + * + * GITLAWB is Gitlawb's token: an ordinary 18-decimal ERC-20 on Base (bridged 1:1 to Robinhood Chain + * over LayerZero OFT), no transfer restrictions, no issuer switch. Pools quoted in it work exactly like + * any other ERC-20 quote (Permit2 path); trading fees are paid out in GITLAWB, or burned when the + * launch names no beneficiary. + * + * USD comes from the deepest GITLAWB market: the Uniswap v4 WETH/GITLAWB pool (dynamic-fee hook, + * tick spacing 200), read on-chain through StateView.getSlot0(poolId) and multiplied by ETH/USD. + * Both sides are 18-dec, so no decimal adjustment. Display + sorting only, never used on-chain. + */ +import type { Address } from "viem"; +import { poolIdOf, sqrtPriceToTokensPerQuote } from "./math.ts"; +import { stockMcapPresets } from "./stocks.ts"; + +export const GITLAWB_ADDRESS = "0x5f980dcfc4c0fa3911554cf5ab288ed0eb13dba3"; +/** + * GITLAWB on Robinhood Chain (4663): a LayerZero V2 OFT, minted 1:1 against GITLAWB locked in the + * GitlawbOFTAdapter on Base (0x3308384bc308d09b3eba9a8f11cc36e993a273a4). Same token, one supply, two + * chains — see the community repo's contracts.ts. Priced from the Base pool (the deep market); the + * bridge keeps the two within arbitrage of each other. + */ +export const GITLAWB_ADDRESS_ROBINHOOD = "0xd1b0d44e4f6ed940fcc7a9f59bf30daf62ccfe3d"; +export const GITLAWB_ADDRESSES: Record<"base" | "robinhood", string> = { base: GITLAWB_ADDRESS, robinhood: GITLAWB_ADDRESS_ROBINHOOD }; +export const GITLAWB_SYMBOL = "GITLAWB"; +export const GITLAWB_NAME = "Gitlawb"; +export const GITLAWB_DECIMALS = 18; +export const GITLAWB_SITE = "https://gitlawb.com"; + +/** + * The WETH/GITLAWB v4 pool key on Base (Initialize event at block 43,202,530): a dynamic-fee pool + * (fee 0x800000 = "the hook sets the fee"), tick spacing 200. Its id is derived, never pasted; + * gitlawb.test.ts pins it to the on-chain id. + */ +export const GITLAWB_POOL_KEY = { + currency0: "0x4200000000000000000000000000000000000006" as Address, // WETH + currency1: GITLAWB_ADDRESS as Address, + fee: 0x800000, + tickSpacing: 200, + hooks: "0xbb7784a4d481184283ed89619a3e3ed143e1adc0" as Address, +} as const; +export const GITLAWB_POOL_ID = poolIdOf(GITLAWB_POOL_KEY); + +/** + * Manipulation cross-check: the Uniswap v3 WETH/GITLAWB 1% pool on Base (token0 WETH, token1 GITLAWB) + * keeps an on-chain oracle (observe), so a 30-minute time-weighted average tick is available without + * any off-chain service. The v4 spot is the primary price (deepest market); when it strays more than + * MAX_SPOT_DEVIATION from the TWAP, the TWAP is used instead, so a single-block price push in the v4 + * pool cannot rewrite market caps, USD volume or trending scores site-wide. + */ +export const GITLAWB_V3_POOL = "0x72c12f0cc0e0c6e97ff9869717a482aff97e3b30" as Address; +export const TWAP_WINDOW_S = 1800; +export const MAX_SPOT_DEVIATION = 0.25; + +/** Time-weighted average tick from two tickCumulative readings `windowS` apart (Uniswap v3 semantics: floor toward -inf). */ +export function twapTick(cumulativeThen: bigint, cumulativeNow: bigint, windowS: number): number { + const delta = cumulativeNow - cumulativeThen; + const w = BigInt(windowS); + let tick = delta / w; + if (delta < 0n && delta % w !== 0n) tick -= 1n; + return Number(tick); +} + +/** USD per GITLAWB from a WETH/GITLAWB tick (GITLAWB per WETH = 1.0001^tick; both 18-dec) and ETH/USD. */ +export function gitlawbUsdFromTick(tick: number, ethUsd: number | null): number | null { + if (ethUsd === null || !(ethUsd > 0) || !Number.isFinite(tick)) return null; + const usd = ethUsd / Math.pow(1.0001, tick); + return Number.isFinite(usd) && usd > 0 ? usd : null; +} + +/** + * Pick the price to publish: the spot unless it deviates from the TWAP by more than `maxDeviation`, + * in which case the TWAP wins. With only one side available that side is used; with neither, null. + */ +export function reconcileGitlawbUsd(spot: number | null, twap: number | null, maxDeviation = MAX_SPOT_DEVIATION): { usd: number | null; source: "spot" | "twap" | null; deviation: number | null } { + if (spot === null && twap === null) return { usd: null, source: null, deviation: null }; + if (spot === null) return { usd: twap, source: "twap", deviation: null }; + if (twap === null) return { usd: spot, source: "spot", deviation: null }; + const deviation = Math.abs(spot - twap) / twap; + return deviation > maxDeviation ? { usd: twap, source: "twap", deviation } : { usd: spot, source: "spot", deviation }; +} + +/** + * USD per GITLAWB from the v4 pool's sqrtPriceX96 (GITLAWB per WETH; both 18-dec) and ETH/USD. + * null only when an input is unusable. Manipulation resistance comes from reconcileGitlawbUsd() + * (spot vs the v3 TWAP), not from a fixed dollar band. + */ +export function gitlawbUsdFromSqrtPrice(sqrtPriceX96: bigint, ethUsd: number | null): number | null { + if (ethUsd === null || !(ethUsd > 0) || sqrtPriceX96 <= 0n) return null; + const gitlawbPerEth = sqrtPriceToTokensPerQuote(sqrtPriceX96, 18); + if (!Number.isFinite(gitlawbPerEth) || gitlawbPerEth <= 0) return null; + const usd = ethUsd / gitlawbPerEth; + return Number.isFinite(usd) && usd > 0 ? usd : null; +} + +/** Starting-market-cap presets in whole GITLAWB for the same dollar targets the stock form uses; empty while the price is unknown. */ +export function gitlawbMcapPresets(usdPrice: number | null): number[] { + return stockMcapPresets(usdPrice ?? 0).map(Math.round); +} + +/** + * Gitlawb's logo (the white branch-and-key mark on black, as on gitlawb.com) as a rounded tile, served from + * our own origin. `public/gitlawb-mark.png` is generated from the web repo's `public/logo.png`: strokes + * boldened slightly so they survive 18–22px badges, cropped to the mark, 160px, transparent corners. + */ +export const GITLAWB_LOGO_PATH = "/gitlawb-mark.png"; +/** The tile's ground — badges use the same black so the tile and the pill read as one piece. */ +export const GITLAWB_LOGO_BG = "#000000"; + +export function isGitlawbAddress(address: string, chain: "base" | "robinhood" = "base"): boolean { + return address.toLowerCase() === GITLAWB_ADDRESSES[chain]; +} diff --git a/app/src/lib/launchpad/gitlawbServer.ts b/app/src/lib/launchpad/gitlawbServer.ts new file mode 100644 index 00000000..1cf437ba --- /dev/null +++ b/app/src/lib/launchpad/gitlawbServer.ts @@ -0,0 +1,77 @@ +import "server-only"; +import { publicClient } from "@/lib/chain"; +import { STATE_VIEW_ABI } from "./abi"; +import { launchpad } from "./config"; +import { ethUsd } from "./ethPrice"; +import { GITLAWB_POOL_ID, GITLAWB_V3_POOL, TWAP_WINDOW_S, gitlawbUsdFromSqrtPrice, gitlawbUsdFromTick, reconcileGitlawbUsd, twapTick } from "./gitlawb"; + +/** + * USD per GITLAWB, server-side. Two on-chain readings, one ETH/USD: + * - spot: StateView.getSlot0 on the Uniswap v4 WETH/GITLAWB pool (the deep market) + * - TWAP: observe([1800, 0]) on the Uniswap v3 WETH/GITLAWB pool (30-minute time-weighted average) + * The spot is published unless it strays >25% from the TWAP, in which case the TWAP is published: a + * single-block push of the v4 price cannot rewrite market caps, USD volume or trending scores. When + * only one side reads, that side is used (logged when it is the unguarded spot). + * + * Stale-while-revalidate so it never sits on a request's critical path once warm: a fresh value is + * served for 60s; after that the cached value is returned immediately and ONE refresh runs in the + * background. Only a cold process (no value yet) awaits the read, capped at 4s. Failures keep the last + * good price for up to 15 minutes; after that GITLAWB pools simply show no USD (and rank with 0 weight + * in USD sorts) until a read succeeds. Never throws. + */ +let usdNow: number | null = null; +let goodAt = 0; // when usdNow was last read successfully +let refreshedAt = 0; // when the last refresh attempt finished +let inflight: Promise| null = null; +const FRESH_MS = 60_000; +const MAX_STALE_MS = 15 * 60_000; +const COLD_WAIT_MS = 4_000; +const READ_TIMEOUT_MS = 8_000; + +const V3_OBSERVE_ABI = [ + { type: "function", name: "observe", stateMutability: "view", inputs: [{ name: "secondsAgos", type: "uint32[]" }], outputs: [{ name: "tickCumulatives", type: "int56[]" }, { name: "secondsPerLiquidityCumulativeX128s", type: "uint160[]" }] }, +] as const; + +async function read(): Promise { + const client = publicClient("base"); + const timeout = new Promise ((_, reject) => setTimeout(() => reject(new Error(`timeout after ${READ_TIMEOUT_MS}ms`)), READ_TIMEOUT_MS).unref?.()); + const [eth, slot0, observed] = await Promise.race([ + Promise.all([ + ethUsd(), + client.readContract({ address: launchpad("base").v4.stateView, abi: STATE_VIEW_ABI, functionName: "getSlot0", args: [GITLAWB_POOL_ID] }).then((r) => r[0] as bigint, () => null), + client.readContract({ address: GITLAWB_V3_POOL, abi: V3_OBSERVE_ABI, functionName: "observe", args: [[TWAP_WINDOW_S, 0]] }).then((r) => r[0] as readonly bigint[], () => null), + ]), + timeout, + ]); + const spot = slot0 === null ? null : gitlawbUsdFromSqrtPrice(slot0, eth); + const twap = observed === null || observed.length < 2 ? null : gitlawbUsdFromTick(twapTick(observed[0], observed[1], TWAP_WINDOW_S), eth); + const { usd, source, deviation } = reconcileGitlawbUsd(spot, twap); + if (source === "twap" && spot !== null) console.warn(`[gitlawb] v4 spot $${spot} is ${Math.round((deviation ?? 0) * 100)}% off the 30m v3 TWAP $${twap}; publishing the TWAP`); + else if (source === "spot" && twap === null) console.warn("[gitlawb] v3 TWAP unavailable; publishing the unguarded v4 spot"); + return usd; +} + +function refresh(): Promise { + return (inflight ??= read() + .then((usd) => { + if (usd !== null) { + usdNow = usd; + goodAt = Date.now(); + } + }) + .catch((err) => console.warn("[gitlawb] price read failed:", err instanceof Error ? err.message : err)) + .finally(() => { + refreshedAt = Date.now(); + inflight = null; + })); +} + +export async function gitlawbUsd(): Promise { + const now = Date.now(); + if (now - refreshedAt >= FRESH_MS) { + const p = refresh(); + // cold start: nothing to serve yet → wait briefly for the first read (bounded) + if (goodAt === 0) await Promise.race([p, new Promise ((r) => setTimeout(r, COLD_WAIT_MS).unref?.())]); + } + return goodAt > 0 && Date.now() - goodAt <= MAX_STALE_MS ? usdNow : null; +} diff --git a/app/src/lib/launchpad/math.test.ts b/app/src/lib/launchpad/math.test.ts index a68af5ee..f7657644 100644 --- a/app/src/lib/launchpad/math.test.ts +++ b/app/src/lib/launchpad/math.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { fdvForStartTick, fmtCompact, fmtEth, fmtPrice, fmtUsd, initialBuyPreview, minOut, poolIdOf, quoteUsdOf, startTickForFdv, sqrtPriceToTokensPerQuote, tickToTokensPerQuote } from "./math.ts"; +import { fdvForStartTick, fmtCompact, fmtEth, fmtQuoteUnits, fmtPrice, fmtUsd, initialBuyPreview, minOut, poolIdOf, quoteUsdOf, startTickForFdv, sqrtPriceToTokensPerQuote, tickToTokensPerQuote } from "./math.ts"; import { encodeV4ExactInSingle } from "./swap.ts"; test("startTickForFdv: 10 ETH FDV on 1B supply ≈ tick 184200 (1 ETH = 100M tokens)", () => { @@ -125,3 +125,14 @@ test("initialBuyPreview: the LP fee reduces output; quote decimals are honoured" assert.ok(u.pctOfSupply > 0.9 && u.pctOfSupply < 1.0, `got ${u.pctOfSupply}`); assert.ok(u.fdvAfter > 1_000 && u.fdvAfter < 1_100, `fdv after ${u.fdvAfter}`); }); + +test("fmtQuoteUnits: stables 2dp, 18-dec ETH-style below 100K, compact above with suffix promotion", () => { + assert.equal(fmtQuoteUnits(12.5, 6), "12.50"); + assert.equal(fmtQuoteUnits(250, 6), "250"); + assert.equal(fmtQuoteUnits(0.0421, 18), "0.0421"); + assert.equal(fmtQuoteUnits(99_999.99999999999, 18), "100K", "float noise at the boundary still compacts"); + assert.equal(fmtQuoteUnits(150_000, 18), "150K"); + assert.equal(fmtQuoteUnits(999_999, 18), "1M", "no 1000.00K"); + assert.equal(fmtQuoteUnits(1_234_567, 18), "1.23M"); + assert.equal(fmtQuoteUnits(594_540_000, 18), "594.54M"); +}); diff --git a/app/src/lib/launchpad/math.ts b/app/src/lib/launchpad/math.ts index 5b5d54ad..52df89db 100644 --- a/app/src/lib/launchpad/math.ts +++ b/app/src/lib/launchpad/math.ts @@ -163,10 +163,22 @@ export function pipsToPct(pips: number): string { return `${Number.isInteger(pct) ? pct : pct.toFixed(2)}%`; } -/** "12.5 USDG" / "0.05 ETH" from a raw amount. */ +const COMPACT = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }); + +/** + * Whole quote units for display. ≤6-dec quotes (stables) show 2 decimals; 18-dec quotes show ETH-style + * precision below 100K units and compact above (GITLAWB: millions per dollar → "1.2M", never + * "1200000.0000"; 999,999 → "1M", not "1000.00K"). + */ +export function fmtQuoteUnits(v: number, decimals: number): string { + if (!Number.isFinite(v)) return "—"; + if (decimals <= 6) return (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(2)).replace(/\.00$/, ""); + return Math.round(Math.abs(v)) >= 100_000 ? COMPACT.format(v) : fmtEth(v); +} + +/** "12.5 USDG" / "0.05 ETH" / "1.2M GITLAWB" from a raw amount. */ export function fmtQuote(raw: bigint | string, decimals: number, symbol: string): string { - const v = units(raw, decimals); - return `${decimals <= 6 ? (v >= 100 ? v.toFixed(0) : v.toFixed(2)).replace(/\.00$/, "") : fmtEth(v)} ${symbol}`; + return `${fmtQuoteUnits(units(raw, decimals), decimals)} ${symbol}`; } export const WAD_ = WAD; diff --git a/app/src/lib/launchpad/ogcard.ts b/app/src/lib/launchpad/ogcard.ts index 6f0cd951..dcc9dec4 100644 --- a/app/src/lib/launchpad/ogcard.ts +++ b/app/src/lib/launchpad/ogcard.ts @@ -1,6 +1,6 @@ /** Pure shaping for the per-token share card (node --test loads this). */ -export type CardInput = { name: string; symbol: string; chain: "base" | "robinhood"; fdv_usd: number | null; fdv_quote: number; quote_symbol: string; change_from_launch: number; lp_fee: number; recipients: { payout: string; bps: number }[]; block_time: string }; -export type Card = { title: string; symbol: string; chainLabel: string; mcap: string; change: string; up: boolean; fee: string; age: string; quote: { symbol: string; ticker: string } | null }; +export type CardInput = { name: string; symbol: string; chain: "base" | "robinhood"; fdv_usd: number | null; fdv_quote: number; quote_key: string; quote_symbol: string; change_from_launch: number; lp_fee: number; recipients: { payout: string; bps: number }[]; block_time: string }; +export type Card = { title: string; symbol: string; chainLabel: string; mcap: string; change: string; up: boolean; fee: string; age: string; quote: { symbol: string; ticker: string; kind: "stock" | "gitlawb" } | null }; const DEAD = "0x000000000000000000000000000000000000dead"; @@ -37,15 +37,16 @@ export function shapeCard(l: CardInput, now: number): Card { up: pct >= 0, fee: feeLabel(l.lp_fee, l.recipients), age: ageLabel(l.block_time, now), - quote: stockQuoteOf(l.quote_symbol), + quote: quotePillOf(l.quote_key, l.quote_symbol), }; } -/** Stock quotes get a "priced in" pill with a ticker tile; ETH / USDG / unknown ("?") do not. */ -export function stockQuoteOf(quoteSymbol: string): Card["quote"] { +/** "priced in" pill: registry stocks get a ticker tile, GITLAWB the Gitlawb tile; ETH / USDG / unknown ("?") get none. */ +export function quotePillOf(quoteKey: string, quoteSymbol: string): Card["quote"] { + if (quoteKey === "gitlawb") return { symbol: "GITLAWB", ticker: "GL", kind: "gitlawb" }; const sym = quoteSymbol.trim(); - if (!sym || sym === "?" || sym === "ETH" || sym === "USDG") return null; - return { symbol: sym.slice(0, 12), ticker: sym.replace(/c$/, "").toUpperCase().slice(0, 5) }; + if (quoteKey !== "stock" || !sym || sym === "?") return null; + return { symbol: sym.slice(0, 12), ticker: sym.replace(/c$/, "").toUpperCase().slice(0, 5), kind: "stock" }; } /** Social previews show ~125 chars: clamp on a word boundary with an ellipsis; short text is untouched. */ diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 25644df9..1d1146d3 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -3,6 +3,8 @@ import { maybeDb } from "@/lib/db"; import { CHAIN_KEYS, chainIdOf, chainKeyOf, type ChainKey } from "@/lib/chainPublic"; import { quoteInfo as staticQuoteInfo, quoteUsdOf, type Quote } from "./config"; import { ensureRegistry, stockByAddress, stockUsdInUse } from "./stocksServer"; +import { gitlawbUsd } from "./gitlawbServer"; +import { GITLAWB_ADDRESS, GITLAWB_ADDRESS_ROBINHOOD } from "./gitlawb"; import { canonicalImageUrl } from "./images"; import { rankTrending } from "./trending"; import { imagePublicBase } from "./imageStore"; @@ -23,6 +25,7 @@ export type LaunchRow = { token_id: number; launcher: string; quote: string; + quote_key: Quote["key"]; // eth | usdg | gitlawb | stock (= any other ERC20) quote_symbol: string; quote_decimals: number; pool_id: string; @@ -68,7 +71,7 @@ export type LaunchRow = { volume_24h_usd: number | null; }; -type Raw = Omit & { +type Raw = Omit & { token_id: bigint; block_number: bigint; tick: number | null; @@ -79,17 +82,21 @@ type Raw = Omit (); +/** GITLAWB USD for this request (filled by `withStocks`; null = unknown → no USD, 0 weight in USD sorts). */ +let gitlawbUsdNow: number | null = null; -/** Server-side quote resolution: static ETH/USDG, else a registry stock, else unknown. */ +/** Server-side quote resolution: static ETH/USDG/GITLAWB (GITLAWB gets the live price), else a registry stock, else unknown. */ function quoteInfo(chain: ChainKey, address: string): Quote { const q = staticQuoteInfo(chain, address); + if (q.key === "gitlawb") return { ...q, usd: gitlawbUsdNow }; if (q.symbol !== "?") return q; const st = stockByAddress(chain, address); return st ? { key: "stock", address: st.address as Quote["address"], symbol: st.symbol, decimals: st.decimals, usd: stockUsdNow.get(st.address) ?? null, name: st.name, logo: st.logo } : q; } -/** Warm the stock registry + prices for the stocks in use, so `shape()` can stay synchronous. */ +/** Warm the stock registry + prices for the stocks in use (and the GITLAWB price), so `shape()` can stay synchronous. */ async function withStocks(): Promise { + gitlawbUsdNow = await gitlawbUsd(); // never throws; served from cache once warm (stale-while-revalidate) try { await ensureRegistry(); // robinhood registry (fail-soft); the Base list is static stockUsdNow = await stockUsdInUse(); @@ -118,6 +125,7 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo return { ...r, chain, + quote_key: q.key, quote_symbol: q.symbol, quote_decimals: q.decimals, token_id: Number(r.token_id), @@ -156,6 +164,8 @@ export const VOLUME_WINDOWS: VolumeWindow[] = ["1h", "24h", "all"]; export type ListOpts = { sort?: LaunchSort; window?: VolumeWindow; chain?: ChainKey | null; filter?: LaunchFilter | null; limit?: number; offset?: number; launcher?: string; ethUsd?: number | null }; const USDG_ADDR = "0x5fc5360d0400a0fd4f2af552add042d716f1d168"; +const BASE_ID = chainIdOf("base"); +const RH_ID = chainIdOf("robinhood"); const NATIVE_ADDR = "0x0000000000000000000000000000000000000000"; const DEAD_ADDR = "0x000000000000000000000000000000000000dead"; @@ -185,11 +195,15 @@ export async function listLaunchesPage(opts: ListOpts = {}): Promise { if (opts.filter === "fee0") conds.push(db`l.lp_fee = 0`); if (opts.filter === "burn") conds.push(db`l.lp_fee > 0 AND jsonb_array_length(l.recipients) = 1 AND lower(l.recipients->0->>'payout') = ${DEAD_ADDR}`); if (opts.filter === "usdg") conds.push(db`l.quote = ${USDG_ADDR}`); + // GITLAWB has a different address per chain; each match is chain-scoped so a same-address token elsewhere is never GITLAWB + const isGitlawb = () => db`((l.chain_id = ${BASE_ID} AND l.quote = ${GITLAWB_ADDRESS}) OR (l.chain_id = ${RH_ID} AND l.quote = ${GITLAWB_ADDRESS_ROBINHOOD}))`; + if (opts.filter === "gitlawb") conds.push(db`${isGitlawb()}`); if (opts.filter === "today") conds.push(db`l.block_time > now() - interval '24 hours'`); const where = conds.length ? db`WHERE ${conds.reduce((a, c) => db`${a} AND ${c}`)}` : db``; // per-row USD factor and quote decimals (USDG is the only non-18-dec quote we list) const stockCase = stockEntries.length ? stockEntries.map(([a, v]) => db`WHEN l.quote = ${a} THEN ${v}::double precision`).reduce((acc, c) => db`${acc} ${c}`) : db``; - const usdPerUnit = db`(CASE WHEN l.quote = ${USDG_ADDR} THEN 1.0 ${stockCase} WHEN l.quote = ${NATIVE_ADDR} THEN ${ethFactor}::double precision ELSE 0.0 END)`; + const gitlawbFactor = gitlawbUsdNow !== null && gitlawbUsdNow > 0 ? gitlawbUsdNow : 0; // unknown → 0 weight, like an unknown stock + const usdPerUnit = db`(CASE WHEN l.quote = ${USDG_ADDR} THEN 1.0 ${stockCase} WHEN ${isGitlawb()} THEN ${gitlawbFactor}::double precision WHEN l.quote = ${NATIVE_ADDR} THEN ${ethFactor}::double precision ELSE 0.0 END)`; const stockDec = stockEntries.map(([a]) => [a, stockByAddress("base", a)?.decimals ?? stockByAddress("robinhood", a)?.decimals ?? 18] as [string, number]).filter(([, d]) => d !== 18); const decCase = stockDec.length ? stockDec.map(([a, d]) => db`WHEN l.quote = ${a} THEN ${d}`).reduce((acc, c) => db`${acc} ${c}`) : db``; const qd = db`(CASE WHEN l.quote = ${USDG_ADDR} THEN 6 ${decCase} ELSE 18 END)`; @@ -249,8 +263,8 @@ export async function getSwaps(chain: ChainKey, token: string, quoteDecimals: nu /** Home tape: launches + trades across chains, newest first. */ export type FeedItem = - | { kind: "launch"; chain: ChainKey; at: string; tx_hash: string; token: string; name: string; symbol: string; launcher: string; lp_fee: number; image_url: string | null } - | { kind: "swap"; chain: ChainKey; at: string; tx_hash: string; token: string; name: string; symbol: string; trader: string | null; is_buy: boolean; is_dev: boolean; quote_wei: string; quote_symbol: string; quote_decimals: number; usd: number | null; image_url: string | null }; + | { kind: "launch"; chain: ChainKey; at: string; tx_hash: string; token: string; name: string; symbol: string; launcher: string; lp_fee: number; quote_key: Quote["key"]; image_url: string | null } + | { kind: "swap"; chain: ChainKey; at: string; tx_hash: string; token: string; name: string; symbol: string; trader: string | null; is_buy: boolean; is_dev: boolean; quote_wei: string; quote_key: Quote["key"]; quote_symbol: string; quote_decimals: number; usd: number | null; image_url: string | null }; export async function getLaunchFeed(limit = 24, ethUsd: number | null = null): Promise { const db = maybeDb(); @@ -269,11 +283,11 @@ export async function getLaunchFeed(limit = 24, ethUsd: number | null = null): P ) x ORDER BY at DESC LIMIT ${n}`; // each arm pre-limited on an indexed time column: the union never scans the whole swaps table return rows.map((r) => { const chain = chainKeyOf(r.chain_id) ?? "base"; - if (r.kind === "launch") return { kind: "launch", chain, at: r.at, tx_hash: r.tx_hash, token: r.token, name: r.name, symbol: r.symbol, launcher: r.who ?? "", lp_fee: r.lp_fee ?? 0, image_url: canonicalImageUrl(r.image_url, imagePublicBase()) }; + if (r.kind === "launch") return { kind: "launch", chain, at: r.at, tx_hash: r.tx_hash, token: r.token, name: r.name, symbol: r.symbol, launcher: r.who ?? "", lp_fee: r.lp_fee ?? 0, quote_key: quoteInfo(chain, r.quote).key, image_url: canonicalImageUrl(r.image_url, imagePublicBase()) }; const q = quoteInfo(chain, r.quote); const qu = quoteUsd(q, ethUsd); const wei = r.quote_wei ?? "0"; - return { kind: "swap", chain, at: r.at, tx_hash: r.tx_hash, token: r.token, name: r.name, symbol: r.symbol, trader: r.who, is_buy: Boolean(r.is_buy), is_dev: Boolean(r.is_dev), quote_wei: wei, quote_symbol: q.symbol, quote_decimals: q.decimals, usd: qu === null ? null : units(wei, q.decimals) * qu, image_url: canonicalImageUrl(r.image_url, imagePublicBase()) }; + return { kind: "swap", chain, at: r.at, tx_hash: r.tx_hash, token: r.token, name: r.name, symbol: r.symbol, trader: r.who, is_buy: Boolean(r.is_buy), is_dev: Boolean(r.is_dev), quote_wei: wei, quote_key: q.key, quote_symbol: q.symbol, quote_decimals: q.decimals, usd: qu === null ? null : units(wei, q.decimals) * qu, image_url: canonicalImageUrl(r.image_url, imagePublicBase()) }; }); } @@ -284,7 +298,7 @@ export type LaunchTotals = { volume_usd: number; fees_burned_usd: number; fees_to_creators_usd: number; - by_chain: Record ; + by_chain: Record ; }; export async function getLaunchTotals(ethUsd: number | null = null): Promise { @@ -294,7 +308,7 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise { assert.equal(matchesFilter(row({ lp_fee: 10_000 }), "burn"), true, "1% + dead recipient = burn"); assert.equal(matchesFilter(row({ lp_fee: 10_000, recipients: [{ payout: "0xabc", bps: 10000 }] }), "burn"), false); assert.equal(matchesFilter(row(), "burn"), false, "0% pools are not 'burn'"); - assert.equal(matchesFilter(row({ quote_symbol: "USDG" }), "usdg"), true); + assert.equal(matchesFilter(row({ quote_key: "usdg" }), "usdg"), true); + assert.equal(matchesFilter(row({ quote_key: "gitlawb" }), "gitlawb"), true); + assert.equal(matchesFilter(row({ quote_key: "eth" }), "gitlawb"), false); assert.equal(matchesFilter(row(), "today"), true); assert.equal(matchesFilter(row({ block_time: new Date(Date.now() - 3 * 86_400_000).toISOString() }), "today"), false); assert.equal(matchesFilter(row(), null), true); diff --git a/app/src/lib/launchpad/search.ts b/app/src/lib/launchpad/search.ts index e44a6ff2..4937adcf 100644 --- a/app/src/lib/launchpad/search.ts +++ b/app/src/lib/launchpad/search.ts @@ -1,13 +1,15 @@ /** Pure search / filter helpers (client + server; node --test loads this directly). */ -export type LaunchFilter = "fee0" | "burn" | "usdg" | "today"; -export const FILTERS: { key: LaunchFilter; label: string; title: string }[] = [ +export type LaunchFilter = "fee0" | "burn" | "usdg" | "gitlawb" | "today"; +/** `chain`: the filter only makes sense on that chain (its quote is not offered elsewhere) → hidden when another chain is selected. */ +export const FILTERS: { key: LaunchFilter; label: string; title: string; chain?: "base" | "robinhood" }[] = [ { key: "fee0", label: "0% fee", title: "feeless pools" }, { key: "burn", label: "fees burned", title: "no beneficiary; every fee is burned" }, - { key: "usdg", label: "USDG", title: "priced in USDG (Robinhood Chain)" }, + { key: "usdg", label: "USDG", title: "priced in USDG (Robinhood Chain)", chain: "robinhood" }, + { key: "gitlawb", label: "GITLAWB", title: "priced in GITLAWB (Base or Robinhood Chain)" }, { key: "today", label: "today", title: "launched in the last 24 hours" }, ]; export function isFilter(v: unknown): v is LaunchFilter { - return v === "fee0" || v === "burn" || v === "usdg" || v === "today"; + return v === "fee0" || v === "burn" || v === "usdg" || v === "gitlawb" || v === "today"; } /** Trim, collapse whitespace, strip a leading $ (people type $SYM), lowercase. */ @@ -19,7 +21,7 @@ export function isAddressQuery(q: string): boolean { } const DEAD = "0x000000000000000000000000000000000000dead"; -type Matchable = { name: string; symbol: string; token: string; lp_fee: number; quote_symbol: string; block_time: string; recipients: { payout: string; bps: number }[] }; +type Matchable = { name: string; symbol: string; token: string; lp_fee: number; quote_key: string; block_time: string; recipients: { payout: string; bps: number }[] }; /** Client-side match over an already-loaded row. Name/symbol prefix or substring, or exact address. */ export function matchesQuery(l: Matchable, q: string): boolean { @@ -33,7 +35,8 @@ export function matchesFilter(l: Matchable, f: LaunchFilter | null, now = Date.n if (!f) return true; if (f === "fee0") return l.lp_fee === 0; if (f === "burn") return l.lp_fee > 0 && l.recipients.length === 1 && l.recipients[0].payout.toLowerCase() === DEAD; - if (f === "usdg") return l.quote_symbol === "USDG"; + if (f === "usdg") return l.quote_key === "usdg"; + if (f === "gitlawb") return l.quote_key === "gitlawb"; return now - new Date(l.block_time).getTime() < 86_400_000; } diff --git a/contracts/test/LaunchFactory.gitlawb.fork.t.sol b/contracts/test/LaunchFactory.gitlawb.fork.t.sol new file mode 100644 index 00000000..f7ac0b06 --- /dev/null +++ b/contracts/test/LaunchFactory.gitlawb.fork.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {LaunchFactory} from "src/LaunchFactory.sol"; +import {LaunchLocker} from "src/LaunchLocker.sol"; +import {LaunchToken} from "src/LaunchToken.sol"; + +interface IERC20Meta { + function decimals() external view returns (uint8); + function symbol() external view returns (string memory); + function balanceOf(address) external view returns (uint256); + function approve(address, uint256) external returns (bool); + function transfer(address, uint256) external returns (bool); +} + +/// Can a launch be quoted in GITLAWB (Gitlawb's token on Base)? Against the LIVE Base +/// factory: launch a token priced in GITLAWB with NO beneficiary, buy it with GITLAWB, +/// sell some back, collect — and prove the 1% fee was BURNED as GITLAWB (sent to 0x…dEaD). +/// GITLAWB is borrowed from the Uniswap v4 PoolManager (which provably holds it: the deepest +/// GITLAWB market is a v4 pool) via prank — no mocks anywhere. +/// FORK_TESTS=true BASE_RPC_URL=… forge test --match-contract LaunchGitlawbQuote -vv +contract LaunchGitlawbQuote is Test { + address constant FACTORY = 0x815542E8b392389A1389E22E588E4B62A67Ade72; + address constant LOCKER = 0xcd1680D26922fcd9CabFbb8a56bA40C333fD842a; + address constant PM = 0x498581fF718922c3f8e6A244956aF099B2652b2b; + address constant GITLAWB = 0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3; // Gitlawb (18 dec) + address constant DEAD = 0x000000000000000000000000000000000000dEaD; + + bool forked; + LaunchFactory factory = LaunchFactory(FACTORY); + PoolSwapTest router; + address creator = makeAddr("gitlawb-creator"); + address buyer = makeAddr("gitlawb-buyer"); + + function setUp() public { + if (!vm.envOr("FORK_TESTS", false)) return; + vm.createSelectFork(vm.envOr("BASE_RPC_URL", string("https://mainnet.base.org"))); + forked = true; + router = new PoolSwapTest(IPoolManager(PM)); + vm.deal(creator, 1 ether); + vm.deal(buyer, 1 ether); + // borrow 200M GITLAWB (~$3.4K at $1.7e-5) from the PoolManager for the buyer + uint256 pmBal = IERC20Meta(GITLAWB).balanceOf(PM); + assertGt(pmBal, 200_000_000e18, "PoolManager holds GITLAWB (the v4 WETH/GITLAWB pool)"); + vm.prank(PM); + IERC20Meta(GITLAWB).transfer(buyer, 200_000_000e18); + } + + function test_fork_launchQuotedInGITLAWB_buySellBurn() public { + if (!forked) return; + assertEq(IERC20Meta(GITLAWB).decimals(), 18); + assertEq(IERC20Meta(GITLAWB).symbol(), "GITLAWB"); + LaunchFactory.LaunchParams memory p; + p.name = "Lawb Fan Token"; + p.symbol = "LAWBFAN"; + p.quote = GITLAWB; + p.lpFee = 10_000; + // 1B supply at an FDV of 600M GITLAWB (≈ $10k): tokens per GITLAWB = 1e9/6e8 ≈ 1.667 → tick ≈ ln(1.667)/ln(1.0001) ≈ 5,108 → 5,200 + p.startTick = 5_200; + // recipients empty = fees burned + (bytes32 salt,) = factory.findSalt(creator, keccak256("lawbfan"), p.name, p.symbol, 0, "", GITLAWB, 64); + p.salt = salt; + vm.prank(creator); + (address token, uint256 tokenId) = factory.launch(p); + assertGt(uint160(token), uint160(GITLAWB), "token sorts above the quote"); + PoolKey memory key = factory.poolKeyOf(token); + + uint256 deadBefore = IERC20Meta(GITLAWB).balanceOf(DEAD); + + // buy with 100M GITLAWB + vm.startPrank(buyer); + IERC20Meta(GITLAWB).approve(address(router), 100_000_000e18); + router.swap( + key, + SwapParams({zeroForOne: true, amountSpecified: -100_000_000e18, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + uint256 got = LaunchToken(token).balanceOf(buyer); + assertGt(got, 100_000_000e18, "more than 100M tokens for 100M GITLAWB at about 1.6 tokens per GITLAWB"); + // sell half back for GITLAWB + LaunchToken(token).approve(address(router), got / 2); + uint256 glBefore = IERC20Meta(GITLAWB).balanceOf(buyer); + router.swap( + key, + SwapParams({ + zeroForOne: false, amountSpecified: -int256(got / 2), sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + vm.stopPrank(); + assertGt(IERC20Meta(GITLAWB).balanceOf(buyer), glBefore, "got GITLAWB back"); + + // fees: 1% of the GITLAWB that came in, burned (no beneficiary) → lands at 0x…dEaD + (uint256 quoteOut,) = LaunchLocker(payable(LOCKER)).collect(tokenId); + assertApproxEqRel(quoteOut, 1_000_000e18, 2e16, "about 1% of 100M GITLAWB collected"); + assertEq(IERC20Meta(GITLAWB).balanceOf(DEAD) - deadBefore, quoteOut, "every collected GITLAWB fee was burned"); + assertEq(IERC20Meta(GITLAWB).balanceOf(creator), 0, "creator got nothing: no beneficiary"); + } +} diff --git a/contracts/test/LaunchFactory.gitlawbRobinhood.fork.t.sol b/contracts/test/LaunchFactory.gitlawbRobinhood.fork.t.sol new file mode 100644 index 00000000..c5c953d8 --- /dev/null +++ b/contracts/test/LaunchFactory.gitlawbRobinhood.fork.t.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {LaunchFactory} from "src/LaunchFactory.sol"; +import {LaunchLocker} from "src/LaunchLocker.sol"; +import {LaunchToken} from "src/LaunchToken.sol"; + +interface IERC20Meta { + function decimals() external view returns (uint8); + function symbol() external view returns (string memory); + function balanceOf(address) external view returns (uint256); + function approve(address, uint256) external returns (bool); + function transfer(address, uint256) external returns (bool); +} + +interface IOFT { + function endpoint() external view returns (address); + function peers(uint32 eid) external view returns (bytes32); +} + +/// GITLAWB on Robinhood Chain is the LayerZero OFT of the Base token. Against the LIVE Robinhood +/// factory: launch a token priced in that GITLAWB with NO beneficiary, buy, sell some back, collect, +/// and prove the 1% fee was BURNED as GITLAWB. Also pins the bridge wiring: the OFT's LayerZero +/// endpoint and its Base peer (the GitlawbOFTAdapter that locks the Base token). +/// FORK_TESTS=true ROBINHOOD_RPC_URL=... forge test --match-contract LaunchGitlawbRobinhoodQuote -vv +contract LaunchGitlawbRobinhoodQuote is Test { + address constant FACTORY = 0x815542E8b392389A1389E22E588E4B62A67Ade72; + address constant LOCKER = 0xcd1680D26922fcd9CabFbb8a56bA40C333fD842a; + address constant PM = 0x8366a39CC670B4001A1121B8F6A443A643e40951; + address constant GITLAWB = 0xd1b0d44E4f6ed940fcC7A9F59Bf30Daf62cCFe3D; // LayerZero OFT (18 dec) + address constant LZ_ENDPOINT_ROBINHOOD = 0x6F475642a6e85809B1c36Fa62763669b1b48DD5B; + address constant BASE_OFT_ADAPTER = 0x3308384bc308D09b3Eba9A8F11cC36e993A273A4; + uint32 constant BASE_EID = 30184; + address constant DEAD = 0x000000000000000000000000000000000000dEaD; + + bool forked; + LaunchFactory factory = LaunchFactory(FACTORY); + PoolSwapTest router; + address creator = makeAddr("gitlawb-rh-creator"); + address buyer = makeAddr("gitlawb-rh-buyer"); + + function setUp() public { + if (!vm.envOr("FORK_TESTS", false)) return; + vm.createSelectFork(vm.envOr("ROBINHOOD_RPC_URL", string("https://rpc.mainnet.chain.robinhood.com"))); + forked = true; + router = new PoolSwapTest(IPoolManager(PM)); + vm.deal(creator, 1 ether); + vm.deal(buyer, 1 ether); + // borrow 20M GITLAWB from the PoolManager (it holds tens of millions across GITLAWB pools) + uint256 pmBal = IERC20Meta(GITLAWB).balanceOf(PM); + assertGt(pmBal, 20_000_000e18, "PoolManager holds GITLAWB on Robinhood Chain"); + vm.prank(PM); + IERC20Meta(GITLAWB).transfer(buyer, 20_000_000e18); + } + + function test_fork_bridgeWiring() public view { + if (!forked) return; + assertEq(IERC20Meta(GITLAWB).symbol(), "GITLAWB"); + assertEq(IERC20Meta(GITLAWB).decimals(), 18); + assertEq(IOFT(GITLAWB).endpoint(), LZ_ENDPOINT_ROBINHOOD, "LayerZero V2 endpoint on Robinhood Chain"); + assertEq(address(uint160(uint256(IOFT(GITLAWB).peers(BASE_EID)))), BASE_OFT_ADAPTER, "peer on Base = GitlawbOFTAdapter"); + } + + function test_fork_launchQuotedInGITLAWB_buySellBurn() public { + if (!forked) return; + LaunchFactory.LaunchParams memory p; + p.name = "Lawb Fan RH"; + p.symbol = "LAWBRH"; + p.quote = GITLAWB; + p.lpFee = 10_000; + // 1B supply at an FDV of 600M GITLAWB: tokens per GITLAWB ~ 1.667 -> tick ~ 5,108 -> 5,200 + p.startTick = 5_200; + // recipients empty = fees burned + (bytes32 salt,) = factory.findSalt(creator, keccak256("lawbrh"), p.name, p.symbol, 0, "", GITLAWB, 64); + p.salt = salt; + vm.prank(creator); + (address token, uint256 tokenId) = factory.launch(p); + assertGt(uint160(token), uint160(GITLAWB), "token sorts above the quote"); + PoolKey memory key = factory.poolKeyOf(token); + + uint256 deadBefore = IERC20Meta(GITLAWB).balanceOf(DEAD); + + // buy with 10M GITLAWB + vm.startPrank(buyer); + IERC20Meta(GITLAWB).approve(address(router), 10_000_000e18); + router.swap( + key, + SwapParams({zeroForOne: true, amountSpecified: -10_000_000e18, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + uint256 got = LaunchToken(token).balanceOf(buyer); + assertGt(got, 10_000_000e18, "more than 10M tokens for 10M GITLAWB at about 1.6 tokens per GITLAWB"); + // sell half back + LaunchToken(token).approve(address(router), got / 2); + uint256 glBefore = IERC20Meta(GITLAWB).balanceOf(buyer); + router.swap( + key, + SwapParams({ + zeroForOne: false, amountSpecified: -int256(got / 2), sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + vm.stopPrank(); + assertGt(IERC20Meta(GITLAWB).balanceOf(buyer), glBefore, "got GITLAWB back"); + + // fees: 1% of the GITLAWB that came in, burned (no beneficiary) + (uint256 quoteOut,) = LaunchLocker(payable(LOCKER)).collect(tokenId); + assertApproxEqRel(quoteOut, 100_000e18, 2e16, "about 1% of 10M GITLAWB collected"); + assertEq(IERC20Meta(GITLAWB).balanceOf(DEAD) - deadBefore, quoteOut, "every collected GITLAWB fee was burned"); + assertEq(IERC20Meta(GITLAWB).balanceOf(creator), 0, "creator got nothing: no beneficiary"); + } +}