diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63598ba9..900b7264 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,19 @@ jobs: app: name: app · lint, typecheck, unit tests, build runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: quote_indexer + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 defaults: run: working-directory: app @@ -30,6 +43,10 @@ jobs: - run: npm run lint - run: npx tsc --noEmit -p . - run: npm test + - name: quote fee indexer integration + env: + QUOTE_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/quote_indexer + run: node --conditions=react-server --import tsx scripts/quote-indexer-check.mts - name: production build env: NEXT_PUBLIC_SITE_URL: https://openlaunch.lol @@ -53,5 +70,7 @@ jobs: - run: forge build --sizes - run: forge test --match-path test/LaunchFactory.t.sol -vv - run: forge test --match-path test/LaunchFactory.arcGuard.t.sol -vv + - run: forge test --match-path test/QuoteFees.t.sol -vv + - run: forge test --match-path test/LaunchEvents.t.sol -vv - run: script/check-arc-factory.sh # live-fork suites (FORK_TESTS=true) need RPC access and run locally before a release; see README diff --git a/README.md b/README.md index 8aa41f80..8f9ccd31 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,7 @@ Found something? Open an issue or reach out on X: [@openlaunch_lol](https://x.co ## License MIT — see [`LICENSE`](LICENSE). + +## Quote-only creator fees + +New launches can use the optional quote-only suite: zero LP fees plus an immutable hook charging the quote asset on buys and sells. Existing deployments keep their fee behavior. See [contracts and deployment guide](contracts/docs/QUOTE_FEES.md). diff --git a/app/.env.example b/app/.env.example index 92461b96..9b11b3e5 100644 --- a/app/.env.example +++ b/app/.env.example @@ -52,3 +52,23 @@ IMAGE_STORE=local # Wallets allowed to moderate posts (comma-separated, lowercase) ADMIN_WALLETS= + +# Quote-only creator fees: all addresses + deployment block required. Keep launch disabled until verified. +NEXT_PUBLIC_QUOTE_FACTORY= +NEXT_PUBLIC_QUOTE_LOCKER= +NEXT_PUBLIC_QUOTE_VAULT= +NEXT_PUBLIC_QUOTE_HOOK= +NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK= +NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED=false +NEXT_PUBLIC_QUOTE_FACTORY_ROBINHOOD= +NEXT_PUBLIC_QUOTE_LOCKER_ROBINHOOD= +NEXT_PUBLIC_QUOTE_VAULT_ROBINHOOD= +NEXT_PUBLIC_QUOTE_HOOK_ROBINHOOD= +NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK_ROBINHOOD= +NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED_ROBINHOOD=false +NEXT_PUBLIC_QUOTE_FACTORY_ARC= +NEXT_PUBLIC_QUOTE_LOCKER_ARC= +NEXT_PUBLIC_QUOTE_VAULT_ARC= +NEXT_PUBLIC_QUOTE_HOOK_ARC= +NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK_ARC= +NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED_ARC=false diff --git a/app/db/schema.sql b/app/db/schema.sql index 00cc689c..04736027 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -319,6 +319,34 @@ CREATE TABLE IF NOT EXISTS bb_quote_tokens ( PRIMARY KEY (chain_id, address) ); +-- Immutable deployment and pool identity. lp_fee remains the displayed trading rate. +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS suite_id text NOT NULL DEFAULT 'lp-v1'; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS fee_asset_mode text NOT NULL DEFAULT 'both'; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS factory_address text; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS locker_address text; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS fee_contract_address text; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS hook_address text NOT NULL DEFAULT '0x0000000000000000000000000000000000000000'; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS pool_fee_pips integer; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS creator_fee_pips integer; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS tick_spacing integer NOT NULL DEFAULT 200; +ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS fees_quote_accrued numeric(78,0) NOT NULL DEFAULT 0; +UPDATE bb_launches SET pool_fee_pips = lp_fee WHERE pool_fee_pips IS NULL; +UPDATE bb_launches SET creator_fee_pips = lp_fee WHERE creator_fee_pips IS NULL; +-- Nullable during rolling deploys: the legacy indexer can still insert rows. + +ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS quote_fee numeric(78,0) NOT NULL DEFAULT 0; +ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS trader_amount0 numeric(78,0); +ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS trader_amount1 numeric(78,0); +ALTER TABLE bb_launch_fee_events ADD COLUMN IF NOT EXISTS fee_contract_address text; +-- New suites need their own cursor when enabled after the legacy cursor has already passed their deployment. +CREATE TABLE IF NOT EXISTS bb_launch_suite_cursor ( + chain_id integer NOT NULL, + suite_id text NOT NULL, + factory_address text NOT NULL, + cursor_block bigint NOT NULL DEFAULT 0, + PRIMARY KEY (chain_id, suite_id, factory_address) +); + -- ── token banners (2026-10-05) ───────────────────────────────────────────── -- An optional wide image the creator uploads beside the logo. Same rules as image_url (https only; uploads are -- re-encoded server-side, here to a 1500×500 WebP). NULL means no banner: the market cards draw one from the logo. diff --git a/app/scripts/quote-indexer-check.mts b/app/scripts/quote-indexer-check.mts new file mode 100644 index 00000000..05fef737 --- /dev/null +++ b/app/scripts/quote-indexer-check.mts @@ -0,0 +1,75 @@ +/** Local-only integration check. Run against an empty disposable database with tsconfig.json (real DB module). */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { encodeAbiParameters, encodeEventTopics, parseEventLogs, type Abi, type AbiEvent, type Address, type Hex, type Log } from "viem"; +if (!process.env.QUOTE_TEST_DATABASE_URL || new URL(process.env.QUOTE_TEST_DATABASE_URL).hostname !== "127.0.0.1") throw new Error("Set QUOTE_TEST_DATABASE_URL to a disposable database on 127.0.0.1"); +process.env.DATABASE_URL = process.env.QUOTE_TEST_DATABASE_URL; +const factory="0x000000000000000000000000000000000000a001", locker="0x000000000000000000000000000000000000a002", vault="0x000000000000000000000000000000000000a003", hook="0x00000000000000000000000000000000000020cc"; +const legacyFactory="0x000000000000000000000000000000000000a101", legacyLocker="0x000000000000000000000000000000000000a102"; +Object.assign(process.env,{ NEXT_PUBLIC_QUOTE_FACTORY:factory,NEXT_PUBLIC_QUOTE_LOCKER:locker,NEXT_PUBLIC_QUOTE_VAULT:vault,NEXT_PUBLIC_QUOTE_HOOK:hook,NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK:"1",NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED:"true",NEXT_PUBLIC_LAUNCH_FACTORY:legacyFactory,NEXT_PUBLIC_LAUNCH_LOCKER:legacyLocker,LAUNCH_DEPLOY_BLOCK:"1" }); +const [{db},{publicClient},{QUOTE_FACTORY_ABI,QUOTE_HOOK_ABI,QUOTE_VAULT_ABI},{POOL_MANAGER_ABI,LAUNCH_FACTORY_ABI,LAUNCH_LOCKER_ABI},{applyLaunchTx,pollLaunches},{walletQuoteFees},{launchpad}] = await Promise.all([ + import("../src/lib/db"),import("../src/lib/chain"),import("../src/lib/launchpad/quote-abi"),import("../src/lib/launchpad/abi"),import("../src/lib/launchpad/indexer"),import("../src/lib/launchpad/queries"),import("../src/lib/launchpad/config")]); +const sql=db(); +const client=publicClient("base"); +const token="0x000000000000000000000000000000000000ffff" as Address, quote="0x0000000000000000000000000000000000000000" as Address, wallet="0x000000000000000000000000000000000000b001" as Address; +const legacyToken="0x000000000000000000000000000000000000fff1" as Address; +const pool=`0x${"1".repeat(64)}` as Hex, hash=`0x${"2".repeat(64)}` as Hex; +function event(abi: Abi,name: string,address: Address,args: Record,index: number): Log { + const e=abi.find((a)=>a.type==="event" && a.name===name) as AbiEvent; + const fields=e.inputs.filter((i)=>!i.indexed); + return { address,topics:encodeEventTopics({abi:[e],eventName:name,args}),data:encodeAbiParameters(fields,fields.map((i)=>args[i.name!])),blockNumber:2n,blockHash:hash,transactionHash:hash,transactionIndex:0,logIndex:index,removed:false } as Log; +} +let logs=[ + event(QUOTE_FACTORY_ABI,"QuoteLaunched",factory,{token,tokenId:1n,launcher:wallet,quote,poolId:pool,startTick:184200,creatorFeePips:10000,supply:1000000000n*10n**18n,metadataURI:""},0), + event(POOL_MANAGER_ABI,"Swap",launchpad("base").v4.poolManager,{id:pool,sender:wallet,amount0:-99n,amount1:1000n,sqrtPriceX96:2n**96n,liquidity:1n,tick:1,fee:0},1), + event(QUOTE_HOOK_ABI,"QuoteSwap",hook,{poolId:pool,executor:wallet,zeroForOne:true,exactInput:true,quoteFee:1n,amount0:-100n,amount1:1000n},2), + event(QUOTE_VAULT_ABI,"Credited",vault,{tokenId:1n,account:wallet,currency:quote,amount:1n},4), + event(QUOTE_VAULT_ABI,"Collected",vault,{tokenId:1n,token,quoteAmount:1n,tokenAmount:0n},5), +]; +client.getTransactionReceipt=async()=>({status:"success",logs}) as Awaited>; +client.getBlock=async()=>({timestamp:123n}) as Awaited>; +client.getTransaction=async()=>({from:wallet}) as Awaited>; +client.readContract=(async (p: {functionName: string})=> p.functionName==="name"?"Quote":p.functionName==="symbol"?"QT":[{payout:wallet,bps:10000}]) as typeof client.readContract; +client.getBlockNumber=async()=>4n; +client.getLogs=(async (p: {address: string | string[];fromBlock: bigint;toBlock: bigint})=>parseEventLogs({abi:[...QUOTE_FACTORY_ABI,...QUOTE_HOOK_ABI,...QUOTE_VAULT_ABI,...POOL_MANAGER_ABI,...LAUNCH_FACTORY_ABI,...LAUNCH_LOCKER_ABI],logs:logs.filter((l)=>(Array.isArray(p.address)?p.address:[p.address]).some((a)=>l.address.toLowerCase()===a.toLowerCase()) && l.blockNumber!>=p.fromBlock && l.blockNumber!<=p.toBlock)})) as typeof client.getLogs; +try { + const schema=await readFile(new URL("../db/schema.sql",import.meta.url),"utf8"); + await sql.begin((tx)=>tx.unsafe(schema)); await sql.begin((tx)=>tx.unsafe(schema)); + assert.equal((await sql`SELECT count(*) AS n FROM bb_launches`)[0].n,0n,"use an empty test database"); + const first=await applyLaunchTx("base",hash); assert.deepEqual([first.launches,first.swaps,first.fees],[1,1,2]); + const retry=await applyLaunchTx("base",hash); assert.deepEqual([retry.launches,retry.swaps,retry.fees],[0,0,0]); + const [launch]=await sql`SELECT * FROM bb_launches WHERE token=${token}`; + assert.equal(launch.suite_id,"quote-v2"); assert.equal(launch.fee_contract_address,vault); assert.equal(launch.hook_address,hook); assert.equal(launch.pool_fee_pips,0); assert.equal(launch.lp_fee,10000); + assert.equal(launch.volume_quote,"100"); assert.equal(launch.fees_quote_accrued,"1"); assert.equal(launch.fees_quote_collected,"1"); + const [swap]=await sql`SELECT * FROM bb_launch_swaps`; assert.equal(swap.amount0,"-99"); assert.equal(swap.trader_amount0,"-100"); assert.equal(swap.quote_fee,"1"); + assert.equal((await walletQuoteFees(wallet)).get(`8453:${token}`),undefined,"credits have not been paid"); + // Both suites can own the same NFT ID; only the emitting contract identifies its launch. + logs = [ + event(LAUNCH_FACTORY_ABI,"Launched",legacyFactory,{token:legacyToken,tokenId:1n,launcher:wallet,quote,poolId:`0x${"3".repeat(64)}`,startTick:184200,lpFee:10000,supply:1000000000n*10n**18n,metadataURI:""},7), + event(LAUNCH_LOCKER_ABI,"Collected",legacyLocker,{tokenId:1n,token:legacyToken,quoteAmount:7n,tokenAmount:0n},8), + event(LAUNCH_LOCKER_ABI,"Paid",legacyLocker,{tokenId:1n,account:wallet,currency:quote,amount:7n},9), + event(LAUNCH_LOCKER_ABI,"Credited",legacyLocker,{account:wallet,currency:quote,amount:2n},10), + event(LAUNCH_LOCKER_ABI,"Claimed",legacyLocker,{account:wallet,currency:quote,amount:2n},11), + ]; + const legacy=await applyLaunchTx("base",hash); + assert.deepEqual([legacy.launches,legacy.swaps,legacy.fees],[1,0,4]); + assert.equal((await sql`SELECT fees_quote_collected FROM bb_launches WHERE token=${legacyToken}`)[0].fees_quote_collected,"7"); + assert.equal((await sql`SELECT fees_quote_collected FROM bb_launches WHERE token=${token}`)[0].fees_quote_collected,"1"); + assert.equal((await walletQuoteFees(wallet)).get(`8453:${token}`),undefined); + assert.equal((await sql`SELECT token FROM bb_launch_fee_events WHERE kind='paid' AND fee_contract_address=${legacyLocker}`)[0].token,legacyToken); + assert.equal((await sql`SELECT count(*) AS n FROM bb_launch_fee_events WHERE fee_contract_address IS NULL`)[0].n,0n); + logs=[event(QUOTE_VAULT_ABI,"Claimed",vault,{tokenId:1n,account:wallet,currency:quote,amount:1n},6)]; + await applyLaunchTx("base",hash); await applyLaunchTx("base",hash); + assert.equal((await walletQuoteFees(wallet)).get(`8453:${token}`),"1"); + // A chain cursor ahead of the new factory must not skip its backfill. + await sql`UPDATE bb_launches SET factory_address=NULL,locker_address=NULL,fee_contract_address=NULL WHERE token=${legacyToken}`; + await sql`INSERT INTO bb_launch_sync_cursor (chain_id,cursor_block) VALUES(8453,100) ON CONFLICT(chain_id) DO UPDATE SET cursor_block=100`; + await pollLaunches("base"); + const [cursor]=await sql`SELECT * FROM bb_launch_suite_cursor WHERE suite_id='quote-v2'`; + assert.equal(cursor.cursor_block,2n); + // The chain cursor follows the slowest suite (lp-v1 inherited 100), so health lag shows the backfill. + assert.equal((await sql`SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id=8453`)[0].cursor_block,2n); + const [migrated]=await sql`SELECT factory_address,locker_address,fee_contract_address FROM bb_launches WHERE token=${legacyToken}`; + assert.deepEqual(migrated,{factory_address:legacyFactory,locker_address:legacyLocker,fee_contract_address:legacyLocker}); + console.log("quote indexer: schema replay, dedupe, trader amounts, suite isolation, credit/claim earnings and cursor migration passed"); +} finally { await sql.end(); } diff --git a/app/scripts/rebuild-launch-totals.sql b/app/scripts/rebuild-launch-totals.sql index 681175e5..a59a7065 100644 --- a/app/scripts/rebuild-launch-totals.sql +++ b/app/scripts/rebuild-launch-totals.sql @@ -13,7 +13,7 @@ LOCK TABLE bb_launches IN EXCLUSIVE MODE; WITH s AS ( SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells, - COALESCE(sum(abs(amount0)), 0) AS volume_quote, max(block_time) AS last_trade_at + COALESCE(sum(abs(COALESCE(trader_amount0, amount0))), 0) AS volume_quote, max(block_time) AS last_trade_at FROM bb_launch_swaps GROUP BY 1, 2 ) SELECT 'drift before' AS what, @@ -21,13 +21,16 @@ SELECT 'drift before' AS what, count(*) FILTER (WHERE (l.buys + l.sells) > 0 AND s.token IS NULL) AS totals_without_swaps FROM bb_launches l LEFT JOIN s ON s.chain_id = l.chain_id AND s.token = l.token; --- swaps → buys / sells / volume / last trade +-- swaps → buys / sells / volume / accrued quote fees / last trade +-- (volume is the trader's quote amount: quote-only launches record it next to the pool's own delta) UPDATE bb_launches l SET - buys = COALESCE(s.buys, 0), sells = COALESCE(s.sells, 0), volume_quote = COALESCE(s.volume_quote, 0), last_trade_at = s.last_trade_at + buys = COALESCE(s.buys, 0), sells = COALESCE(s.sells, 0), volume_quote = COALESCE(s.volume_quote, 0), + fees_quote_accrued = COALESCE(s.fees_quote_accrued, 0), last_trade_at = s.last_trade_at FROM (SELECT l2.chain_id, l2.token, (SELECT count(*) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token AND x.is_buy) AS buys, (SELECT count(*) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token AND NOT x.is_buy) AS sells, - (SELECT COALESCE(sum(abs(amount0)), 0) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS volume_quote, + (SELECT COALESCE(sum(abs(COALESCE(trader_amount0, amount0))), 0) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS volume_quote, + (SELECT COALESCE(sum(quote_fee), 0) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS fees_quote_accrued, (SELECT max(block_time) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS last_trade_at FROM bb_launches l2) s WHERE l.chain_id = s.chain_id AND l.token = s.token; @@ -53,7 +56,7 @@ UPDATE bb_launches l SET WHERE l.chain_id = f.chain_id AND l.token = f.token; WITH s AS ( - SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells, COALESCE(sum(abs(amount0)), 0) AS volume_quote + SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells, COALESCE(sum(abs(COALESCE(trader_amount0, amount0))), 0) AS volume_quote FROM bb_launch_swaps GROUP BY 1, 2 ) SELECT 'drift after' AS what, diff --git a/app/src/app/agents/page.tsx b/app/src/app/agents/page.tsx index 950fbd15..ae693f66 100644 --- a/app/src/app/agents/page.tsx +++ b/app/src/app/agents/page.tsx @@ -110,7 +110,7 @@ export default function AgentsPage() {

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, TWIG (Base), GITLAWB (Base and Robinhood Chain), USDG (Robinhood Chain), USDC (Arc) 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 use Uniswap v4 with tick spacing 200. Legacy pools have no hook; quote-only pools have a fixed fee hook and zero LP fee. The quote can be ETH, TWIG (Base), GITLAWB (Base and Robinhood Chain), USDG (Robinhood Chain), USDC (Arc) or a supported issuer-registry stock token. Get the actual pool key with poolKeyOf(token) on the launch's recorded factory. Quote-only fees are collected from its recorded vault with collect(tokenId), and credited fees with claim(tokenId). 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/me/route.ts b/app/src/app/api/me/route.ts index 166e27d1..dbe4894f 100644 --- a/app/src/app/api/me/route.ts +++ b/app/src/app/api/me/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { isAddress } from "viem"; -import { getWalletTokens, getWalletTrades, listLaunches } from "@/lib/launchpad/queries"; +import { getWalletTokens, getWalletTrades, listLaunches, walletQuoteFees } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { memo } from "@/lib/launchpad/memo"; @@ -13,7 +13,8 @@ export async function GET(req: Request) { try { const usd = await ethUsd(); const data = await memo(`me:${w}`, 3_000, async () => { - const [launches, tokens, trades] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50)]); + const [launches, tokens, trades, paid] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50), walletQuoteFees(w)]); + for (const launch of launches) if (launch.suite_id === "quote-v2") launch.wallet_fees_quote_paid = paid.get(`${launch.chain_id}:${launch.token}`) ?? "0"; return { wallet: w, ethUsd: usd, launches, tokens, trades }; }); return NextResponse.json(data, { headers: { "cache-control": "no-store" } }); diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index b54d28aa..90f434b3 100644 --- a/app/src/app/llms.txt/route.ts +++ b/app/src/app/llms.txt/route.ts @@ -40,11 +40,12 @@ ${verificationLine("Arc (5042)", a, [ ## 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, TWIG on Base, GITLAWB, USDG on Robinhood Chain, USDC on Arc, or a registry stock token; tick spacing 200, no hook) at the chosen start tick +2. initializes a Uniswap v4 pool quote/token (quote = ETH, TWIG on Base, GITLAWB, USDG on Robinhood Chain, USDC on Arc, or a registry stock token; tick spacing 200; legacy pools have 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. Anyone may call locker.collect(tokenId): accrued fees are paid straight to recipients (or burned). +Optional quote-v2 suite: creatorFeePips replaces lpFee in the launch tuple; actual pool LP fee is 0 with an immutable quote fee hook. Quote fees accrue on buys and sells. Use the recorded factory and pool key; collect from the recorded vault with collect(tokenId), claim credits with claim(tokenId). Rows expose suite_id, fee_asset_mode, factory_address, locker_address, fee_contract_address, hook_address, pool_fee_pips and creator_fee_pips. Metadata requests include suite_id=quote-v2 for that suite. ## Contracts Base (8453): LaunchFactory ${b.factory ?? "(not deployed yet)"} · LaunchLocker ${b.locker ?? "(not deployed yet)"} diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index ebb88eea..00c58d39 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -15,7 +15,7 @@ export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it 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, TWIG on Base, GITLAWB or USDG on Robinhood Chain, a tokenized stock (Coinbase stocks on Base, Robinhood Stock Tokens on Robinhood Chain), or USDC on Arc. 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, TWIG on Base, GITLAWB or USDG on Robinhood Chain, a tokenized stock (Coinbase stocks on Base, Robinhood Stock Tokens on Robinhood Chain), or USDC on Arc. Choose fees in both tokens or only the quote asset where available. 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; diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index 0ca669ca..24233062 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -23,7 +23,8 @@ 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, SWAP_SITES, TICK_SPACING, type Quote } from "@/lib/launchpad/config"; +import { SWAP_SITES, type Quote } from "@/lib/launchpad/config"; +import { lockerForLaunch, poolKeyForLaunch } from "@/lib/launchpad/suites"; import UnlistedPairBadge from "@/components/launchpad/UnlistedPairBadge"; import { GITLAWB_SITE } from "@/lib/launchpad/gitlawb"; import GitlawbBadge from "@/components/launchpad/GitlawbBadge"; @@ -40,7 +41,6 @@ import { stockByAddress } from "@/lib/launchpad/stocksServer"; import { BRAND_DOMAIN, BRAND_X } from "@/lib/brand"; import { clampSocial } from "@/lib/launchpad/ogcard"; import { capDisplay } from "@/lib/launchpad/market-cap"; -import { launchpad } from "@/lib/launchpad/config"; import { tokenTint } from "@/lib/launchpad/tintServer"; import { proofFacts } from "@/lib/launchpad/proof"; import TokenProof, { type ProofLink } from "@/components/launchpad/TokenProof"; @@ -94,7 +94,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s const mode = feeModeOf(l.lp_fee, l.recipients); const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }); const proof = proofFacts({ holders, symbol: l.symbol, launcher: l.launcher, lpFee: l.lp_fee, mode, recipients: l.recipients.length }); - const locker = launchpad(chain).locker; + const locker = lockerForLaunch(l); const proofLinks: Partial> = { ...(locker ? { lock: { href: explorerAddress(chain, locker), label: "View locker", external: true } } : {}), creator: { href: explorerAddress(chain, l.launcher), label: "Creator wallet", external: true }, @@ -102,7 +102,8 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s launch: { href: explorerTx(chain, l.tx_hash), label: "Launch transaction", external: true }, fees: { href: "#contracts", label: "Fee settings" }, }; - const poolKey = { currency0: l.quote as Address, currency1: l.token as Address, fee: l.lp_fee, tickSpacing: TICK_SPACING, hooks: NATIVE as Address }; + const poolKey = poolKeyForLaunch(l); + const quoteOnly = l.fee_asset_mode === "quote"; const priceUsd = l.price_usd; const chainLabel = CHAIN_LABELS[chain]; const shareText = `${l.name} ($${l.symbol}) on ${chainLabel}. ${l.lp_fee === 0 ? "0% fee" : mode === "burn" ? "fees burned" : "no platform fee"}, liquidity locked forever`; @@ -110,7 +111,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s const supplyLabel = fmtCompact(Number(BigInt(l.supply)) / 1e18, 0); const feeRoute = mode === "free" ? "No trading fee" : `${pipsToPct(l.lp_fee)} trading fee → ${mode === "burn" ? "burned" : mode === "split" ? "beneficiaries" : "beneficiary"}`; // the trade box's own wording: short, no arrow - const tradeFee = mode === "free" ? "None" : `${pipsToPct(l.lp_fee)}, ${mode === "burn" ? "burned" : mode === "split" ? `to ${l.recipients.length} recipients` : "to the recipient"}`; + const tradeFee = mode === "free" ? "None" : `${pipsToPct(l.lp_fee)}${quoteOnly ? ` in ${quote.symbol}` : ""}, ${mode === "burn" ? "burned" : mode === "split" ? `to ${l.recipients.length} recipients` : "to the recipient"}`; const swapSite = SWAP_SITES[chain]; const utility = "inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-line px-2.5 text-xs text-muted hover:border-line-strong hover:text-ink"; // a chip on the cover: dark glass, so it reads over any banner in either theme (display is set where it is used) @@ -229,8 +230,8 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s {/* the token at a glance beside the trade box, as other launchpads keep it; the full record is in the About tab */} - - + +
@@ -251,9 +252,12 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s {shortAddr(l.token)} ↗} /> {shortAddr(l.tx_hash)} ↗} /> } /> - + {l.factory_address ? {shortAddr(l.factory_address)} ↗} /> : null} + {l.fee_contract_address ? {shortAddr(l.fee_contract_address)} ↗} /> : null} + {quoteOnly && l.hook_address ? {shortAddr(l.hook_address)} ↗} /> : null} + {unlisted ? {shortAddr(l.quote)} ↗} /> : null} - + diff --git a/app/src/components/launchpad/CollectPanel.tsx b/app/src/components/launchpad/CollectPanel.tsx index 576b6306..ae0ecdd2 100644 --- a/app/src/components/launchpad/CollectPanel.tsx +++ b/app/src/components/launchpad/CollectPanel.tsx @@ -2,16 +2,18 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useConfig, useReadContracts } from "wagmi"; +import { useConfig, useReadContract, useReadContracts } from "wagmi"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { getPublicClient, getWalletClient } from "wagmi/actions"; import { type Address, type Hex } from "viem"; import { btn } from "@/components/ui"; import { toast } from "./TxToasts"; -import { LAUNCH_LOCKER_ABI } from "@/lib/launchpad/abi"; -import { DEAD, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; +import { QUOTE_VAULT_ABI } from "@/lib/launchpad/quote-abi"; +import { feeContractForLaunch, isQuoteFeeLaunch, type LaunchIdentity } from "@/lib/launchpad/suites"; +import { claimRequest, claimableContracts, collectRequest, type FeeTarget } from "@/lib/launchpad/fee-actions"; +import { DEAD, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; import { fmtQuote, fmtTokens, fmtUsd, pipsToPct } from "@/lib/launchpad/math"; -import { BUILDER_DATA_SUFFIX, CHAINS, explorerAddress, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; +import { CHAINS, explorerAddress, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; import { friendlyError } from "@/lib/errors"; import { feeSidesUsd } from "@/lib/launchpad/creator"; import { feeModeOf } from "./FeeChip"; @@ -21,10 +23,11 @@ import { Spinner } from "@/components/Skeleton"; * Fee routing card: who gets the trading fee, what has been collected / burned * so far, and the permissionless `collect` + `claim` buttons. Collect pulls * accrued fees out of the pool for everyone at once; claim withdraws the - * caller's own credited balance. Fees accrue on both sides of the pool (quote - * from buys, the launched token from sells), so totals and claims show both. + * caller's own credited balance. Legacy fees use both currencies; quote-only + * launches collect and claim quote fees from their immutable vault. */ export default function CollectPanel({ + launchIdentity, chain, quote, token, @@ -39,6 +42,7 @@ export default function CollectPanel({ priceQuote, ethUsd, }: { + launchIdentity?: LaunchIdentity; chain: ChainKey; quote: Quote; token: Address; @@ -57,20 +61,26 @@ export default function CollectPanel({ const config = useConfig(); const { address, chainId } = useHydratedAccount(); const CHAIN = CHAINS[chain]; - const LOCKER_ADDRESS = launchpad(chain).locker; + const identity = launchIdentity ?? { chain }; + const quoteOnly = isQuoteFeeLaunch(identity); + const LOCKER_ADDRESS = feeContractForLaunch(identity); + const target: FeeTarget | null = LOCKER_ADDRESS ? { launch: identity, feeContract: LOCKER_ADDRESS, tokenId: BigInt(tokenId) } : null; const quoteUsd = quoteUsdOf(quote, ethUsd); const [phase, setPhase] = useState<{ k: "idle" } | { k: "busy"; what: "collect" | "claim"; currency?: Address } | { k: "sent"; hash: Hex; what: "collect" | "claim"; currency?: Address } | { k: "error"; message: string }>({ k: "idle" }); - // Credited balances on both sides: [quote, launched token]. + const pending = useReadContract({ + address: LOCKER_ADDRESS ?? undefined, + chainId: CHAIN.id, + abi: QUOTE_VAULT_ABI, + functionName: "pendingFees", + args: [BigInt(tokenId)], + query: { enabled: Boolean(quoteOnly && LOCKER_ADDRESS), refetchInterval: 20_000 }, + }); + + // Legacy credits use both currencies; quote-only credits belong to this launch. const mine = useReadContracts({ - contracts: [quote.address, token].map((currency) => ({ - address: LOCKER_ADDRESS ?? undefined, - chainId: CHAIN.id, - abi: LAUNCH_LOCKER_ABI, - functionName: "claimable" as const, - args: address ? ([address, currency] as const) : undefined, - })), - query: { enabled: Boolean(address && LOCKER_ADDRESS), refetchInterval: 20_000 }, + contracts: target && address ? claimableContracts(target, CHAIN.id, address, quote.address, token) : [], + query: { enabled: Boolean(address && target), refetchInterval: 20_000 }, }); const isBurnOnly = feeModeOf(lpFee, recipients) === "burn"; @@ -86,26 +96,21 @@ export default function CollectPanel({ const ft = (raw: bigint) => `${fmtTokens(raw)} ${symbol}`; async function send(what: "collect" | "claim", currency?: Address) { - if (!LOCKER_ADDRESS || !address) return; + if (!target || !address) return; try { setPhase({ k: "busy", what, currency }); const pub = getPublicClient(config, { chainId: CHAIN.id })!; const wallet = await getWalletClient(config, { chainId: CHAIN.id }); - let hash: Hex; - if (what === "collect") { - const { request } = await pub.simulateContract({ address: LOCKER_ADDRESS, abi: LAUNCH_LOCKER_ABI, functionName: "collect", args: [BigInt(tokenId)], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); - hash = await wallet.writeContract(request); - } else { - const { request } = await pub.simulateContract({ address: LOCKER_ADDRESS, abi: LAUNCH_LOCKER_ABI, functionName: "claim", args: [currency ?? quote.address], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); - hash = await wallet.writeContract(request); - } + const request = what === "collect" ? await collectRequest(pub, target, address) : await claimRequest(pub, target, address, currency ?? quote.address); + const hash: Hex = await wallet.writeContract(request); setPhase({ k: "sent", hash, what, currency }); const receipt = await pub.waitForTransactionReceipt({ hash }); if (receipt.status !== "success") throw new Error("Transaction reverted on-chain."); await fetch(`/api/launch/sync?chain=${chain}&tx=${hash}`, { method: "POST" }).catch(() => {}); void mine.refetch(); + if (quoteOnly) void pending.refetch(); router.refresh(); - toast({ kind: "collect", title: what === "collect" ? `Fees collected for ${symbol}` : "Claimed", sub: isBurnOnly ? "Burned on the spot" : "Paid to the beneficiaries" }); + toast({ kind: "collect", title: what === "collect" ? `Fees collected for ${symbol}` : "Claimed", sub: what === "claim" ? "Paid to your wallet" : isBurnOnly ? "Burned on the spot" : "Allocated to the beneficiaries" }); setPhase({ k: "idle" }); } catch (err) { setPhase({ k: "error", message: friendlyError(err) }); @@ -123,7 +128,7 @@ export default function CollectPanel({

Where the fees go

- {pipsToPct(lpFee)} + {pipsToPct(lpFee)}{quoteOnly ? ` in ${quote.symbol}` : ""}
{lpFee === 0 ? ( @@ -132,10 +137,10 @@ export default function CollectPanel({ <>
    {isBurnOnly && recipients.length === 0 ?
  • Burned100%
  • : null} - {recipients.map((r) => { + {recipients.map((r, index) => { const burn = r.payout.toLowerCase() === DEAD.toLowerCase(); return ( -
  • +
  • {burn ? ( @@ -152,34 +157,40 @@ export default function CollectPanel({ })}
+ {quoteOnly ? ( +
+
Awaiting collection
+
{pending.isError ? "Unavailable" : pending.data === undefined ? "Reading…" : fq(pending.data)}
+
+ ) : null}
{isBurnOnly ? "Burned so far" : "Collected so far"}
{fq(isBurnOnly ? burned.quote : collected.quote)}
-
{ft(isBurnOnly ? burned.token : collected.token)}
+ {!quoteOnly ?
{ft(isBurnOnly ? burned.token : collected.token)}
: null} {usd(isBurnOnly ? burned : collected) ?
{usd(isBurnOnly ? burned : collected)}
: null}
{isBurnOnly ? "Where it went" : "Burned"}
{isBurnOnly ? ( -
Both sides sent to the dead address. Nobody can claim them.
+
{quoteOnly ? quote.symbol : "Both sides"} sent to the dead address. Nobody can claim them.
) : ( <> {/* amber only once something has actually burned; zero reads as the plain fact it is */}
0n ? "text-warm-ink" : "text-muted"}`}>{fq(burned.quote)}
-
0n ? "text-warm-ink" : "text-muted"}`}>{ft(burned.token)}
+ {!quoteOnly ?
0n ? "text-warm-ink" : "text-muted"}`}>{ft(burned.token)}
: null} )}
{!isBurnOnly ? (
-
Paid to beneficiaries
-
{fq(toPeople.quote)} + {ft(toPeople.token)}
+
Allocated to beneficiaries
+
{fq(toPeople.quote)}{!quoteOnly ? <> + {ft(toPeople.token)} : null}
{usd(toPeople) ?
{usd(toPeople)}
: null}
) : null}
- {claims.map((c) => ( @@ -189,7 +200,7 @@ export default function CollectPanel({ ))}

- Anyone can collect. Collecting pulls accrued fees out of the pool and {isBurnOnly ? "burns them on the spot" : "pays the beneficiaries directly, in the same transaction"}. Liquidity never moves. + Anyone can collect. Collecting pulls accrued fees out of the pool and {isBurnOnly ? "burns them on the spot" : "distributes them to the beneficiaries. Failed payouts stay claimable"}. Liquidity never moves.

)} diff --git a/app/src/components/launchpad/LaunchFeeSettings.tsx b/app/src/components/launchpad/LaunchFeeSettings.tsx index a84ba002..901f8000 100644 --- a/app/src/components/launchpad/LaunchFeeSettings.tsx +++ b/app/src/components/launchpad/LaunchFeeSettings.tsx @@ -12,6 +12,11 @@ import styles from "./LaunchFeeSettings.module.css"; export type FeeBeneficiary = "burn" | "me" | "custom"; type Props = { + feeAssetMode?: "both" | "quote"; + quoteAvailable?: boolean; + bothAvailable?: boolean; + quoteSymbol?: string; + onFeeAssetChange?: (mode: "both" | "quote") => void; feePips: number; beneficiary: FeeBeneficiary; address?: string; @@ -21,7 +26,8 @@ type Props = { onBeneficiaryChange: (value: FeeBeneficiary) => void; }; -export default function LaunchFeeSettings({ feePips, beneficiary, address, split, children, onFeeChange, onBeneficiaryChange }: Props) { +/** The launch form's trading-fee step: the fee asset (where the quote-only suite can launch), the rate and the beneficiaries. */ +export default function LaunchFeeSettings({ feeAssetMode = "both", quoteAvailable = false, bothAvailable = true, quoteSymbol = "quote asset", onFeeAssetChange, feePips, beneficiary, address, split, children, onFeeChange, onBeneficiaryChange }: Props) { const id = useId(); const feePerHundred = feePips / 10_000; const custom = beneficiary === "custom"; @@ -41,6 +47,20 @@ export default function LaunchFeeSettings({ feePips, beneficiary, address, split

Choose what traders pay on each buy and sell.

+ {/* Offered only where the quote-only suite can launch; otherwise the form is the original one. */} + {quoteAvailable ? ( +
+ Receive fees in +
+ {(["both", "quote"] as const).map((mode) => )} +
+
+ ) : null} +
Trading fee rate
@@ -125,7 +145,7 @@ export default function LaunchFeeSettings({ feePips, beneficiary, address, split

); diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 20784faf..8cd3fc48 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import { useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { getPublicClient, getWalletClient } from "wagmi/actions"; -import { maxUint160, maxUint256, parseEventLogs, parseUnits, zeroAddress, type Address, type Hex, type PublicClient, type WalletClient } from "viem"; +import { maxUint160, maxUint256, parseEventLogs, parseUnits, type Address, type Hex, type PublicClient, type WalletClient } from "viem"; import LaunchPreview from "./LaunchPreview"; import { previewLaunch } from "@/lib/launchpad/launch-preview"; import ImageUpload from "./ImageUpload"; @@ -21,7 +21,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; 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 { DEAD, DEFAULT_SUPPLY, FEE_PRESETS, GAS_RESERVE_WEI, MAX_RECIPIENTS, STOCK_SOURCE, TICK_SPACING, launchpad, quoteUsdOf, sharesGasBalance, type Quote } from "@/lib/launchpad/config"; +import { DEAD, DEFAULT_SUPPLY, FEE_PRESETS, GAS_RESERVE_WEI, MAX_RECIPIENTS, STOCK_SOURCE, launchpad, quoteUsdOf, sharesGasBalance, type Quote } from "@/lib/launchpad/config"; import { bpsToPct, buildRecipients, describeShares, emptyRow, isBurnAddress, type Recipient, type RecipientRow } from "@/lib/launchpad/recipients"; import { capChipLabel, capDisplay, capEntry, capPick, capPresets, capToQuote } from "@/lib/launchpad/market-cap"; import { uppercaseInPlace } from "@/lib/launchpad/symbol-input"; @@ -30,6 +30,8 @@ import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUnitsExact, fmtUsd, init import { BUY_PRESETS, defaultFirstBuy, gasReserveInQuote, suggestFirstBuy } from "@/lib/launchpad/first-buy"; import { getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "@/lib/launchpad/first-buy-session"; import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; +import { poolKeyForLaunch, suiteFor, type SuiteId } from "@/lib/launchpad/suites"; +import { SUITE_KINDS } from "@/lib/launchpad/suite-kinds"; import { GITLAWB_SITE } from "@/lib/launchpad/gitlawb"; import { TWIG_WRAP_URL, gitlawbLinkedUsd } from "@/lib/launchpad/twig"; import { parseXHandle } from "@/lib/launchpad/xHandle"; @@ -102,7 +104,7 @@ const CHAIN_COPY: Record["v4"]; quote: Quote; feePips: number; CHAIN: (typeof CHAINS)[ChainKey]; setPhase: (p: Phase) => void }; +type FirstBuyCtx = { pub: PublicClient; wallet: WalletClient; address: Address; V4: ReturnType["v4"]; quote: Quote; key: PoolKey; CHAIN: (typeof CHAINS)[ChainKey]; setPhase: (p: Phase) => void }; function parseBuyAmount(v: string, decimals: number): bigint | null | undefined { try { @@ -118,9 +120,7 @@ function parseBuyAmount(v: string, decimals: number): bigint | null | undefined /** 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 }; + const { pub, wallet, address, V4, quote, key, CHAIN, setPhase } = ctx; 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; @@ -172,10 +172,16 @@ async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, a return received > 0n ? { hash: h, out: received, exact: true } : { hash: h, out, exact: false }; } +/** The suite a chain launches with by default: the original one, unless the quote-only suite is the only one that can launch there. */ +const defaultSuite = (k: ChainKey): SuiteId => (!suiteFor(k, "lp-v1") && suiteFor(k, "quote-v2")?.launchEnabled ? "quote-v2" : "lp-v1"); + export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = DEFAULT_CHAIN }: { ethUsd: number | null; gitlawbUsd?: number | null; initialChain?: ChainKey }) { const router = useRouter(); const [chain, setChain] = useState(initialChain); const cfg = launchpad(chain); + const [suiteId, setSuiteId] = useState(defaultSuite(initialChain)); + const suite = suiteFor(chain, suiteId); + const quoteSuite = suiteFor(chain, "quote-v2"); const CHAIN = CHAINS[chain]; const CHAIN_LABEL = CHAIN_LABELS[chain]; const [quoteKey, setQuoteKey] = useState(launchpad(initialChain).quotes[0].key); @@ -253,6 +259,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D // kept across attempts so an identical retry is idempotent; when the details changed since the key was registered // the server answers 409 and launch() rotates BOTH (a key is locked to the details it was first registered with). const metaKeyRef = useRef(null); + const attemptSuiteRef = useRef(null); const [phase, setPhase] = useState({ k: "idle" }); // Starting cap: entered in dollars whenever the quote has a USD price, else in quote units (lib/launchpad/market-cap.ts). @@ -335,7 +342,8 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D // An unfinished split (or "Me" before a wallet connects) has no recipients yet; that must preview as the routing being set up, // never as a burn. Launching stays blocked by the validation errors until the list is complete. const feeMode = feePips > 0 && beneficiary !== "burn" && recipients.length === 0 ? (beneficiary === "custom" && rows.length > 1 ? "split" : "creator") : feeModeOf(feePips, recipients); - const feeRouteSub = feeMode === "free" ? "free pool" : feeMode === "burn" ? "burned" : feeMode === "split" ? `split ${recipients.length || rows.length} ways` : "to beneficiary"; + const feeAssetSub = suiteId === "quote-v2" ? `${quote.symbol} only · ` : ""; + const feeRouteSub = feeAssetSub + (feeMode === "free" ? "free pool" : feeMode === "burn" ? "burned" : feeMode === "split" ? `split ${recipients.length || rows.length} ways` : "to beneficiary"); const setRow = (i: number, patch: Partial) => setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r))); const addRow = (payout = "") => setRows((rs) => (rs.length >= MAX_RECIPIENTS ? rs : [...rs, { payout, pct: rs.length === 0 ? "100" : "" }])); const removeRow = (i: number) => setRows((rs) => (rs.length <= 1 ? [emptyRow()] : rs.filter((_, j) => j !== i))); @@ -348,15 +356,17 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D const hasRow = (payout: string) => rows.some((r) => r.payout.trim().toLowerCase() === payout.toLowerCase()); async function launch() { - if (!valid || !address || !cfg.factory || startTick === null) return; - const FACTORY_ADDRESS = cfg.factory; + if (!valid || !address || !suite?.launchEnabled || startTick === null) return; + const FACTORY_ADDRESS = suite.factory; + const attemptSuite = `${chain}:${suiteId}`; + if (attemptSuiteRef.current !== attemptSuite) { saltRef.current = null; metaKeyRef.current = null; attemptSuiteRef.current = attemptSuite; } let salt = saltRef.current ?? (saltRef.current = randomSalt()); let metaKey = metaKeyRef.current ?? (metaKeyRef.current = randomSalt()); const register = async (s: Hex) => { const res = await fetch("/api/launch/meta", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ chain, launcher: address, salt: s, meta_key: metaKey, name: name.trim(), symbol: symbolClean, description, image_url: image, banner_url: banner, website, x_handle: x }), + body: JSON.stringify({ chain, suite_id: suiteId, launcher: address, salt: s, meta_key: metaKey, name: name.trim(), symbol: symbolClean, description, image_url: image, banner_url: banner, website, x_handle: x }), }); const j = (await res.json()) as { uri?: string; token?: string; error?: string }; return { status: res.status, ...j }; @@ -406,14 +416,9 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D salt, recipients, }; - const { request } = await pub.simulateContract({ - address: FACTORY_ADDRESS, - abi: LAUNCH_FACTORY_ABI, - functionName: "launch", - args: [params], - account: address, - dataSuffix: BUILDER_DATA_SUFFIX, - }); + // each suite's factory names the creator's rate differently in the same tuple + const kind = SUITE_KINDS[suiteId]; + const { request } = await pub.simulateContract({ address: FACTORY_ADDRESS, abi: kind.factoryAbi, functionName: "launch", args: [{ ...params, [kind.rateField]: feePips }], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); setPhase({ k: "signing" }); const wallet = await getWalletClient(config, { chainId: CHAIN.id }); @@ -421,7 +426,8 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D setPhase({ k: "sent", hash }); const receipt = await pub.waitForTransactionReceipt({ hash }); if (receipt.status !== "success") throw new Error("Transaction reverted on-chain."); - const [ev] = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs }); + const factoryLogs = receipt.logs.filter((l) => l.address.toLowerCase() === FACTORY_ADDRESS.toLowerCase()); + const [ev] = parseEventLogs({ abi: kind.factoryAbi, eventName: kind.launchedEvent.name, logs: factoryLogs }) as unknown as { args: { token: Address } }[]; 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. @@ -430,7 +436,9 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D 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); + // Built from the suite, not read back with poolKeyOf: a node that lags the launch block would revert the read. + const key = poolKeyForLaunch({ suite_id: suiteId, quote: quote.address, token: ev.args.token, lp_fee: feePips, pool_fee_pips: suite.feeAssetMode === "quote" ? 0 : feePips, hook_address: suite.hook }); + const r = await firstBuy({ pub, wallet, address, V4: cfg.v4, quote, key, CHAIN, setPhase }, ev.args.token, hash, initialBuyRaw); buyHash = r.hash; bought = { out: r.out, exact: r.exact }; } catch (err) { @@ -474,7 +482,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D ) : null} { if (k === chain) return; // the active chain: nothing to switch, nothing to reset setChain(k); + setSuiteId(defaultSuite(k)); setQuoteKey(launchpad(k).quotes[0].key); // a stock belongs to one chain's registry: never carry a Base pick over to Robinhood (or back) setStock(null); @@ -807,6 +816,11 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D {/* 4 · fees */} setSuiteId(mode === "quote" ? "quote-v2" : "lp-v1")} feePips={feePips} beneficiary={beneficiary} address={address} @@ -914,7 +928,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D -

Includes price impact and the pool fee; the exact amount is quoted on-chain right before the buy.

+

Includes price impact and the trading fee; the exact amount is quoted on-chain right before the buy.

) : suggestion.reason === "insufficient" ? (

Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet {sharedGas ? `does not hold enough ${quote.symbol} for the buy plus its gas` : "holds only gas"}. The launch stays free; you can buy on the token page later.

@@ -926,7 +940,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D

No first buy. The launch stays free.{defaultFirstBuy(quote) ? <> : null}

) : null}

A token with no holders and no price move looks dead on every screener and sits under quiet launches on the home page. Your first buy opens the chart. Clear it and the launch stays free.

-

Other traders can buy before you. First-buy slippage tolerance: {FIRST_BUY_SLIPPAGE_BPS / 100}%. Network gas and pool fees apply.

+

Other traders can buy before you. First-buy slippage tolerance: {FIRST_BUY_SLIPPAGE_BPS / 100}%. Network gas and trading fees apply.

@@ -940,7 +954,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D
    {[ ["Deploys a plain ERC-20", "no mint, no pause, no blacklist, no tax"], - ["Opens a Uniswap v4 pool", `${quote.symbol} / your token on ${CHAIN_LABELS[chain]}, no hook`], + ["Opens a Uniswap v4 pool", `${quote.symbol} / your token on ${CHAIN_LABELS[chain]}${suiteId === "quote-v2" ? ", quote fees on buys and sells" : ", 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%" : feeMode === "burn" ? "burned at collect time" : recipients.length === 0 ? "to the beneficiaries you name, claimable any time" : `${describeShares(recipients, shortAddr)}, claimable any time`], ...(initialBuyRaw ? [["Buys your first tokens", `${initialBuyLabel} right after the launch confirms, with a second wallet prompt`]] : []), diff --git a/app/src/components/launchpad/LaunchReceipt.tsx b/app/src/components/launchpad/LaunchReceipt.tsx index 62a006ea..54db300a 100644 --- a/app/src/components/launchpad/LaunchReceipt.tsx +++ b/app/src/components/launchpad/LaunchReceipt.tsx @@ -1,10 +1,10 @@ import { ArrowUpRight } from "lucide-react"; -import { launchpad } from "@/lib/launchpad/config"; +import type { Address } from "viem"; import { explorerAddress, explorerTx, type ChainKey } from "@/lib/chainPublic"; /** A receipt of the launch mechanics, not a claim about current wallet balances. Set like one: dotted leaders, mono figures. */ -export default function LaunchReceipt({ chain, symbol, supply, txHash }: { chain: ChainKey; symbol: string; supply: string; txHash: string }) { - const locker = launchpad(chain).locker; +/** `locker` is the contract holding this launch's position NFT (each suite deploys its own); null links to the rules instead. */ +export default function LaunchReceipt({ chain, symbol, supply, txHash, locker }: { chain: ChainKey; symbol: string; supply: string; txHash: string; locker: Address | null }) { return

    The launch receipt

    diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx index 3ae07bde..d46f73fb 100644 --- a/app/src/components/launchpad/MeDashboard.tsx +++ b/app/src/components/launchpad/MeDashboard.tsx @@ -16,15 +16,17 @@ import FeeChip, { feeModeOf } from "./FeeChip"; import EditTokenSheet from "./EditTokenSheet"; import { toast } from "./TxToasts"; import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/vendor/tabs"; -import { LAUNCH_LOCKER_ABI, ERC20_MIN_ABI } from "@/lib/launchpad/abi"; -import { VISIBLE_CHAINS, launchpad } from "@/lib/launchpad/config"; +import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; +import { feeContractForLaunch, isQuoteFeeLaunch } from "@/lib/launchpad/suites"; +import { collectRequest, pendingFees } from "@/lib/launchpad/fee-actions"; +import { VISIBLE_CHAINS } from "@/lib/launchpad/config"; import { earnedSides, feeShareBps, feeSidesUsd, hasFees, holdingUsd, isBurnOnly, type FeeSides } from "@/lib/launchpad/creator"; import { fmtCompact, fmtQuote, fmtTokens, fmtUsd } from "@/lib/launchpad/math"; import { capDisplay } from "@/lib/launchpad/market-cap"; import type { LaunchRow, WalletTrade } from "@/lib/launchpad/queries"; import type { EditFields } from "@/lib/launchpad/editAuth"; import { ago } from "@/lib/launchpad/time"; -import { BUILDER_DATA_SUFFIX, CHAINS, CHAIN_SHORT, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; +import { CHAINS, CHAIN_SHORT, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; import { friendlyError } from "@/lib/errors"; import { SkRow, SkStat } from "@/components/Skeleton"; import ConnectWallet from "@/components/ConnectWallet"; @@ -88,15 +90,14 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine const p: Pending = {}; await Promise.all( me.launches.map(async (l) => { - const cfg = launchpad(l.chain); - if (!cfg.locker || l.lp_fee === 0) { + const feeContract = feeContractForLaunch(l); + if (!feeContract || l.lp_fee === 0) { p[key(l)] = { quote: 0n, token: 0n }; return; } try { const pub = getPublicClient(config, { chainId: CHAINS[l.chain].id })!; - const { result } = await pub.simulateContract({ address: cfg.locker, abi: LAUNCH_LOCKER_ABI, functionName: "collect", args: [BigInt(l.token_id)], account: address }); - p[key(l)] = { quote: result[0], token: result[1] }; + p[key(l)] = await pendingFees(pub, { launch: l, feeContract, tokenId: BigInt(l.token_id) }, address); } catch { p[key(l)] = null; } @@ -126,18 +127,18 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine }, [me, address, config]); async function collect(l: LaunchRow) { - const cfg = launchpad(l.chain); - if (!cfg.locker || !address) return; + const feeContract = feeContractForLaunch(l); + if (!feeContract || !address) return; setBusy(key(l)); try { const pub = getPublicClient(config, { chainId: CHAINS[l.chain].id })!; const wallet = await getWalletClient(config, { chainId: CHAINS[l.chain].id }); - const { request } = await pub.simulateContract({ address: cfg.locker, abi: LAUNCH_LOCKER_ABI, functionName: "collect", args: [BigInt(l.token_id)], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + const request = await collectRequest(pub, { launch: l, feeContract, tokenId: BigInt(l.token_id) }, address); const hash = await wallet.writeContract(request); const receipt = await pub.waitForTransactionReceipt({ hash }); if (receipt.status !== "success") throw new Error("Transaction reverted on-chain."); await fetch(`/api/launch/sync?chain=${l.chain}&tx=${hash}`, { method: "POST" }).catch(() => {}); - toast({ kind: "collect", title: `Fees collected for ${l.symbol}`, sub: isBurnOnly(l.recipients) ? "Burned on the spot" : "Paid to the beneficiaries", chain: l.chain, token: l.token, symbol: l.symbol }); + toast({ kind: "collect", title: `Fees collected for ${l.symbol}`, sub: isBurnOnly(l.recipients) ? "Burned on the spot" : "Allocated to the beneficiaries", chain: l.chain, token: l.token, symbol: l.symbol }); await load(); } catch (e) { toast({ kind: "info", title: `Collect failed for ${l.symbol}`, sub: friendlyError(e) }); @@ -262,9 +263,9 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
    -
    {earnedUsdRow !== null ? `${earned.token > 0n ? "≈ " : ""}${fmtUsd(earnedUsdRow)}` : fmtQuote(earned.quote, l.quote_decimals, l.quote_symbol)}
    +
    {earnedUsdRow !== null ? `${earned.token > 0n ? "≈ " : ""}${fmtUsd(earnedUsdRow)}` : fmtQuote(earned.quote, l.quote_decimals, l.quote_symbol)}
    {earnedUsdRow === null && earned.token > 0n ?
    {fmtTokens(earned.token)} {l.symbol}
    : null} -
    earned · {share / 100}% share
    +
    {isQuoteFeeLaunch(l) ? "paid" : "earned"} · {share / 100}% share
    {p === undefined ? "…" : p === null ? "—" : fmtQuote(p.quote, l.quote_decimals, l.quote_symbol)}
    diff --git a/app/src/components/launchpad/collect-panel.test.ts b/app/src/components/launchpad/collect-panel.test.ts index 424004a8..7394e6ee 100644 --- a/app/src/components/launchpad/collect-panel.test.ts +++ b/app/src/components/launchpad/collect-panel.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; const source = readFileSync(new URL("./CollectPanel.tsx", import.meta.url), "utf8"); +const actions = readFileSync(new URL("../../lib/launchpad/fee-actions.ts", import.meta.url), "utf8"); const page = readFileSync(new URL("../../app/t/[chain]/[token]/page.tsx", import.meta.url), "utf8"); // Every collect pays beneficiaries both pool currencies: the quote and the launched token. @@ -16,7 +17,7 @@ test("the fee panel shows collected, burned and paid amounts for the quote and t assert.match(source, /ft\(isBurnOnly \? burned\.token : collected\.token\)/); assert.match(source, /fq\(burned\.quote\)/); assert.match(source, /ft\(burned\.token\)/); - assert.match(source, /Paid to beneficiaries/); + assert.match(source, /Allocated to beneficiaries/); assert.match(source, /\{fq\(toPeople\.quote\)\}.*\{ft\(toPeople\.token\)\}/); // USD includes the token side at the pool price and says so when it does assert.match(source, /feeSidesUsd\(sides, quote\.decimals, priceQuote, quoteUsd\)/); @@ -25,12 +26,15 @@ test("the fee panel shows collected, burned and paid amounts for the quote and t test("claim reads and withdraws each currency separately", () => { assert.match(source, /useReadContracts\(/); - assert.match(source, /\[quote\.address, token\]\.map\(\(currency\)/); - assert.match(source, /functionName: "claimable" as const/); + // the reads and the claim call live in lib/launchpad/fee-actions.ts, shared with the dashboard + assert.match(source, /claimableContracts\(target, CHAIN\.id, address, quote\.address, token\)/); + assert.match(actions, /\[quote, token\]\.map\(\(currency\)/); + assert.match(actions, /functionName: "claimable" as const, args: \[account, currency\] as const/); assert.match(source, /\{ currency: quote\.address, raw: \(mine\.data\?\.\[0\]\?\.result/); assert.match(source, /\{ currency: token, raw: \(mine\.data\?\.\[1\]\?\.result/); assert.match(source, /onClick=\{\(\) => void send\("claim", c\.currency\)\}/); - assert.match(source, /functionName: "claim", args: \[currency \?\? quote\.address\]/); + assert.match(source, /claimRequest\(pub, target, address, currency \?\? quote\.address\)/); + assert.match(actions, /functionName: "claim", args: isQuoteFeeLaunch\(t\.launch\) \? \[t\.tokenId\] : \[currency\]/); }); test("pending feedback stays on the button whose transaction was sent", () => { diff --git a/app/src/components/launchpad/launch-fees.test.ts b/app/src/components/launchpad/launch-fees.test.ts index 50519f2a..c6e54ed4 100644 --- a/app/src/components/launchpad/launch-fees.test.ts +++ b/app/src/components/launchpad/launch-fees.test.ts @@ -67,6 +67,28 @@ test("zero fee hides recipient controls and keeps permanence, gas, and price-imp assert.doesNotMatch(text(output), /Claimable by|100% of fees/); }); +test("the fee asset choice appears only where the quote-only suite can launch", () => { + const hidden = render(defaults); + assert.equal(elements(hidden).filter((node) => node.props.name === "fees-asset").length, 0); + assert.doesNotMatch(text(hidden), /Receive fees in|Coming soon|fee asset/); + + let mode = ""; + const shown = render({ ...defaults, quoteAvailable: true, quoteSymbol: "ETH", onFeeAssetChange: (next: string) => { mode = next; } }); + const assets = elements(shown).filter((node) => node.type === "input" && node.props.name === "fees-asset"); + assert.deepEqual(assets.map((node) => [node.props.checked, node.props.disabled]), [[true, false], [false, false]]); + assert.match(text(shown), /Receive fees in/); + assert.match(text(shown), /ETH only/); + assert.match(text(shown), /fee asset, rate\s+cannot be changed later/); + change(assets[1]); + assert.equal(mode, "quote"); + + // A chain with only the quote-only suite keeps the legacy option visible but unselectable. + const quoteOnlyChain = render({ ...defaults, quoteAvailable: true, bothAvailable: false, feeAssetMode: "quote" }); + const only = elements(quoteOnlyChain).filter((node) => node.type === "input" && node.props.name === "fees-asset"); + assert.deepEqual(only.map((node) => [node.props.checked, node.props.disabled]), [[false, true], [true, false]]); + assert.match(text(quoteOnlyChain), /Unavailable on this chain/); +}); + test("each routing choice is controlled and never presents an unfinished split as complete", () => { for (const value of ["burn", "me", "custom"]) { let chosen = ""; diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 1c39d167..80f033ba 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -57,7 +57,7 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", () assert.match(source, /suggestion\.reason === "unknown-balance"/); assert.match(source, /Other traders can buy before you/); assert.match(source, /First-buy slippage tolerance: \{FIRST_BUY_SLIPPAGE_BPS \/ 100\}%/); - assert.match(source, /Network gas and pool fees apply/); + assert.match(source, /Network gas and trading fees apply/); assert.doesNotMatch(source, /first holder/i); // (no em-dash policing: the site copy voice is the maintainers' call) }); @@ -73,12 +73,15 @@ for (const status of ["success", "reverted"]) { assert.ok(declaration); let encodedMinimum: bigint | undefined; let sentValue: bigint | undefined; + const key = { currency0: "zero", currency1: "token", fee: 0, tickSpacing: 200, hooks: "quote-hook" }; + let quotedKey: unknown; + let encodedKey: unknown; const { outputText } = ts.transpileModule(`(${declaration})`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }); // Inert wallet/RPC dependencies: no connection, signature or network request. const firstBuy = runInNewContext(outputText, { TICK_SPACING: 200, zeroAddress: "zero", V4_QUOTER_ABI: [], UNIVERSAL_ROUTER_ABI: [], ERC20_TRANSFER_EVENT: {}, FIRST_BUY_SLIPPAGE_BPS: 300, BUILDER_DATA_SUFFIX: "0x", minOut, - encodeV4ExactInSingle: (input: { minOut: bigint }) => { encodedMinimum = input.minOut; return { commands: "0x10", inputs: [] }; }, + encodeV4ExactInSingle: (input: { minOut: bigint; key: unknown }) => { encodedMinimum = input.minOut; encodedKey = input.key; return { commands: "0x10", inputs: [] }; }, parseEventLogs: () => [ { address: "token", args: { to: "wallet", value: 975n } }, { address: "other-token", args: { to: "wallet", value: 500n } }, @@ -87,8 +90,8 @@ for (const status of ["success", "reverted"]) { }) as (ctx: unknown, token: string, hash: string, amount: bigint) => Promise<{ hash: string; out: bigint; exact: boolean }>; const result = firstBuy({ pub: { - simulateContract: async (input: { functionName: string; value?: bigint }) => { - if (input.functionName === "quoteExactInputSingle") return { result: [1_000n] }; + simulateContract: async (input: { functionName: string; value?: bigint; args?: { poolKey: unknown }[] }) => { + if (input.functionName === "quoteExactInputSingle") { quotedKey = input.args?.[0].poolKey; return { result: [1_000n] }; } sentValue = input.value; return { request: {} }; }, @@ -96,7 +99,7 @@ for (const status of ["success", "reverted"]) { }, wallet: { writeContract: async () => "buy-hash" }, address: "wallet", V4: { quoter: "quoter", universalRouter: "router", swapLayout: "v1" }, quote: { key: "eth", address: "zero" }, - feePips: 0, CHAIN: { id: 8453 }, setPhase: () => {}, + key, CHAIN: { id: 8453 }, setPhase: () => {}, }, "token", "launch-hash", 100n); if (status === "success") { const bought = await result; @@ -108,6 +111,8 @@ for (const status of ["success", "reverted"]) { } assert.equal(encodedMinimum, 970n); assert.equal(sentValue, 100n); + assert.equal(quotedKey, key); + assert.equal(encodedKey, key, "First buy preserves the factory's hook and zero LP fee"); }); } @@ -132,7 +137,7 @@ test("stock quote: one blocking message, a chip that names the issuer, a way out assert.match(source, /setQuoteKey\(cfg\.quotes\[0\]\?\.key \?\? "eth"\);\s*setStock\(null\);\s*setStockQ\(""\);\s*setMcapPick\(null\);\s*setCustomMcap\(""\);/); // switching chains drops the picked stock: a registry address from one chain must never become the other chain's quote // ...but re-clicking the active chain is a no-op, so it cannot wipe the picked stock - assert.match(source, /if \(k === chain\) return;[^}]*setChain\(k\);\s*setQuoteKey\(launchpad\(k\)\.quotes\[0\]\.key\);[^}]*setStock\(null\);\s*setStockQ\(""\);\s*setStockHits\(\[\]\);/); + assert.match(source, /if \(k === chain\) return;[^}]*setChain\(k\);[^}]*setQuoteKey\(launchpad\(k\)\.quotes\[0\]\.key\);[^}]*setStock\(null\);\s*setStockQ\(""\);\s*setStockHits\(\[\]\);/); // the issuer disclaimer is rendered from the per-chain copy table (a Record: a new chain must write its own) assert.match(source, /const CHAIN_COPY: Record\{CHAIN_COPY\[chain\]\.stock\?\.issuer\}<\/p>/); @@ -169,6 +174,11 @@ test("a rejected first-buy entry is ignored, never read as declining the first b assert.doesNotMatch(source, /sanitizeDecimalInput\(e\.target\.value\); if \(v\) chooseFirstBuy\(v\); else declineFirstBuy\(\)/); }); +test("the first buy takes its pool key from the suite, not from a poolKeyOf read a lagging node can revert", () => { + assert.doesNotMatch(source, /functionName: "poolKeyOf"/); + assert.match(source, /const key = poolKeyForLaunch\(\{ suite_id: suiteId, quote: quote\.address, token: ev\.args\.token,/); +}); + test("the preview draws the real board card from the form, with no link and nothing sent", () => { assert.match(source, /const previewRow = previewLaunch\(\{ chain, name, symbol: symbolClean,/); assert.match(source, //); diff --git a/app/src/components/launchpad/me-dashboard.test.ts b/app/src/components/launchpad/me-dashboard.test.ts index f02739f8..27858363 100644 --- a/app/src/components/launchpad/me-dashboard.test.ts +++ b/app/src/components/launchpad/me-dashboard.test.ts @@ -6,6 +6,8 @@ const source = readFileSync(new URL("./MeDashboard.tsx", import.meta.url), "utf8 const css = readFileSync(new URL("./MeDashboard.module.css", import.meta.url), "utf8"); const page = readFileSync(new URL("../../app/me/page.tsx", import.meta.url), "utf8"); const edit = readFileSync(new URL("./EditTokenSheet.tsx", import.meta.url), "utf8"); +// collect and the pending-fee read are shared with the token page's fee panel +const actions = readFileSync(new URL("../../lib/launchpad/fee-actions.ts", import.meta.url), "utf8"); // Source contracts complement the disconnected responsive browser review. // They do not simulate a connected wallet or sign transactions. @@ -50,7 +52,8 @@ test("unavailable RPC values remain distinct from a zero balance or zero pending }); test("fee collection and full signed metadata editing keep their existing transaction paths", () => { - assert.match(source, /functionName: "collect", args: \[BigInt\(l\.token_id\)\], account: address, dataSuffix: BUILDER_DATA_SUFFIX/); + assert.match(source, /collectRequest\(pub, \{ launch: l, feeContract, tokenId: BigInt\(l\.token_id\) \}, address\)/); + assert.match(actions, /functionName: "collect", args: \[t\.tokenId\], account, dataSuffix: BUILDER_DATA_SUFFIX/); assert.match(source, /wallet\.writeContract\(request\)/); assert.match(source, /waitForTransactionReceipt\(\{ hash \}\)/); assert.match(source, /\/api\/launch\/sync\?chain=\$\{l\.chain\}&tx=\$\{hash\}/); @@ -80,7 +83,8 @@ test("dashboard controls, tables and responsive styling remain accessible and th test("dashboard fees cover both pool sides: quote and the launched token", () => { // collect() returns (quoteOut, tokenOut); a sells-only pool must still count as collectable. - assert.match(source, /p\[key\(l\)\] = \{ quote: result\[0\], token: result\[1\] \}/); + assert.match(source, /p\[key\(l\)\] = await pendingFees\(pub, /); + assert.match(actions, /return \{ quote: result\[0\], token: result\[1\] \}/); assert.match(source, /filter\(\(l\) => hasFees\(pending\[key\(l\)\]\)\)/); assert.match(source, /disabled=\{busy !== null \|\| !hasFees\(p\)\}/); // earned (total and per row) prices the token share too, and marks it as an estimate diff --git a/app/src/components/launchpad/transaction-safety.test.ts b/app/src/components/launchpad/transaction-safety.test.ts index a01608ee..73854cfc 100644 --- a/app/src/components/launchpad/transaction-safety.test.ts +++ b/app/src/components/launchpad/transaction-safety.test.ts @@ -85,11 +85,11 @@ for (const status of ["reverted", "success"]) { const effects: string[] = []; const phases: { k: string; message?: string }[] = []; const send = handler("./CollectPanel.tsx", "send", { - LOCKER_ADDRESS: "locker", address: "wallet", config: {}, CHAIN: { id: 8453 }, - chain: "base", tokenId: 1, quote: { address: "quote" }, symbol: "TEST", isBurnOnly: false, - LAUNCH_LOCKER_ABI: [], BUILDER_DATA_SUFFIX: "0x", + target: { feeContract: "locker" }, address: "wallet", config: {}, CHAIN: { id: 8453 }, + chain: "base", quote: { address: "quote" }, symbol: "TEST", isBurnOnly: false, quoteOnly: false, + collectRequest: async () => ({}), claimRequest: async () => ({}), setPhase: (phase: { k: string; message?: string }) => phases.push(phase), - getPublicClient: () => ({ simulateContract: async () => ({ request: {} }), waitForTransactionReceipt: async () => ({ status }) }), + getPublicClient: () => ({ waitForTransactionReceipt: async () => ({ status }) }), getWalletClient: async () => ({ writeContract: async () => "0xhash" }), fetch: async () => { effects.push("sync"); }, mine: { refetch: () => effects.push("balance") }, router: { refresh: () => effects.push("refresh") }, @@ -112,10 +112,9 @@ for (const status of ["reverted", "success"]) { const busyStates: (string | null)[] = []; const collect = handler("./MeDashboard.tsx", "collect", { address: "wallet", config: {}, CHAINS: { base: { id: 8453 } }, - launchpad: () => ({ locker: "locker" }), key: () => "base:token", - LAUNCH_LOCKER_ABI: [], BUILDER_DATA_SUFFIX: "0x", + feeContractForLaunch: () => "locker", collectRequest: async () => ({}), key: () => "base:token", setBusy: (value: string | null) => busyStates.push(value), - getPublicClient: () => ({ simulateContract: async () => ({ request: {} }), waitForTransactionReceipt: async () => ({ status }) }), + getPublicClient: () => ({ waitForTransactionReceipt: async () => ({ status }) }), getWalletClient: async () => ({ writeContract: async () => "0xhash" }), fetch: async () => { effects.push("sync"); }, load: async () => { effects.push("reload"); }, isBurnOnly: () => false, friendlyError: (error: Error) => error.message, diff --git a/app/src/lib/launchpad/config.ts b/app/src/lib/launchpad/config.ts index 7c9bd05e..967b8140 100644 --- a/app/src/lib/launchpad/config.ts +++ b/app/src/lib/launchpad/config.ts @@ -1,3 +1,4 @@ +import { quoteDeployment } from "./quote-config"; import { isAddress, type Address } from "viem"; import { CHAIN_KEYS, SITE_URL, isChainKey, type ChainKey } from "@/lib/chainPublic"; import { unlistedQuote } from "./unlisted-quote"; @@ -168,7 +169,7 @@ const CFG: Record = { configured: false, }, }; -for (const k of CHAIN_KEYS) CFG[k].configured = CFG[k].factory !== null && CFG[k].locker !== null; +for (const k of CHAIN_KEYS) CFG[k].configured = (CFG[k].factory !== null && CFG[k].locker !== null) || quoteDeployment(k) !== null; export function launchpad(key: ChainKey): ChainLaunchpad { return CFG[key]; diff --git a/app/src/lib/launchpad/creator.ts b/app/src/lib/launchpad/creator.ts index 7113496c..6ea138d1 100644 --- a/app/src/lib/launchpad/creator.ts +++ b/app/src/lib/launchpad/creator.ts @@ -23,9 +23,10 @@ export type FeeSides = { quote: bigint; token: bigint }; /** This wallet's paid share on both sides, from the indexed collected/burned totals. */ export function earnedSides( - l: { fees_quote_collected: string; fees_quote_burned: string; fees_token_collected: string; fees_token_burned: string }, + l: { wallet_fees_quote_paid?: string; fees_quote_collected: string; fees_quote_burned: string; fees_token_collected: string; fees_token_burned: string }, shareBps: number, ): FeeSides { + if (l.wallet_fees_quote_paid !== undefined) return { quote: BigInt(l.wallet_fees_quote_paid), token: 0n }; return { quote: earnedRaw(l.fees_quote_collected, l.fees_quote_burned, shareBps), token: earnedRaw(l.fees_token_collected, l.fees_token_burned, shareBps) }; } diff --git a/app/src/lib/launchpad/fee-actions.ts b/app/src/lib/launchpad/fee-actions.ts new file mode 100644 index 00000000..7937a2f8 --- /dev/null +++ b/app/src/lib/launchpad/fee-actions.ts @@ -0,0 +1,47 @@ +import type { Abi, Address, PublicClient } from "viem"; +import { BUILDER_DATA_SUFFIX } from "../chainPublic"; +import { LAUNCH_LOCKER_ABI } from "./abi"; +import { QUOTE_VAULT_ABI } from "./quote-abi"; +import type { FeeSides } from "./creator"; +import { isQuoteFeeLaunch, type LaunchIdentity } from "./suites"; +import { SUITE_KINDS } from "./suite-kinds"; + +/** + * Collecting and claiming a launch's fees. The locker (lp-v1) and the vault (quote-v2) differ in how pending + * fees are read and how credits are keyed; the panels call these instead of choosing an ABI themselves. + */ +export type FeeTarget = { launch: Pick; feeContract: Address; tokenId: bigint }; + +/** The fee contract's ABI for this launch: the locker's or the vault's. */ +const feeAbi = (t: FeeTarget) => SUITE_KINDS[isQuoteFeeLaunch(t.launch) ? "quote-v2" : "lp-v1"].feeAbi; + +/** Uncollected fees, read with eth_call (no gas). The vault exposes them; the locker only reveals them through a simulated collect. */ +export async function pendingFees(pub: PublicClient, t: FeeTarget, account: Address): Promise { + if (isQuoteFeeLaunch(t.launch)) { + const quote = await pub.readContract({ address: t.feeContract, abi: QUOTE_VAULT_ABI, functionName: "pendingFees", args: [t.tokenId] }); + return { quote, token: 0n }; + } + const { result } = await pub.simulateContract({ address: t.feeContract, abi: LAUNCH_LOCKER_ABI, functionName: "collect", args: [t.tokenId], account }); + return { quote: result[0], token: result[1] }; +} + +/** A simulated `collect(tokenId)`, ready for writeContract. Both fee contracts share the signature. */ +export async function collectRequest(pub: PublicClient, t: FeeTarget, account: Address) { + const { request } = await pub.simulateContract({ address: t.feeContract, abi: feeAbi(t), functionName: "collect", args: [t.tokenId], account, dataSuffix: BUILDER_DATA_SUFFIX }); + return request; +} + +/** A simulated claim of the caller's credited balance: per launch on the vault, per currency on the locker. */ +export async function claimRequest(pub: PublicClient, t: FeeTarget, account: Address, currency: Address) { + const { request } = await pub.simulateContract({ address: t.feeContract, abi: feeAbi(t), functionName: "claim", args: isQuoteFeeLaunch(t.launch) ? [t.tokenId] : [currency], account, dataSuffix: BUILDER_DATA_SUFFIX }); + return request; +} + +/** One `claimable` read. Both contracts return a uint256; the ABI is left loose so either fits one useReadContracts call. */ +export type ClaimableRead = { address: Address; chainId: number; abi: Abi; functionName: "claimable"; args: readonly unknown[] }; + +/** Reads of `account`'s credited balances for useReadContracts, in [quote, token] order. The vault only ever credits the quote. */ +export function claimableContracts(t: FeeTarget, chainId: number, account: Address, quote: Address, token: Address): ClaimableRead[] { + if (isQuoteFeeLaunch(t.launch)) return [{ address: t.feeContract, chainId, abi: QUOTE_VAULT_ABI, functionName: "claimable" as const, args: [t.tokenId, account] as const }]; + return [quote, token].map((currency) => ({ address: t.feeContract, chainId, abi: LAUNCH_LOCKER_ABI, functionName: "claimable" as const, args: [account, currency] as const })); +} diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index 4bd81581..1f8b7bf8 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -3,7 +3,11 @@ import { parseEventLogs, type Address, type Hex, type Log } from "viem"; import { publicClient } from "@/lib/chain"; import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; import { maybeDb, errMessage, type Db } from "@/lib/db"; -import { LAUNCH_FACTORY_ABI, LAUNCH_LOCKER_ABI, POOL_MANAGER_ABI, ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCHED_EVENT, POOL_SWAP_EVENT, LOCKER_EVENTS } from "./abi"; +import { LAUNCH_LOCKER_ABI, POOL_MANAGER_ABI, ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, POOL_SWAP_EVENT } from "./abi"; +import { QUOTE_HOOK_ABI } from "./quote-abi"; +import { launchSuites, feeContractForLaunch, type LaunchSuite } from "./suites"; +import { SUITE_KINDS } from "./suite-kinds"; +import { pairQuoteSwaps, type QuoteSwapLog } from "./quote-swaps"; import { CONFIGURED_CHAINS, launchpad, listedQuoteAddresses } from "./config"; import { validDecimals } from "./unlisted-quote"; import { DEAD_ADDR, ZERO_ADDR } from "./holders"; @@ -35,16 +39,6 @@ export type LaunchSyncResult = { caught_up?: boolean; }; -const DEPLOY_BLOCK_ENV: Record string | undefined> = { - base: () => process.env.LAUNCH_DEPLOY_BLOCK, - robinhood: () => process.env.LAUNCH_DEPLOY_BLOCK_ROBINHOOD, - arc: () => process.env.LAUNCH_DEPLOY_BLOCK_ARC, -}; - -export function launchDeployBlock(chain: ChainKey): bigint { - const raw = (DEPLOY_BLOCK_ENV[chain]() ?? "").trim(); - return /^\d+$/.test(raw) ? BigInt(raw) : 0n; -} /** Blocks left behind the head before a range is indexed. Arc finalizes every block (no reorgs), so none there. */ const DEFAULT_CONFIRMATIONS: Record = { base: 2, robinhood: 2, arc: 0 }; /** @@ -60,8 +54,8 @@ function confirmations(chain: ChainKey): bigint { function skipReason(chain: ChainKey): string | null { if (!maybeDb()) return "db_unconfigured"; - if (!launchpad(chain).configured) return "launchpad_unconfigured"; - if (launchDeployBlock(chain) === 0n) return "deploy_block_unset"; + if (launchSuites(chain).length === 0) return "launchpad_unconfigured"; + if (!launchSuites(chain).some((s) => s.deployBlock > 0n)) return "deploy_block_unset"; return null; } @@ -98,13 +92,14 @@ async function fromOf(chain: ChainKey, hash: Hex): Promise { } // ── apply ──────────────────────────────────────────────────────────────────── -type Launched = { token: Address; tokenId: bigint; launcher: Address; quote: Address; poolId: Hex; startTick: number; lpFee: number; supply: bigint; metadataURI: string }; +type Launched = { token: Address; tokenId: bigint; launcher: Address; quote: Address; poolId: Hex; startTick: number; lpFee?: number; creatorFeePips?: number; supply: bigint; metadataURI: string }; -async function applyLaunched(db: Db, chain: ChainKey, log: Log & { args: Launched }): Promise { +/** Record a launch from its factory event with the suite that emitted it; false when the row already exists. */ +async function applyLaunched(db: Db, chain: ChainKey, log: Log & { args: Launched }, suite: LaunchSuite): Promise { const a = log.args; const token = a.token.toLowerCase(); const client = publicClient(chain); - const locker = launchpad(chain).locker!; + const locker = suite.locker; const [name, symbol, recipients] = await Promise.all([ client.readContract({ address: a.token, abi: ERC20_MIN_ABI, functionName: "name" }).catch(() => "?"), client.readContract({ address: a.token, abi: ERC20_MIN_ABI, functionName: "symbol" }).catch(() => "?"), @@ -116,10 +111,10 @@ async function applyLaunched(db: Db, chain: ChainKey, log: Log & { args: Launche const time = await timeOf(chain, log.blockNumber!); const rows = await db` INSERT INTO bb_launches (chain_id, token, token_id, launcher, quote, pool_id, start_tick, lp_fee, supply, metadata_uri, name, symbol, - block_number, block_time, tx_hash, log_index, tick, recipients) - VALUES (${chainIdOf(chain)}, ${token}, ${a.tokenId}, ${a.launcher.toLowerCase()}, ${a.quote.toLowerCase()}, ${a.poolId.toLowerCase()}, ${a.startTick}, ${a.lpFee}, + block_number, block_time, tx_hash, log_index, tick, recipients, suite_id, fee_asset_mode, factory_address, locker_address, fee_contract_address, hook_address, pool_fee_pips, creator_fee_pips, tick_spacing) + VALUES (${chainIdOf(chain)}, ${token}, ${a.tokenId}, ${a.launcher.toLowerCase()}, ${a.quote.toLowerCase()}, ${a.poolId.toLowerCase()}, ${a.startTick}, ${a.creatorFeePips ?? a.lpFee ?? 0}, ${a.supply.toString()}, ${a.metadataURI}, ${name.slice(0, 64)}, ${symbol.slice(0, 16)}, - ${log.blockNumber!}, ${time}, ${log.transactionHash!.toLowerCase()}, ${log.logIndex!}, ${a.startTick}, ${db.json(recipients)}) + ${log.blockNumber!}, ${time}, ${log.transactionHash!.toLowerCase()}, ${log.logIndex!}, ${a.startTick}, ${db.json(recipients)}, ${suite.id}, ${suite.feeAssetMode}, ${suite.factory.toLowerCase()}, ${suite.locker.toLowerCase()}, ${suite.feeContract.toLowerCase()}, ${suite.hook.toLowerCase()}, ${SUITE_KINDS[suite.id].poolFeePips(a.creatorFeePips ?? a.lpFee ?? 0)}, ${a.creatorFeePips ?? a.lpFee ?? 0}, 200) ON CONFLICT (chain_id, token) DO NOTHING RETURNING token`; return rows.length > 0; @@ -127,14 +122,18 @@ async function applyLaunched(db: Db, chain: ChainKey, log: Log & { args: Launche type Swap = { id: Hex; sender: Address; amount0: bigint; amount1: bigint; sqrtPriceX96: bigint; liquidity: bigint; tick: number; fee: number }; -async function applySwap(db: Db, chain: ChainKey, log: Log & { args: Swap }, poolToToken: Map): Promise { +/** Record a core swap, with the hook's trader amounts for a quote-only pool, and roll it into the launch totals in one transaction; false when already stored. */ +async function applySwap(db: Db, chain: ChainKey, log: Log & { args: Swap }, poolToToken: Map, hookSwap?: QuoteSwapLog): Promise { const a = log.args; const poolId = a.id.toLowerCase(); const token = poolToToken.get(poolId); if (!token) return false; const cid = chainIdOf(chain); - const isBuy = a.amount0 < 0n; // swapper paid quote (currency0) - const absQuote = a.amount0 < 0n ? -a.amount0 : a.amount0; + const trader0 = hookSwap?.args.amount0 ?? a.amount0; + const trader1 = hookSwap?.args.amount1 ?? a.amount1; + const quoteFee = hookSwap?.args.quoteFee ?? 0n; + const isBuy = trader0 < 0n; // swapper paid quote (currency0) + const absQuote = trader0 < 0n ? -trader0 : trader0; const [time, trader] = await Promise.all([timeOf(chain, log.blockNumber!), fromOf(chain, log.transactionHash!)]); const bn = log.blockNumber!; const li = log.logIndex!; @@ -143,15 +142,16 @@ async function applySwap(db: Db, chain: ChainKey, log: Log & { args: Swap }, poo return db.begin(async (tx) => { const t = tx as unknown as Db; const inserted = await t` - INSERT INTO bb_launch_swaps (chain_id, tx_hash, log_index, token, pool_id, trader, amount0, amount1, sqrt_price_x96, tick, is_buy, block_number, block_time) + INSERT INTO bb_launch_swaps (chain_id, tx_hash, log_index, token, pool_id, trader, amount0, amount1, sqrt_price_x96, tick, is_buy, block_number, block_time, quote_fee, trader_amount0, trader_amount1) VALUES (${cid}, ${log.transactionHash!.toLowerCase()}, ${log.logIndex!}, ${token}, ${poolId}, ${trader}, ${a.amount0.toString()}, ${a.amount1.toString()}, - ${a.sqrtPriceX96.toString()}, ${a.tick}, ${isBuy}, ${log.blockNumber!}, ${time}) + ${a.sqrtPriceX96.toString()}, ${a.tick}, ${isBuy}, ${log.blockNumber!}, ${time}, ${quoteFee.toString()}, ${trader0.toString()}, ${trader1.toString()}) ON CONFLICT DO NOTHING RETURNING tx_hash`; if (inserted.length === 0) return false; await t` UPDATE bb_launches SET volume_quote = volume_quote + ${absQuote.toString()}::numeric, + fees_quote_accrued = fees_quote_accrued + ${quoteFee.toString()}::numeric, buys = buys + ${isBuy ? 1 : 0}, sells = sells + ${isBuy ? 0 : 1}, last_trade_at = GREATEST(COALESCE(last_trade_at, ${time}::timestamptz), ${time}::timestamptz), @@ -169,7 +169,7 @@ type FeeLog = Log & | { eventName: "Collected"; args: { tokenId: bigint; token: Address; quoteAmount: bigint; tokenAmount: bigint } } | { eventName: "Burned"; args: { tokenId: bigint; currency: Address; amount: bigint } } | { eventName: "Paid"; args: { tokenId: bigint; account: Address; currency: Address; amount: bigint } } - | { eventName: "Credited" | "Claimed"; args: { account: Address; currency: Address; amount: bigint } } + | { eventName: "Credited" | "Claimed"; args: { tokenId?: bigint; account: Address; currency: Address; amount: bigint } } ); async function applyFee(pool: Db, chain: ChainKey, log: FeeLog, tokenIdToToken: Map): Promise { @@ -184,10 +184,12 @@ async function applyFeeIn(db: Db, chain: ChainKey, cid: number, time: string, lo const li = log.logIndex!; const bn = log.blockNumber!; if (log.eventName === "Collected") { + const info = tokenIdToToken.get(`${log.address.toLowerCase()}:${log.args.tokenId}`); const t = log.args.token.toLowerCase(); + if (!info || info.token !== t) return false; const r = await db` - INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, token_id, token, quote_amount, token_amount, block_number, block_time) - VALUES (${cid}, ${tx}, ${li}, 'collected', ${log.args.tokenId}, ${t}, ${log.args.quoteAmount.toString()}, ${log.args.tokenAmount.toString()}, ${bn}, ${time}) + INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, token_id, token, fee_contract_address, quote_amount, token_amount, block_number, block_time) + VALUES (${cid}, ${tx}, ${li}, 'collected', ${log.args.tokenId}, ${t}, ${log.address.toLowerCase()}, ${log.args.quoteAmount.toString()}, ${log.args.tokenAmount.toString()}, ${bn}, ${time}) ON CONFLICT DO NOTHING RETURNING tx_hash`; if (r.length === 0) return false; await db`UPDATE bb_launches SET fees_quote_collected = fees_quote_collected + ${log.args.quoteAmount.toString()}::numeric, @@ -195,15 +197,15 @@ async function applyFeeIn(db: Db, chain: ChainKey, cid: number, time: string, lo return true; } if (log.eventName === "Burned") { - const info = tokenIdToToken.get(log.args.tokenId.toString()); + const info = tokenIdToToken.get(`${log.address.toLowerCase()}:${log.args.tokenId}`); // Unknown position = the launch itself is not indexed yet (receipt path racing the poller). Inserting now would // pin the row with token NULL and skip the launch totals for good (the poller's later pass hits ON CONFLICT and // the rebuild script joins on token), so leave it for the poller, which indexes the launch first. if (!info) return false; const cur = log.args.currency.toLowerCase(); const r = await db` - INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, token_id, token, currency, amount, block_number, block_time) - VALUES (${cid}, ${tx}, ${li}, 'burned', ${log.args.tokenId}, ${info.token}, ${cur}, ${log.args.amount.toString()}, ${bn}, ${time}) + INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, token_id, token, fee_contract_address, currency, amount, block_number, block_time) + VALUES (${cid}, ${tx}, ${li}, 'burned', ${log.args.tokenId}, ${info.token}, ${log.address.toLowerCase()}, ${cur}, ${log.args.amount.toString()}, ${bn}, ${time}) ON CONFLICT DO NOTHING RETURNING tx_hash`; if (r.length === 0) return false; if (cur === info.quote) await db`UPDATE bb_launches SET fees_quote_burned = fees_quote_burned + ${log.args.amount.toString()}::numeric WHERE chain_id = ${cid} AND token = ${info.token}`; @@ -211,30 +213,38 @@ async function applyFeeIn(db: Db, chain: ChainKey, cid: number, time: string, lo return true; } if (log.eventName === "Paid") { - const info = tokenIdToToken.get(log.args.tokenId.toString()); + const info = tokenIdToToken.get(`${log.address.toLowerCase()}:${log.args.tokenId}`); if (!info) return false; // same as Burned: the poller fills it in once the launch is indexed const r = await db` - INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, token_id, token, currency, account, amount, block_number, block_time) - VALUES (${cid}, ${tx}, ${li}, 'paid', ${log.args.tokenId}, ${info.token}, ${log.args.currency.toLowerCase()}, ${log.args.account.toLowerCase()}, ${log.args.amount.toString()}, ${bn}, ${time}) + INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, token_id, token, fee_contract_address, currency, account, amount, block_number, block_time) + VALUES (${cid}, ${tx}, ${li}, 'paid', ${log.args.tokenId}, ${info.token}, ${log.address.toLowerCase()}, ${log.args.currency.toLowerCase()}, ${log.args.account.toLowerCase()}, ${log.args.amount.toString()}, ${bn}, ${time}) ON CONFLICT DO NOTHING RETURNING tx_hash`; return r.length > 0; } + const info = log.args.tokenId === undefined ? undefined : tokenIdToToken.get(`${log.address.toLowerCase()}:${log.args.tokenId}`); + if (log.args.tokenId !== undefined && !info) return false; const r = await db` - INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, currency, account, amount, block_number, block_time) - VALUES (${cid}, ${tx}, ${li}, ${log.eventName === "Credited" ? "credited" : "claimed"}, ${log.args.currency.toLowerCase()}, ${log.args.account.toLowerCase()}, ${log.args.amount.toString()}, ${bn}, ${time}) + INSERT INTO bb_launch_fee_events (chain_id, tx_hash, log_index, kind, token_id, token, fee_contract_address, currency, account, amount, block_number, block_time) + VALUES (${cid}, ${tx}, ${li}, ${log.eventName === "Credited" ? "credited" : "claimed"}, ${log.args.tokenId ?? null}, ${info?.token ?? null}, ${log.address.toLowerCase()}, ${log.args.currency.toLowerCase()}, ${log.args.account.toLowerCase()}, ${log.args.amount.toString()}, ${bn}, ${time}) ON CONFLICT DO NOTHING RETURNING tx_hash`; return r.length > 0; } -async function poolMaps(db: Db, chain: ChainKey): Promise<{ poolToToken: Map; tokenIdToToken: Map }> { - const rows = await db<{ token: string; pool_id: string; token_id: bigint; quote: string }[]>`SELECT token, pool_id, token_id, quote FROM bb_launches WHERE chain_id = ${chainIdOf(chain)}`; +type PoolInfo = { token: string; quote: string; pool_id: string; token_id: bigint; suite_id: string; fee_contract_address: string | null; hook_address: string }; +/** The chain's indexed launches as lookups: pool id → token, fee contract + NFT id → launch, quote-only pool → hook. `source` narrows them to one suite. */ +async function poolMaps(db: Db, chain: ChainKey, source?: LaunchSuite) { + const rows = await db`SELECT token, pool_id, token_id, quote, suite_id, fee_contract_address, hook_address FROM bb_launches WHERE chain_id = ${chainIdOf(chain)}`; const poolToToken = new Map(); const tokenIdToToken = new Map(); - for (const r of rows) { - poolToToken.set(r.pool_id, r.token); - tokenIdToToken.set(r.token_id.toString(), { token: r.token, quote: r.quote }); + const quotePools = new Map(); + for (const row of rows) { + const fee = feeContractForLaunch({ ...row, chain }); + if (source && fee?.toLowerCase() !== source.feeContract.toLowerCase()) continue; + poolToToken.set(row.pool_id, row.token); + if (fee) tokenIdToToken.set(`${fee.toLowerCase()}:${row.token_id}`, { token: row.token, quote: row.quote }); + if (row.suite_id === "quote-v2") quotePools.set(row.pool_id, row.hook_address); } - return { poolToToken, tokenIdToToken }; + return { poolToToken, tokenIdToToken, quotePools }; } /** @@ -243,36 +253,32 @@ async function poolMaps(db: Db, chain: ChainKey): Promise<{ poolToToken: Map([["robinhood", 1000]]); -/** Apply every launchpad-relevant log in one range. Launched first so same-range swaps resolve. */ -async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint): Promise<{ launches: number; swaps: number; fees: number }> { +/** Launches precede swaps and fees; every source owns a separate resumable cursor. */ +async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint, suite: LaunchSuite): Promise<{ launches: number; swaps: number; fees: number }> { const client = publicClient(chain); - const cfg = launchpad(chain); - const factory = cfg.factory!; - const locker = cfg.locker!; - const pm = cfg.v4.poolManager; - let launches = 0; - let swaps = 0; - let fees = 0; - + const pm = launchpad(chain).v4.poolManager; + const kind = SUITE_KINDS[suite.id]; + let launches = 0, swaps = 0, fees = 0; // every range goes through fetchLogsSplit: a node that caps results per call (Arc: 2000) gets the range halved until it answers - const launchedLogs = await fetchLogsSplit((f, t) => client.getLogs({ address: factory, event: LAUNCHED_EVENT, fromBlock: f, toBlock: t }), from, to); - for (const l of launchedLogs) if (await applyLaunched(db, chain, l as Log & { args: Launched })) launches++; - - const { poolToToken, tokenIdToToken } = await poolMaps(db, chain); - if (poolToToken.size > 0) { + const launchedLogs = await fetchLogsSplit((f, t) => client.getLogs({ address: suite.factory, event: kind.launchedEvent, fromBlock: f, toBlock: t }), from, to); + for (const log of launchedLogs) if (await applyLaunched(db, chain, log as unknown as Log & { args: Launched }, suite)) launches++; + const { poolToToken, tokenIdToToken, quotePools } = await poolMaps(db, chain, suite); + if (poolToToken.size) { const ids = [...poolToToken.keys()] as Hex[]; // every pool id is one topic selector: past the node's cap the id list goes out in slices (the 1001st Robinhood launch stopped the indexer) // the selector split is outermost, so a result-cap refusal bisects the block range of the one slice that overflowed only const swapLogs = byChainOrder( - (await fetchBySelectors(SELECTOR_CAPS, chain, ids, (slice) => fetchLogsSplit((f, t) => client.getLogs({ address: pm, event: POOL_SWAP_EVENT, args: { id: slice }, fromBlock: f, toBlock: t }), from, to))) as Log[], + (await fetchBySelectors(SELECTOR_CAPS, chain, ids, (slice) => fetchLogsSplit((f, t) => client.getLogs({ address: pm, event: POOL_SWAP_EVENT, args: { id: slice }, fromBlock: f, toBlock: t }), from, to))) as (Log & { args: Swap })[], ); - for (const l of swapLogs) if (await applySwap(db, chain, l as Log & { args: Swap }, poolToToken)) swaps++; + const hookEvent = kind.hookSwapEvent; + const hookLogs = hookEvent ? await fetchLogsSplit((f, t) => client.getLogs({ address: suite.hook, event: hookEvent, strict: true, fromBlock: f, toBlock: t }), from, to) : []; + const paired = pairQuoteSwaps(swapLogs, hookLogs as unknown as QuoteSwapLog[], quotePools); + for (const log of swapLogs) if (await applySwap(db, chain, log, poolToToken, paired.get(`${log.transactionHash}:${log.logIndex}`))) swaps++; } - const feeLogs = await fetchLogsSplit((f, t) => client.getLogs({ address: locker, events: LOCKER_EVENTS, fromBlock: f, toBlock: t }), from, to); - for (const l of feeLogs) if (await applyFee(db, chain, l as unknown as FeeLog, tokenIdToToken)) fees++; - // holder balances: every Transfer of every launched token (idempotent; backfill covers history) - const tokens = [...new Set([...poolToToken.values()])]; - if (tokens.length > 0) await applyTransfers(db, chain, tokens, from, to); + const feeLogs = await fetchLogsSplit((f, t) => client.getLogs({ address: suite.feeContract, events: kind.feeEvents, fromBlock: f, toBlock: t }), from, to); + for (const log of feeLogs) if (await applyFee(db, chain, log as unknown as FeeLog, tokenIdToToken)) fees++; + const tokens = [...new Set(poolToToken.values())]; + if (tokens.length) await applyTransfers(db, chain, tokens, from, to); return { launches, swaps, fees }; } @@ -312,7 +318,7 @@ const SYNCED_FOREVER = 9223372036854775807n; // bigint max = "history fully scan /** System addresses that hold launched tokens on the protocol's behalf (never counted as holders): pool, position manager, locker, factory, plus router/Permit2 which keep swap dust. */ export function systemAddresses(chain: ChainKey): string[] { const cfg = launchpad(chain); - return [cfg.v4.poolManager, cfg.v4.positionManager, cfg.locker!, cfg.factory!, cfg.v4.universalRouter, cfg.v4.permit2].map((a) => a.toLowerCase()); + return [cfg.v4.poolManager, cfg.v4.positionManager, cfg.v4.universalRouter, cfg.v4.permit2, ...launchSuites(chain).flatMap((s) => [s.locker, s.factory, s.feeContract, s.hook])].map((a) => a.toLowerCase()); } /** @@ -504,13 +510,12 @@ export async function healLaunchReads(chain: ChainKey): Promise { const db = maybeDb(); if (!db || skipReason(chain)) return 0; const cid = chainIdOf(chain); - const rows = await db<{ token: string; token_id: bigint; name: string; symbol: string; lp_fee: number; recipients: unknown }[]>` - SELECT token, token_id, name, symbol, lp_fee, recipients FROM bb_launches + const rows = await db<{ token: string; token_id: bigint; name: string; symbol: string; lp_fee: number; recipients: unknown; locker_address: string | null; suite_id: string }[]>` + SELECT token, token_id, name, symbol, lp_fee, recipients, locker_address, suite_id FROM bb_launches WHERE chain_id = ${cid} AND (name = '?' OR symbol = '?' OR (lp_fee > 0 AND recipients = '[]'::jsonb)) ORDER BY block_number DESC LIMIT 10`; if (rows.length === 0) return 0; const client = publicClient(chain); - const locker = launchpad(chain).locker!; let healed = 0; for (const r of rows) { const token = r.token as Address; @@ -519,7 +524,7 @@ export async function healLaunchReads(chain: ChainKey): Promise { r.symbol === "?" ? client.readContract({ address: token, abi: ERC20_MIN_ABI, functionName: "symbol" }).catch(() => null) : Promise.resolve(r.symbol), r.lp_fee > 0 && Array.isArray(r.recipients) && r.recipients.length === 0 ? client - .readContract({ address: locker, abi: LAUNCH_LOCKER_ABI, functionName: "recipientsOf", args: [BigInt(r.token_id)] }) + .readContract({ address: (r.locker_address ?? launchSuites(chain).find((s) => s.id === r.suite_id)?.locker) as Address, abi: LAUNCH_LOCKER_ABI, functionName: "recipientsOf", args: [BigInt(r.token_id)] }) .then((x) => x.map((y) => ({ payout: y.payout.toLowerCase(), bps: Number(y.bps) }))) .catch(() => null) : Promise.resolve(null), @@ -612,29 +617,46 @@ async function run(chain: ChainKey): Promise { // (head_block used to move only on success, so a chain that failed every run kept reporting its last small lag) let knownHead: bigint | null = null; try { + const legacy = launchSuites(chain).find((s) => s.id === "lp-v1"); + if (legacy) await db` + UPDATE bb_launches SET factory_address = COALESCE(factory_address, ${legacy.factory.toLowerCase()}), + locker_address = COALESCE(locker_address, ${legacy.locker.toLowerCase()}), + fee_contract_address = COALESCE(fee_contract_address, ${legacy.feeContract.toLowerCase()}) + WHERE chain_id = ${cid} AND suite_id = 'lp-v1' + AND (factory_address IS NULL OR locker_address IS NULL OR fee_contract_address IS NULL)`; await db`INSERT INTO bb_launch_sync_cursor (chain_id) VALUES (${cid}) ON CONFLICT DO NOTHING`; const [{ cursor_block }] = await db<{ cursor_block: bigint }[]>`SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id = ${cid}`; const head = await publicClient(chain).getBlockNumber(); knownHead = head; const confirmed = head - confirmations(chain); - const deploy = launchDeployBlock(chain); - let from = cursor_block > 0n ? cursor_block - syncOverlapBlocks() : deploy; - if (from < deploy) from = deploy; const totals = { launches: 0, swaps: 0, fees: 0 }; - let chunks = 0; - let to = from; - while (from <= confirmed && chunks < SYNC_MAX_CHUNKS_PER_CALL) { - to = from + SYNC_CHUNK_BLOCKS - 1n; - if (to > confirmed) to = confirmed; - const r = await applyRange(db, chain, from, to); - totals.launches += r.launches; - totals.swaps += r.swaps; - totals.fees += r.fees; - await db`UPDATE bb_launch_sync_cursor SET cursor_block = ${to}, head_block = ${head}, last_run_at = now(), last_error = NULL WHERE chain_id = ${cid}`; - from = to + 1n; - chunks++; + // The chain cursor reports the slowest suite, so the lag on /api/health never hides one that is behind. + let slowest: bigint | null = null; + let caughtUp = true; + for (const suite of launchSuites(chain).filter((s) => s.deployBlock > 0n)) { + // Inherit the old cursor once for v1; a new factory always starts at its own deployment. + await db`INSERT INTO bb_launch_suite_cursor (chain_id, suite_id, factory_address, cursor_block) + VALUES (${cid}, ${suite.id}, ${suite.factory.toLowerCase()}, ${suite.id === "lp-v1" ? cursor_block : 0n}) ON CONFLICT DO NOTHING`; + const [cursor] = await db<{ cursor_block: bigint }[]>`SELECT cursor_block FROM bb_launch_suite_cursor WHERE chain_id = ${cid} AND suite_id = ${suite.id} AND factory_address = ${suite.factory.toLowerCase()}`; + let reached = BigInt(cursor.cursor_block); + let from = reached > 0n ? reached - syncOverlapBlocks() : suite.deployBlock; + if (from < suite.deployBlock) from = suite.deployBlock; + let chunks = 0; + while (from <= confirmed && chunks < SYNC_MAX_CHUNKS_PER_CALL) { + const to = from + SYNC_CHUNK_BLOCKS - 1n < confirmed ? from + SYNC_CHUNK_BLOCKS - 1n : confirmed; + const result = await applyRange(db, chain, from, to, suite); + totals.launches += result.launches; totals.swaps += result.swaps; totals.fees += result.fees; + await db`UPDATE bb_launch_suite_cursor SET cursor_block = ${to} WHERE chain_id = ${cid} AND suite_id = ${suite.id} AND factory_address = ${suite.factory.toLowerCase()}`; + await db`UPDATE bb_launch_sync_cursor SET head_block = ${head}, last_run_at = now() WHERE chain_id = ${cid}`; + reached = to; + from = to + 1n; chunks++; + } + if (from <= confirmed) caughtUp = false; + if (slowest === null || reached < slowest) slowest = reached; } - return { status: "synced", from: from.toString(), to: to.toString(), head: head.toString(), ...totals, caught_up: from > confirmed }; + const chainCursor = slowest ?? BigInt(cursor_block); + await db`UPDATE bb_launch_sync_cursor SET cursor_block = ${chainCursor}, head_block = ${head}, last_run_at = now(), last_error = NULL WHERE chain_id = ${cid}`; + return { status: "synced", to: chainCursor.toString(), head: head.toString(), ...totals, caught_up: caughtUp }; } catch (err) { const msg = errMessage(err); // the stored message reaches /api/health unauthenticated: never with the upstream URL (a keyed provider URL carries its API key) @@ -654,29 +676,33 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise l.address.toLowerCase() === factory) }); - for (const l of launched) { - if (await applyLaunched(db, chain, l as unknown as Log & { args: Launched })) launches++; - tokens.push(l.args.token.toLowerCase()); + for (const suite of launchSuites(chain)) { + const logs = receipt.logs.filter((l) => l.address.toLowerCase() === suite.factory.toLowerCase()); + const kind = SUITE_KINDS[suite.id]; + const launched = parseEventLogs({ abi: kind.factoryAbi, eventName: kind.launchedEvent.name, logs }) as unknown as (Log & { args: Launched })[]; + for (const log of launched) { + if (await applyLaunched(db, chain, log, suite)) launches++; + tokens.push(log.args.token.toLowerCase()); + } } - const { poolToToken, tokenIdToToken } = await poolMaps(db, chain); + const { poolToToken, tokenIdToToken, quotePools } = await poolMaps(db, chain); const swapLogs = parseEventLogs({ abi: POOL_MANAGER_ABI, eventName: "Swap", logs: receipt.logs.filter((l) => l.address.toLowerCase() === pm) }); - for (const l of swapLogs) { - if (await applySwap(db, chain, l as unknown as Log & { args: Swap }, poolToToken)) { + const hookLogs = parseEventLogs({ abi: QUOTE_HOOK_ABI, logs: receipt.logs }); + const paired = pairQuoteSwaps(swapLogs, hookLogs as QuoteSwapLog[], quotePools); + for (const log of swapLogs) { + if (await applySwap(db, chain, log as unknown as Log & { args: Swap }, poolToToken, paired.get(`${log.transactionHash}:${log.logIndex}`))) { swaps++; - const t = poolToToken.get(l.args.id.toLowerCase()); - if (t && !tokens.includes(t)) tokens.push(t); + const token = poolToToken.get(log.args.id.toLowerCase()); + if (token && !tokens.includes(token)) tokens.push(token); } } - const feeLogs = parseEventLogs({ abi: LAUNCH_LOCKER_ABI, logs: receipt.logs.filter((l) => l.address.toLowerCase() === locker) }); - for (const l of feeLogs) if (await applyFee(db, chain, l as unknown as FeeLog, tokenIdToToken)) fees++; + for (const suite of launchSuites(chain)) { + const logs = receipt.logs.filter((l) => l.address.toLowerCase() === suite.feeContract.toLowerCase()); + const parsed = parseEventLogs({ abi: SUITE_KINDS[suite.id].feeAbi, logs }); + for (const log of parsed) if (["Collected", "Paid", "Burned", "Credited", "Claimed"].includes(log.eventName) && await applyFee(db, chain, log as unknown as FeeLog, tokenIdToToken)) fees++; + } return { status: "applied", launches, swaps, fees, tokens }; } diff --git a/app/src/lib/launchpad/meta.ts b/app/src/lib/launchpad/meta.ts index a6266332..3844b26d 100644 --- a/app/src/lib/launchpad/meta.ts +++ b/app/src/lib/launchpad/meta.ts @@ -6,7 +6,8 @@ import { publicClient } from "@/lib/chain"; import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; import { maybeDb } from "@/lib/db"; import { LAUNCH_FACTORY_ABI } from "./abi"; -import { DEFAULT_SUPPLY, launchpad } from "./config"; +import { DEFAULT_SUPPLY } from "./config"; +import { suiteFor } from "./suites"; /** * Off-chain launch metadata. The form stores it BEFORE sending the launch tx, @@ -19,7 +20,8 @@ import { metaUriFor as uriFor, metaWriteDecision, type MetaInput as Input, type /** Predict the token address for these params (metadataURI is part of the CREATE2 init hash, so it must be the final URI). */ export async function predictToken(m: Input, uri: string): Promise
    { - const factory = launchpad(m.chain).factory; + const suite = suiteFor(m.chain, m.suite_id ?? "lp-v1"); + const factory = suite?.launchEnabled ? suite.factory : null; if (!factory) throw new Error(`launchpad unconfigured on ${m.chain}`); return publicClient(m.chain).readContract({ address: factory, diff --git a/app/src/lib/launchpad/metaShared.ts b/app/src/lib/launchpad/metaShared.ts index 9d8ac106..c7de751d 100644 --- a/app/src/lib/launchpad/metaShared.ts +++ b/app/src/lib/launchpad/metaShared.ts @@ -12,11 +12,12 @@ import { isAddress, type Hex } from "viem"; import { SITE_URL, isChainKey, type ChainKey, CHAIN_KEY_PATTERN } from "../chainPublic.ts"; import { parseXHandle } from "./xHandle.ts"; -export type MetaInput = { chain: ChainKey; launcher: string; salt: Hex; meta_key: Hex; name: string; symbol: string; description?: string; image_url?: string; banner_url?: string; website?: string; x_handle?: string }; +export type MetaInput = { chain: ChainKey; suite_id?: "lp-v1" | "quote-v2"; launcher: string; salt: Hex; meta_key: Hex; name: string; symbol: string; description?: string; image_url?: string; banner_url?: string; website?: string; x_handle?: string }; export const LIMITS = { name: 32, symbol: 10, description: 280 } as const; const BYTES32 = /^0x[0-9a-fA-F]{64}$/; export function validateMeta(m: Partial & { meta_key?: string }): { ok: true; value: MetaInput } | { ok: false; error: string } { + if (m.suite_id !== undefined && m.suite_id !== "lp-v1" && m.suite_id !== "quote-v2") return { ok: false, error: "suite_id: unknown deployment" }; const name = (m.name ?? "").trim(); const symbol = (m.symbol ?? "").trim().toUpperCase(); if (!name || name.length > LIMITS.name) return { ok: false, error: `name: 1–${LIMITS.name} characters` }; @@ -47,7 +48,7 @@ export function validateMeta(m: Partial & { meta_key?: string }): { o const x = parseXHandle(m.x_handle); if (!x.ok) return { ok: false, error: x.error }; const x_handle = x.handle || undefined; - return { ok: true, value: { chain: m.chain, launcher: m.launcher, salt: m.salt as Hex, meta_key: meta_key.toLowerCase() as Hex, name, symbol, description, image_url, banner_url, website, x_handle } }; + return { ok: true, value: { chain: m.chain, ...(m.suite_id ? { suite_id: m.suite_id } : {}), launcher: m.launcher, salt: m.salt as Hex, meta_key: meta_key.toLowerCase() as Hex, name, symbol, description, image_url, banner_url, website, x_handle } }; } /** The on-chain metadataURI: keyed by (launcher, meta_key) — stable across the salt search. */ diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 0a5bfcde..386c12f2 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -29,6 +29,17 @@ import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, compareSe */ export type LaunchRow = { chain: ChainKey; + suite_id?: "lp-v1" | "quote-v2"; + fee_asset_mode?: "both" | "quote"; + factory_address?: string | null; + locker_address?: string | null; + fee_contract_address?: string | null; + hook_address?: string | null; + pool_fee_pips?: number; + creator_fee_pips?: number; + tick_spacing?: number; + fees_quote_accrued?: string; + wallet_fees_quote_paid?: string; chain_id: number; token: string; token_id: number; @@ -257,8 +268,8 @@ export const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.w LEFT JOIN bb_launch_meta m ON m.chain_id = l.chain_id AND m.token = l.token LEFT JOIN LATERAL ( SELECT count(*) FILTER (WHERE ${HOUR})::int AS n1, count(DISTINCT s.trader) FILTER (WHERE ${HOUR})::int AS t1, - count(DISTINCT s.trader) FILTER (WHERE ${HOUR} AND ${OUTSIDE})::int AS t1_ex, COALESCE(sum(abs(amount0)) FILTER (WHERE ${HOUR}), 0)::text AS v1, - count(*)::int AS n24, count(DISTINCT s.trader) FILTER (WHERE ${OUTSIDE})::int AS t24_ex, COALESCE(sum(abs(amount0)), 0)::text AS v24, + count(DISTINCT s.trader) FILTER (WHERE ${HOUR} AND ${OUTSIDE})::int AS t1_ex, COALESCE(sum(abs(COALESCE(trader_amount0, amount0))) FILTER (WHERE ${HOUR}), 0)::text AS v1, + count(*)::int AS n24, count(DISTINCT s.trader) FILTER (WHERE ${OUTSIDE})::int AS t24_ex, COALESCE(sum(abs(COALESCE(trader_amount0, amount0))), 0)::text AS v24, max(s.block_time) FILTER (WHERE ${OUTSIDE}) AS last_outside_at FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token AND s.block_time > now() - interval '${LIVE_WINDOW_HOURS} hours' ) w ON true`; @@ -424,7 +435,7 @@ export async function getSwaps(chain: ChainKey, token: string, quoteDecimals: nu const db = maybeDb(); if (!db) return []; const rows = await db<{ tx_hash: string; log_index: number; token: string; trader: string | null; amount0: string; amount1: string; is_buy: boolean; block_time: string; sqrt_price_x96: string }[]>` - SELECT tx_hash, log_index, token, trader, amount0, amount1, is_buy, block_time, sqrt_price_x96 FROM bb_launch_swaps + SELECT tx_hash, log_index, token, trader, COALESCE(trader_amount0, amount0)::text AS amount0, COALESCE(trader_amount1, amount1)::text AS amount1, is_buy, block_time, sqrt_price_x96 FROM bb_launch_swaps WHERE chain_id = ${chainIdOf(chain)} AND token = ${token.toLowerCase()} ORDER BY block_number DESC, log_index DESC LIMIT ${Math.min(200, limit)}`; return rows.map((r) => ({ ...r, price_quote: quotePerToken(BigInt(r.sqrt_price_x96), quoteDecimals) })); } @@ -481,7 +492,7 @@ export async function getLaunchFeed(limit = 24, ethUsd: number | null = null): P async (after, size) => ( await db<{ chain_id: number; at: string; tx_hash: string; log_index: number; token: string; name: string; symbol: string; quote: string; trader: string | null; is_buy: boolean; quote_wei: string; image_url: string | null; is_dev: boolean | null }[]>` - SELECT s.chain_id, s.block_time AS at, s.tx_hash, s.log_index, s.token, l.name, l.symbol, l.quote, s.trader, s.is_buy, abs(s.amount0) AS quote_wei, m.image_url, (s.trader = l.launcher) AS is_dev + SELECT s.chain_id, s.block_time AS at, s.tx_hash, s.log_index, s.token, l.name, l.symbol, l.quote, s.trader, s.is_buy, abs(COALESCE(s.trader_amount0, s.amount0)) AS quote_wei, m.image_url, (s.trader = l.launcher) AS is_dev FROM (SELECT * FROM bb_launch_swaps ${after ? db`WHERE block_time <= ${after.at} AND (block_time, log_index, chain_id, tx_hash) < (${after.at}, ${after.log_index}, ${chainIdOf(after.chain)}, ${after.tx_hash})` : db``} ORDER BY block_time DESC, log_index DESC, chain_id DESC, tx_hash DESC LIMIT ${size}) s @@ -640,7 +651,7 @@ export async function getCandles(chain: ChainKey, token: string, intervalS: numb WITH s AS ( SELECT floor(extract(epoch FROM block_time) / ${intervalS})::bigint * ${intervalS} AS t, 1.0 / (power(sqrt_price_x96::double precision / 79228162514264337593543950336.0, 2) * ${scale}) AS p, - abs(amount0) AS v, block_number, log_index + abs(COALESCE(trader_amount0, amount0)) AS v, block_number, log_index FROM bb_launch_swaps WHERE chain_id = ${chainIdOf(chain)} AND token = ${token.toLowerCase()} AND block_time >= to_timestamp(${from}) AND block_time <= to_timestamp(${asOf}) @@ -676,7 +687,7 @@ export async function getWalletSwaps(chain: ChainKey, token: string, wallet: str const db = maybeDb(); if (!db) return []; const rows = await db<{ t: number; is_buy: boolean; quote: string }[]>` - SELECT extract(epoch FROM block_time)::int AS t, is_buy, abs(amount0)::text AS quote FROM bb_launch_swaps + SELECT extract(epoch FROM block_time)::int AS t, is_buy, abs(COALESCE(trader_amount0, amount0))::text AS quote FROM bb_launch_swaps WHERE chain_id = ${chainIdOf(chain)} AND token = ${token.toLowerCase()} AND trader = ${wallet.toLowerCase()} AND block_time <= to_timestamp(${asOf}) ORDER BY block_number DESC, log_index DESC LIMIT ${Math.min(500, limit)}`; @@ -706,7 +717,7 @@ export async function getWalletTrades(wallet: string, ethUsd: number | null = nu if (!db) return []; await withStocks(); const rows = await db<{ chain_id: number; token: string; symbol: string; name: string; quote: string; tx_hash: string; is_buy: boolean; amount0: string; amount1: string; block_time: string }[]>` - SELECT s.chain_id, s.token, l.symbol, l.name, l.quote, s.tx_hash, s.is_buy, s.amount0, s.amount1, s.block_time + SELECT s.chain_id, s.token, l.symbol, l.name, l.quote, s.tx_hash, s.is_buy, COALESCE(s.trader_amount0, s.amount0)::text AS amount0, COALESCE(s.trader_amount1, s.amount1)::text AS amount1, s.block_time FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token WHERE s.trader = ${wallet.toLowerCase()} ORDER BY s.block_number DESC, s.log_index DESC LIMIT ${Math.min(200, limit)}`; return rows.map((r) => { @@ -784,3 +795,16 @@ export async function getTrending(ethUsd: number | null = null): Promise> { + const db = maybeDb(); + if (!db) return new Map(); + const rows = await db<{ chain_id: number; token: string; paid: string }[]>` + SELECT e.chain_id, e.token, sum(e.amount)::text AS paid FROM bb_launch_fee_events e + JOIN bb_launches l ON l.chain_id = e.chain_id AND l.token = e.token + WHERE l.suite_id = 'quote-v2' AND e.currency = l.quote AND e.account = ${wallet.toLowerCase()} + AND e.fee_contract_address = l.fee_contract_address + AND e.kind IN ('paid', 'claimed') GROUP BY e.chain_id, e.token`; + return new Map(rows.map((r) => [`${r.chain_id}:${r.token}`, r.paid])); +} diff --git a/app/src/lib/launchpad/quote-abi.ts b/app/src/lib/launchpad/quote-abi.ts new file mode 100644 index 00000000..017f382d --- /dev/null +++ b/app/src/lib/launchpad/quote-abi.ts @@ -0,0 +1,38 @@ +import { parseAbi } from "viem"; + +export const QUOTE_FACTORY_ABI = parseAbi([ + "struct Recipient { address payout; uint16 bps; }", + "struct LaunchParams { string name; string symbol; string metadataURI; address quote; uint256 supply; int24 startTick; uint24 creatorFeePips; bytes32 salt; Recipient[] recipients; }", + "struct PoolKey { address currency0; address currency1; uint24 fee; int24 tickSpacing; address hooks; }", + "function launch(LaunchParams p) returns (address token, uint256 tokenId)", + "function predictToken(address launcher, bytes32 salt, string name, string symbol, uint256 supply, string metadataURI) view returns (address)", + "function findSalt(address launcher, bytes32 baseSalt, string name, string symbol, uint256 supply, string metadataURI, address quote, uint256 maxTries) view returns (bytes32 salt, address token)", + "function poolKeyOf(address token) view returns (PoolKey)", + "function locker() view returns (address)", "function vault() view returns (address)", "function hook() view returns (address)", + "event QuoteLaunched(address indexed token, uint256 indexed tokenId, address indexed launcher, address quote, bytes32 poolId, int24 startTick, uint24 creatorFeePips, uint256 supply, string metadataURI)", + "error BadFee()", "error BadSupply()", "error BadTick()", "error QuoteOrdering()", "error SaltUsed()", "error NoSaltFound()", "error NativeQuoteUnsupported()", +]); + +export const QUOTE_VAULT_ABI = parseAbi([ + "struct Recipient { address payout; uint16 bps; }", + "function collect(uint256 tokenId) returns (uint256 quoteOut, uint256 tokenOut)", + "function collectMany(uint256[] tokenIds)", + "function claim(uint256 tokenId) returns (uint256)", + "function claimFor(uint256 tokenId, address account) returns (uint256)", + "function claimable(uint256 tokenId, address account) view returns (uint256)", + "function pendingFees(uint256 tokenId) view returns (uint256)", + "function recipientsOf(uint256 tokenId) view returns (Recipient[])", + "event Collected(uint256 indexed tokenId, address indexed token, uint256 quoteAmount, uint256 tokenAmount)", + "event Paid(uint256 indexed tokenId, address indexed account, address indexed currency, uint256 amount)", + "event Credited(uint256 indexed tokenId, address indexed account, address indexed currency, uint256 amount)", + "event Claimed(uint256 indexed tokenId, address indexed account, address indexed currency, uint256 amount)", + "event Burned(uint256 indexed tokenId, address indexed currency, uint256 amount)", + "error UnknownPosition()", "error TransferFailed()", "error BadRedemption()", +]); + +export const QUOTE_HOOK_ABI = parseAbi([ + "event QuoteSwap(bytes32 indexed poolId, address indexed executor, bool zeroForOne, bool exactInput, uint256 quoteFee, int256 amount0, int256 amount1)", +]); +export const QUOTE_LAUNCHED_EVENT = QUOTE_FACTORY_ABI.find((e) => e.type === "event")!; +export const QUOTE_VAULT_EVENTS = QUOTE_VAULT_ABI.filter((e) => e.type === "event"); +export const QUOTE_SWAP_EVENT = QUOTE_HOOK_ABI[0]; diff --git a/app/src/lib/launchpad/quote-config.test.ts b/app/src/lib/launchpad/quote-config.test.ts new file mode 100644 index 00000000..3650d6f7 --- /dev/null +++ b/app/src/lib/launchpad/quote-config.test.ts @@ -0,0 +1,10 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { quoteDeployment } from "./quote-config.ts"; +const configured=["0x000000000000000000000000000000000000a001","0x000000000000000000000000000000000000a002","0x000000000000000000000000000000000000a003","0x00000000000000000000000000000000000020cc","100","true"]; +test("requires every address, positive deployment block and exact hook flags",()=>{ + assert.equal(quoteDeployment("base",configured)?.launchEnabled,true); + for(let i=0;i<5;i++){const invalid=[...configured];invalid[i]="";assert.equal(quoteDeployment("base",invalid),null);} + const bad=[...configured];bad[3]="0x000000000000000000000000000000000000ffff";assert.equal(quoteDeployment("base",bad),null); + const off=[...configured];off[5]="false";assert.equal(quoteDeployment("base",off)?.launchEnabled,false); +}); diff --git a/app/src/lib/launchpad/quote-config.ts b/app/src/lib/launchpad/quote-config.ts new file mode 100644 index 00000000..9c0b170d --- /dev/null +++ b/app/src/lib/launchpad/quote-config.ts @@ -0,0 +1,32 @@ +import { isAddress, type Address } from "viem"; +import type { ChainKey } from "../chainPublic"; +// Explicit env accesses are required for Next.js to include these values in the client bundle. +export const QUOTE_CONFIG = { + base: [process.env.NEXT_PUBLIC_QUOTE_FACTORY, process.env.NEXT_PUBLIC_QUOTE_LOCKER, process.env.NEXT_PUBLIC_QUOTE_VAULT, process.env.NEXT_PUBLIC_QUOTE_HOOK, process.env.NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK, process.env.NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED], + robinhood: [process.env.NEXT_PUBLIC_QUOTE_FACTORY_ROBINHOOD, process.env.NEXT_PUBLIC_QUOTE_LOCKER_ROBINHOOD, process.env.NEXT_PUBLIC_QUOTE_VAULT_ROBINHOOD, process.env.NEXT_PUBLIC_QUOTE_HOOK_ROBINHOOD, process.env.NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK_ROBINHOOD, process.env.NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED_ROBINHOOD], + arc: [process.env.NEXT_PUBLIC_QUOTE_FACTORY_ARC, process.env.NEXT_PUBLIC_QUOTE_LOCKER_ARC, process.env.NEXT_PUBLIC_QUOTE_VAULT_ARC, process.env.NEXT_PUBLIC_QUOTE_HOOK_ARC, process.env.NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK_ARC, process.env.NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED_ARC], +} as const; + +/** A configured address: well-formed and not the zero address. */ +export function nonZeroAddress(value: string | null | undefined): Address | null { + const v = value?.trim(); + return v && isAddress(v) && BigInt(v) !== 0n ? (v as Address) : null; +} +/** A deployment block from configuration; 0 when unset or malformed. */ +export function parseBlock(value: string | undefined): bigint { + const v = value?.trim(); + return v && /^\d+$/.test(v) ? BigInt(v) : 0n; +} +/** QuoteFeeHook's permissions (HOOK_FLAGS in QuoteFeeHook.sol) are the low 14 bits of its address. */ +export function isQuoteHook(hook: Address): boolean { + return (BigInt(hook) & 0x3fffn) === 0x20ccn; +} + +/** The chain's quote-only deployment from the environment, or null unless every address, the deploy block and the hook's permission bits are valid. */ +export function quoteDeployment(chain: ChainKey, values: readonly (string | undefined)[] = QUOTE_CONFIG[chain]) { + const [f, l, v, h, d, enabled] = values; + const factory = nonZeroAddress(f), locker = nonZeroAddress(l), feeContract = nonZeroAddress(v), hook = nonZeroAddress(h); + const deployBlock = parseBlock(d); + if (!factory || !locker || !feeContract || !hook || deployBlock === 0n || !isQuoteHook(hook)) return null; + return { factory, locker, feeContract, hook, deployBlock, launchEnabled: enabled === "true" }; +} diff --git a/app/src/lib/launchpad/quote-fees.test.ts b/app/src/lib/launchpad/quote-fees.test.ts new file mode 100644 index 00000000..610902cd --- /dev/null +++ b/app/src/lib/launchpad/quote-fees.test.ts @@ -0,0 +1,59 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { encodeFunctionData, decodeFunctionData, zeroAddress } from "viem"; +import { QUOTE_FACTORY_ABI, QUOTE_VAULT_ABI } from "./quote-abi.ts"; +import { poolKeyForLaunch, feeContractForLaunch, lockerForLaunch, isQuoteFeeLaunch } from "./suites.ts"; +import { earnedSides } from "./creator.ts"; +import { validateMeta } from "./metaShared.ts"; +const token="0x000000000000000000000000000000000000ffff", hook="0x00000000000000000000000000000000000020cc", vault="0x000000000000000000000000000000000000aaaa"; +test("quote launches use stored zero LP fee and hook; legacy keeps its LP rate",()=>{ + const base={quote:zeroAddress,token,lp_fee:10000}; + assert.equal(poolKeyForLaunch(base).fee,10000); assert.equal(poolKeyForLaunch(base).hooks,zeroAddress); + const key=poolKeyForLaunch({...base,pool_fee_pips:0,hook_address:hook}); assert.equal(key.fee,0); assert.equal(key.hooks,hook); + assert.equal(feeContractForLaunch({chain:"base",suite_id:"quote-v2",fee_contract_address:vault}),vault); + assert.equal(isQuoteFeeLaunch({suite_id:"quote-v2"}),true); +}); +test("quote factory launch selector matches the tuple layout with a creator fee",()=>{ + const data=encodeFunctionData({abi:QUOTE_FACTORY_ABI,functionName:"launch",args:[{name:"Quote",symbol:"QT",metadataURI:"",quote:zeroAddress,supply:0n,startTick:184200,creatorFeePips:30000,salt:`0x${"a".repeat(64)}`,recipients:[]}]}); + const decoded=decodeFunctionData({abi:QUOTE_FACTORY_ABI,data}); assert.equal(decoded.functionName,"launch"); + if(decoded.functionName==="launch") assert.equal(decoded.args[0].creatorFeePips,30000); +}); +test("vault claims are per launch, with a uint256 token ID",()=>{ + const data=encodeFunctionData({abi:QUOTE_VAULT_ABI,functionName:"claim",args:[123n]}); + assert.deepEqual(decodeFunctionData({abi:QUOTE_VAULT_ABI,data}).args,[123n]); +}); +test("metadata accepts a known suite and rejects arbitrary deployments",()=>{ + const meta={chain:"base" as const,launcher:token,salt:`0x${"a".repeat(64)}` as const,name:"Quote",symbol:"QT"}; + assert.equal(validateMeta({...meta,suite_id:"quote-v2"}).ok,true); + assert.equal(validateMeta({...meta,suite_id:"unknown" as "quote-v2"}).ok,false); +}); +test("wallet quote earnings use indexed payments, including partial burns and credits",()=>{ + const totals={fees_quote_collected:"100",fees_quote_burned:"40",fees_token_collected:"0",fees_token_burned:"0",wallet_fees_quote_paid:"60"}; + assert.deepEqual(earnedSides(totals,6000),{quote:60n,token:0n}); + assert.deepEqual(earnedSides({...totals,wallet_fees_quote_paid:"0"},6000),{quote:0n,token:0n}); +}); + +test("quote pool identity fails closed when its hook or actual pool fee is missing",()=>{ + assert.throws(()=>poolKeyForLaunch({suite_id:"quote-v2",quote:zeroAddress,token,lp_fee:10000})); + assert.throws(()=>poolKeyForLaunch({suite_id:"quote-v2",quote:zeroAddress,token,lp_fee:10000,pool_fee_pips:0,hook_address:vault})); + assert.throws(()=>poolKeyForLaunch({suite_id:"unknown",quote:zeroAddress,token,lp_fee:10000})); + assert.equal(feeContractForLaunch({chain:"base",suite_id:"quote-v2"}),null); + assert.equal(feeContractForLaunch({chain:"base",suite_id:"lp-v1",fee_contract_address:zeroAddress}),null); + assert.equal(feeContractForLaunch({chain:"base",suite_id:"unknown"}),null); +}); + +test("the launch form derives the first-buy pool key from the suite, with no RPC read", () => { + // The same fields LaunchForm passes right after the launch receipt. + const form = { quote: zeroAddress, token, lp_fee: 10000 }; + assert.deepEqual(poolKeyForLaunch({ ...form, suite_id: "quote-v2", pool_fee_pips: 0, hook_address: hook }), { currency0: zeroAddress, currency1: token, fee: 0, tickSpacing: 200, hooks: hook }); + assert.deepEqual(poolKeyForLaunch({ ...form, suite_id: "lp-v1", pool_fee_pips: 10000, hook_address: zeroAddress }), { currency0: zeroAddress, currency1: token, fee: 10000, tickSpacing: 200, hooks: zeroAddress }); +}); + +test("a launch's locker is the one its own suite deployed", () => { + const locker = "0x000000000000000000000000000000000000bbbb"; + assert.equal(lockerForLaunch({ chain: "base", suite_id: "quote-v2", locker_address: locker }), locker); + // A quote-only launch never falls back to the original deployment's locker. + assert.equal(lockerForLaunch({ chain: "base", suite_id: "quote-v2" }), null); + assert.equal(lockerForLaunch({ chain: "base", suite_id: "lp-v1", locker_address: zeroAddress }), null); + assert.equal(lockerForLaunch({ chain: "base", suite_id: "unknown" }), null); +}); diff --git a/app/src/lib/launchpad/quote-swaps.test.ts b/app/src/lib/launchpad/quote-swaps.test.ts new file mode 100644 index 00000000..bcf921e0 --- /dev/null +++ b/app/src/lib/launchpad/quote-swaps.test.ts @@ -0,0 +1,22 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { pairQuoteSwaps, type QuoteSwapLog } from "./quote-swaps.ts"; +const pool = "0xpool", hook = "0xhook"; +const pools = new Map([[pool,hook]]); +const feeLog = (tx: string,index: number,amount0: bigint,amount1: bigint,quoteFee: bigint): QuoteSwapLog => ({ address: hook,transactionHash:tx,logIndex:index,args:{poolId:pool,amount0,amount1,quoteFee} }); +test("pairs multiple buys/sells in one receipt, retaining core and trader amounts",()=>{ + const core=[{transactionHash:"tx",logIndex:2,args:{id:pool,amount0:-99n,amount1:10n}},{transactionHash:"tx",logIndex:5,args:{id:pool,amount0:50n,amount1:-5n}}]; + const fees=[feeLog("tx",3,-100n,10n,1n),feeLog("tx",6,49n,-5n,1n)]; + const paired=pairQuoteSwaps(core,fees,pools); + assert.equal(paired.get("tx:2")?.args.amount0,-100n); assert.equal(paired.get("tx:5")?.args.amount0,49n); +}); +test("rejects missing, forged or inconsistent hook events so the cursor retries",()=>{ + const core=[{transactionHash:"tx",logIndex:2,args:{id:pool,amount0:-99n,amount1:10n}}]; + assert.throws(()=>pairQuoteSwaps(core,[],pools)); + assert.throws(()=>pairQuoteSwaps(core,[{...feeLog("tx",3,-100n,10n,1n),address:"other"}],pools)); + assert.throws(()=>pairQuoteSwaps(core,[feeLog("tx",3,-100n,11n,1n)],pools)); + assert.throws(()=>pairQuoteSwaps(core,[feeLog("other",3,-100n,10n,1n)],pools)); +}); +test("legacy pools keep core accounting and require no hook event",()=>{ + assert.equal(pairQuoteSwaps([{transactionHash:"tx",logIndex:2,args:{id:"legacy",amount0:-100n,amount1:10n}}],[],pools).size,0); +}); diff --git a/app/src/lib/launchpad/quote-swaps.ts b/app/src/lib/launchpad/quote-swaps.ts new file mode 100644 index 00000000..793b4f90 --- /dev/null +++ b/app/src/lib/launchpad/quote-swaps.ts @@ -0,0 +1,28 @@ +/** Correlate core pool deltas with the authenticated hook's trader deltas, in receipt order. */ +export type QuoteSwapLog = { + address: string; transactionHash: string | null; logIndex: number | null; + args: { poolId: string; quoteFee: bigint; amount0: bigint; amount1: bigint }; +}; +type CoreSwapLog = { transactionHash: string | null; logIndex: number | null; args: { id?: string; amount0?: bigint; amount1?: bigint } }; +/** Pair each core Swap on a quote-only pool with the hook's QuoteSwap from the same transaction; throws when one is missing or inconsistent so the range is retried. */ +export function pairQuoteSwaps(core: CoreSwapLog[], hooks: QuoteSwapLog[], quotePools: Map): Map { + const queues = new Map(); + for (const log of [...hooks].sort((a, b) => (a.logIndex ?? 0) - (b.logIndex ?? 0))) { + const pool = log.args.poolId.toLowerCase(); + if (quotePools.get(pool)?.toLowerCase() !== log.address.toLowerCase()) continue; + const key = `${log.transactionHash}:${pool}`; + const queue = queues.get(key) ?? []; + queue.push(log); queues.set(key, queue); + } + const result = new Map(); + for (const log of [...core].sort((a, b) => (a.logIndex ?? 0) - (b.logIndex ?? 0))) { + const pool = log.args.id?.toLowerCase(); + if (!pool || !quotePools.has(pool)) continue; + const hook = queues.get(`${log.transactionHash}:${pool}`)?.shift(); + if (!hook || hook.args.amount0 + hook.args.quoteFee !== log.args.amount0 || hook.args.amount1 !== log.args.amount1) { + throw new Error(`Missing or inconsistent quote fee event for ${log.transactionHash}:${log.logIndex}`); + } + result.set(`${log.transactionHash}:${log.logIndex}`, hook); + } + return result; +} diff --git a/app/src/lib/launchpad/suite-kinds.ts b/app/src/lib/launchpad/suite-kinds.ts new file mode 100644 index 00000000..47b2df9f --- /dev/null +++ b/app/src/lib/launchpad/suite-kinds.ts @@ -0,0 +1,44 @@ +import type { Abi, AbiEvent } from "viem"; +import { LAUNCH_FACTORY_ABI, LAUNCH_LOCKER_ABI, LAUNCHED_EVENT, LOCKER_EVENTS } from "./abi"; +import { QUOTE_FACTORY_ABI, QUOTE_VAULT_ABI, QUOTE_LAUNCHED_EVENT, QUOTE_VAULT_EVENTS, QUOTE_SWAP_EVENT } from "./quote-abi"; +import type { SuiteId } from "./suites"; + +/** + * What differs between launch suites on-chain, in one place: which contracts to speak to and how the pool's + * own fee relates to the creator's rate. Callers pick a kind by suite id instead of branching on it. + */ +export type SuiteKind = { + factoryAbi: Abi; + /** The factory's launch event (its name differs per suite). */ + launchedEvent: AbiEvent; + /** The contract that collects and pays fees: the locker for lp-v1, the vault for quote-v2. */ + feeAbi: Abi; + feeEvents: readonly AbiEvent[]; + /** Hook event that accompanies every core Swap and carries the trader's amounts; null when the pool has no hook. */ + hookSwapEvent: AbiEvent | null; + /** Name of the creator's rate in the factory's LaunchParams tuple. */ + rateField: "lpFee" | "creatorFeePips"; + /** The pool key's own LP fee for a launch with this creator rate. */ + poolFeePips: (ratePips: number) => number; +}; + +export const SUITE_KINDS: Record = { + "lp-v1": { + factoryAbi: LAUNCH_FACTORY_ABI, + launchedEvent: LAUNCHED_EVENT, + feeAbi: LAUNCH_LOCKER_ABI, + feeEvents: LOCKER_EVENTS, + hookSwapEvent: null, + rateField: "lpFee", + poolFeePips: (ratePips) => ratePips, + }, + "quote-v2": { + factoryAbi: QUOTE_FACTORY_ABI, + launchedEvent: QUOTE_LAUNCHED_EVENT, + feeAbi: QUOTE_VAULT_ABI, + feeEvents: QUOTE_VAULT_EVENTS, + hookSwapEvent: QUOTE_SWAP_EVENT, + rateField: "creatorFeePips", + poolFeePips: () => 0, + }, +}; diff --git a/app/src/lib/launchpad/suites.ts b/app/src/lib/launchpad/suites.ts new file mode 100644 index 00000000..a00f4061 --- /dev/null +++ b/app/src/lib/launchpad/suites.ts @@ -0,0 +1,71 @@ +import { type Address } from "viem"; +import { launchpad, NATIVE } from "./config"; +import { type ChainKey } from "../chainPublic"; +import { type PoolKey } from "./swap"; +import { isQuoteHook, nonZeroAddress, parseBlock, quoteDeployment } from "./quote-config"; + +export type FeeAssetMode = "both" | "quote"; +export type SuiteId = "lp-v1" | "quote-v2"; +export type LaunchSuite = { + id: SuiteId; + chain: ChainKey; + factory: Address; + locker: Address; + feeContract: Address; + hook: Address; + feeAssetMode: FeeAssetMode; + deployBlock: bigint; + launchEnabled: boolean; +}; +const LEGACY_BLOCK: Record string | undefined> = { + base: () => process.env.LAUNCH_DEPLOY_BLOCK, + robinhood: () => process.env.LAUNCH_DEPLOY_BLOCK_ROBINHOOD, + arc: () => process.env.LAUNCH_DEPLOY_BLOCK_ARC, +}; + +/** The launch suites configured on a chain: the original locker-based one, and the quote-only one where deployed. */ +export function launchSuites(chain: ChainKey): LaunchSuite[] { + const cfg = launchpad(chain); + const suites: LaunchSuite[] = cfg.factory && cfg.locker ? [{ id: "lp-v1", chain, factory: cfg.factory, locker: cfg.locker, + feeContract: cfg.locker, hook: NATIVE, feeAssetMode: "both", deployBlock: parseBlock(LEGACY_BLOCK[chain]()), launchEnabled: true }] : []; + const quote = quoteDeployment(chain); + if (quote) suites.push({ ...quote, id: "quote-v2", chain, feeAssetMode: "quote" }); + return suites; +} + +/** One configured suite by id, or null. */ +export function suiteFor(chain: ChainKey, id: SuiteId = "lp-v1"): LaunchSuite | null { + return launchSuites(chain).find((s) => s.id === id) ?? null; +} +/** Whether a stored suite id is one this build knows. */ +export function isSuiteId(value: unknown): value is SuiteId { return value === "lp-v1" || value === "quote-v2"; } +export type LaunchIdentity = { + chain: ChainKey; suite_id?: string; fee_asset_mode?: FeeAssetMode; factory_address?: string | null; + locker_address?: string | null; fee_contract_address?: string | null; hook_address?: string | null; +}; +/** A launch's recorded contract. Only migrated v1 records may omit it; they fall back to the original deployment. */ +function recordedContract(l: LaunchIdentity, recorded: string | null | undefined, legacy: "feeContract" | "locker"): Address | null { + if (l.suite_id !== undefined && !isSuiteId(l.suite_id)) return null; + if (recorded != null) return nonZeroAddress(recorded); + return isQuoteFeeLaunch(l) ? null : suiteFor(l.chain, "lp-v1")?.[legacy] ?? null; +} +/** The contract that collects and pays this launch's fees (locker or vault), or null when its identity is incomplete. */ +export function feeContractForLaunch(l: LaunchIdentity): Address | null { + return recordedContract(l, l.fee_contract_address, "feeContract"); +} +/** The locker that holds this launch's position NFT (each suite deploys its own). */ +export function lockerForLaunch(l: LaunchIdentity): Address | null { + return recordedContract(l, l.locker_address, "locker"); +} +/** Whether a launch's fees are charged only in the quote asset (the quote-v2 suite). */ +export function isQuoteFeeLaunch(l: Pick): boolean { + return l.suite_id === "quote-v2" || l.fee_asset_mode === "quote"; +} +/** The launch's Uniswap v4 pool key from its stored identity; throws for a quote-only launch whose hook or pool fee is missing. */ +export function poolKeyForLaunch(l: { suite_id?: string; fee_asset_mode?: FeeAssetMode; quote: string; token: string; lp_fee: number; pool_fee_pips?: number; tick_spacing?: number; hook_address?: string | null }): PoolKey { + if (l.suite_id !== undefined && !isSuiteId(l.suite_id)) throw new Error("Unknown launch suite"); + const hook = nonZeroAddress(l.hook_address); + if (isQuoteFeeLaunch(l) && (l.pool_fee_pips !== 0 || !hook || !isQuoteHook(hook) || (l.tick_spacing ?? 200) !== 200)) throw new Error("Quote fee pool identity is incomplete"); + return { currency0: l.quote as Address, currency1: l.token as Address, fee: l.pool_fee_pips ?? l.lp_fee, + tickSpacing: l.tick_spacing ?? 200, hooks: hook ?? NATIVE }; +} diff --git a/contracts/docs/LAUNCHPAD.md b/contracts/docs/LAUNCHPAD.md index 2a06170f..9487a052 100644 --- a/contracts/docs/LAUNCHPAD.md +++ b/contracts/docs/LAUNCHPAD.md @@ -1,3 +1,7 @@ +# Scope: original v1 suite + +This document describes the original deployments. The new optional quote-only fee suite is documented in [QUOTE_FEES.md](QUOTE_FEES.md). + # Launchpad — free token launches on Uniswap v4 (Base) **LIVE on Base mainnet (block 50940130) and Robinhood Chain (block 55880172), 2026-09-06** — same addresses on both: LaunchFactory `0x815542E8b392389A1389E22E588E4B62A67Ade72`, LaunchLocker `0xcd1680D26922fcd9CabFbb8a56bA40C333fD842a` (`deployments/launchpad-base.json`, `deployments/launchpad-robinhood.json`). Robinhood default quote: USDG `0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168` (6 dec). diff --git a/contracts/docs/QUOTE_FEES.md b/contracts/docs/QUOTE_FEES.md new file mode 100644 index 00000000..48d3fe03 --- /dev/null +++ b/contracts/docs/QUOTE_FEES.md @@ -0,0 +1,113 @@ +# Quote-only creator fees + +New launches may choose quote-only fees using `QuoteLaunchFactory`, `QuoteFeeHook`, and `QuoteFeeVault`. The original factory and locker source remain unchanged. Existing pools retain their original fee behavior. + +## Contracts and settlement + +The factory atomically creates an ownerless `LaunchLocker`, a vault, and a hook with permissions `0x20cc`. It predicts its second CREATE child (the vault), mines the hook's CREATE2 address, and checks both actual addresses in its constructor. There is no initializer, admin, mutable fee, upgrade, pause, treasury, or platform allocation. + +A launch creates a quote/token pool with **LP fee 0**, tick spacing 200, quote as currency0, and the suite's hook. The creator fee is fixed at launch, from 0 to 30,000 pips (3%). The NFT goes directly to the locker. Recipients are registered in the locker and vault, then swaps are activated. Nobody can initialize another pool with this hook outside the factory. Arc rejects address(0) quotes in both the factory and hook; use the six-decimal USDC ERC-20 address. + +For denominator `D = 1,000,000` and creator rate `r`: + +| Swap | Quote fee | When charged | +| --- | --- | --- | +| Buy, exact quote input `Q` | floor(Q × r / D) | Before swap; pool receives Q minus fee | +| Sell, exact token input | floor(gross quote output × r / D) | After swap; trader receives output minus fee | +| Buy, exact token output | ceil(pool quote input × r / (D − r)) | After swap; fee is added to quote owed | +| Sell, exact quote output `Q` | ceil(Q × r / (D − r)) | Before swap; pool produces Q plus fee | + +For exact output, rounding up covers the configured rate on gross quote. Integer rounding can make dust-sized fees zero. Full specified fills are required in every direction, including zero-fee pools. Price-limit or liquidity-limited partial fills revert the whole swap; the hook's claims and accrual roll back. This avoids charging a specified fee on an unfilled amount, and it means a swap that fills nothing cannot move the price outside the liquidity range. The ordinary Universal Router single-hop exact-input path is supported, and so is the app's multi-hop "Buy with ETH" route (WETH → GITLAWB → TWIG → token) ending in a quote-only pool, where the hook charges the TWIG hop. Call the chain's real V4Quoter with the actual pool key; it includes the hook fee and rolls back all changes. + +The hook mints PoolManager ERC-6909 quote claims directly to the vault and records the fee against the launch's pool. The vault keeps one pending total per position and emits no event of its own on a swap; `QuoteSwap.quoteFee` is the accrual record. It never sends funds to recipients during a swap. Each successful swap emits `QuoteSwap(poolId, executor, zeroForOne, exactInput, quoteFee, amount0, amount1)`. Amounts are trader deltas, after the creator fee. `executor` is the router/callback caller; it is not represented as the trader's wallet. + +## Collection, credits and burns + +Anyone can call `vault.collect(tokenId)` or `collectMany(ids)`. Collection clears only that position's pending fees, burns exactly those vault claims during a guarded PoolManager unlock, and takes the corresponding real quote asset. A balance check rejects an incomplete redemption. Unsolicited quote balances or claims are not distributed as launch revenue. + +Recipients and shares are fixed at launch. Up to seven nonzero recipients must sum to 10,000 bps. Empty recipients mean 100% to `0x…dEaD`. Each share floors its allocation; the final recipient receives the remainder. Duplicate recipients are handled by adding their credits and indexing their separate payout events. + +Recipient transfers execute in a bounded self-call (200,000 gas). A revert, false return, malformed return, or reentrant collection attempt rolls that transfer back and creates a reserved credit for that recipient and launch. Other recipients can still be paid. `claim(tokenId)` and permissionless `claimFor(tokenId, account)` pay the recorded account; there is no arbitrary destination. A failed claim reverts and preserves the credit. Burn transfers must succeed or the entire collection reverts. Shares are sent to the dead address, not removed from quote total supply. + +PoolManager claims back pending fees; real vault balances back reserved credits. Multiple launches sharing one quote cannot collect one another's recorded fees. Collection leaves NFT ownership and liquidity unchanged. The legacy locker remains the immutable custody contract. Its separate LP collection path has no swap LP fees in these zero-LP-fee pools; donations and unrelated transfers are not creator swap fees. + +Quotes should be standard, non-rebasing ERC-20s or native ETH. Fee-on-transfer and other nonstandard balance behavior is unsupported. Uniswap protocol fees are controlled by Uniswap's PoolManager and are separate from the creator rate. + +## App and indexer + +`lp-v1` and `quote-v2` suites coexist. Launch records store factory, locker, fee contract, hook, actual pool fee, creator rate, fee asset mode, and tick spacing. The existing `lp_fee` field remains the displayed trading rate for API compatibility. Trade and first-buy calls use the actual pool key. Quote-only collection uses the vault and its per-position claims; UI totals omit a launched-token fee leg. + +Core `PoolManager.Swap` deltas and `QuoteSwap` trader deltas are both preserved. The indexer pairs authenticated hook events with core swaps by transaction, pool and occurrence, and checks delta consistency. Missing or inconsistent hook events stop the range for retry. Prices use core sqrt price; displayed trades, candles' volume and aggregate volume use the actual trader's quote amount. Wallet earnings sum `Paid` and `Claimed` events; credits count when paid. Recipient amounts are not estimated from collection totals for quote-only launches. + +Fee event position IDs are bound to the emitting fee contract. Receipt and range ingestion use the same apply functions and dedupe keys. Each suite/factory has a cursor, so adding a deployment behind an existing chain cursor backfills its history. The per-chain cursor that `/api/health` reads is the slowest suite's, so a suite that is behind shows as lag. Accrued quote fees are totalled from `QuoteSwap.quoteFee` with each swap; the vault emits no accrual event. The migration labels existing rows as v1 and keeps nullable pool-rate fields compatible with legacy writers during rolling deploys. The poller fills missing v1 contract addresses once from the original deployment registry. + +## Deploy and enable + +1. Review the contracts and run the checks below. +2. Dry-run `forge script script/DeployQuoteLaunchFactory.s.sol:DeployQuoteLaunchFactory --rpc-url `. Set `DEPLOYER_PRIVATE_KEY` through the normal secure local environment. Base, Robinhood and Arc dependencies match the deployment records; other chains require explicit `POOL_MANAGER` and `POSITION_MANAGER`. +3. Inspect the simulated factory, locker, vault, hook, salt, code sizes, and deployer nonce. Broadcast only the reviewed deployment. Changing the deployer nonce or compilation settings requires mining again. +4. Verify all four contracts and immutable dependencies on the chain explorer. Hook constructor arguments are `(poolManager, factory, vault)`; vault arguments are `(poolManager, positionManager, locker, factory, hook)`; locker takes `positionManager`; factory takes `(poolManager, positionManager, permit2, hookSalt)`. +5. Apply `app/db/schema.sql` with the normal migration command. Configure all five `NEXT_PUBLIC_QUOTE_{FACTORY,LOCKER,VAULT,HOOK,DEPLOY_BLOCK}` values (add `_ROBINHOOD` or `_ARC` for those chains). The registry rejects incomplete addresses, zero deployment blocks and incorrect hook permission bits. +6. Keep `NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED` false while indexing and checking the deployment. Set it to true and rebuild to offer launches. Public settings are compiled into the client bundle. Disabling the launch flag still permits indexing and servicing existing quote-only launches. Do not replace a historical suite's addresses with an unrelated deployment. + +No new production addresses are invented or deployed by this change. The example environment keeps the new launch option disabled until configured. + +## Verification + +```sh +cd contracts +forge build --sizes +forge test --match-path test/QuoteFees.t.sol -vv +FORK_TESTS=true forge test --no-match-path test/LaunchFactory.arc.fork.t.sol -vv +FOUNDRY_PROFILE=arc FORK_TESTS=true ARC_FORK_TESTS=true arc-forge test --match-contract 'QuoteFeesArcFork|LaunchFactoryArcFork' -vv +FOUNDRY_BASE=true FORK_TESTS=true B20_FORK_TESTS=true base-forge test --match-test test_fork_coinbaseStockRouterBuySellCollect -vv +script/check-arc-factory.sh +cd ../app +npm run lint +npm run typecheck +npm test +npm run build +``` + +Arc's tests require [Circle's Arc Foundry](https://github.com/circlefin/arc-foundry/releases) runtime; ordinary Foundry skips the new Arc tests unless explicitly enabled. Base and Robinhood tests exercise the deployed Universal Routers, quoters, Permit2 and PoolManagers with ETH/USDC/USDG buys, sells, collection and burns. Local tests cover all four directions with 6-, 8- and 18-decimal quotes, exact output, partial-fill rollback, payout failure and claims, gas-consuming recipients, false-return tokens, reentrancy, pool isolation, allocation rounding and separate protocol fees. Interleaved-pool fuzz tests check claim backing, credit reserves and accrued/collected conservation after every operation. + +Additional live tests cover GITLAWB on both chains and Robinhood AAPL. Coinbase B20 stocks require [Base's native-precompile Foundry runtime](https://github.com/base/base-anvil), with the separate `B20_FORK_TESTS` opt-in. Ordinary Foundry cannot execute a B20 token's `decimals()` or balance/transfer calls; that runtime failure is not a fee-hook result. + +For the DB integration check, start a disposable PostgreSQL instance and an empty database, then run from `app`: + +```sh +QUOTE_TEST_DATABASE_URL=postgres://postgres:@127.0.0.1:55432/ \ + node --conditions=react-server --import tsx scripts/quote-indexer-check.mts +``` + +It applies the real schema twice, uses the real DB and indexer with deterministic RPC receipts, and verifies deduplication, core/trader amounts, fee attribution across suites sharing an NFT ID, credit/claim earnings, legacy identities and late-suite backfill. CI runs this check against a disposable PostgreSQL service. + +### Implementation verification — 3 October 2026 + +- Full ordinary Foundry regression: **95 passed**. The three native-runtime cases skipped in that run passed separately: Arc's two quote tests and the B20 stock test. +- Circle Arc Foundry: **11 passed**, including nine existing Arc tests and two quote-only tests. Base's Foundry: **1 B20 stock test passed**. Combined contract coverage: **107 passing tests**, with 256 iterations for each of the three local fuzz tests. +- App: type checking, lint, and production build passed; **764 tests passed**, with ten existing optional live Relay checks skipped. +- Real PostgreSQL integration passed, including schema replay, receipt replay, suite/NFT isolation, trader deltas, paid earnings, and legacy/cursor migration. The fee asset selector was checked in the browser with no console errors. +- Atomic deployment was simulated and executed on a disposable Base fork; the transaction succeeded and all four contracts' code and immutable dependencies were read back and checked. Actual deployment gas was **7,144,882**. Production deployment remains a separate operation. + +Runtime sizes in bytes: hook **5,261**, vault **6,240**, factory **14,752**. Factory init code is **34,262** bytes. Both runtime and init-code limits are respected. Solidity 0.8.26, Cancun, IR compilation, optimizer 200 runs; dependency submodule pins are unchanged. + +Toolchains: ordinary Foundry 1.8.3 (`cae51ad458`), Circle Arc Foundry release `v0.8.0-2` (`d497bee`), Base Foundry `98e7839c65f64aee9627b69a9b98b79afaeb1fae`. The two native runtimes were downloaded from their official releases and their published SHA256 hashes were verified. Fork snapshots during the final runs: Base 52,137,309; Robinhood 79,414,368–79,414,414; Arc 24,115,804; B20 Base 52,137,228. These were latest-head runs, so later runs may use newer blocks. + +### Review follow-up — 5 October 2026 + +After review the vault no longer keeps a per-quote `pendingClaims` total or emits `FeeAccrued`; neither was read on-chain. The hook is unchanged apart from a comment. The sizes, deployment gas and native-runtime results above predate this change; the figures below replace them. + +- Ordinary Foundry with fork suites off: **84 passed**, 24 skipped. `QuoteFees.t.sol` has 28 tests, including a new one pinning that a sell which fills nothing reverts and leaves the price unchanged. +- `FORK_TESTS=true forge test --match-path test/QuoteFees.fork.t.sol` on Base and Robinhood: **9 passed**. The two Arc tests and the B20 stock test were skipped and still need their native runtimes. +- Runtime sizes in bytes: vault **6,047** (was 6,240); hook (5,261) and factory (14,752) unchanged. Factory init code is **34,069** bytes. +- App: type checking, lint and the production build (with CI's environment) passed; **768 tests passed**, with the ten optional live checks skipped. +- PostgreSQL 16 integration check passed on a disposable local container. `scripts/rebuild-launch-totals.sql`, run on the data it indexed, found no drift and kept the indexer's totals. +- `DeployQuoteLaunchFactory` ran on a local anvil fork of Base (block 52,242,300) from a fresh deployer. All four contracts landed at the predicted addresses; their 16 immutable dependencies and the hook's `0x20cc` permission bits were read back and matched. Deployment gas was **7,103,135**. +- Circle Arc Foundry `v0.8.0-2` (`forge 1.7.1-dev`): **11 passed**, the two quote-only Arc tests and the nine existing Arc tests, forked from Arc's latest head. The aarch64 Linux build ran in an `ubuntu:24.04` container; its SHA-256 matched Circle's published `.sha256` file and GitHub's asset digest. The macOS build needs Homebrew's `libusb`. +- After merging `main` (which added the ETH route): `test_fork_ethRouteBuysTwigQuotedLaunchInOneCall` buys a TWIG-quoted quote-only launch with 0.01 ETH through the real Universal Router in one call. The quoter's `quoteExactInput` over the three-hop path matched the tokens received exactly, the vault accrued `feeAmount(twigIn, 10000)` where `twigIn` is hop 1's GITLAWB output wrapped 1:1, and `collect` paid the recipient in TWIG. Base fork suite: **5 passed** with the B20 test skipped. +- Not re-run: the B20 stock test. On 6 October 2026 the `foundry_nightly_darwin_arm64.tar.gz` asset under Base's commit-pinned tag `nightly-98e7839c…` was replaced (SHA-256 now `7b4f41db…`, previously `39c8e7e7…`), and GitHub returned no build attestation for either version. The replacement was not run. + +## Reference and attribution + +The settlement design was informed by o1's MIT-licensed, verified [LaunchHook/FeeEscrow source on Base](https://basescan.org/address/0x1f91c998e7c2F4b690D75BDBf6502BDcD6e02AcC#code), linked from its [production registry](https://docs.o1.exchange/launchpad/reference/production-contracts). This implementation uses this repository's pinned v4 interfaces and pips, fixed recipients, and an immutable NFT locker. It excludes o1's mutable controllers, referral splits and buybacks. diff --git a/contracts/script/DeployQuoteLaunchFactory.s.sol b/contracts/script/DeployQuoteLaunchFactory.s.sol new file mode 100644 index 00000000..4dc8c96a --- /dev/null +++ b/contracts/script/DeployQuoteLaunchFactory.s.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; +import {Script, console} from "forge-std/Script.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {HookMiner} from "@uniswap/v4-periphery/test/shared/HookMiner.sol"; +import {QuoteFeeHook} from "src/QuoteFeeHook.sol"; +import {QuoteLaunchFactory} from "src/QuoteLaunchFactory.sol"; + +/// Atomic deployment: mine the hook salt for the deployer's next CREATE factory address. +/// Dry-run first. Any deployer nonce change requires rerunning mining and simulation. +contract DeployQuoteLaunchFactory is Script { + address constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + /// Known PoolManager and PositionManager per chain; zero where a chain must pass its own. + function _known(uint256 chainId) internal pure returns (address pm, address posm) { + if (chainId == 8453) { + return (0x498581fF718922c3f8e6A244956aF099B2652b2b, 0x7C5f5A4bBd8fD63184577525326123B519429bDc); + } + if (chainId == 4663) { + return (0x8366a39CC670B4001A1121B8F6A443A643e40951, 0x58daec3116aae6D93017bAAea7749052E8a04fA7); + } + if (chainId == 5042) { + // Uniswap/contracts deployments/5042.md — same PoolManager address as Robinhood, a different PositionManager + return (0x8366a39CC670B4001A1121B8F6A443A643e40951, 0x6049c9a0e26405C0985f9E3685C87d0aE917f82B); + } + return (address(0), address(0)); + } + + /// Mine the hook salt for the deployer's next CREATE address and deploy the whole suite in one transaction. + function run() external returns (QuoteLaunchFactory factory) { + uint256 pk = vm.envUint("DEPLOYER_PRIVATE_KEY"); + (address pmDefault, address posmDefault) = _known(block.chainid); + IPoolManager pm = IPoolManager(vm.envOr("POOL_MANAGER", pmDefault)); + IPositionManager posm = IPositionManager(vm.envOr("POSITION_MANAGER", posmDefault)); + address predictedFactory = vm.computeCreateAddress(vm.addr(pk), vm.getNonce(vm.addr(pk))); + address predictedVault = vm.computeCreateAddress(predictedFactory, 2); + (address predictedHook, bytes32 salt) = HookMiner.find( + predictedFactory, + uint160(0x20cc), + type(QuoteFeeHook).creationCode, + abi.encode(pm, predictedFactory, predictedVault) + ); + vm.startBroadcast(pk); + factory = new QuoteLaunchFactory(pm, posm, IAllowanceTransfer(PERMIT2), salt); + vm.stopBroadcast(); + require( + address(factory) == predictedFactory && address(factory.hook()) == predictedHook + && address(factory.vault()) == predictedVault, + "deployment prediction mismatch" + ); + console.log("Chain:", block.chainid); + console.log("QuoteLaunchFactory:", address(factory)); + console.log("LaunchLocker:", address(factory.locker())); + console.log("QuoteFeeVault:", address(factory.vault())); + console.log("QuoteFeeHook:", address(factory.hook())); + console.logBytes32(salt); + } +} diff --git a/contracts/src/QuoteFeeHook.sol b/contracts/src/QuoteFeeHook.sol new file mode 100644 index 00000000..0415d501 --- /dev/null +++ b/contracts/src/QuoteFeeHook.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {SwapParams, ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { + BeforeSwapDelta, + BeforeSwapDeltaLibrary, + toBeforeSwapDelta +} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; +import {QuoteFeeVault} from "./QuoteFeeVault.sol"; + +/// @notice Fixed creator fees charged only in currency0 (the quote), with zero LP fees. +/// Settlement pattern informed by o1's MIT LaunchHook/FeeEscrow; see docs/QUOTE_FEES.md. +contract QuoteFeeHook is IHooks { + using PoolIdLibrary for PoolKey; + using SafeCast for uint256; + uint160 public constant HOOK_FLAGS = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG; + uint24 public constant MAX_CREATOR_FEE = 30_000; + uint256 public constant DENOMINATOR = 1_000_000; + IPoolManager public immutable poolManager; + address public immutable factory; + QuoteFeeVault public immutable vault; + + struct Config { + uint24 feePips; + bool registered; + bool active; + } + mapping(PoolId => Config) public poolConfig; + event QuoteSwap( + PoolId indexed poolId, + address indexed executor, + bool zeroForOne, + bool exactInput, + uint256 quoteFee, + int256 amount0, + int256 amount1 + ); + error NotFactory(); + error NotPoolManager(); + error BadPool(); + error UnknownPool(); + error PartialFillUnsupported(); + error HookNotImplemented(); + error NativeQuoteUnsupported(); + + constructor(IPoolManager pm, address factory_, QuoteFeeVault vault_) { + poolManager = pm; + factory = factory_; + vault = vault_; + Hooks.validateHookPermissions(this, getHookPermissions()); + } + modifier onlyPoolManager() { + if (msg.sender != address(poolManager)) revert NotPoolManager(); + _; + } + modifier onlyFactory() { + if (msg.sender != factory) revert NotFactory(); + _; + } + + /// @notice The permissions this hook's address must encode. + function getHookPermissions() public pure returns (Hooks.Permissions memory p) { + p.beforeInitialize = true; + p.beforeSwap = true; + p.afterSwap = true; + p.beforeSwapReturnDelta = true; + p.afterSwapReturnDelta = true; + } + + /// @notice Record a pool the factory is about to initialize; swaps stay off until `activate`. Factory only. + function register(PoolKey calldata key, uint24 feePips) external onlyFactory { + if (block.chainid == 5042 && Currency.unwrap(key.currency0) == address(0)) revert NativeQuoteUnsupported(); + PoolId id = key.toId(); + if ( + poolConfig[id].registered || address(key.hooks) != address(this) || key.fee != 0 || key.tickSpacing != 200 + || feePips > MAX_CREATOR_FEE + || uint160(Currency.unwrap(key.currency0)) >= uint160(Currency.unwrap(key.currency1)) + ) revert BadPool(); + poolConfig[id] = Config(feePips, true, false); + } + + /// @notice Turn swaps on once the vault knows the pool's position. Factory only. + function activate(PoolId id) external onlyFactory { + if (!poolConfig[id].registered || poolConfig[id].active || vault.tokenIdOfPool(id) == 0) revert BadPool(); + poolConfig[id].active = true; + } + + /// @notice Only the factory may initialize a registered pool with this hook. + function beforeInitialize(address sender, PoolKey calldata key, uint160) + external + view + onlyPoolManager + returns (bytes4) + { + if (sender != factory || !poolConfig[key.toId()].registered) revert BadPool(); + return IHooks.beforeInitialize.selector; + } + + /// @notice When the quote amount is specified, charge the fee up front as claims minted to the vault. + function beforeSwap(address, PoolKey calldata key, SwapParams calldata params, bytes calldata) + external + onlyPoolManager + returns (bytes4, BeforeSwapDelta, uint24) + { + Config memory cfg = _config(key); + if (!_quoteSpecified(params)) return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + uint256 fee = feeAmount(_magnitude(params.amountSpecified), cfg.feePips, params.amountSpecified > 0); + if (fee == 0) return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + int128 signedFee = fee.toInt128(); + poolManager.mint(address(vault), key.currency0.toId(), fee); + return (IHooks.beforeSwap.selector, toBeforeSwapDelta(signedFee, 0), 0); + } + + /// @notice Charge the fee on the actual quote delta when the token amount was specified, require a full fill, and accrue it to the vault. + function afterSwap( + address sender, + PoolKey calldata key, + SwapParams calldata params, + BalanceDelta delta, + bytes calldata + ) external onlyPoolManager returns (bytes4, int128) { + Config memory cfg = _config(key); + bool specifiedQuote = _quoteSpecified(params); + bool exactOutput = params.amountSpecified > 0; + uint256 fee = feeAmount( + specifiedQuote ? _magnitude(params.amountSpecified) : _magnitude(int256(delta.amount0())), + cfg.feePips, + exactOutput + ); + int128 signedFee = fee.toInt128(); + bool specified0 = params.amountSpecified < 0 ? params.zeroForOne : !params.zeroForOne; + int256 actual = specified0 ? int256(delta.amount0()) : int256(delta.amount1()); + // Full fills only, in every direction. A quote-specified swap has its fee charged up front on the whole + // amount, and an unfilled swap would otherwise move the price outside the liquidity range at no cost. + if (actual != params.amountSpecified + (specifiedQuote ? int256(signedFee) : int256(0))) { + revert PartialFillUnsupported(); + } + if (fee != 0) { + if (!specifiedQuote) poolManager.mint(address(vault), key.currency0.toId(), fee); + vault.accrue(key.toId(), fee); + } + emit QuoteSwap( + key.toId(), + sender, + params.zeroForOne, + !exactOutput, + fee, + int256(delta.amount0()) - int256(signedFee), + int256(delta.amount1()) + ); + return (IHooks.afterSwap.selector, specifiedQuote ? int128(0) : signedFee); + } + + /// @notice The fee on a quote amount: a share of the amount for exact input, grossed up for exact output. + function feeAmount(uint256 amount, uint24 feePips, bool exactOutput) public pure returns (uint256) { + if (feePips > MAX_CREATOR_FEE) revert BadPool(); + return exactOutput + ? Math.mulDiv(amount, feePips, DENOMINATOR - feePips, Math.Rounding.Ceil) + : Math.mulDiv(amount, feePips, DENOMINATOR); + } + + /// @dev The pool's config; reverts unless the pool is active. + function _config(PoolKey calldata key) internal view returns (Config memory cfg) { + cfg = poolConfig[key.toId()]; + if (!cfg.active) revert UnknownPool(); + } + + /// @dev Whether the swap's specified amount is the quote (currency0). + function _quoteSpecified(SwapParams calldata p) internal pure returns (bool) { + return (p.amountSpecified < 0) == p.zeroForOne; + } + + /// @dev Absolute value, safe at the int256 minimum. + function _magnitude(int256 amount) internal pure returns (uint256) { + return amount < 0 ? uint256(-(amount + 1)) + 1 : uint256(amount); + } + + /// @dev Not a permission of this hook. + function afterInitialize(address, PoolKey calldata, uint160, int24) external pure returns (bytes4) { + revert HookNotImplemented(); + } + + /// @dev Not a permission of this hook. + function beforeAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) + external + pure + returns (bytes4) + { + revert HookNotImplemented(); + } + + /// @dev Not a permission of this hook. + function afterAddLiquidity( + address, + PoolKey calldata, + ModifyLiquidityParams calldata, + BalanceDelta, + BalanceDelta, + bytes calldata + ) external pure returns (bytes4, BalanceDelta) { + revert HookNotImplemented(); + } + + /// @dev Not a permission of this hook. + function beforeRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) + external + pure + returns (bytes4) + { + revert HookNotImplemented(); + } + + /// @dev Not a permission of this hook. + function afterRemoveLiquidity( + address, + PoolKey calldata, + ModifyLiquidityParams calldata, + BalanceDelta, + BalanceDelta, + bytes calldata + ) external pure returns (bytes4, BalanceDelta) { + revert HookNotImplemented(); + } + + /// @dev Not a permission of this hook. + function beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { + revert HookNotImplemented(); + } + + /// @dev Not a permission of this hook. + function afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { + revert HookNotImplemented(); + } +} diff --git a/contracts/src/QuoteFeeVault.sol b/contracts/src/QuoteFeeVault.sol new file mode 100644 index 00000000..4fa01384 --- /dev/null +++ b/contracts/src/QuoteFeeVault.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; +import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; +import {LaunchLocker, IERC721Owner} from "./LaunchLocker.sol"; + +/// @notice Quote trading fees, backed by PoolManager claims until collected. +/// Each pool owns its recorded pending fees; failed payouts are reserved per recipient and launch. +/// No owner, setters, liquidity operations, arbitrary destinations, or balance sweeps. +contract QuoteFeeVault is IUnlockCallback, ReentrancyGuard { + using SafeERC20 for IERC20; + + struct Position { + address token; + address quote; + PoolId poolId; + uint256 pending; + LaunchLocker.Recipient[] recipients; + } + + uint256 public constant BPS = 10_000; + uint256 public constant MAX_RECIPIENTS = 7; + address public constant DEAD = 0x000000000000000000000000000000000000dEaD; + uint256 public constant PAYOUT_GAS = 200_000; + IPoolManager public immutable poolManager; + IPositionManager public immutable positionManager; + LaunchLocker public immutable locker; + address public immutable factory; + address public immutable hook; + mapping(uint256 => Position) internal _positions; + mapping(PoolId => uint256) public tokenIdOfPool; + mapping(uint256 => mapping(address => uint256)) public claimable; + mapping(address => uint256) public reserved; + bool private _redeeming; + + event Registered(uint256 indexed tokenId, address indexed token, address indexed quote, PoolId poolId); + event Collected(uint256 indexed tokenId, address indexed token, uint256 quoteAmount, uint256 tokenAmount); + event Paid(uint256 indexed tokenId, address indexed account, address indexed currency, uint256 amount); + event Credited(uint256 indexed tokenId, address indexed account, address indexed currency, uint256 amount); + event Claimed(uint256 indexed tokenId, address indexed account, address indexed currency, uint256 amount); + event Burned(uint256 indexed tokenId, address indexed currency, uint256 amount); + error NotFactory(); + error NotHook(); + error NotSelf(); + error NotPoolManager(); + error UnknownPosition(); + error BadRegistration(); + error BadRecipients(); + error TransferFailed(); + error BadRedemption(); + + constructor(IPoolManager pm, IPositionManager posm, LaunchLocker custody, address factory_, address hook_) { + poolManager = pm; + positionManager = posm; + locker = custody; + factory = factory_; + hook = hook_; + } + + receive() external payable { + if (msg.sender != address(poolManager) || !_redeeming) revert BadRedemption(); + } + + /// @notice Bind a position minted to the locker to its pool and fee recipients. Factory only, once per position. + function register( + uint256 tokenId, + address token, + address quote, + PoolId poolId, + LaunchLocker.Recipient[] calldata recipients + ) external { + if (msg.sender != factory) revert NotFactory(); + if ( + _positions[tokenId].token != address(0) || tokenIdOfPool[poolId] != 0 || tokenId == 0 || token == address(0) + || IERC721Owner(address(positionManager)).ownerOf(tokenId) != address(locker) + || locker.tokenOf(tokenId) != token || locker.quoteOf(tokenId) != quote + ) revert BadRegistration(); + if (recipients.length == 0 || recipients.length > MAX_RECIPIENTS) revert BadRecipients(); + Position storage p = _positions[tokenId]; + p.token = token; + p.quote = quote; + p.poolId = poolId; + uint256 sum; + for (uint256 i; i < recipients.length; ++i) { + if (recipients[i].payout == address(0) || recipients[i].bps == 0) revert BadRecipients(); + sum += recipients[i].bps; + p.recipients.push(recipients[i]); + } + if (sum != BPS) revert BadRecipients(); + tokenIdOfPool[poolId] = tokenId; + emit Registered(tokenId, token, quote, poolId); + } + + /// @notice Record a fee the hook minted to this vault as claims. Hook only. + function accrue(PoolId poolId, uint256 amount) external { + if (msg.sender != hook) revert NotHook(); + uint256 tokenId = tokenIdOfPool[poolId]; + if (tokenId == 0) revert UnknownPosition(); + if (amount == 0) return; + _positions[tokenId].pending += amount; + } + + /// @notice Quote fees accrued to a position and not yet collected. + function pendingFees(uint256 tokenId) external view returns (uint256) { + return _position(tokenId).pending; + } + + /// @notice The position's fixed fee recipients. + function recipientsOf(uint256 tokenId) external view returns (LaunchLocker.Recipient[] memory) { + return _position(tokenId).recipients; + } + + /// @notice The position's quote asset. + function quoteOf(uint256 tokenId) external view returns (address) { + return _position(tokenId).quote; + } + + /// @notice The position's launched token. + function tokenOf(uint256 tokenId) external view returns (address) { + return _position(tokenId).token; + } + + /// @notice Redeem a position's pending claims and pay them to its recipients; a failed payout becomes a credit. Anyone may call. + function collect(uint256 tokenId) public nonReentrant returns (uint256 quoteOut, uint256 tokenOut) { + Position storage p = _position(tokenId); + quoteOut = p.pending; + if (quoteOut == 0) return (0, 0); + p.pending = 0; + uint256 beforeBalance = _balance(p.quote); + _redeeming = true; + poolManager.unlock(abi.encode(p.quote, quoteOut)); + _redeeming = false; + if (_balance(p.quote) != beforeBalance + quoteOut) revert BadRedemption(); + uint256 remaining = quoteOut; + for (uint256 i; i < p.recipients.length; ++i) { + LaunchLocker.Recipient storage r = p.recipients[i]; + uint256 share = i == p.recipients.length - 1 ? remaining : Math.mulDiv(quoteOut, r.bps, BPS); + remaining -= share; + if (share == 0) continue; + if (r.payout == DEAD) { + _pay(p.quote, DEAD, share); + emit Burned(tokenId, p.quote, share); + } else { + // The self-call rolls back a token that transfers but returns false/malformed data. + (bool ok,) = + address(this).call{gas: PAYOUT_GAS}(abi.encodeCall(this.executePayout, (p.quote, r.payout, share))); + if (ok) { + emit Paid(tokenId, r.payout, p.quote, share); + } else { + claimable[tokenId][r.payout] += share; + reserved[p.quote] += share; + emit Credited(tokenId, r.payout, p.quote, share); + } + } + } + emit Collected(tokenId, p.token, quoteOut, 0); + return (quoteOut, 0); + } + + /// @notice `collect` for several positions in one transaction. + function collectMany(uint256[] calldata tokenIds) external { + for (uint256 i; i < tokenIds.length; ++i) { + collect(tokenIds[i]); + } + } + + /// @dev One payout in its own call frame, so a token that transfers but returns false or malformed data is rolled back and credited. Self only. + function executePayout(address currency, address account, uint256 amount) external { + if (msg.sender != address(this)) revert NotSelf(); + _pay(currency, account, amount); + } + + /// @notice Withdraw the caller's credited share of a position. + function claim(uint256 tokenId) external nonReentrant returns (uint256) { + return _claim(tokenId, msg.sender); + } + + /// @notice Push `account`'s credited share of a position to `account`. Anyone may call. + function claimFor(uint256 tokenId, address account) external nonReentrant returns (uint256) { + return _claim(tokenId, account); + } + + /// @dev Pay a credit and release its reserve. + function _claim(uint256 tokenId, address account) internal returns (uint256 amount) { + Position storage p = _position(tokenId); + amount = claimable[tokenId][account]; + if (amount == 0) return 0; + claimable[tokenId][account] = 0; + reserved[p.quote] -= amount; + _pay(p.quote, account, amount); + emit Claimed(tokenId, account, p.quote, amount); + } + + /// @notice PoolManager callback during `collect`: burn exactly the redeemed claims and take the asset. + function unlockCallback(bytes calldata data) external returns (bytes memory) { + if (msg.sender != address(poolManager)) revert NotPoolManager(); + if (!_redeeming) revert BadRedemption(); + (address quote, uint256 amount) = abi.decode(data, (address, uint256)); + Currency currency = Currency.wrap(quote); + poolManager.burn(address(this), currency.toId(), amount); + poolManager.take(currency, address(this), amount); + return ""; + } + + /// @dev The registered position; reverts for an unknown id. + function _position(uint256 tokenId) internal view returns (Position storage p) { + p = _positions[tokenId]; + if (p.token == address(0)) revert UnknownPosition(); + } + + /// @dev This contract's balance of a currency, native or ERC-20. + function _balance(address currency) internal view returns (uint256) { + return currency == address(0) ? address(this).balance : IERC20(currency).balanceOf(address(this)); + } + + /// @dev Transfer `amount` of `currency` to `account`, reverting on failure. + function _pay(address currency, address account, uint256 amount) internal { + if (currency == address(0)) { + (bool ok,) = account.call{value: amount}(""); + if (!ok) revert TransferFailed(); + } else { + IERC20(currency).safeTransfer(account, amount); + } + } +} diff --git a/contracts/src/QuoteLaunchFactory.sol b/contracts/src/QuoteLaunchFactory.sol new file mode 100644 index 00000000..8333c915 --- /dev/null +++ b/contracts/src/QuoteLaunchFactory.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; +import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol"; +import {LiquidityAmounts} from "@uniswap/v4-periphery/src/libraries/LiquidityAmounts.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; + +import {LaunchToken} from "./LaunchToken.sol"; +import {LaunchLocker} from "./LaunchLocker.sol"; +import {QuoteFeeHook} from "./QuoteFeeHook.sol"; +import {QuoteFeeVault} from "./QuoteFeeVault.sol"; + +/// @title LaunchFactory +/// @notice Permissionless token launches with immutable fees in the quote asset only. +/// LP NFTs remain permanently held by a fresh LaunchLocker. The hook charges the +/// creator fee and QuoteFeeVault owns the matching PoolManager claims. +contract QuoteLaunchFactory { + using PoolIdLibrary for PoolKey; + + struct LaunchParams { + string name; + string symbol; + string metadataURI; + address quote; // address(0) = native ETH; else any ERC20 (USDG, a stock token, WETH…) + uint256 supply; // 0 → DEFAULT_SUPPLY + int24 startTick; // multiple of TICK_SPACING; price = 1.0001^tick token per quote unit + uint24 creatorFeePips; // pips, 0 ≤ creatorFeePips ≤ MAX_CREATOR_FEE (10_000 = 1%); 0 = feeless pool + bytes32 salt; // scoped to msg.sender; token address must sort above `quote` (see findSalt) + LaunchLocker.Recipient[] recipients; // bps must sum to 10_000; empty = [DEAD: 100%] (fees burned) + } + + struct Info { + uint256 tokenId; + address launcher; + address quote; + int24 startTick; + uint24 creatorFeePips; + } + + int24 public constant TICK_SPACING = 200; + uint24 public constant MAX_CREATOR_FEE = 30_000; // 3% + uint256 public constant DEFAULT_SUPPLY = 1_000_000_000e18; + address public constant DEAD = 0x000000000000000000000000000000000000dEaD; + + IPoolManager public immutable poolManager; + IPositionManager public immutable positionManager; + IAllowanceTransfer public immutable permit2; + LaunchLocker public immutable locker; + QuoteFeeVault public immutable vault; + QuoteFeeHook public immutable hook; + + mapping(address token => Info) public infoOf; + uint256 public launchCount; + + event QuoteLaunched( + address indexed token, + uint256 indexed tokenId, + address indexed launcher, + address quote, + PoolId poolId, + int24 startTick, + uint24 creatorFeePips, + uint256 supply, + string metadataURI + ); + + error BadSupply(); + error BadFee(); + error BadTick(); + error NoLiquidity(); + error SaltUsed(); + error QuoteOrdering(); + error NoSaltFound(); + error NativeQuoteUnsupported(); + error BadDeployment(); + + constructor( + IPoolManager poolManager_, + IPositionManager positionManager_, + IAllowanceTransfer permit2_, + bytes32 hookSalt + ) { + if ( + address(poolManager_).code.length == 0 || address(positionManager_).code.length == 0 + || address(permit2_).code.length == 0 + ) revert BadDeployment(); + poolManager = poolManager_; + positionManager = positionManager_; + permit2 = permit2_; + locker = new LaunchLocker(positionManager_); + // This contract's second CREATE child is the vault. CREATE2 hook creation is third. + address predictedVault = + address(uint160(uint256(keccak256(abi.encodePacked(hex"d694", address(this), hex"02"))))); + bytes32 hookHash = keccak256( + abi.encodePacked(type(QuoteFeeHook).creationCode, abi.encode(poolManager_, address(this), predictedVault)) + ); + address expectedHook = + address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), hookSalt, hookHash))))); + vault = new QuoteFeeVault(poolManager_, positionManager_, locker, address(this), expectedHook); + hook = new QuoteFeeHook{salt: hookSalt}(poolManager_, address(this), vault); + if (address(vault) != predictedVault || address(hook) != expectedHook) revert BadDeployment(); + } + + /// @notice Deploy a token and lock 100% of it as single-sided liquidity. + function launch(LaunchParams calldata p) external returns (address token, uint256 tokenId) { + if (block.chainid == 5042 && p.quote == address(0)) revert NativeQuoteUnsupported(); + uint256 supply = p.supply == 0 ? DEFAULT_SUPPLY : p.supply; + if (supply > type(uint128).max) revert BadSupply(); + if (p.creatorFeePips > MAX_CREATOR_FEE) revert BadFee(); + + int24 tickLower = TickMath.minUsableTick(TICK_SPACING); + if ( + p.startTick % TICK_SPACING != 0 || p.startTick <= tickLower + || p.startTick > TickMath.maxUsableTick(TICK_SPACING) + ) revert BadTick(); + + // 1. token — full supply lands on this contract. A CREATE2 collision + // would burn the entire gas limit, so pre-check the address instead. + bytes32 salt = keccak256(abi.encode(msg.sender, p.salt)); + { + address predicted = _predict(salt, p.name, p.symbol, supply, msg.sender, p.metadataURI); + if (predicted.code.length != 0) revert SaltUsed(); + // quote must be currency0: strictly lower address than the token + if (uint160(predicted) <= uint160(p.quote)) revert QuoteOrdering(); + } + token = address(new LaunchToken{salt: salt}(p.name, p.symbol, supply, msg.sender, p.metadataURI)); + + // 2. pool at the start price + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(p.quote), + currency1: Currency.wrap(token), + fee: 0, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + hook.register(key, p.creatorFeePips); + uint160 sqrtUpper = TickMath.getSqrtPriceAtTick(p.startTick); + poolManager.initialize(key, sqrtUpper); + + // 3. single-sided position [minTick, startTick]; current tick == upper → token-only + uint128 liquidity = + LiquidityAmounts.getLiquidityForAmount1(TickMath.getSqrtPriceAtTick(tickLower), sqrtUpper, supply); + if (liquidity == 0) revert NoLiquidity(); + + LaunchToken(token).approve(address(permit2), supply); + permit2.approve(token, address(positionManager), uint160(supply), uint48(block.timestamp)); + + tokenId = positionManager.nextTokenId(); + bytes memory actions = abi.encodePacked(uint8(Actions.MINT_POSITION), uint8(Actions.SETTLE_PAIR)); + bytes[] memory params = new bytes[](2); + params[0] = abi.encode( + key, tickLower, p.startTick, uint256(liquidity), uint128(0), uint128(supply), address(locker), bytes("") + ); + params[1] = abi.encode(key.currency0, key.currency1); + positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp); + + // hygiene: drop the allowance, burn whatever rounding left behind + LaunchToken(token).approve(address(permit2), 0); + uint256 dust = LaunchToken(token).balanceOf(address(this)); + if (dust > 0) require(LaunchToken(token).transfer(DEAD, dust), "dust burn failed"); + + // 4. fee routing — no beneficiary named ⇒ every fee is burned + if (p.recipients.length == 0) { + LaunchLocker.Recipient[] memory burn = new LaunchLocker.Recipient[](1); + burn[0] = LaunchLocker.Recipient({payout: DEAD, bps: uint16(locker.BPS())}); + locker.register(tokenId, token, p.quote, burn); + vault.register(tokenId, token, p.quote, key.toId(), burn); + } else { + locker.register(tokenId, token, p.quote, p.recipients); + vault.register(tokenId, token, p.quote, key.toId(), p.recipients); + } + + hook.activate(key.toId()); + infoOf[token] = Info({ + tokenId: tokenId, + launcher: msg.sender, + quote: p.quote, + startTick: p.startTick, + creatorFeePips: p.creatorFeePips + }); + unchecked { + ++launchCount; + } + emit QuoteLaunched( + token, tokenId, msg.sender, p.quote, key.toId(), p.startTick, p.creatorFeePips, supply, p.metadataURI + ); + } + + // ── Views ──────────────────────────────────────────────────────────────── + + /// @notice The pool key for a token launched here (reverts if unknown). + function poolKeyOf(address token) external view returns (PoolKey memory) { + Info memory i = infoOf[token]; + if (i.tokenId == 0) revert BadTick(); + return PoolKey({ + currency0: Currency.wrap(i.quote), + currency1: Currency.wrap(token), + fee: 0, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + } + + /// @notice Pre-compute the token address a `launch()` from `launcher` would produce. + function predictToken( + address launcher, + bytes32 salt, + string calldata name, + string calldata symbol, + uint256 supply, + string calldata metadataURI + ) external view returns (address) { + if (supply == 0) supply = DEFAULT_SUPPLY; + return _predict(keccak256(abi.encode(launcher, salt)), name, symbol, supply, launcher, metadataURI); + } + + /// @notice Find a salt (derived from `baseSalt`) whose token address sorts + /// above `quote`, so the launch passes the currency-ordering check. Pure + /// hashing, so one `eth_call` is enough; ~50% of candidates qualify, and + /// `maxTries` bounds the loop. Returns the salt to pass in `LaunchParams`. + function findSalt( + address launcher, + bytes32 baseSalt, + string calldata name, + string calldata symbol, + uint256 supply, + string calldata metadataURI, + address quote, + uint256 maxTries + ) external view returns (bytes32 salt, address token) { + if (supply == 0) supply = DEFAULT_SUPPLY; + for (uint256 i; i < maxTries; ++i) { + salt = i == 0 ? baseSalt : keccak256(abi.encode(baseSalt, i)); + token = _predict(keccak256(abi.encode(launcher, salt)), name, symbol, supply, launcher, metadataURI); + if (uint160(token) > uint160(quote) && token.code.length == 0) return (salt, token); + } + revert NoSaltFound(); + } + + /// @dev The CREATE2 address of the token a launch with these parameters would deploy. + function _predict( + bytes32 scopedSalt, + string memory name, + string memory symbol, + uint256 supply, + address launcher, + string memory metadataURI + ) internal view returns (address) { + bytes32 initHash = keccak256( + abi.encodePacked(type(LaunchToken).creationCode, abi.encode(name, symbol, supply, launcher, metadataURI)) + ); + return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), scopedSalt, initHash))))); + } +} diff --git a/contracts/test/QuoteFees.fork.t.sol b/contracts/test/QuoteFees.fork.t.sol new file mode 100644 index 00000000..1925ca03 --- /dev/null +++ b/contracts/test/QuoteFees.fork.t.sol @@ -0,0 +1,329 @@ +// 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 {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; +import {IV4Quoter} from "@uniswap/v4-periphery/src/interfaces/IV4Quoter.sol"; +import {IV4Router} from "@uniswap/v4-periphery/src/interfaces/IV4Router.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PathKey} from "@uniswap/v4-periphery/src/libraries/PathKey.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {HookMiner} from "@uniswap/v4-periphery/test/shared/HookMiner.sol"; +import {QuoteLaunchFactory} from "src/QuoteLaunchFactory.sol"; +import {QuoteFeeHook} from "src/QuoteFeeHook.sol"; +import {LaunchLocker, IERC721Owner} from "src/LaunchLocker.sol"; +import {LaunchToken} from "src/LaunchToken.sol"; + +interface IQuoteRouter { + function execute(bytes calldata, bytes[] calldata, uint256) external payable; +} + +interface IQuoteERC20 { + function approve(address, uint256) external returns (bool); + function balanceOf(address) external view returns (uint256); + function decimals() external view returns (uint8); +} + +/// Real routers, quoters, Permit2 and PoolManager. Opt in with FORK_TESTS=true. +/// Arc requires ARC_FORK_TESTS=true and Circle's runtime. B20 requires B20_FORK_TESTS=true and Base's runtime. +abstract contract QuoteFeesFork is Test { + using PoolIdLibrary for PoolKey; + address constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + QuoteLaunchFactory factory; + address buyer = makeAddr("quote-v2-fork-buyer"); + address recipient = makeAddr("quote-v2-fork-recipient"); + bool forked; + + struct V1Params { + PoolKey poolKey; + bool zeroForOne; + uint128 amountIn; + uint128 amountOutMinimum; + bytes hookData; + } + + /// Multi-hop exact input in the same original layout (no minHopPriceX36): what eth-route.ts encodes for Base. + struct V1ExactInput { + Currency currencyIn; + PathKey[] path; + uint128 amountIn; + uint128 amountOutMinimum; + } + function network() + internal + view + virtual + returns ( + uint256 id, + string memory rpc, + address pm, + address posm, + address router, + address quoter, + address quote, + bool v2 + ); + receive() external payable {} + + function setUp() public { + (uint256 id, string memory rpc, address pm, address posm,,,,) = network(); + if (!vm.envOr("FORK_TESTS", false) || (id == 5042 && !vm.envOr("ARC_FORK_TESTS", false))) return; + vm.createSelectFork(rpc); + forked = true; + assertEq(block.chainid, id); + address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + address vault = vm.computeCreateAddress(predicted, 2); + (, bytes32 salt) = + HookMiner.find(predicted, 0x20cc, type(QuoteFeeHook).creationCode, abi.encode(pm, predicted, vault)); + factory = new QuoteLaunchFactory(IPoolManager(pm), IPositionManager(posm), IAllowanceTransfer(PERMIT2), salt); + } + + function _launch(address quote, bool burn) internal returns (address token, uint256 id) { + QuoteLaunchFactory.LaunchParams memory p; + p.name = "Quote Fork"; + p.symbol = "QFORK"; + p.quote = quote; + uint8 decimals = quote == address(0) ? 18 : IQuoteERC20(quote).decimals(); + p.startTick = decimals == 18 ? int24(184200) : decimals == 8 ? int24(345400) : int24(391400); + p.creatorFeePips = 10000; + if (!burn) { + p.recipients = new LaunchLocker.Recipient[](1); + p.recipients[0] = LaunchLocker.Recipient(recipient, 10000); + } + (p.salt,) = + factory.findSalt(address(this), keccak256(abi.encode(quote, burn)), p.name, p.symbol, 0, "", quote, 256); + return factory.launch(p); + } + + function _swap(PoolKey memory key, bool buy, uint128 amount, uint128 minimum, address router, bool v2) internal { + bytes[] memory params = new bytes[](3); + params[0] = v2 + ? abi.encode(IV4Router.ExactInputSingleParams(key, buy, amount, minimum, 0, "")) + : abi.encode(V1Params(key, buy, amount, minimum, "")); + address input = Currency.unwrap(buy ? key.currency0 : key.currency1); + params[1] = abi.encode(input, uint256(amount)); + params[2] = abi.encode(buy ? key.currency1 : key.currency0, uint256(minimum)); + bytes[] memory inputs = new bytes[](1); + inputs[0] = abi.encode(hex"060c0f", params); + vm.startPrank(buyer); + if (input != address(0)) { + IQuoteERC20(input).approve(PERMIT2, type(uint256).max); + IAllowanceTransfer(PERMIT2).approve(input, router, type(uint160).max, uint48(block.timestamp + 30 days)); + } + IQuoteRouter(router).execute{value: input == address(0) ? amount : 0}(hex"10", inputs, block.timestamp + 600); + vm.stopPrank(); + } + + function _roundTrip(address quote, bool burn) internal { + (uint256 chainId,,, address posm, address router, address quoter,, bool v2) = network(); + (address token, uint256 id) = _launch(quote, burn); + address payee = burn ? factory.DEAD() : recipient; + uint256 beforeTokenFees = LaunchToken(token).balanceOf(payee); + PoolKey memory key = factory.poolKeyOf(token); + assertEq(key.fee, 0); + assertEq(IERC721Owner(posm).ownerOf(id), address(factory.locker())); + uint256 unit = quote == address(0) ? 1 ether : 10 ** IQuoteERC20(quote).decimals(); + uint128 amount = quote == address(0) ? uint128(0.1 ether) : uint128(100 * unit); + if (chainId == 5042) { + vm.deal(buyer, 1000 ether); + } else if (quote == address(0)) { + vm.deal(buyer, 10 ether); + } else if (quote == 0xb200000000000000000000C2e324d24d7eEcd1fb) { + // Native B20 calls do not participate in stdStorage's SLOAD recording. + // base/base-std test/lib/mocks/MockB20Storage.sol: base.b20 + BALANCES_OFFSET (4). + uint256 location = 0xc78b71fee795ddd74aff64ea9b2474194c938c3196430e10bb5f01ed48434000; + vm.store(quote, keccak256(abi.encode(buyer, location + 4)), bytes32(1000 * unit)); + assertEq(IQuoteERC20(quote).balanceOf(buyer), 1000 * unit, "B20 fixture layout changed"); + } else { + deal(quote, buyer, 1000 * unit); + } + (uint256 out,) = + IV4Quoter(quoter).quoteExactInputSingle(IV4Quoter.QuoteExactSingleParams(key, true, amount, "")); + assertEq(factory.vault().pendingFees(id), 0); + _swap(key, true, amount, uint128(out), router, v2); + assertEq(LaunchToken(token).balanceOf(buyer), out); + assertEq(factory.vault().pendingFees(id), amount / 100); + uint128 tokenIn = uint128(out / 2); + (uint256 sellOut,) = + IV4Quoter(quoter).quoteExactInputSingle(IV4Quoter.QuoteExactSingleParams(key, false, tokenIn, "")); + uint256 beforeQuote = quote == address(0) ? buyer.balance : IQuoteERC20(quote).balanceOf(buyer); + _swap(key, false, tokenIn, uint128(sellOut), router, v2); + assertEq((quote == address(0) ? buyer.balance : IQuoteERC20(quote).balanceOf(buyer)) - beforeQuote, sellOut); + uint256 pending = factory.vault().pendingFees(id); + assertGt(pending, amount / 100); + uint256 beforePay = quote == address(0) ? payee.balance : IQuoteERC20(quote).balanceOf(payee); + uint256 liq = IPositionManager(posm).getPositionLiquidity(id); + (uint256 collected, uint256 tokenFees) = factory.vault().collect(id); + assertEq(collected, pending); + assertEq(tokenFees, 0); + assertEq((quote == address(0) ? payee.balance : IQuoteERC20(quote).balanceOf(payee)) - beforePay, pending); + assertEq(LaunchToken(token).balanceOf(payee), beforeTokenFees); + assertEq(IPositionManager(posm).getPositionLiquidity(id), liq); + } + + function test_fork_realRouterQuoteBuySellCollect() public { + vm.skip(!forked); + (,,,,,, address quote,) = network(); + _roundTrip(quote, false); + } + + function test_fork_realQuoteBurn() public { + vm.skip(!forked); + (,,,,,, address quote,) = network(); + _roundTrip(quote, true); + } +} + +contract QuoteFeesBaseFork is QuoteFeesFork { + function network() + internal + view + override + returns (uint256, string memory, address, address, address, address, address, bool) + { + return ( + 8453, + vm.envOr("BASE_RPC_URL", string("https://mainnet.base.org")), + 0x498581fF718922c3f8e6A244956aF099B2652b2b, + 0x7C5f5A4bBd8fD63184577525326123B519429bDc, + 0x6fF5693b99212Da76ad316178A184AB56D299b43, + 0x0d5e0F971ED27FBfF6c2837bf31316121532048D, + 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + false + ); + } + + function test_fork_nativeRouterBuySellCollect() public { + vm.skip(!forked); + _roundTrip(address(0), false); + } + + function test_fork_gitlawbRouterBuySellCollect() public { + vm.skip(!forked); + _roundTrip(0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3, false); + } + + address constant WETH = 0x4200000000000000000000000000000000000006; + address constant GITLAWB = 0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3; + address constant TWIG = 0x6aC18bcf4eDE02591d4917452700ea5eaAea13c1; + IHooks constant GITLAWB_POOL_HOOK = IHooks(0xbB7784A4d481184283Ed89619A3e3ed143e1Adc0); + IHooks constant TWIG_WRAP_HOOK = IHooks(0xf7423f48886F86F551B517254d21AF4267732888); + + /// A quote-only launch priced in TWIG, at the start tick the TWIG launch tests use (1B supply at about 600M TWIG). + function _launchTwig() internal returns (address token, uint256 id) { + QuoteLaunchFactory.LaunchParams memory p; + p.name = "Twig Quote Fork"; + p.symbol = "TQF"; + p.quote = TWIG; + p.startTick = 5_200; + p.creatorFeePips = 10000; + p.recipients = new LaunchLocker.Recipient[](1); + p.recipients[0] = LaunchLocker.Recipient(recipient, 10000); + (p.salt,) = factory.findSalt(address(this), keccak256("eth-route"), p.name, p.symbol, 0, "", TWIG, 256); + return factory.launch(p); + } + + /// The app's "Buy with ETH" route (app/src/lib/launchpad/eth-route.ts) ending in a quote-only pool: one Universal + /// Router call wraps the ETH and swaps WETH -> GITLAWB -> TWIG -> token, the last hop through the fee hook. + function test_fork_ethRouteBuysTwigQuotedLaunchInOneCall() public { + vm.skip(!forked); + (,,, address posm, address router, address quoter,,) = network(); + (address token, uint256 id) = _launchTwig(); + PoolKey memory key = factory.poolKeyOf(token); + PathKey[] memory path = new PathKey[](3); + path[0] = PathKey(Currency.wrap(GITLAWB), 0x800000, 200, GITLAWB_POOL_HOOK, ""); + path[1] = PathKey(Currency.wrap(TWIG), 0, 1, TWIG_WRAP_HOOK, ""); + path[2] = PathKey(Currency.wrap(token), key.fee, key.tickSpacing, key.hooks, ""); + uint128 amount = 0.01 ether; + (uint256 out,) = + IV4Quoter(quoter).quoteExactInput(IV4Quoter.QuoteExactParams(Currency.wrap(WETH), path, amount)); + // the TWIG that reaches the launch's pool is hop 1's GITLAWB output, wrapped 1:1 by hop 2 + PoolKey memory gitlawbPool = + PoolKey(Currency.wrap(WETH), Currency.wrap(GITLAWB), 0x800000, 200, GITLAWB_POOL_HOOK); + (uint256 twigIn,) = + IV4Quoter(quoter).quoteExactInputSingle(IV4Quoter.QuoteExactSingleParams(gitlawbPool, true, amount, "")); + + bytes[] memory params = new bytes[](3); + params[0] = abi.encode(V1ExactInput(Currency.wrap(WETH), path, amount, uint128(out))); + params[1] = abi.encode(WETH, uint256(0), false); // SETTLE the open WETH delta from the router's own balance + params[2] = abi.encode(token, out); // TAKE_ALL + bytes[] memory inputs = new bytes[](2); + inputs[0] = abi.encode(address(2), uint256(amount)); // WRAP_ETH to the router itself + inputs[1] = abi.encode(hex"070b0f", params); // SWAP_EXACT_IN, SETTLE, TAKE_ALL + vm.deal(buyer, 1 ether); + vm.prank(buyer); + IQuoteRouter(router).execute{value: amount}(hex"0b10", inputs, block.timestamp + 600); + + assertEq(LaunchToken(token).balanceOf(buyer), out, "the quoter priced the whole route, hook fee included"); + uint256 fee = factory.vault().pendingFees(id); + assertEq(fee, factory.hook().feeAmount(twigIn, 10000, false), "1% of the TWIG that entered the pool"); + uint256 liq = IPositionManager(posm).getPositionLiquidity(id); + (uint256 collected,) = factory.vault().collect(id); + assertEq(collected, fee); + assertEq(IQuoteERC20(TWIG).balanceOf(recipient), fee, "paid in TWIG"); + assertEq(IPositionManager(posm).getPositionLiquidity(id), liq); + } + + function test_fork_coinbaseStockRouterBuySellCollect() public { + vm.skip(!forked || !vm.envOr("B20_FORK_TESTS", false)); + _roundTrip(0xb200000000000000000000C2e324d24d7eEcd1fb, false); + } +} + +contract QuoteFeesRobinhoodFork is QuoteFeesFork { + function network() + internal + view + override + returns (uint256, string memory, address, address, address, address, address, bool) + { + return ( + 4663, + vm.envOr("ROBINHOOD_RPC_URL", string("https://rpc.mainnet.chain.robinhood.com")), + 0x8366a39CC670B4001A1121B8F6A443A643e40951, + 0x58daec3116aae6D93017bAAea7749052E8a04fA7, + 0x8876789976dEcBfCbBbe364623C63652db8C0904, + 0x8Dc178eFB8111BB0973Dd9d722ebeFF267c98F94, + 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168, + true + ); + } + + function test_fork_nativeRouterBuySellCollect() public { + vm.skip(!forked); + _roundTrip(address(0), false); + } + + function test_fork_gitlawbRouterBuySellCollect() public { + vm.skip(!forked); + _roundTrip(0xd1b0d44E4f6ed940fcC7A9F59Bf30Daf62cCFe3D, false); + } + + function test_fork_robinhoodStockRouterBuySellCollect() public { + vm.skip(!forked); + _roundTrip(0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9, false); + } +} + +contract QuoteFeesArcFork is QuoteFeesFork { + function network() + internal + view + override + returns (uint256, string memory, address, address, address, address, address, bool) + { + return ( + 5042, + vm.envOr("ARC_RPC_URL", string("https://rpc.mainnet.arc.io")), + 0x8366a39CC670B4001A1121B8F6A443A643e40951, + 0x6049c9a0e26405C0985f9E3685C87d0aE917f82B, + 0x4fcA4a51Ab4F23A7447b3284fBd7D73289A89Fb1, + 0x8Dc178eFB8111BB0973Dd9d722ebeFF267c98F94, + 0x3600000000000000000000000000000000000000, + true + ); + } +} diff --git a/contracts/test/QuoteFees.t.sol b/contracts/test/QuoteFees.t.sol new file mode 100644 index 00000000..d2321a80 --- /dev/null +++ b/contracts/test/QuoteFees.t.sol @@ -0,0 +1,665 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test, Vm} from "forge-std/Test.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {PositionManager} from "@uniswap/v4-periphery/src/PositionManager.sol"; +import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; +import {IPositionDescriptor} from "@uniswap/v4-periphery/src/interfaces/IPositionDescriptor.sol"; +import {IWETH9} from "@uniswap/v4-periphery/src/interfaces/external/IWETH9.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {DeployPermit2} from "permit2/test/utils/DeployPermit2.sol"; + +import {QuoteLaunchFactory} from "src/QuoteLaunchFactory.sol"; +import {QuoteFeeHook} from "src/QuoteFeeHook.sol"; +import {QuoteFeeVault} from "src/QuoteFeeVault.sol"; +import {HookMiner} from "@uniswap/v4-periphery/test/shared/HookMiner.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {CustomRevert} from "@uniswap/v4-core/src/libraries/CustomRevert.sol"; +import {V4Quoter} from "@uniswap/v4-periphery/src/lens/V4Quoter.sol"; +import {IV4Quoter} from "@uniswap/v4-periphery/src/interfaces/IV4Quoter.sol"; +import {LaunchLocker} from "src/LaunchLocker.sol"; +import {LaunchToken} from "src/LaunchToken.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +contract QuoteBadToken is MockERC20 { + bool public reject; + address public immutable payoutSender; + + constructor(address sender) MockERC20("Quote", "QUOTE", 6) { + payoutSender = sender; + } + + function setReject(bool value) external { + reject = value; + } + + function transfer(address to, uint256 amount) public override returns (bool) { + super.transfer(to, amount); + return !(reject && msg.sender == payoutSender); // payout moves funds and then returns false + } +} + +contract QuoteReentrant { + QuoteFeeVault public vault; + uint256 public id; + + function configure(QuoteFeeVault v, uint256 i) external { + vault = v; + id = i; + } + + receive() external payable { + vault.collect(id); + } +} + +contract QuoteCanReceive { + bool public reject = true; + + function allow() external { + reject = false; + } + + receive() external payable { + require(!reject); + } +} + +contract QuoteGasConsumer { + receive() external payable { + assembly { + for {} 1 {} {} + } + } +} + +contract QuoteFeesTest is Test, DeployPermit2 { + using StateLibrary for IPoolManager; + using PoolIdLibrary for PoolKey; + + IPoolManager manager; + IAllowanceTransfer permit2; + PositionManager posm; + PoolSwapTest swapRouter; + QuoteLaunchFactory factory; + LaunchLocker locker; + QuoteFeeVault vault; + QuoteFeeHook hook; + + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address buyer = makeAddr("buyer"); + + // 1 ETH = 100M tokens (both 18 dec) → tick ≈ 184,200; 1B supply ≈ 10 ETH FDV + int24 constant START_TICK = 184_200; + uint24 constant LP_FEE = 10_000; // 1% + + receive() external payable {} + + function setUp() public { + manager = IPoolManager(address(new PoolManager(address(this)))); + permit2 = IAllowanceTransfer(deployPermit2()); + posm = new PositionManager(manager, permit2, 50_000, IPositionDescriptor(address(0)), IWETH9(address(0))); + swapRouter = new PoolSwapTest(manager); + address predictedFactory = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + address predictedVault = vm.computeCreateAddress(predictedFactory, 2); + (, bytes32 hookSalt) = HookMiner.find( + predictedFactory, + uint160( + Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG + ), + type(QuoteFeeHook).creationCode, + abi.encode(manager, predictedFactory, predictedVault) + ); + factory = new QuoteLaunchFactory(manager, posm, permit2, hookSalt); + vault = factory.vault(); + hook = factory.hook(); + locker = factory.locker(); + vm.deal(buyer, 100 ether); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + function _recipients() internal view returns (LaunchLocker.Recipient[] memory r) { + r = new LaunchLocker.Recipient[](2); + r[0] = LaunchLocker.Recipient(alice, 6_000); + r[1] = LaunchLocker.Recipient(bob, 4_000); + } + + function _params(bytes32 salt, LaunchLocker.Recipient[] memory r) + internal + pure + returns (QuoteLaunchFactory.LaunchParams memory p) + { + p.name = "Trend Coin"; + p.symbol = "TREND"; + p.metadataURI = "ipfs://meta"; + p.supply = 0; + p.startTick = START_TICK; + p.creatorFeePips = LP_FEE; + p.salt = salt; + p.recipients = r; + } + + function _launch(bytes32 salt) internal returns (address token, uint256 tokenId) { + return factory.launch(_params(salt, _recipients())); + } + + function _key(address token) internal view returns (PoolKey memory) { + return factory.poolKeyOf(token); + } + + function _buy(address token, uint256 ethIn) internal returns (BalanceDelta) { + PoolKey memory key = _key(token); + vm.prank(buyer); + return swapRouter.swap{value: ethIn}( + key, + SwapParams({ + zeroForOne: true, amountSpecified: -int256(ethIn), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + } + + function _sell(address token, uint256 tokenIn) internal returns (BalanceDelta) { + PoolKey memory key = _key(token); + vm.startPrank(buyer); + LaunchToken(token).approve(address(swapRouter), tokenIn); + BalanceDelta d = swapRouter.swap( + key, + SwapParams({ + zeroForOne: false, amountSpecified: -int256(tokenIn), sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + vm.stopPrank(); + return d; + } + + function test_launchLockedAndFixed() public { + (address token, uint256 id) = _launch("locked"); + PoolKey memory key = _key(token); + assertEq(key.fee, 0); + assertEq(address(key.hooks), address(hook)); + assertEq(posm.ownerOf(id), address(locker)); + assertGt(posm.getPositionLiquidity(id), 0); + assertEq(vault.quoteOf(id), address(0)); + vm.expectRevert(); + posm.transferFrom(address(locker), buyer, id); + } + + function test_buyAndSellOnlyPayQuote() public { + (address token, uint256 id) = _launch("both"); + _buy(token, 1 ether); + assertEq(vault.pendingFees(id), 0.01 ether); + uint256 amount = LaunchToken(token).balanceOf(buyer) / 2; + uint256 beforeFees = vault.pendingFees(id); + BalanceDelta d = _sell(token, amount); + uint256 sellFee = vault.pendingFees(id) - beforeFees; + assertGt(sellFee, 0); + assertApproxEqAbs(sellFee, uint256(uint128(d.amount0())) / 99, 1); + (uint256 paid, uint256 tokens) = vault.collect(id); + assertEq(tokens, 0); + assertEq(alice.balance + bob.balance, paid); + assertEq(LaunchToken(token).balanceOf(alice), 0); + assertEq(manager.balanceOf(address(vault), 0), 0); + assertEq(vault.pendingFees(id), 0); + (uint256 again,) = vault.collect(id); + assertEq(again, 0); + } + + function test_exactOutputBuyAndSell() public { + (address token, uint256 id) = _launch("exactout"); + uint256 tokens = 1_000_000e18; + PoolKey memory key = _key(token); + vm.prank(buyer); + BalanceDelta d = swapRouter.swap{value: 1 ether}( + key, + SwapParams(true, int256(tokens), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + assertEq(LaunchToken(token).balanceOf(buyer), tokens); + uint256 buyFee = vault.pendingFees(id); + assertGt(buyFee, 0); + assertApproxEqAbs(buyFee, uint256(uint128(-d.amount0())) / 100, 1); + vm.startPrank(buyer); + LaunchToken(token).approve(address(swapRouter), tokens); + d = swapRouter.swap( + _key(token), + SwapParams(false, int256(0.001 ether), TickMath.MAX_SQRT_PRICE - 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + vm.stopPrank(); + assertEq(d.amount0(), int128(0.001 ether)); + assertEq(vault.pendingFees(id) - buyFee, hook.feeAmount(0.001 ether, 10_000, true)); + } + + function test_quoterIncludesHookFeeAndRollsBack() public { + (address token, uint256 id) = _launch("quoter"); + V4Quoter q = new V4Quoter(manager); + (uint256 quote,) = q.quoteExactInputSingle(IV4Quoter.QuoteExactSingleParams(_key(token), true, 1 ether, "")); + assertEq(vault.pendingFees(id), 0); + assertEq(manager.balanceOf(address(vault), 0), 0); + BalanceDelta d = _buy(token, 1 ether); + assertEq(quote, uint256(uint128(d.amount1()))); + } + + function test_partialFillRollsBackBeforeFee() public { + (address token, uint256 id) = _launch("partial"); + PoolKey memory key = _key(token); + vm.expectRevert(); + vm.prank(buyer); + swapRouter.swap{value: 1 ether}( + key, + SwapParams(true, -int256(1 ether), TickMath.getSqrtPriceAtTick(START_TICK - 200)), + PoolSwapTest.TestSettings(false, false), + "" + ); + assertEq(vault.pendingFees(id), 0); + assertEq(manager.balanceOf(address(vault), 0), 0); + } + + function test_zeroFillSellCannotMovePrice() public { + (address token,) = _launch("zero-fill"); + PoolKey memory key = _key(token); + (uint160 priceBefore,,,) = manager.getSlot0(key.toId()); + // No quote in the pool yet: a sell fills nothing and would only drag the price to its limit. + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(hook), + IHooks.afterSwap.selector, + abi.encodeWithSelector(QuoteFeeHook.PartialFillUnsupported.selector), + abi.encodeWithSelector(Hooks.HookCallFailed.selector) + ) + ); + vm.prank(buyer); + swapRouter.swap( + key, + SwapParams(false, -int256(1 ether), TickMath.MAX_SQRT_PRICE - 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + (uint160 priceAfter,,,) = manager.getSlot0(key.toId()); + assertEq(priceAfter, priceBefore); + } + + function test_sharedQuotePoolsCannotCollectEachOther() public { + (address a, uint256 aId) = _launch("a"); + (address b, uint256 bId) = _launch("b"); + _buy(a, 1 ether); + _buy(b, 2 ether); + vault.collect(aId); + assertEq(vault.pendingFees(bId), 0.02 ether); + assertEq(manager.balanceOf(address(vault), 0), 0.02 ether); + vault.collect(bId); + assertEq(alice.balance + bob.balance, 0.03 ether); + } + + function test_failedPayoutCreditCanBeClaimed() public { + QuoteCanReceive r = new QuoteCanReceive(); + LaunchLocker.Recipient[] memory recipients = new LaunchLocker.Recipient[](2); + recipients[0] = LaunchLocker.Recipient(address(r), 5000); + recipients[1] = LaunchLocker.Recipient(bob, 5000); + (address token, uint256 id) = factory.launch(_params("credit", recipients)); + _buy(token, 1 ether); + vault.collect(id); + assertEq(vault.claimable(id, address(r)), 0.005 ether); + assertEq(vault.reserved(address(0)), 0.005 ether); + assertEq(bob.balance, 0.005 ether); + r.allow(); + vault.claimFor(id, address(r)); + assertEq(address(r).balance, 0.005 ether); + assertEq(vault.reserved(address(0)), 0); + assertEq(vault.claimable(id, address(r)), 0); + assertEq(vault.claimFor(id, address(r)), 0); + } + + function test_burnAndPartialSplit() public { + LaunchLocker.Recipient[] memory recipients = new LaunchLocker.Recipient[](2); + recipients[0] = LaunchLocker.Recipient(alice, 6000); + recipients[1] = LaunchLocker.Recipient(vault.DEAD(), 4000); + (address token, uint256 id) = factory.launch(_params("burn", recipients)); + _buy(token, 1 ether); + uint256 beforeDead = vault.DEAD().balance; + vault.collect(id); + assertEq(alice.balance, 0.006 ether); + assertEq(vault.DEAD().balance - beforeDead, 0.004 ether); + } + + function test_emptyRecipientsBurnAll() public { + (address token, uint256 id) = factory.launch(_params("empty", new LaunchLocker.Recipient[](0))); + _buy(token, 1 ether); + uint256 beforeDead = vault.DEAD().balance; + vault.collect(id); + assertEq(vault.DEAD().balance - beforeDead, 0.01 ether); + } + + function test_unexpectedBalancesAreNotRevenue() public { + (address token, uint256 id) = _launch("gift"); + vm.deal(address(vault), 1 ether); + _buy(token, 1 ether); + vault.collect(id); + assertEq(alice.balance + bob.balance, 0.01 ether); + assertEq(address(vault).balance, 1 ether); + } + + function test_zeroFee() public { + QuoteLaunchFactory.LaunchParams memory p = _params("free", _recipients()); + p.creatorFeePips = 0; + (address token, uint256 id) = factory.launch(p); + _buy(token, 1 ether); + _sell(token, LaunchToken(token).balanceOf(buyer) / 2); + assertEq(vault.pendingFees(id), 0); + (uint256 q, uint256 t) = vault.collect(id); + assertEq(q + t, 0); + } + + function test_unauthorizedCalls() public { + vm.expectRevert(QuoteFeeVault.NotHook.selector); + vault.accrue(PoolId.wrap(bytes32(0)), 1); + vm.expectRevert(QuoteFeeVault.NotSelf.selector); + vault.executePayout(address(0), buyer, 1); + vm.expectRevert(QuoteFeeVault.NotPoolManager.selector); + vault.unlockCallback(""); + vm.expectRevert(QuoteFeeHook.NotFactory.selector); + hook.activate(PoolId.wrap(bytes32(0))); + } + + function test_arcNativeQuoteRejected() public { + vm.chainId(5042); + vm.expectRevert(QuoteLaunchFactory.NativeQuoteUnsupported.selector); + factory.launch(_params("arc", _recipients())); + } + + function testFuzz_buyFeeAndAllocation(uint96 input, uint16 share) public { + uint256 amount = bound(uint256(input), 1000, 10 ether); + uint16 bps = uint16(bound(share, 1, 9999)); + LaunchLocker.Recipient[] memory r = new LaunchLocker.Recipient[](2); + r[0] = LaunchLocker.Recipient(alice, bps); + r[1] = LaunchLocker.Recipient(bob, 10000 - bps); + (address token, uint256 id) = factory.launch(_params("fuzz", r)); + _buy(token, amount); + uint256 fee = amount / 100; + assertEq(vault.pendingFees(id), fee); + vault.collect(id); + assertEq(alice.balance, fee * bps / 10000); + assertEq(alice.balance + bob.balance, fee); + } + + function _erc20Launch(bytes32 salt, LaunchLocker.Recipient[] memory recipients) + internal + returns (QuoteBadToken quote, address token, uint256 id) + { + QuoteBadToken implementation = new QuoteBadToken(address(vault)); + vm.etch(address(0x10000), address(implementation).code); + quote = QuoteBadToken(address(0x10000)); + QuoteLaunchFactory.LaunchParams memory p = _params(salt, recipients); + p.quote = address(quote); + p.startTick = 391_400; + (p.salt,) = factory.findSalt(address(this), salt, p.name, p.symbol, 0, p.metadataURI, p.quote, 64); + (token, id) = factory.launch(p); + quote.mint(buyer, 1000e6); + } + + function _buyQuote(QuoteBadToken quote, address token, uint256 amount) internal { + PoolKey memory key = _key(token); + vm.startPrank(buyer); + quote.approve(address(swapRouter), amount); + swapRouter.swap( + key, + SwapParams(true, -int256(amount), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + vm.stopPrank(); + } + + function test_erc20QuoteBuySellCollect() public { + (QuoteBadToken quote, address token, uint256 id) = _erc20Launch("erc20", _recipients()); + _buyQuote(quote, token, 100e6); + assertEq(vault.pendingFees(id), 1e6); + _sell(token, LaunchToken(token).balanceOf(buyer) / 2); + uint256 pending = vault.pendingFees(id); + assertGt(pending, 1e6); + uint256 liquidity = posm.getPositionLiquidity(id); + vault.collect(id); + assertEq(quote.balanceOf(alice) + quote.balanceOf(bob), pending); + assertEq(LaunchToken(token).balanceOf(alice) + LaunchToken(token).balanceOf(bob), 0); + assertEq(posm.getPositionLiquidity(id), liquidity); + assertEq(manager.balanceOf(address(vault), uint160(address(quote))), 0); + } + + function test_falseReturnAfterTransferCreditsWithoutDoublePay() public { + (QuoteBadToken quote, address token, uint256 id) = _erc20Launch("false", _recipients()); + _buyQuote(quote, token, 100e6); + quote.setReject(true); + vault.collect(id); + assertEq(quote.balanceOf(alice) + quote.balanceOf(bob), 0); + assertEq(quote.balanceOf(address(vault)), 1e6); + assertEq(vault.claimable(id, alice), 600000); + assertEq(vault.reserved(address(quote)), 1e6); + vm.expectRevert(); + vault.claimFor(id, alice); + assertEq(vault.claimable(id, alice), 600000); + quote.setReject(false); + vault.claimFor(id, alice); + vault.claimFor(id, bob); + assertEq(quote.balanceOf(alice) + quote.balanceOf(bob), 1e6); + assertEq(vault.reserved(address(quote)), 0); + } + + function test_failedBurnRevertsWholeCollection() public { + (QuoteBadToken quote, address token, uint256 id) = _erc20Launch("burn-fail", new LaunchLocker.Recipient[](0)); + _buyQuote(quote, token, 100e6); + quote.setReject(true); + vm.expectRevert(); + vault.collect(id); + assertEq(vault.pendingFees(id), 1e6); + assertEq(quote.balanceOf(address(vault)), 0); + assertEq(manager.balanceOf(address(vault), uint160(address(quote))), 1e6); + } + + function test_reentrantRecipientCannotConsumeOtherFees() public { + QuoteReentrant r = new QuoteReentrant(); + LaunchLocker.Recipient[] memory recipients = new LaunchLocker.Recipient[](2); + recipients[0] = LaunchLocker.Recipient(address(r), 5000); + recipients[1] = LaunchLocker.Recipient(bob, 5000); + (address token, uint256 id) = factory.launch(_params("reenter", recipients)); + r.configure(vault, id); + _buy(token, 1 ether); + vault.collect(id); + assertEq(vault.claimable(id, address(r)), 0.005 ether); + assertEq(bob.balance, 0.005 ether); + assertEq(address(vault).balance, vault.reserved(address(0))); + } + + function test_hookFlagsAndImmutables() public view { + assertEq(uint160(address(hook)) & Hooks.ALL_HOOK_MASK, hook.HOOK_FLAGS()); + assertEq(address(hook.vault()), address(vault)); + assertEq(vault.hook(), address(hook)); + assertEq(hook.factory(), address(factory)); + assertEq(vault.factory(), address(factory)); + } + + function test_protocolFeesRemainSeparate() public { + (address token, uint256 id) = _launch("protocol"); + manager.setProtocolFeeController(address(this)); + manager.setProtocolFee(_key(token), uint24(1000 | (1000 << 12))); + _buy(token, 1 ether); + assertEq(vault.pendingFees(id), 0.01 ether); + assertGt(manager.protocolFeesAccrued(Currency.wrap(address(0))), 0); + _sell(token, LaunchToken(token).balanceOf(buyer) / 2); + assertGt(manager.protocolFeesAccrued(Currency.wrap(token)), 0); + uint256 pending = vault.pendingFees(id); + vault.collect(id); + assertEq(alice.balance + bob.balance, pending); + } + + function test_gasConsumingRecipientGetsCreditWhileOthersArePaid() public { + QuoteGasConsumer receiver = new QuoteGasConsumer(); + LaunchLocker.Recipient[] memory r = new LaunchLocker.Recipient[](2); + r[0] = LaunchLocker.Recipient(address(receiver), 5000); + r[1] = LaunchLocker.Recipient(bob, 5000); + (address token, uint256 id) = factory.launch(_params("gas", r)); + _buy(token, 1 ether); + vault.collect(id); + assertEq(vault.claimable(id, address(receiver)), 0.005 ether); + assertEq(bob.balance, 0.005 ether); + assertEq(address(vault).balance, vault.reserved(address(0))); + } + + function test_badRateAndRecipientsRollbackLaunch() public { + QuoteLaunchFactory.LaunchParams memory p = _params("bad", _recipients()); + p.creatorFeePips = 30001; + vm.expectRevert(QuoteLaunchFactory.BadFee.selector); + factory.launch(p); + p.creatorFeePips = 30000; + p.recipients[0].bps = 5999; + vm.expectRevert(); + factory.launch(p); + assertEq(factory.launchCount(), 0); + } + + function _fourDirectionsERC20(uint8 decimals, int24 startTick) internal { + MockERC20 implementation = new MockERC20("Quote", "QUOTE", decimals); + vm.etch(address(0x10000), address(implementation).code); + MockERC20 quote = MockERC20(address(0x10000)); + QuoteLaunchFactory.LaunchParams memory p = _params("four-directions", _recipients()); + p.quote = address(quote); + p.startTick = startTick; + (p.salt,) = factory.findSalt(address(this), p.salt, p.name, p.symbol, 0, p.metadataURI, p.quote, 64); + (address token, uint256 id) = factory.launch(p); + PoolKey memory key = _key(token); + uint256 unit = 10 ** decimals; + quote.mint(buyer, 100 * unit); + vm.startPrank(buyer); + quote.approve(address(swapRouter), type(uint256).max); + BalanceDelta d = swapRouter.swap( + key, + SwapParams(true, -int256(unit), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + assertEq(d.amount0(), -int128(int256(unit))); + assertEq(vault.pendingFees(id), unit / 100); + uint256 pending = vault.pendingFees(id); + uint256 exactTokens = LaunchToken(token).balanceOf(buyer) / 10; + d = swapRouter.swap( + key, + SwapParams(true, int256(exactTokens), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + assertEq(d.amount1(), int128(int256(exactTokens))); + assertApproxEqAbs(vault.pendingFees(id) - pending, uint256(uint128(-d.amount0())) / 100, 1); + pending = vault.pendingFees(id); + LaunchToken(token).approve(address(swapRouter), type(uint256).max); + d = swapRouter.swap( + key, + SwapParams(false, int256(unit / 100), TickMath.MAX_SQRT_PRICE - 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + assertEq(d.amount0(), int128(int256(unit / 100))); + assertEq(vault.pendingFees(id) - pending, hook.feeAmount(unit / 100, 10000, true)); + pending = vault.pendingFees(id); + d = swapRouter.swap( + key, + SwapParams(false, -int256(LaunchToken(token).balanceOf(buyer) / 2), TickMath.MAX_SQRT_PRICE - 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + uint256 sellFee = vault.pendingFees(id) - pending; + assertEq(sellFee, (uint256(uint128(d.amount0())) + sellFee) / 100); + vm.stopPrank(); + pending = vault.pendingFees(id); + assertEq(manager.balanceOf(address(vault), uint160(address(quote))), pending); + vault.collect(id); + assertEq(quote.balanceOf(alice) + quote.balanceOf(bob), pending); + assertEq(vault.pendingFees(id), 0); + assertEq(manager.balanceOf(address(vault), uint160(address(quote))), 0); + } + + function test_erc20SixDecimalsAllDirections() public { + _fourDirectionsERC20(6, 391400); + } + + function test_erc20EightDecimalsAllDirections() public { + _fourDirectionsERC20(8, 345400); + } + + function test_erc20EighteenDecimalsAllDirections() public { + _fourDirectionsERC20(18, START_TICK); + } + + function testFuzz_interleavedPoolsConserveClaimsAndCredits(uint128 actions, uint24 rate) public { + rate = uint24(bound(rate, 0, 30000)); + QuoteCanReceive receiver = new QuoteCanReceive(); + LaunchLocker.Recipient[] memory r = new LaunchLocker.Recipient[](1); + r[0] = LaunchLocker.Recipient(address(receiver), 10000); + QuoteLaunchFactory.LaunchParams memory p = _params("invariant-a", r); + p.creatorFeePips = rate; + (address a, uint256 aId) = factory.launch(p); + p.salt = "invariant-b"; + (address b, uint256 bId) = factory.launch(p); + uint256 accrued; + uint256 collected; + for (uint256 i; i < 16; ++i) { + uint256 action = (uint256(actions) >> (i * 2)) & 3; + if (action < 2) { + _buy(action == 0 ? a : b, 0.01 ether); + accrued += uint256(0.01 ether) * uint256(rate) / 1e6; + } else { + (uint256 amount,) = vault.collect(action == 2 ? aId : bId); + collected += amount; + } + uint256 pending = vault.pendingFees(aId) + vault.pendingFees(bId); + assertEq(accrued, collected + pending); + assertEq(manager.balanceOf(address(vault), 0), pending); + assertEq(vault.reserved(address(0)), collected); + assertEq(address(vault).balance, collected); + assertEq(vault.claimable(aId, address(receiver)) + vault.claimable(bId, address(receiver)), collected); + } + vault.collectMany(_ids(aId, bId)); + receiver.allow(); + vault.claimFor(aId, address(receiver)); + vault.claimFor(bId, address(receiver)); + assertEq(address(receiver).balance, accrued); + assertEq(vault.reserved(address(0)), 0); + assertEq(manager.balanceOf(address(vault), 0), 0); + } + + function _ids(uint256 a, uint256 b) internal pure returns (uint256[] memory ids) { + ids = new uint256[](2); + ids[0] = a; + ids[1] = b; + } + + function testFuzz_rounding(uint128 amount, uint24 rate) public view { + rate = uint24(bound(rate, 0, 30000)); + uint256 inputFee = hook.feeAmount(amount, rate, false); + assertEq(inputFee, uint256(amount) * rate / 1e6); + uint256 outputFee = hook.feeAmount(amount, rate, true); + uint256 gross = uint256(amount) + outputFee; + // Gross-up always covers the chosen rate; at most one unit is lost to rounding. + assertGe(outputFee * 1e6, gross * rate); + assertLt(outputFee * 1e6 - gross * rate, 1e6); + } +} diff --git a/docs/quote-only-fees-plan.md b/docs/quote-only-fees-plan.md new file mode 100644 index 00000000..4eb1b4a5 --- /dev/null +++ b/docs/quote-only-fees-plan.md @@ -0,0 +1,342 @@ +Implementation reference: [full implementation and deployment guide](../contracts/docs/QUOTE_FEES.md). The original plan below is retained for design context. + +# Quote asset fees implementation plan + +Status: implemented on `codex/quote-only-fees`; see the linked guide for final behavior, verification, and deployment instructions. The planning baseline below was prepared on 3 October 2026 against repository commit `03842b5`, with implementation choices refined during development. + +## Objective and scope + +Allow a creator to choose fees paid entirely in the pool's quote asset when launching a new token. A USDC pair should earn USDC on both buys and sells; an ETH pair should earn ETH; a stock pair should earn that Stock Token. Fees should continue to go entirely to the fixed beneficiaries or burn allocation chosen at launch. + +Implement this as a new contract deployment alongside the current deployment. The launch form selects the appropriate factory. Existing launches continue to use their original contracts, and creators can still choose the current fees-in-both-assets model for new launches. Existing pools cannot acquire a hook or move their permanently locked liquidity. + +The recommended design adds a quote fee hook and a separate fee vault, while reusing the existing token and locker source. Each new factory deploys a **fresh locker instance**; the already deployed locker cannot register positions from a new factory. + +Keep the existing economic constraints: no platform cut, no creation charge, no owner, no pause, no upgrade path, immutable fee rates and recipients, and permanently locked initial liquidity. The contract accepts creator fee rates from 0 to 30,000 pips; the UI keeps its 0%, 1%, and 3% presets. One pip is one millionth of the fee basis, so 10,000 pips means 1%. + +Estimated effort is **8–12 focused engineering days** for the implementation, integration tests, documentation, and deployment tooling. This refines the earlier one-to-two-week estimate. Independent contract review, review findings, and production deployment scheduling are additional. The implementation is being delivered as the full contract, app, indexer and deployment change. + +## Reference implementation and current constraints + +The o1 source inspected for this plan is the verified Base Standard suite linked from its [production contract registry](https://docs.o1.exchange/launchpad/reference/production-contracts): + +- [LaunchHook and included FeeEscrow source](https://basescan.org/address/0x1f91c998e7c2F4b690D75BDBf6502BDcD6e02AcC#code): `_beforeSwap`, `_afterSwap`, `_feeAmount`, `_quoteCurrencyAndSpecified`, `_requireFullSpecifiedFill`, and escrow redemption are useful references. Both source files carry MIT license headers. +- [Launch factory source](https://basescan.org/address/0x1176122eb77AD6a2339322Cda7C4D7ea9BfA63dC#code): its pool construction uses a zero LP fee and the launch hook. +- [Documented fee behavior](https://docs.o1.exchange/launchpad/trading/fees-referrals): ordinary recipient fees are denominated in the paired asset for both directions. + +Adapt the fee calculation and Uniswap claim settlement ideas. Openlaunch's implementation should retain its existing custody model and fixed recipient allocations. Any copied code needs its license notices and an attribution record. Pin the source snapshot and deployed address used during implementation; an explorer page can contain multiple source files and historical suites differ. + +Repository constraints that drive this plan: + +| Current code | Consequence | +| --- | --- | +| `contracts/src/LaunchFactory.sol` constructs a pool with `fee = lpFee` and a zero hook | A quote-only pool needs a new factory and pool identity. | +| `contracts/src/LaunchLocker.sol` owns the NFT forever and distributes both LP fee assets | Keep that custody source intact; new quote trading fees belong in a separate vault. | +| `contracts/src/LaunchFactoryArc.sol` rejects native quote addresses | Preserve that restriction for the new Arc factory because native and ERC-20 USDC share backing. | +| `app/src/lib/launchpad/config.ts` selects one factory and locker per chain | Add a registry of contract suites and resolve addresses per launch. | +| `app/src/components/launchpad/LaunchForm.tsx` and the token page construct hook-free pool keys | Update both ordinary trades and the optional first buy. | +| `app/src/lib/launchpad/meta.ts` predicts addresses with the chain's current factory | Metadata registration and salt search must use the selected suite. | +| The indexer and UI use `lp_fee` for both pool identity and displayed fee | Separate the actual LP fee from the creator trading fee. | +| `app/src/components/launchpad/FeeChip.tsx` already uses `feeModeOf` for beneficiary/burn classification | Use a separate name for the fee asset choice. | + +All repository paths in this plan are relative to `/Users/dev/projects/openlaunch`. New filenames below are proposed implementation targets. + +## Product behavior + +For a nonzero trading fee, add a choice labeled **Fee assets**: + +- **Both pool assets**: current behavior; buys earn quote assets and sells earn launched tokens. Keep this as the initial default. +- **Quote asset only**: show the selected asset symbol, such as “USDC only”; both trade directions earn that asset. + +Hide the asset choice at 0% and use the existing factory for zero-fee launches from the UI. The new contracts should still handle a zero fee correctly for direct callers and tests. Changing the quote should update the label. Changing the suite must invalidate any prepared launch transaction, token prediction, or salt-search result. + +The recipient choices remain “Burn the fees,” “My wallet,” and custom splits. A quote-only burn sends the quote asset to the dead address when collected. Rates and destinations are fixed at launch. + +A buy spending 100 USDC with a 1% creator fee sends 1 USDC to fee accounting and 99 USDC into the pool. A sell producing 100 USDC before the creator fee sends 1 USDC to fee accounting and 99 USDC to the seller. These examples exclude rounding and any separate Uniswap protocol fee. There is no secondary sale of accumulated launch-token fees. + +Collection remains permissionless and pays the configured recipients. Failed ordinary payouts become claimable credits. The dashboard distinguishes fees awaiting collection, amounts paid, outstanding credits, and amounts actually burned. + +## Contract architecture + +### Components + +| Component | Responsibility | +| --- | --- | +| New `QuoteLaunchFactory.sol` | Deploy token; register the pool; initialize and seed it; create the permanent position; bind quote fee accounting. | +| New `QuoteLaunchFactoryArc.sol` | Apply the native-quote rejection for Arc through a small specialization of the new factory. | +| Existing `LaunchToken.sol` | Fixed supply and existing metadata behavior. | +| Fresh instance of existing `LaunchLocker.sol` | Own the position NFT permanently using the current code and registration checks. | +| New `QuoteFeeHook.sol` | Apply the fixed fee in currency0, which remains the quote asset; mint matching Uniswap claims to the vault. | +| New `QuoteFeeVault.sol` | Track earned fees separately for each pool, redeem them at collection, split payments, and retain failed payouts for claims. | + +The pool uses `fee = 0`, tick spacing 200, and the new hook. The creator fee is a separate stored value. Keep the existing token address ordering, token-only initial position, supply handling, salt scoping, and rounding-dust behavior. + +Flow: + +1. The factory creates the token and registers the exact pool key and immutable economics. +2. It initializes the pool and mints the position NFT directly to its fresh locker. +3. It registers the position with the locker and vault, then activates the pool for swaps. +4. A swap runs through the hook. The hook creates a quote-denominated Uniswap claim owned by the vault and records the same amount against that pool. +5. Anyone calls `vault.collect(tokenId)`. The vault redeems only that pool's recorded claims and distributes the resulting quote asset. + +An ERC-6909 claim is a redeemable balance held inside Uniswap's PoolManager. It lets the hook account for fees without sending assets to arbitrary beneficiary wallets during a swap. + +The new locker is custody for the LP position; the vault is the source of quote trading fees. Existing locker behavior for unsolicited transfers or LP donations is separate from hook fee accounting. Do not classify those transfers as quote-only trading fees or sweep them into the vault. + +### Hook permissions and registration + +Enable only the callbacks required for pool initialization and swap fees: `beforeInitialize`, `beforeSwap`, `afterSwap`, and the two swap return-delta permissions. Derive the permission mask from the pinned Uniswap dependency and validate it in the hook constructor. + +Only the immutable factory may register a pool. Registration must validate the hook address, zero LP fee, quote/token ordering, spacing, rate bounds, and uniqueness. Every hook callback must accept calls only from the bound PoolManager. An initialization callback must also require the registered pool and factory initiator. Swap callbacks must reject unregistered or incompletely initialized pools. + +The factory remains permissionless to launch through. Registration is an integrity check on pools using this hook, not a restriction on who may launch or trade. Ordinary third-party liquidity additions may remain possible as they are today; all creator fees in this pool still follow the launch's allocation, and its zero LP fee does not reward additional LPs with trading fees. The original position stays locked in the locker. + +Keep all beneficiary transfers outside swap callbacks. The vault's hook-only accrual method should perform bounded accounting and emit events, with no beneficiary calls. Ignore arbitrary hook data unless a documented field is introduced later; it must never choose fee recipients or change rates. + +## Fee calculation + +Use raw asset units and integer arithmetic throughout. Let `D = 1_000_000` and `r` be the configured creator fee in pips. Adapt o1's rounding approach to this denominator; do not mix its basis-point units with Openlaunch pips. + +| Swap request | Quote fee | Callback charging the fee | Result | +| --- | --- | --- | --- | +| Exact-input buy, spending `Q` quote | `floor(Q * r / D)` | Before swap, specified-currency delta | Pool receives `Q - fee`; total quote spent is `Q`. | +| Exact-input sell, pool outputs `G` quote | `floor(G * r / D)` | After swap, unspecified-currency delta | Seller receives `G - fee`. | +| Exact-output buy, pool needs `C` quote | `ceil(C * r / (D - r))` | After swap, unspecified-currency delta | Buyer pays `C + fee` for the requested tokens. | +| Exact-output sell, seller requests `N` net quote | `ceil(N * r / (D - r))` | Before swap, specified-currency delta | Pool outputs `N + fee`; seller receives `N`. | + +Use full-precision multiplication/division and checked conversions to the signed delta types. Check the adjusted amount as well as the fee for overflow. Test zero, dust-sized amounts, and boundary-sized swaps. There must be exactly one fee charge per swap. + +The app continues to offer exact-input swaps. Supporting exact-output correctly in the contracts makes direct Uniswap integrations predictable without adding a new UI flow. + +For the first version, require a full fill in both directions and both amount modes. Compare the actual specified-currency pool delta against the requested amount adjusted by any before-swap fee. Revert with a documented `PartialFillUnsupported` error on a partial fill. In particular, a fee reserved before a swap must never survive when the corresponding quoted amount was not consumed. This is an explicit compatibility limit for external routers with tight price limits. + +Emit a swap event after successful callback checks that includes the pool ID, callback executor, direction, exact-input/output mode, quote fee, and signed amounts after the hook fee. With quote as currency0, the caller's quote delta is the core swap's `amount0 - quoteFee`; the token delta is unchanged. Verify these fields against real wallet balances in router tests. The executor is a router or callback contract and must not be labeled as the user's wallet. + +## Vault accounting and collection + +### State and public surface + +Use `poolId` as the internal accounting identity. Bind each pool to its quote, token, NFT ID, and recipient list once. Maintain a `tokenId` lookup for the existing collection UX. + +Proposed public functions: + +- `register(...)`: factory-only binding after NFT ownership has been established. +- `accrue(poolId, amount)`: hook-only addition to pending fees for a registered pool. +- `pendingFees(tokenId)`: uncollected quote amount. +- `collect(tokenId)` and `collectMany(tokenIds)`: redeem and distribute pending fees. +- `claimable(tokenId, account)`: failed payout belonging to this launch and recipient. +- `claim(tokenId)` and `claimFor(tokenId, account)`: pay recorded credits to the recorded recipient. +- `recipientsOf(tokenId)` and the quote/token lookups needed by the app and indexer. + +Keep failed credits per launch and recipient. This makes a recovered payout attributable to the correct launch. The vault also needs currency-level reserved totals to prove its real-token backing. V1's aggregate `claim(address currency)` ABI remains supported through a separate app adapter. + +### Collection sequence + +1. Read this pool's pending amount and clear that accounting before external interactions under a reentrancy guard. A zero amount is a no-op. +2. Unlock the PoolManager, burn exactly that amount of the vault's quote claim, and take exactly that amount of the real quote asset into the vault. +3. Return from the PoolManager callback before paying recipients. +4. Split the redeemed amount using the existing basis-point allocation. The last recipient receives rounding dust. +5. Push ordinary shares. If a transfer fails, retain the funds as that launch's recipient credit. Use robust ERC-20 return-data handling and bounded payout execution, with gas limits verified against supported quote tokens. A failed attempt must roll back the token transfer itself: use an external self-call payout helper restricted to the vault, which reverts on false/malformed returns, and catch that revert before recording a credit. Do not credit an amount after a token transferred it but returned false. +6. Send burn shares to `DEAD`. Match current semantics: if the quote refuses this burn transfer, revert the collection for this launch. Swaps and other launches remain independent; never report a failed transfer as burned. +7. Emit collection, payment, credit, and burn events with pool or token identity. Later claims emit their own events without incrementing collected totals again. + +Restrict `unlockCallback` to the PoolManager and an active, internally initiated redemption. Encode only the expected quote and amount, and always take funds into the vault. It must not accept an arbitrary payout destination or liquidity operation. Claims can only pay the account to which the credit belongs. + +Preserve the existing expectation of ordinary, non-rebasing quote transfers. A fee-on-transfer or otherwise nonstandard quote needs explicit compatibility work; declaring an arbitrary address as a quote must not be treated as proof that it obeys those accounting assumptions. Check the amount actually received during redemption for the supported assets and fail atomically on a mismatch. + +### Accounting invariants + +- For each quote currency, the vault's Uniswap claim balance is at least the sum of recorded pending fees across pools using it. +- For each quote currency, the vault's real balance is at least the sum of reserved failed payouts. +- Each collection satisfies `collected = directly paid + newly credited + burned`. +- Each pool satisfies `total accrued = total collected + pending fees`. +- Claiming a credit decreases both the recipient credit and reserved balance by exactly the paid amount. +- No collection can consume another pool's pending fees or another recipient's credit. +- Unsolicited real-token or ERC-6909 transfers do not increase recorded earnings. Track only amounts authorized by the hook; do not distribute `balance - reserved` as pool revenue. + +On Arc, allow only the ERC-20 representation of USDC as its USDC quote, and retain native-quote rejection at both factory and hook registration boundaries. Use six-decimal quote units even though native gas accounting uses 18 decimals. Review and test any native `receive` path so the two representations cannot become separate spendable ledgers. + +## Factory and deployment work + +Implement the new factory without editing the verified v1 source. Give its launch parameters an explicit `creatorFeePips` field. Keep the existing `predictToken`, `findSalt`, and `poolKeyOf` concepts, with a v2 ABI that exposes the complete actual pool key and new deployment addresses. + +During launch, normalize an empty beneficiary list into a 100% burn allocation and validate the same seven-recipient limit and 10,000-basis-point sum as today. Register the exact same immutable allocation with the locker and vault. Initialize and seed the pool within one transaction; do not allow it to trade before all bindings exist. Reuse the existing supply limits, zero-quote-deposit checks, allowances cleanup, and token dust burn. + +Use a shared new factory implementation with a small Arc specialization rather than creating another pair of large, divergent source copies. Keep the existing v1 Arc source-drift check intact. + +Add a deployment script that mines the hook address using the final compiler output and permission mask. Deploy and bind the complete suite in one transaction. A concrete approach is an ordinary CREATE deployment of the factory, whose constructor creates its locker and vault and then the CREATE2 hook: + +- Predict the factory from the deployer's nonce and predict the vault from the factory's child-creation order. +- Mine the hook salt using those addresses and the final hook constructor arguments. +- Give the vault the expected immutable hook address, deploy the hook, and assert that every actual address and dependency matches the prediction before the constructor completes. +- Allow necessary forward references during construction, then verify the complete bindings before deployment succeeds. Do not leave an externally callable initializer or persistent deployment privilege. + +This prediction is separate from launch-token salt mining. Do not reuse the old deployment script's nonce-zero/same-address assumptions. Record per-chain factory, locker, vault, hook, PoolManager, PositionManager, creation transaction, deployment block, compiler settings, code hashes, and permission mask. Check runtime and init-code size limits early; if constructor composition exceeds them, use an atomic deployment helper with the same completed bindings. + +## Data model and indexing + +### Contract suite registry + +Add `app/src/lib/launchpad/suites.ts` with versioned suite definitions containing: + +- Chain, suite ID, contract version, factory, locker, fee contract, hook, and deployment block. +- Fee asset mode: `both` or `quote`. +- ABI/adapter identity and the correct Uniswap dependencies for that chain. +- Whether the application currently offers new launches through this suite. + +Retain v1 environment variables as inputs to legacy suite entries. Resolve all reads and writes from the launch's stored suite. Keep the chain-level quote catalog separate. Hiding a suite from new-launch choices must not remove its trading, indexing, or claim support. This is application configuration, not an onchain pause. + +### Schema changes + +Use additive, idempotent migrations in the existing schema/migration workflow. + +For launches, add `suite_id`, `contract_version`, `factory_address`, `locker_address`, `fee_contract_address`, `hook_address`, `pool_fee_pips`, `creator_fee_pips`, `fee_asset_mode`, and actual tick spacing. Backfill v1 rows from the matching chain deployment: zero hook, original LP fee in both rate columns, and mode `both`. + +Preserve `lp_fee` temporarily as a documented compatibility field while callers move to the explicit fields. For v2 records it can mirror the user-facing creator rate for old read clients, but must never be used to construct the actual pool key. All transaction-building paths must be upgraded before v2 launches become available. + +For fee events, retain the emitter/fee contract, suite, pool ID, token identity, and event kind. Keep the existing `(chain_id, tx_hash, log_index)` idempotency key. Add accrued-quote totals separately from collected, paid, credited, claimed, and burned totals. The v2 launched-token trading-fee totals remain zero. + +For swaps, preserve the core PoolManager amounts and add the hook fee and amounts including that fee. Keep existing pool-volume semantics for `volume_quote` so historical comparisons remain coherent; expose actual quote spent/received separately in trade details. Pool price and candles continue to come from core pool price/tick data. + +### Event contract + +Finalize these event fields alongside the contract tests before writing the indexer adapter: + +| Emitter and proposed event | Required information and meaning | +| --- | --- | +| Factory `QuoteLaunched` | Token, NFT ID, launcher, quote, pool ID, start tick, creator fee pips, supply, and metadata URI. Decode with the v2 ABI; do not reinterpret a v1 LP fee field. | +| Hook `QuoteSwap` | Pool ID, executor, direction, exact-input/output mode, quote fee, and signed swap amounts including that fee. Emit once per completed swap, including zero-fee swaps. The quote fee is the accrual record: it increases accrued totals, not paid or collected totals. | +| Vault `Collected` | NFT/pool identity and quote amount redeemed for allocation. | +| Vault `Paid`, `Credited`, `Claimed`, `Burned` | NFT/pool identity, quote currency, amount, and recipient where applicable. Credits record retained funds; claims record their later payment; burns record an actual transfer to `DEAD`. | + +Use event identifiers together with the emitting address and chain. Pin topic layouts and conservation relationships in `QuoteFeeEvents.t.sol`. Keep v1 ABI definitions unchanged. + +### Indexer changes + +1. Decode launch receipts by their verified emitting factory and ABI, then persist the suite and full pool identity. Never accept a client-selected arbitrary fee contract. +2. Read v1 recipients from the locker and v2 recipients from the vault. Make healing jobs and immediate receipt sync use the same resolution. +3. Scan all configured factories and fee contracts. Query shared PoolManager swap logs once for known pools. +4. Index the new hook swap event even for zero-fee swaps. Match it to the corresponding core Swap in the same transaction and pool, in execution order. A transaction may trade the same pool multiple times; a transaction hash alone is insufficient. +5. Keep each trade and its aggregate updates atomic. Persist core and hook data together for v2, or retry when the receipt is incomplete. Do not double-count a fee when collection or a later claim occurs. +6. Qualify NFT lookup keys by chain and PositionManager, and accounting lookups by emitting fee contract and pool. Token IDs are globally unique only within one PositionManager. +7. Retain the chain cursor for shared scans, and add per-suite backfill progress. Adding a suite whose deployment predates the cursor must trigger a bounded replay from its deployment block. Enable launches only after catch-up succeeds. +8. Update system-address/holder exclusions for every suite. Preserve the existing transaction-sender attribution behavior and do not equate the hook executor with the trader. + +Keep the current overlap/deduplication behavior and test it for both event families. Full removal of orphaned-chain events would be a separate indexer change if the current implementation does not already support it. + +## App integration + +### Launch preparation and first buy + +Update `LaunchForm.tsx`, `LaunchFeeSettings.tsx`, `metaShared.ts`, `meta.ts`, and the launch metadata route to carry a validated suite ID. Resolve it from server configuration; do not allow callers to provide an arbitrary factory address. + +The selected suite must consistently determine prediction, salt search, launch parameters, simulation, event decoding, and the optional first buy. Keep the stable metadata key independent of salt search. Reset the prepared attempt when chain or suite changes so an old predicted token cannot be submitted to a different factory. + +Fetch `poolKeyOf(token)` from the launch's factory after the launch, or use a verified full key from its receipt/adapter. Replace the hardcoded zero hook in `firstBuy`. Preserve the existing separate-transaction first buy: a failed buy must still display a successful launch. + +### Trading and fee labels + +Update the token page and `TradePanel.tsx` to use the stored actual pool key. Pass it through the existing v4 quoter and router encoder. Hook-aware simulation must determine output and slippage protection; do not subtract the creator fee a second time in the browser. + +Use `creator_fee_pips` for fee labels and filters, and `pool_fee_pips` for the key. A v2 pool with `pool_fee_pips = 0` and `creator_fee_pips = 10_000` must display a 1% trading fee. Update fee chips, burn/free filters, launch lists, previews, social cards, feeds, and explanatory copy accordingly. + +Keep the existing beneficiary classification separate from the new asset mode; rename `feeModeOf` to `feeDestinationOf` if needed. UI labels should describe the chosen asset and rate without exposing deployment versions or hook internals. + +### Collection and creator dashboard + +Add a small adapter layer for fee reads and transactions: + +- V1: existing locker, two fee currencies, existing collection and currency-based claim calls. +- V2: quote vault, `pendingFees(tokenId)`, one fee currency, and launch-specific claim calls. + +Use it in `CollectPanel.tsx` and `MeDashboard.tsx`, including simulated collect previews, collection, and pending-credit reads. For quote-only launches, show a single quote amount and the appropriate burn text. Keep old launch pages displaying both assets. + +For v2, compute recipient earnings from exact payment and credit events. `Paid + Credited` records earned amounts already allocated; claiming a credit changes its paid status and must not count it as newly earned. Uncollected shares are a separate preview using the same allocation rounding as the vault. The current helper `(collected - burned) * recipientShare` is not suitable for exact per-recipient earnings when a split includes a burn; add a targeted regression if that helper is reused or corrected. + +Update the wallet API to return suite and fee information for both creators and fee beneficiaries needed by these views. Verify existing signed metadata edits still authorize the indexed launcher correctly for both suites. + +### Documentation and external consumers + +Update the README, contract guide, production runbook, agents page, `llms.txt`, rules/about content, and any “no hook” or “both assets” claims to describe both launch modes. Document the new factory/vault ABIs, actual pool-key retrieval, exact-output behavior, and partial-fill restriction. Include attribution for adapted source and the compatibility impact on third-party routers. + +## Implementation milestones + +The milestones can be reviewed as commits in one feature PR or split into dependent PRs. Keep the v2 launch option unavailable until the complete flow passes its acceptance tests. + +| Milestone | Deliverable | Completion evidence | Effort | +| --- | --- | --- | --- | +| 1. Contract implementation | Fee math, hook callbacks, minimal accrual/redemption; real pinned v4 dependencies | Native/ERC-20 buy and sell, exact-output cases, quoter/router compatibility, measured gas and build sizes | 1–2 days | +| 2. Complete contracts | New factories, production vault accounting, immutable deployment wiring, splits/burns/credits | Unit/fuzz/invariant tests and preserved liquidity custody | 2–3 days | +| 3. Suite and data support | Registry, migrations, dual event decoding, catch-up, metadata prediction | Legacy data preserved; both suites replay idempotently; full pool keys match onchain | 1.5–2 days | +| 4. App integration | Asset selector, launch/first buy/trade/collect, earnings and labels | Complete browser flows against local forks, including old launches | 1.5–2 days | +| 5. Release validation | All chain forks, deployment rehearsals, source verification workflow, docs and CI | Reviewable PR with actual check results and deployment manifests | 2–3 days | + +The high end includes Arc-specific debugging. Re-estimate after milestone 1 if the installed periphery or chain execution rules require a different settlement path. + +## Test plan + +### Contract behavior + +- Test all four direction/amount combinations in the fee table with native and ERC-20 quotes, including 6-, 8-, and 18-decimal assets. +- Test rates 0, 1%, 3%, arbitrary accepted pips, and rejection above the cap. Test dust fees, integer boundaries, rounding, signed casts, and repeated swaps. +- Compare quote fee, trader net amounts, pool deltas, and vault claim backing. Exercise Uniswap protocol-fee settings separately so a zero LP fee is never assumed to mean zero total cost. +- Force partial fills at price limits and liquidity boundaries. Assert complete rollback, including claims, events, and vault accounting. +- Test an empty beneficiary list, one recipient, seven recipients, partial burns, duplicate recipient addresses, invalid addresses, and incorrect allocation sums. Freeze both allocations and rate after launch. +- Test native recipients that reject ETH, reverting/false-return/malformed-return ERC-20 transfers, reentrancy attempts, and a quote that refuses the dead address. Assert the documented credit or rollback behavior. +- Interleave trades and collections across two pools sharing a quote, and across different quotes. Add unsolicited quote tokens and unsolicited ERC-6909 claims; prove they cannot alter another pool's fees or reserved credits. +- Call collection and claims twice; confirm no double payout. Test `collectMany` and unauthorized register/accrue/callback calls. +- Attempt NFT transfer, approval, liquidity withdrawal, minting extra launch tokens, rate changes, and recipient changes. Confirm that the initial position and economics remain fixed. +- Test factory address prediction, launch salt collisions, hook permission bits, wrong dependency bindings, and atomic deployment rollback. + +Suggested new suites: `QuoteFeeMath.t.sol`, `QuoteLaunchFactory.t.sol`, `QuoteFeeHook.t.sol`, `QuoteFeeVault.t.sol`, `QuoteFeeInvariants.t.sol`, and `QuoteFeeEvents.t.sol`. + +### Chain integration + +- Base: native ETH and USDC through the deployed v4 quoter and Universal Router; cover GITLAWB and a representative B20 stock quote. +- Robinhood: native ETH, USDG, GITLAWB, and a representative Stock Token; preserve stock denomination handling. +- Arc: ERC-20 USDC through the chain's actual router layout using Circle's Arc Foundry runtime. Prove native-quote refusal, six/18-decimal separation, and correct reservation after a blocked payout. +- On each chain, exercise launch → buy → sell → collect → claim when relevant, then show an existing v1 launch can still trade and collect. + +Third-party router recognition of a new hook is a separate integration question. The release must verify Openlaunch's supported quoter/router path and document any external routing limitations actually observed. + +### App and indexer behavior + +- Backfill migrations are repeatable and preserve v1 fee totals and addresses. +- The same chain can index v1 and v2 transactions, repeated scans, out-of-order receipt sync, multiple swaps of one pool per transaction, and delayed suite registration. +- Selecting quote-only uses the same factory for metadata prediction, salt search, simulation, launch receipt decoding, and first buy. Test switching modes and retrying failures. +- Quoted output equals the supported router simulation and respects the wallet's minimum output. Test fee-free and nonzero-fee v2 pools. +- A 1% quote-only launch is never classified as free because its LP fee is zero. +- Dashboard earnings correctly handle a 60% wallet / 40% burn split, unpaid credits, later claims, and a beneficiary who is not the creator. +- Verify complete browser flows on desktop and mobile for each fee mode, including signing rejection, reverted swaps, first-buy failure after a successful launch, and collection retries. + +### Required checks + +Run the existing contributing checks: application lint, TypeScript checking, unit tests, production build, contract build, existing unit suites, and live forks. Extend `.github/workflows/ci.yml` so the new contract unit/fuzz/invariant suites actually run; its current explicit test paths will not discover new suites automatically. Include the existing event suite and the new event accounting suite. + +Record the exact fork blocks, chain toolchain, commands, gas measurements, and failures or skipped checks in the PR. Use Arc Foundry for Arc execution. A passing upstream Forge run is not Arc integration evidence. + +## Rollout and recovery + +1. Merge additive schema and suite support with existing launches still using v1 by default. +2. Rehearse the new deployment against pinned forks. Verify initialization, immutables, permission bits, bytecode size, and full launch/trade/collection flow. +3. Complete contract review, resolve findings, and rerun affected checks before production deployment. +4. Deploy and verify the Base quote suite first. Register its deployment block, let the indexer catch up, and expose the new choice after deployment checks pass. +5. Repeat the chain-specific process for Robinhood and Arc. Keep the option unavailable on a chain until its own suite is ready. +6. Keep v1 factories and all historical suite entries readable indefinitely. Observe actual receipts and indexed totals from the first new launches before widening availability. + +If a new suite has a problem, remove it from the application's new-launch choices and investigate its existing pools. That action does not pause the immutable factory, disable direct launches, recover locked liquidity, or alter fees on already launched tokens. A contract correction requires another deployment and another suite entry. Keep trading and claims accessible wherever the existing contracts can safely support them. + +## Acceptance criteria + +- A creator can explicitly choose quote-only fees for a new nonzero-fee launch on each enabled chain. +- Both buy and sell trading fees accrue entirely in the configured quote asset, with no conversion swap or platform recipient. +- The fee math, rounding, fill rules, supported currencies, and events match the documented behavior. +- Collection sends exactly the launch's earned quote fees to its fixed recipients or burn destination; failed ordinary payments remain correctly backed and claimable. +- All accounting invariants hold under fuzzing and interleaved pools, collections, and claims. +- The initial liquidity remains permanently locked, and rates/recipients cannot be changed after launch. +- Existing tokens retain working trade, collection, claim, metadata, and dashboard flows. +- Metadata prediction and the first buy work with the actual chosen factory and pool key. +- The app and indexer distinguish LP fees, creator fees, accrued fees, payouts, credits, and burns without double counting. +- Every supported chain has passing integration evidence, verified deployment tooling, and accurate documentation before its quote-only option is enabled. + +The first implementation step is milestone 1: prove the four swap cases and quote-claim redemption against the repository's pinned Uniswap dependencies. That settles the main technical uncertainty before the broader app and migration work.