diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index e87c75b2..b8ac788f 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -2,17 +2,18 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { useAccount, useConfig, useConnect, useSwitchChain } from "wagmi"; +import { useAccount, useBalance, useConfig, useConnect, useReadContract, useSwitchChain } from "wagmi"; import { getPublicClient, getWalletClient } from "wagmi/actions"; -import { isAddress, parseEventLogs, type Address, type Hex } from "viem"; +import { isAddress, maxUint160, maxUint256, parseEventLogs, parseUnits, zeroAddress, type Address, type Hex, type PublicClient, type WalletClient } from "viem"; import TokenAvatar from "./TokenAvatar"; import ImageUpload from "./ImageUpload"; import FeeChip from "./FeeChip"; import { toast } from "./TxToasts"; import { btn, card, helper, input, label } from "@/components/ui"; -import { LAUNCH_FACTORY_ABI } from "@/lib/launchpad/abi"; -import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; -import { fdvForStartTick, fmtCompact, fmtUsd, startTickForFdv, tickToTokensPerQuote } from "@/lib/launchpad/math"; +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 { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; import { stockMcapPresets } from "@/lib/launchpad/stocks"; import { CHAINS, CHAIN_LABELS, CHAIN_KEYS, BUILDER_DATA_SUFFIX, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; import { friendlyError } from "@/lib/errors"; @@ -21,7 +22,9 @@ import { startNav } from "@/components/RouteProgress"; /** * Launch flow — honest states, nothing claimed before the chain says so: - * idle → saving (metadata → predicted address) → simulating → signing → sent (hash) → indexing → done → /t/ + * idle → saving (metadata → predicted address) → simulating → signing → sent (hash) → [buying] → indexing → done → /t/ + * `buying` is the optional first buy: a second transaction right after the launch is confirmed. It can fail + * (rejected, sniped past slippage) without the launch failing — the launch is already on-chain by then. */ type Phase = | { k: "idle" } @@ -29,18 +32,95 @@ type Phase = | { k: "simulating" } | { k: "signing" } | { k: "sent"; hash: Hex } + | { k: "buying"; hash: Hex; step: "quote" | "approve" | "sign" } // hash = the confirmed launch + | { k: "buying"; hash: Hex; step: "sent"; buyHash: Hex } | { k: "indexing"; hash: Hex } | { k: "done"; hash: Hex; token: string } | { k: "error"; message: string }; type Beneficiary = "burn" | "me" | "custom"; +const FIRST_BUY_SLIPPAGE_BPS = 300; // nobody has traded yet, but a same-block sniper can move the price a little +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: [] }; + function randomSalt(): Hex { const b = new Uint8Array(32); crypto.getRandomValues(b); return `0x${Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("")}`; } +type FirstBuyCtx = { pub: PublicClient; wallet: WalletClient; address: Address; V4: ReturnType["v4"]; quote: Quote; feePips: number; CHAIN: (typeof CHAINS)[ChainKey]; setPhase: (p: Phase) => void }; + +function parseBuyAmount(v: string, decimals: number): bigint | null | undefined { + try { + const t = v.trim(); + if (!t) return null; + const raw = parseUnits(t, decimals); + return raw > 0n ? raw : null; + } catch { + return undefined; // typed something that is not a number + } +} + +/** Buy `amountIn` of the freshly launched token through the Universal Router — same path as the token page's trade panel. */ +/** Resolves with what the wallet actually received (from the receipt's Transfer logs); `exact` is false only if no such log was found and the quote is returned instead. */ +async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, amountIn: bigint): Promise<{ hash: Hex; out: bigint; exact: boolean }> { + const { pub, wallet, address, V4, quote, feePips, CHAIN, setPhase } = ctx; + // the factory guarantees the token sorts above the quote, so the quote is always currency0 + const key: PoolKey = { currency0: quote.address, currency1: tokenAddr, fee: feePips, tickSpacing: TICK_SPACING, hooks: zeroAddress }; + setPhase({ k: "buying", hash: launchHash, step: "quote" }); + // public nodes can lag the launch block by one: retry the quote a few times before giving up + let out: bigint | null = null; + for (let attempt = 0; out === null; attempt++) { + try { + const { result } = await pub.simulateContract({ address: V4.quoter, abi: V4_QUOTER_ABI, functionName: "quoteExactInputSingle", args: [{ poolKey: key, zeroForOne: true, exactAmount: amountIn, hookData: "0x" }] }); + out = result[0]; + } catch (err) { + if (attempt >= 5) throw err; + await new Promise((r) => setTimeout(r, 1500)); + } + } + if (quote.key !== "eth") { + const payToken = quote.address; + const erc20Allowance = await pub.readContract({ address: payToken, abi: ERC20_MIN_ABI, functionName: "allowance", args: [address, V4.permit2] }); + if (erc20Allowance < amountIn) { + setPhase({ k: "buying", hash: launchHash, step: "approve" }); + const h = await wallet.writeContract({ address: payToken, abi: ERC20_MIN_ABI, functionName: "approve", args: [V4.permit2, maxUint256], dataSuffix: BUILDER_DATA_SUFFIX, chain: CHAIN, account: address }); + await pub.waitForTransactionReceipt({ hash: h }); + } + const [pAmount, pExp] = await pub.readContract({ address: V4.permit2, abi: PERMIT2_ABI, functionName: "allowance", args: [address, payToken, V4.universalRouter] }); + const now = Math.floor(Date.now() / 1000); + if (pAmount < amountIn || pExp <= now + 60) { + setPhase({ k: "buying", hash: launchHash, step: "approve" }); + const h = await wallet.writeContract({ address: V4.permit2, abi: PERMIT2_ABI, functionName: "approve", args: [payToken, V4.universalRouter, maxUint160, now + PERMIT_EXPIRY_S], dataSuffix: BUILDER_DATA_SUFFIX, chain: CHAIN, account: address }); + await pub.waitForTransactionReceipt({ hash: h }); + } + } + const { commands, inputs } = encodeV4ExactInSingle({ key, zeroForOne: true, amountIn, minOut: minOut(out, FIRST_BUY_SLIPPAGE_BPS), layout: V4.swapLayout }); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 600); + const { request } = await pub.simulateContract({ + address: V4.universalRouter, + abi: UNIVERSAL_ROUTER_ABI, + functionName: "execute", + args: [commands, inputs, deadline], + value: quote.key === "eth" ? amountIn : 0n, + account: address, + dataSuffix: BUILDER_DATA_SUFFIX, + }); + setPhase({ k: "buying", hash: launchHash, step: "sign" }); + const h = await wallet.writeContract(request); + setPhase({ k: "buying", hash: launchHash, step: "sent", buyHash: h }); + const rc = await pub.waitForTransactionReceipt({ hash: h }); + if (rc.status !== "success") throw new Error("The buy reverted on-chain."); + // the executed amount can be below the quote (down to the slippage floor): read it off the receipt + const received = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: rc.logs }) + .filter((l) => l.address.toLowerCase() === tokenAddr.toLowerCase() && l.args.to.toLowerCase() === address.toLowerCase()) + .reduce((sum, l) => sum + l.args.value, 0n); + 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 }) { const router = useRouter(); const [chain, setChain] = useState(initialChain); @@ -87,6 +167,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: const [feePips, setFeePips] = useState(0); const [beneficiary, setBeneficiary] = useState("burn"); const [customAddr, setCustomAddr] = useState(""); + const [initialBuy, setInitialBuy] = useState(""); // Generated lazily at launch time (a render-time random value would break hydration). const saltRef = useRef(null); // The metadataURI is keyed by meta_key, so findSalt can change the salt freely within one attempt. Both refs are @@ -103,6 +184,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: 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 symbolClean = symbol.trim().toUpperCase(); + const initialBuyRaw = parseBuyAmount(initialBuy, quote.decimals); const errors: string[] = []; if (name.trim().length === 0 || name.trim().length > 32) errors.push("Name: 1–32 characters."); if (!/^[A-Z0-9]{1,10}$/.test(symbolClean)) errors.push("Symbol: 1–10 letters or digits."); @@ -111,9 +193,22 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: 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."); - const valid = errors.length === 0; + if (initialBuyRaw === undefined) errors.push(`First buy: enter an amount in ${quote.symbol}, or leave it empty.`); const onChain = chainId === CHAIN.id; + // balance of whatever the first buy is paid with (only read while an amount is typed) + const ethBal = useBalance({ address, chainId: CHAIN.id, query: { enabled: Boolean(address) && quote.key === "eth" && Boolean(initialBuyRaw), refetchInterval: 15_000 } }); + const quoteBal = useReadContract({ address: quote.address, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: address ? [address] : undefined, chainId: CHAIN.id, query: { enabled: Boolean(address) && quote.key !== "eth" && Boolean(initialBuyRaw), refetchInterval: 15_000 } }); + const buyBalance: bigint | undefined = quote.key === "eth" ? ethBal.data?.value : (quoteBal.data as bigint | undefined); + const buyBalanceFailed = quote.key === "eth" ? ethBal.isError : quoteBal.isError; + // the launch is irreversible and the buy comes after it: never let a launch through while the buy's funding is unknown + if (initialBuyRaw && address && buyBalance === undefined) errors.push(buyBalanceFailed ? `First buy: could not read your ${quote.symbol} balance. Retry, or clear the amount.` : "First buy: checking your balance…"); + if (initialBuyRaw && buyBalance !== undefined && initialBuyRaw + (quote.key === "eth" ? GAS_RESERVE_WEI : 0n) > buyBalance) + errors.push(quote.key === "eth" ? "First buy: not enough ETH (leave a little for gas)." : `First buy: not enough ${quote.symbol} in this wallet.`); + const valid = errors.length === 0; + const buyPreview = initialBuyRaw && startTick !== null ? initialBuyPreview({ startTick, amountInRaw: initialBuyRaw, lpFeePips: feePips, quoteDecimals: quote.decimals }) : null; + const buyUsd = initialBuyRaw && quoteUsd ? units(initialBuyRaw, quote.decimals) * quoteUsd : null; + const fmtPct = (p: number) => (p >= 10 ? p.toFixed(0) : p >= 1 ? p.toFixed(1) : p.toFixed(2)) + "%"; const recipients = useMemo(() => { if (feePips === 0 || beneficiary === "burn") return [] as { payout: Address; bps: number }[]; @@ -198,10 +293,27 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: const [ev] = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs }); const token = (ev?.args.token ?? meta.token ?? "").toLowerCase(); + // Optional first buy. The launch is already on-chain: whatever happens here must not read as a launch failure. + let buyHash: Hex | null = null; + let buyProblem: string | null = null; + let bought: { out: bigint; exact: boolean } | null = null; + if (initialBuyRaw && ev?.args.token) { + try { + const r = await firstBuy({ pub, wallet, address, V4: cfg.v4, quote, feePips, CHAIN, setPhase }, ev.args.token, hash, initialBuyRaw); + buyHash = r.hash; + bought = { out: r.out, exact: r.exact }; + } catch (err) { + buyProblem = friendlyError(err, { slippagePct: FIRST_BUY_SLIPPAGE_BPS / 100 }); + } + } + setPhase({ k: "indexing", hash }); await fetch(`/api/launch/sync?chain=${chain}&tx=${hash}`, { method: "POST" }).catch(() => {}); + if (buyHash) await fetch(`/api/launch/sync?chain=${chain}&tx=${buyHash}`, { method: "POST" }).catch(() => {}); setPhase({ k: "done", hash, token }); toast({ kind: "launch", title: `${name.trim()} is live on ${CHAIN_LABEL}`, sub: "Liquidity locked forever. Taking you to your token.", chain, token, symbol: symbolClean, celebrate: true }); + if (bought !== null) toast({ kind: "buy", title: `You bought ${bought.exact ? "" : "about "}${fmtCompact(Number(bought.out) / 1e18)} ${symbolClean}`, sub: "First holder. Confirmed on " + CHAIN_LABEL, chain, token, symbol: symbolClean }); + if (buyProblem) toast({ kind: "info", title: "Launched, but the first buy did not go through", sub: `${buyProblem} You can buy on the token page.`, chain, token, symbol: symbolClean }); startNav(); router.push(`/t/${chain}/${token}`); } catch (err) { @@ -485,6 +597,53 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: {/* submit */} +
+
+

+ First buy · optional +

+ a second transaction, right after the launch confirms +
+
+ {BUY_PRESETS[quote.key].map((v) => { + const active = initialBuy.trim() === v; + return ( + + ); + })} +
+ setInitialBuy(e.target.value.replace(/[^0-9.]/g, ""))} + placeholder="none" + inputMode="decimal" + aria-label={`first buy amount in ${quote.symbol}`} + /> + {quote.symbol} +
+ {initialBuyRaw && buyBalance !== undefined ? ( + balance {fmtCompact(units(buyBalance, quote.decimals), quote.decimals <= 8 ? 2 : 4)} + ) : null} +
+ {buyPreview ? ( +

+ You become the first holder with about {fmtCompact(buyPreview.tokensOut, 0)} {symbolClean || "tokens"}{" "} + ({fmtPct(buyPreview.pctOfSupply)} of supply{buyUsd ? ` · ≈ ${fmtUsd(buyUsd)}` : ""}). Market cap after your buy:{" "} + {fmtMcap(buyPreview.fdvAfter)}. Includes price impact and the pool fee; the exact amount is quoted on-chain right before the buy. +

+ ) : ( +

Seed yourself as the first holder so the chart does not open empty. Skip it and the pool opens untouched.

+ )} +
+
{errors.length > 0 && (name || symbol) ? (
    @@ -506,11 +665,13 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: if (c) connect({ connector: c }); }} onSwitch={() => void switchChainAsync({ chainId: CHAIN.id })} + label={initialBuyRaw ? "Launch + first buy — free, gas only" : "Launch — free, gas only"} />

    One transaction on {CHAIN_LABEL}: deploys the token, creates the Uniswap v4 pool ({quote.symbol} / your token), locks 100% of the supply in it forever, and registers the fee routing. Cost: gas only, usually a few cents. Nothing is refundable and nothing can be edited afterwards. + {initialBuyRaw ? ` Then a second transaction buys ${initialBuy.trim()} ${quote.symbol} of your token${quote.key !== "eth" ? " (with a one-time approval the first time)" : ""}; if you reject it, the launch still stands.` : ""}

@@ -530,7 +691,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: {description.trim() ?

{description.trim()}

: null}
- + f.pips === feePips)?.label ?? "—"} sub={feePips === 0 ? "free pool" : beneficiary === "burn" ? "burned" : "to beneficiary"} />
@@ -541,6 +702,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: ["Opens a Uniswap v4 pool", `${quote.symbol} / your token on ${CHAIN_LABELS[chain]}, no hook`], ["Locks 100% of supply as liquidity", "the position NFT lives in an ownerless locker, forever"], ["Routes trading fees", feePips === 0 ? "nothing to route at 0%" : beneficiary === "burn" ? "burned at collect time" : "100% to the beneficiary, claimable any time"], + ...(initialBuyRaw ? [["Buys your first tokens", `${initialBuy.trim()} ${quote.symbol} right after the launch confirms — a second wallet prompt`]] : []), ].map(([t, d]) => (
  • @@ -575,6 +737,7 @@ function SubmitButton({ phase, onConnect, onSwitch, + label, }: { chainLabel: string; configured: boolean; @@ -585,6 +748,7 @@ function SubmitButton({ phase: Phase; onConnect: () => void; onSwitch: () => void; + label: string; }) { const cls = `${btn.primary} w-full min-h-12 text-[15px]`; if (!configured) @@ -610,6 +774,7 @@ function SubmitButton({ simulating: "Checking the launch…", signing: "Confirm in your wallet…", sent: `Confirming on ${chainLabel}…`, + buying: "Launched! Buying your first tokens…", indexing: "Almost there…", done: "Launched!", }; @@ -621,7 +786,7 @@ function SubmitButton({ {busy} ) : ( - "Launch — free, gas only" + label )} ); @@ -634,14 +799,24 @@ function PhaseNote({ phase, chain }: { phase: Phase; chain: ChainKey }) { {phase.message}

    ); - if (phase.k === "sent" || phase.k === "indexing" || phase.k === "done") + if (phase.k === "sent" || phase.k === "buying" || phase.k === "indexing" || phase.k === "done") return (

    - Transaction{" "} - - {phase.hash.slice(0, 10)}… + {phase.k === "buying" && phase.step === "sent" ? "Launched. Buy " : phase.k === "buying" ? "Launch " : "Transaction "} + + {(phase.k === "buying" && phase.step === "sent" ? phase.buyHash : phase.hash).slice(0, 10)}… - {phase.k === "done" ? " confirmed. Taking you to your token." : " sent."} + {phase.k === "done" + ? " confirmed. Taking you to your token." + : phase.k === "buying" + ? phase.step === "quote" + ? " confirmed. Pricing your first buy…" + : phase.step === "approve" + ? " confirmed. Approve the quote token in your wallet (one time)…" + : phase.step === "sign" + ? " confirmed. Confirm the buy in your wallet…" + : " sent, waiting for confirmation…" + : " sent."}

    ); return null; diff --git a/app/src/lib/errors.ts b/app/src/lib/errors.ts index 8a5f1f4e..99d10c43 100644 --- a/app/src/lib/errors.ts +++ b/app/src/lib/errors.ts @@ -12,16 +12,20 @@ const CONTRACT_ERROR_MESSAGES: Record = { BadRecipients: "Beneficiary shares must add up to 100%.", }; -export function friendlyError(err: unknown): string { +/** `slippagePct` = the tolerance the failed call actually used (the trade panel's 1% by default). */ +export function friendlyError(err: unknown, opts: { slippagePct?: number } = {}): string { + const slippagePct = opts.slippagePct ?? 1; if (err instanceof BaseError) { if (err.walk((e) => e instanceof UserRejectedRequestError)) return "You cancelled in your wallet."; const rev = err.walk((e) => e instanceof ContractFunctionRevertedError) as ContractFunctionRevertedError | null; const name = rev?.data?.errorName; + const slippage = /slippage|amountOutMinimum|TooLittleReceived/i; if (name && CONTRACT_ERROR_MESSAGES[name]) return CONTRACT_ERROR_MESSAGES[name]; + if (name && slippage.test(name)) return `Price moved more than ${slippagePct}%. Try again.`; // decoded router error (e.g. V4TooLittleReceived) if (name) return `Reverted: ${name}`; const short = err.shortMessage || err.message; if (/insufficient funds/i.test(short)) return "Not enough ETH for this transaction plus gas."; - if (/slippage|amountOutMinimum|TooLittleReceived|V4TooLittleReceived/i.test(short)) return "Price moved more than 1%. Try again."; + if (slippage.test(short)) return `Price moved more than ${slippagePct}%. Try again.`; return short.length > 200 ? `${short.slice(0, 200)}…` : short; } if (err instanceof Error) return err.message; diff --git a/app/src/lib/launchpad/math.test.ts b/app/src/lib/launchpad/math.test.ts index fc19b19e..11549f65 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, fmtEth, minOut, poolIdOf, quoteUsdOf, startTickForFdv, sqrtPriceToTokensPerQuote, tickToTokensPerQuote } from "./math.ts"; +import { fdvForStartTick, fmtEth, 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)", () => { @@ -90,3 +90,29 @@ test("quoteUsdOf: stables/stocks use their own price, ETH only for the native ad assert.equal(quoteUsdOf({ address: "0xabc0000000000000000000000000000000000001", usd: 229.01 }, 2500), 229.01, "stock with live price"); assert.equal(quoteUsdOf({ address: "0xabc0000000000000000000000000000000000001", usd: null }, 2500), null, "unknown ERC20 must not be priced as ETH"); }); + +test("initialBuyPreview: a tiny first buy pays the opening price; bigger buys get less per ETH", () => { + const tick = startTickForFdv(10); // 10 ETH FDV, 1B supply → ~1e8 tokens per ETH at the open + const tiny = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 12n }); // 0.000001 ETH + const perEthAtOpen = tickToTokensPerQuote(tick); + assert.ok(Math.abs(tiny.tokensOut / 1e-6 - perEthAtOpen) / perEthAtOpen < 1e-4, "tiny buy ≈ opening price"); + const one = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 18n }); // 1 ETH into a 10 ETH FDV pool + assert.ok(one.tokensOut < perEthAtOpen, "price impact: less than the opening rate"); + assert.ok(one.tokensOut > perEthAtOpen * 0.5, "…but not absurdly less"); + assert.ok(one.pctOfSupply > 5 && one.pctOfSupply < 10, `1 ETH into 10 ETH FDV ≈ 9% of supply, got ${one.pctOfSupply}`); + assert.ok(one.fdvAfter > 10, "market cap rises after a buy"); + const ten = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 19n }); + assert.ok(ten.tokensOut > one.tokensOut && ten.pctOfSupply < 100, "monotonic and never more than the supply"); +}); + +test("initialBuyPreview: the LP fee reduces output; quote decimals are honoured", () => { + const tick = startTickForFdv(10); + const free = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 17n, lpFeePips: 0 }); + const fee1 = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 17n, lpFeePips: 10_000 }); + assert.ok(Math.abs(fee1.tokensOut / free.tokensOut - 0.99) < 1e-3, "1% fee → ~1% fewer tokens"); + // 6-decimal quote: 1,000 USDG FDV, buy 10 USDG → ~1% of supply at the open, a bit less with impact + const t6 = startTickForFdv(1_000, 6); + const u = initialBuyPreview({ startTick: t6, amountInRaw: 10n * 10n ** 6n, quoteDecimals: 6 }); + 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}`); +}); diff --git a/app/src/lib/launchpad/math.ts b/app/src/lib/launchpad/math.ts index 009e686c..5b5d54ad 100644 --- a/app/src/lib/launchpad/math.ts +++ b/app/src/lib/launchpad/math.ts @@ -85,6 +85,31 @@ export function poolIdOf(key: { currency0: Address; currency1: Address; fee: num ); } +/** + * What the very first buy into a fresh launch pool returns, before anyone else + * has traded. Mirrors the factory exactly: the whole supply sits as currency1 + * liquidity in [minUsableTick, startTick], the pool is initialized at the + * upper edge, and a buy (quote in, zeroForOne) walks the price down the range: + * L = supply / (√Pu − √Pl) (getLiquidityForAmount1) + * 1/√Pnew = 1/√Pu + Δin·(1 − fee)/L (Δ currency0 for a move) + * out = L · (√Pu − √Pnew) (Δ currency1 released) + * Floats are plenty for a preview (relative error ≪ 1e-9); the real amount is + * quoted on-chain right before the swap. + */ +export function initialBuyPreview(args: { startTick: number; amountInRaw: bigint; lpFeePips?: number; quoteDecimals?: number; supplyWei?: bigint }): { tokensOut: number; pctOfSupply: number; fdvAfter: number } { + const { startTick, amountInRaw, lpFeePips = 0, quoteDecimals = 18, supplyWei = 10n ** 27n } = args; + const supply = Number(supplyWei); + const minTick = -Math.floor(887_272 / TICK_SPACING) * TICK_SPACING; + const sqrtPu = Math.pow(1.0001, startTick / 2); + const sqrtPl = Math.pow(1.0001, minTick / 2); + const L = supply / (sqrtPu - sqrtPl); + const dIn = Number(amountInRaw) * (1 - lpFeePips / 1_000_000); + const sqrtPnew = Math.max(sqrtPl, 1 / (1 / sqrtPu + dIn / L)); + const outRaw = Math.min(supply, L * (sqrtPu - sqrtPnew)); + const tokensPerQuote = sqrtPnew * sqrtPnew * Math.pow(10, quoteDecimals - 18); + return { tokensOut: outRaw / 1e18, pctOfSupply: (outRaw / supply) * 100, fdvAfter: supply / 1e18 / tokensPerQuote }; +} + /** Slippage-adjusted minimum output. */ export function minOut(amountOut: bigint, slippageBps: number): bigint { return (amountOut * BigInt(10_000 - slippageBps)) / 10_000n;