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() {
-
quote
ETH uses address zero. For USDG on Robinhood Chain, use 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 and call findSalt(...) first so the token address sorts above the quote.
+
quote
ETH uses address zero. For GITLAWB use 0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3 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.
supply
0 means the default 1B supply.
startTick
Sets the opening market cap. A multiple of 200; 184200 ≈ 10 ETH FDV for 1B supply.
lpFee
In pips: 0, 10000 = 1%, or 30000 = 3%.
@@ -106,7 +106,7 @@ export default function AgentsPage() {
03

Trade & collect

Standard pools. Direct contract calls.

-
Swap

Speak Uniswap v4

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.

+
Swap

Speak Uniswap 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.

Collect

Pay out accrued fees

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=[&q=AAPL] → quote assets the launch form may offer on that chain: - * the fixed ones (ETH, USDG) plus tokenized stocks from that chain's registry (with live USD): + * the fixed ones (ETH, USDG, GITLAWB — with GITLAWB's live USD) plus tokenized stocks from that chain's registry (with live USD): * Coinbase tokenized stocks on Base, Robinhood Stock Tokens on Robinhood Chain. * Only registry addresses are ever labelled as stocks. */ @@ -19,7 +20,8 @@ export async function GET(req: Request) { const chain = u.searchParams.get("chain"); if (!isChainKey(chain)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); const q = (u.searchParams.get("q") ?? "").slice(0, 12); - const fixed = launchpad(chain).quotes.map((x) => ({ ...x, usd: x.usd })); + const gl = await gitlawbUsd(); // one price for both chains: the Robinhood token is the Base token bridged 1:1 + const fixed = launchpad(chain).quotes.map((x) => ({ ...x, usd: x.key === "gitlawb" ? gl : x.usd })); const stocks = await memo(`quotes:stocks:${chain}:${q.toUpperCase()}`, 30_000, async () => { let hits: { address: string; symbol: string; name: string; decimals: number; logo: string | null }[]; if (chain === "base") hits = searchBaseStocks(q, 13).map((s) => ({ ...s, logo: stockTileSvg(s.symbol) })); diff --git a/app/src/app/launch/page.tsx b/app/src/app/launch/page.tsx index 62db1bfc..dcca864f 100644 --- a/app/src/app/launch/page.tsx +++ b/app/src/app/launch/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import LaunchForm from "@/components/launchpad/LaunchForm"; import { ethUsd } from "@/lib/launchpad/ethPrice"; +import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer"; import { isChainKey } from "@/lib/chainPublic"; export const metadata: Metadata = { @@ -12,7 +13,7 @@ export const dynamic = "force-dynamic"; export default async function LaunchPage({ searchParams }: { searchParams: Promise<{ chain?: string }> }) { const sp = await searchParams; - const usd = await ethUsd(); + const [usd, gitlawb] = await Promise.all([ethUsd(), gitlawbUsd()]); return ( <>
@@ -20,7 +21,7 @@ export default async function LaunchPage({ searchParams }: { searchParams: Promi

Launch a token

Pick a chain, fill this in, sign once, done. No platform fee. You only pay gas.

- +
); diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index 1be3ef9e..f3644cc3 100644 --- a/app/src/app/llms.txt/route.ts +++ b/app/src/app/llms.txt/route.ts @@ -35,7 +35,7 @@ ${verificationLine("Robinhood Chain (4663)", r, [ ## What a launch does 1. deploys a fixed-supply ERC-20 (1,000,000,000; no mint/pause/blacklist/tax/owner) -2. initializes a Uniswap v4 pool quote/token (quote = ETH, or USDG on Robinhood Chain; tick spacing 200, no hook) at the chosen start tick +2. initializes a Uniswap v4 pool quote/token (quote = ETH, GITLAWB, USDG on Robinhood Chain, or a registry stock token; tick spacing 200, no hook) at the chosen start tick 3. mints one single-sided position holding 100% of supply to an ownerless locker (no withdraw path exists) 4. registers fee routing: lpFee 0 | 10000 (1%) | 30000 (3%) pips; recipients [] = fees burned, else {payout,bps}[] summing to 10000. Fixed forever. @@ -47,6 +47,8 @@ Robinhood Chain (4663): LaunchFactory ${r.factory ?? "(not deployed yet)"} · La Uniswap v4: Base PoolManager 0x498581fF718922c3f8e6A244956aF099B2652b2b, Universal Router 0x6fF5693b99212Da76ad316178A184AB56D299b43; Robinhood PoolManager 0x8366a39CC670B4001A1121B8F6A443A643e40951, Universal Router 0x8876789976decbfcbbbe364623c63652db8c0904 USDG (Robinhood, 6 dec): 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 +GITLAWB (Base, 18 dec): 0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3 (Gitlawb's token; fees paid in GITLAWB, burned when recipients = []) +GITLAWB (Robinhood, 18 dec): 0xd1b0d44E4f6ed940fcC7A9F59Bf30Daf62cCFe3D (LayerZero OFT of the Base token, 1:1) ## Launch (contract call) factory.launch((name, symbol, metadataURI, quote, supply=0, startTick, lpFee, salt, recipients)) diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index a9b4df32..995258c8 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -12,7 +12,7 @@ export const metadata: Metadata = { title: "How it works", description: "What a const STEPS = [ { title: "Your token is deployed", description: "A plain ERC-20 with EIP-2612 permit. Fixed supply, 1 billion by default. No mint, no pause, no blacklist, no transfer tax, no owner.", value: "1B", label: "default supply" }, - { title: "A market opens", description: "A Uniswap v4 pool pairs your token with ETH, a tokenized stock (Coinbase stocks on Base, Robinhood Stock Tokens on Robinhood Chain), or USDG on Robinhood Chain. No hook. The pool starts at the market cap you pick.", value: "v4", label: "Uniswap pool" }, + { title: "A market opens", description: "A Uniswap v4 pool pairs your token with ETH, GITLAWB, a tokenized stock (Coinbase stocks on Base, Robinhood Stock Tokens on Robinhood Chain), or USDG on Robinhood Chain. No hook. The pool starts at the market cap you pick.", value: "v4", label: "Uniswap pool" }, { title: "The liquidity position is locked", description: "100% of the supply goes into one single-sided position at launch. Its NFT is minted to an ownerless locker that has no function to withdraw, transfer or shrink it. Ever.", value: "100%", label: "deposited at launch" }, { title: "Fee routing is written in", description: "The trading fee you choose (0%, 1% or 3%) goes 100% to the beneficiaries you name, or is burned if you name none. Fixed at launch, unchangeable.", value: "0 / 1 / 3%", label: "your trading fee" }, ] as const; @@ -108,6 +108,7 @@ export default function RulesPage() {
How does the price move?

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.

What can change after launch?

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.

+
What is a GITLAWB-quoted pool?

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.

What should I know about stock-quoted pools?

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.

diff --git a/app/src/app/t/[chain]/[token]/opengraph-image.tsx b/app/src/app/t/[chain]/[token]/opengraph-image.tsx index 25a97610..8e30139b 100644 --- a/app/src/app/t/[chain]/[token]/opengraph-image.tsx +++ b/app/src/app/t/[chain]/[token]/opengraph-image.tsx @@ -11,6 +11,17 @@ import { BRAND, BRAND_TLD } from "@/lib/brand"; import { isOwnImageUrl } from "@/lib/launchpad/images"; import { imagePublicBase, readImage } from "@/lib/launchpad/imageStore"; import sharp from "sharp"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { GITLAWB_LOGO_BG, GITLAWB_LOGO_PATH } from "@/lib/launchpad/gitlawb"; + +/** Gitlawb's logo from public/, inlined once per process (satori needs a data URL or absolute URL). */ +let gitlawbLogo: Promise | null = null; +function gitlawbLogoDataUrl(): Promise { + return (gitlawbLogo ??= readFile(path.join(process.cwd(), "public", GITLAWB_LOGO_PATH)) + .then((b) => `data:image/png;base64,${b.toString("base64")}`) + .catch(() => null)); +} export const alt = "token on openlaunch.lol"; export const size = { width: 1200, height: 630 }; @@ -58,6 +69,7 @@ export default async function TokenOg({ params }: { params: Promise<{ chain: str const [usd, fonts] = await Promise.all([ethUsd(), loadOgFonts()]); const l = isChainKey(chain) && isAddress(token) ? await memo(`og:${chain}:${token.toLowerCase()}`, 30_000, () => getLaunch(chain, token, usd)) : null; const card = l ? shapeCard(l, nowMs()) : null; + const glLogo = card?.quote?.kind === "gitlawb" ? await gitlawbLogoDataUrl() : null; const logo = l ? await memo(`og-logo:${chain}:${token.toLowerCase()}`, 60_000, () => ownLogo(l.image_url)) : null; const h = hueOf(token); return new ImageResponse( @@ -106,9 +118,15 @@ export default async function TokenOg({ params }: { params: Promise<{ chain: str
{card.quote ? ( - 4 ? 7 : card.quote.ticker.length > 3 ? 8 : 10, fontWeight: 800, letterSpacing: -0.5, whiteSpace: "nowrap", overflow: "hidden" }}> - {card.quote.ticker} - + {card.quote.kind === "gitlawb" ? ( + + {glLogo ? : null} + + ) : ( + 4 ? 7 : card.quote.ticker.length > 3 ? 8 : 10, fontWeight: 800, letterSpacing: -0.5, whiteSpace: "nowrap", overflow: "hidden" }}> + {card.quote.ticker} + + )} priced in {card.quote.symbol} ) : null} diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index e5b0591c..dae716ad 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -21,7 +21,9 @@ import { memo } from "@/lib/launchpad/memo"; import { ago, nowMs } from "@/lib/launchpad/time"; import { getLaunch, getSwaps } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; -import { NATIVE, TICK_SPACING, uniswapSwapUrl, type Quote } from "@/lib/launchpad/config"; +import { NATIVE, TICK_SPACING, quoteKeyOf, uniswapSwapUrl, type Quote } from "@/lib/launchpad/config"; +import { GITLAWB_SITE } from "@/lib/launchpad/gitlawb"; +import GitlawbBadge from "@/components/launchpad/GitlawbBadge"; import { fmtCompact, fmtPrice, fmtQuote, fmtUsd, pipsToPct } from "@/lib/launchpad/math"; import { marketCount as count } from "@/lib/launchpad/token-market"; import { marketUsd } from "@/lib/launchpad/market-format"; @@ -55,7 +57,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s const l = await getLaunch(chain, token, usd); if (!l) notFound(); const quote: Quote = { - key: l.quote === NATIVE ? "eth" : l.quote_symbol === "USDG" ? "usdg" : "stock", + key: quoteKeyOf(chain, l.quote), address: l.quote as Address, symbol: l.quote_symbol, decimals: l.quote_decimals, @@ -89,7 +91,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s

{l.name}

-
${l.symbol}·Paired with {quote.symbol}·Launched {ago(l.block_time, now)} ago
+
${l.symbol}·{quote.key === "gitlawb" ? : Paired with {quote.symbol}}·Launched {ago(l.block_time, now)} ago
@@ -140,6 +142,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
{stockQuote ?

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} } />
diff --git a/app/src/components/launchpad/GitlawbBadge.tsx b/app/src/components/launchpad/GitlawbBadge.tsx new file mode 100644 index 00000000..94383b4d --- /dev/null +++ b/app/src/components/launchpad/GitlawbBadge.tsx @@ -0,0 +1,31 @@ +import { GITLAWB_LOGO_BG, GITLAWB_LOGO_PATH, GITLAWB_SYMBOL } from "@/lib/launchpad/gitlawb"; + +/** Gitlawb's logo tile (black, white branch-and-key mark; transparent corners baked in), served from our origin. */ +export function GitlawbMark({ size = 16, className = "" }: { size?: number; className?: string }) { + // eslint-disable-next-line @next/next/no-img-element + return ; +} + +/** + * Badge for tokens paired with GITLAWB. Shown wherever a token appears (list, trending, feed, token page, + * dashboard, share card) — the visible perk of picking GITLAWB as the quote asset. "sm" is 20px tall so it sits + * inside a text-sm line without changing row heights. Black like the logo tile, + * white text, a light hairline in dark mode so it still reads as a chip on a dark page. + */ +export default function GitlawbBadge({ size = "sm", className = "", label = GITLAWB_SYMBOL }: { size?: "sm" | "md"; className?: string; label?: string }) { + return ( + + + {label} + + ); +} + +/** True for a row whose server-resolved quote key is GITLAWB (keys come from the quote address, never from on-chain names). */ +export function isGitlawbQuote(quoteKey: string | null | undefined): boolean { + return quoteKey === "gitlawb"; +} diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 5b8e067d..b3898bd0 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -8,13 +8,15 @@ import { isAddress, maxUint160, maxUint256, parseEventLogs, parseUnits, zeroAddr import TokenAvatar from "./TokenAvatar"; import ImageUpload from "./ImageUpload"; import FeeChip from "./FeeChip"; +import GitlawbBadge from "./GitlawbBadge"; import { toast } from "./TxToasts"; import { btn, card, helper, input, label } from "@/components/ui"; import { ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCH_FACTORY_ABI, PERMIT2_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI } from "@/lib/launchpad/abi"; import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, TICK_SPACING, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; -import { fdvForStartTick, fmtCompact, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; +import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; import { stockMcapPresets } from "@/lib/launchpad/stocks"; +import { GITLAWB_SITE, gitlawbMcapPresets } from "@/lib/launchpad/gitlawb"; import { CHAINS, CHAIN_LABELS, CHAIN_KEYS, BUILDER_DATA_SUFFIX, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; import { friendlyError } from "@/lib/errors"; import { Spinner } from "@/components/Skeleton"; @@ -43,7 +45,7 @@ type Beneficiary = "burn" | "me" | "custom"; const FIRST_BUY_SLIPPAGE_BPS = 300; // Other buyers can trade between the launch and this separate buy. const PERMIT_EXPIRY_S = 30 * 24 * 3600; const GAS_RESERVE_WEI = 500_000_000_000_000n; // 0.0005 ETH kept back so the buy itself can pay for gas -const BUY_PRESETS: Record = { 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."}
); })} - {/* every chain offers at least one fixed quote plus tokenized stocks */} + {/* every chain offers at least one fixed quote plus tokenized stocks; Base also offers GITLAWB */} {cfg.quotes.length > 0 ? (
Priced in @@ -384,8 +389,30 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: ))}
- {quote.key === "usdg" ? "Buyers pay with USDG; market cap and fees are in dollars." : quote.key === "stock" ? (chain === "base" ? "Buyers pay with a Coinbase tokenized stock; fees are paid in that stock." : "Buyers pay with a Robinhood Stock Token; fees are paid in that stock.") : "Buyers pay with ETH."} + {quote.key === "usdg" ? "Buyers pay with USDG; market cap and fees are in dollars." : quote.key === "gitlawb" ? "Buyers pay with GITLAWB; fees are paid in GITLAWB, or burned." : quote.key === "stock" ? (chain === "base" ? "Buyers pay with a Coinbase tokenized stock; fees are paid in that stock." : "Buyers pay with a Robinhood Stock Token; fees are paid in that stock.") : "Buyers pay with ETH."} + {cfg.quotes.some((q) => q.key === "gitlawb") && quote.key !== "gitlawb" ? ( + + ) : null} + + ) : null} + {quoteKey === "gitlawb" ? ( +
+ + {quote.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} + {quote.symbol} + {quote.name} + {quote.usd ? {fmtUsd(quote.usd)} : price unavailable} + +

+ Your token carries the badge on the launch list, trending, the activity feed, its page and its share card. GITLAWB is Gitlawb's token{chain === "robinhood" ? ", bridged 1:1 from Base to Robinhood Chain over LayerZero" : " on Base"}: an ordinary ERC-20, no transfer restrictions, no issuer switch. Name no beneficiary and every trading fee burns GITLAWB. Price from the Uniswap v4 WETH/GITLAWB pool on Base.{" "} + gitlawb.com ↗ +

) : null} {quoteKey === "stock" ? ( @@ -508,7 +535,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: }} 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"}`} > - {quote.key === "stock" ? `$${fmtCompact(v * (quote.usd ?? 0), 0)}` : quote.decimals <= 6 ? `$${fmtCompact(v, 0)}` : `${v} ${quote.symbol}`} + {quote.key === "stock" || quote.key === "gitlawb" ? `$${fmtCompact(v * (quote.usd ?? 0), 0)}` : quote.decimals <= 6 ? `$${fmtCompact(v, 0)}` : `${v} ${quote.symbol}`} ); })} @@ -527,8 +554,8 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: {fdvPreview !== null && tokensPerEth !== null ? (

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:
{name.trim() || "Your token"}
{symbolClean || "TICKER"}
- +
+ {quote.key === "gitlawb" ? : null} + +
{description.trim() ?

{description.trim()}

: null}
diff --git a/app/src/components/launchpad/LaunchList.tsx b/app/src/components/launchpad/LaunchList.tsx index 6a797861..ff98c3f2 100644 --- a/app/src/components/launchpad/LaunchList.tsx +++ b/app/src/components/launchpad/LaunchList.tsx @@ -238,7 +238,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
- { const value = values[0]; if (value) pick(sort, window_, value === "all" ? null : value as ChainKey); }}> + { const value = values[0]; if (!value) return; const c = value === "all" ? null : (value as ChainKey); const keep = !filter || FILTERS.some((f) => f.key === filter && (!f.chain || !c || f.chain === c)); pick(sort, window_, c, keep ? filter : null); }}> {CHAIN_FILTERS.map((c) => {c.label})} {showWindow ? { if (values[0]) pick(sort, values[0] as VolumeWindow); }}> @@ -248,7 +248,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index 197907df..a63596b0 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -8,6 +8,7 @@ import { fmtQuote, fmtUsd, pipsToPct } from "@/lib/launchpad/math"; import { CHAIN_SHORT } from "@/lib/chainPublic"; import { ago } from "@/lib/launchpad/time"; import ChangeChip from "./ChangeChip"; +import GitlawbBadge, { isGitlawbQuote } from "./GitlawbBadge"; import { marketUsd } from "@/lib/launchpad/market-format"; const columns = "md:grid-cols-[minmax(0,1fr)_6.5rem_5.5rem_6rem_5rem_2.5rem]"; @@ -49,6 +50,7 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop
{l.name} + {isGitlawbQuote(l.quote_key) ? : null} {hl?.kind === "new" ? New : null}
diff --git a/app/src/components/launchpad/LaunchTape.tsx b/app/src/components/launchpad/LaunchTape.tsx index fe3a62a0..78d717b4 100644 --- a/app/src/components/launchpad/LaunchTape.tsx +++ b/app/src/components/launchpad/LaunchTape.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { Activity, ArrowDownLeft, ArrowUpRight, Plus } from "lucide-react"; import { useLive } from "./LiveProvider"; import TokenAvatar from "./TokenAvatar"; +import GitlawbBadge, { isGitlawbQuote } from "./GitlawbBadge"; import type { FeedItem } from "@/lib/launchpad/queries"; import { fmtQuote } from "@/lib/launchpad/math"; import { CHAIN_SHORT, shortAddr } from "@/lib/chainPublic"; @@ -60,6 +61,7 @@ export default function LaunchTape({ initial }: { initial: FeedItem[] }) {
diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx index 35b1153b..162a9b50 100644 --- a/app/src/components/launchpad/MeDashboard.tsx +++ b/app/src/components/launchpad/MeDashboard.tsx @@ -8,6 +8,7 @@ import { getPublicClient, getWalletClient } from "wagmi/actions"; import type { Address } from "viem"; import TokenAvatar from "./TokenAvatar"; import ChainBadge from "./ChainBadge"; +import GitlawbBadge, { isGitlawbQuote } from "./GitlawbBadge"; import FeeChip, { feeModeOf } from "./FeeChip"; import EditTokenSheet from "./EditTokenSheet"; import { toast } from "./TxToasts"; @@ -246,6 +247,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine {l.name} {l.symbol} + {isGitlawbQuote(l.quote_key) ? : null}
@@ -295,6 +297,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine {t.name} {t.symbol} + {isGitlawbQuote(t.quote_key) ? : null}
{t.my_buys} buys · {t.my_sells} sells · mc {t.fdv_usd !== null ? fmtUsd(t.fdv_usd, { compact: true }) : "—"} diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index 7acda594..efdf60ef 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -11,7 +11,7 @@ import { btn } from "@/components/ui"; import { toast } from "./TxToasts"; import { ERC20_MIN_ABI, PERMIT2_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI } from "@/lib/launchpad/abi"; import { BUY_PRESETS, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; -import { fmtCompact, fmtUsd, minOut, units, pipsToPct } from "@/lib/launchpad/math"; +import { fmtCompact, fmtQuoteUnits, fmtUsd, minOut, units, pipsToPct } from "@/lib/launchpad/math"; import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; import { CHAINS, CHAIN_LABELS, BUILDER_DATA_SUFFIX, explorerTx, type ChainKey } from "@/lib/chainPublic"; import { tradeQuoteKey } from "@/lib/launchpad/token-market"; @@ -38,12 +38,6 @@ type Phase = const SLIPPAGE_BPS = 100; // 1% const PERMIT_EXPIRY_S = 30 * 24 * 3600; -function fmtEthLocal(v: number): string { - if (!Number.isFinite(v) || v === 0) return "0"; - const abs = Math.abs(v); - const digits = abs >= 100 ? 1 : abs >= 1 ? 3 : abs >= 0.01 ? 4 : abs >= 0.0001 ? 6 : 8; - return v.toFixed(digits).replace(/\.?0+$/, ""); -} export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUsd, onTraded }: { chain: ChainKey; token: Address; symbol: string; poolKey: PoolKey; quote: Quote; ethUsd: number | null; onTraded?: () => void }) { const CHAIN = CHAINS[chain]; @@ -52,7 +46,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs const configured = launchpad(chain).configured; const isNative = quote.key === "eth"; const quoteUsd = quoteUsdOf(quote, ethUsd); - const fmtQ = (raw: bigint) => `${quote.decimals <= 6 ? units(raw, quote.decimals).toFixed(2).replace(/\.00$/, "") : fmtEthLocal(units(raw, quote.decimals))} ${quote.symbol}`; + const fmtQ = (raw: bigint) => `${fmtQuoteUnits(units(raw, quote.decimals), quote.decimals)} ${quote.symbol}`; const router = useRouter(); const config = useConfig(); const { address, isConnected, chainId } = useAccount(); @@ -200,7 +194,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs {side === "buy" ? quote.symbol : symbol}
- {side === "buy" ? BUY_PRESETS[quote.key].map((p) => ) : [25, 50, 100].map((pct) => )} + {side === "buy" ? BUY_PRESETS[quote.key].map((p) => ) : [25, 50, 100].map((pct) => )}
diff --git a/app/src/components/launchpad/TrendingStrip.tsx b/app/src/components/launchpad/TrendingStrip.tsx index f42b6204..9963922a 100644 --- a/app/src/components/launchpad/TrendingStrip.tsx +++ b/app/src/components/launchpad/TrendingStrip.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { ArrowUpRight, ChartNoAxesCombined } from "lucide-react"; import TokenAvatar from "./TokenAvatar"; +import GitlawbBadge, { isGitlawbQuote } from "./GitlawbBadge"; import ChangeChip from "./ChangeChip"; import { useLive } from "./LiveProvider"; import type { LaunchRow } from "@/lib/launchpad/queries"; @@ -81,7 +82,7 @@ export default function TrendingStrip({ initial }: { initial: Snap }) {
0{index + 1} ranked - {CHAIN_SHORT[row.chain]} · {row.quote_symbol} + {isGitlawbQuote(row.quote_key) ? {CHAIN_SHORT[row.chain]} · : {CHAIN_SHORT[row.chain]} · {row.quote_symbol}}
diff --git a/app/src/components/sections/rules-guide.test.ts b/app/src/components/sections/rules-guide.test.ts index 3365cbda..f42a7ab0 100644 --- a/app/src/components/sections/rules-guide.test.ts +++ b/app/src/components/sections/rules-guide.test.ts @@ -12,7 +12,9 @@ test("the rules guide retains native navigation and visible risk disclosures", ( for (const anchor of ["launchpad", "fees", "immutable", "know", "contracts"]) { assert.ok(page.includes(`id="${anchor}"`), `missing section: ${anchor}`); } - assert.equal((page.match(/
/g) ?? []).length, 3); + assert.equal((page.match(/
/g) ?? []).length, 4); + assert.match(page, /GITLAWB-quoted pool/); + assert.match(page, /burns GITLAWB on every trade/); assert.match(page, /a locked pool does not make a token valuable/); assert.match(page, /Nothing is refundable/); assert.match(page, /no anti-snipe mechanism/); diff --git a/app/src/lib/launchpad/config.ts b/app/src/lib/launchpad/config.ts index 0aba3547..af19d014 100644 --- a/app/src/lib/launchpad/config.ts +++ b/app/src/lib/launchpad/config.ts @@ -1,5 +1,6 @@ import { isAddress, type Address } from "viem"; import { CHAIN_KEYS, SITE_URL, type ChainKey } from "@/lib/chainPublic"; +import { GITLAWB_ADDRESS, GITLAWB_ADDRESS_ROBINHOOD, GITLAWB_DECIMALS, GITLAWB_LOGO_PATH, GITLAWB_NAME, GITLAWB_SYMBOL } from "./gitlawb"; /** * CLIENT-SAFE launchpad config, per chain. Only NEXT_PUBLIC_* vars are read here. @@ -13,12 +14,15 @@ export const DEAD = "0x000000000000000000000000000000000000dEaD" as const; export const NATIVE = "0x0000000000000000000000000000000000000000" as const; export const BPS = 10_000; -export type Quote = { key: "eth" | "usdg" | "stock"; address: Address; symbol: string; decimals: number; usd: number | null /* fixed USD price (stables); live for stocks */; name?: string; logo?: string | null }; +export type Quote = { key: "eth" | "usdg" | "gitlawb" | "stock"; address: Address; symbol: string; decimals: number; usd: number | null /* fixed USD price (stables); live for stocks + GITLAWB (server-filled) */; name?: string; logo?: string | null }; export type V4 = { poolManager: Address; positionManager: Address; stateView: Address; quoter: Address; universalRouter: Address; permit2: Address; swapLayout: "v1" | "v2" }; export type ChainLaunchpad = { key: ChainKey; factory: Address | null; locker: Address | null; v4: V4; quotes: Quote[]; configured: boolean }; const ETH: Quote = { key: "eth", address: NATIVE, symbol: "ETH", decimals: 18, usd: null }; const USDG: Quote = { key: "usdg", address: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", symbol: "USDG", decimals: 6, usd: 1 }; +/** GITLAWB: usd is null here (client-safe static); the server fills the live price (gitlawbServer.ts). Robinhood's is the LayerZero OFT of the Base token. */ +const GITLAWB: Quote = { key: "gitlawb", address: GITLAWB_ADDRESS as Address, symbol: GITLAWB_SYMBOL, decimals: GITLAWB_DECIMALS, usd: null, name: GITLAWB_NAME, logo: GITLAWB_LOGO_PATH }; +const GITLAWB_RH: Quote = { ...GITLAWB, address: GITLAWB_ADDRESS_ROBINHOOD as Address }; /** Canonical Uniswap v4 deployments (developers.uniswap.org/docs/protocols/v4/deployments). */ const V4_BY_CHAIN: Record = { @@ -42,7 +46,7 @@ const V4_BY_CHAIN: Record = { }, }; /** Quote assets offered per chain, first = default. */ -const QUOTES_BY_CHAIN: Record = { base: [ETH], robinhood: [USDG, ETH] }; +const QUOTES_BY_CHAIN: Record = { base: [ETH, GITLAWB], robinhood: [USDG, ETH, GITLAWB_RH] }; function addr(v: string | undefined): Address | null { const raw = (v ?? "").trim(); @@ -85,6 +89,11 @@ export function quoteInfo(key: ChainKey, address: string): Quote { return QUOTES_BY_CHAIN[key].find((q) => q.address.toLowerCase() === a) ?? { key: "stock", address: address as Address, symbol: "?", decimals: 18, usd: null }; } +/** Quote key for a stored quote address: a fixed quote's key, else "stock" (= some ERC20, priced only if a registry knows it). */ +export function quoteKeyOf(key: ChainKey, address: string): Quote["key"] { + return quoteInfo(key, address).key; +} + export { quoteUsdOf } from "./math"; /** LP fee presets offered in the form (pips). */ @@ -95,9 +104,9 @@ export const FEE_PRESETS = [ ] as const; /** Starting market cap presets per quote (fully diluted, in quote units). */ -export const MCAP_PRESETS: Record = { 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"); + } +}