diff --git a/app/db/schema.sql b/app/db/schema.sql index 11e0040c..2dd1c7a5 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -657,3 +657,49 @@ ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS via_app boolean; -- the scoring rules a season runs on (score.rulesFor): a season keeps the version it was started with, so a rule -- added later never changes a season already running. Every season before this column scores on version 1 ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS rules_version integer NOT NULL DEFAULT 1; +-- Canonical index recovery. Version 0 is legacy hashless history: the indexer +-- rebuilds chain facts from the deployment block, preserving authored records. +ALTER TABLE bb_launch_sync_cursor ADD COLUMN IF NOT EXISTS reorg_version integer NOT NULL DEFAULT 0; +ALTER TABLE bb_launch_sync_cursor ADD COLUMN IF NOT EXISTS reorg_generation bigint NOT NULL DEFAULT 0; +ALTER TABLE bb_launch_sync_cursor ADD COLUMN IF NOT EXISTS rebuilding_until bigint; +CREATE TABLE IF NOT EXISTS bb_launch_blocks ( + chain_id integer NOT NULL, + block_number bigint NOT NULL, + block_hash text NOT NULL, + PRIMARY KEY (chain_id, block_number) +); +CREATE TABLE IF NOT EXISTS bb_launch_recovery ( + chain_id integer NOT NULL, + token text NOT NULL, + tx_hash text NOT NULL, + reason text NOT NULL CHECK (reason IN ('reorg', 'rebuilding')), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (chain_id, token) +); +-- Valuations belong to an exact event and placement, not to whichever live +-- quote happens to exist during recovery. Retain them separately from facts. +CREATE TABLE IF NOT EXISTS bb_launch_replay_values ( + chain_id integer NOT NULL, + kind text NOT NULL CHECK (kind IN ('swap', 'fee', 'pair')), + tx_hash text NOT NULL, + log_index integer NOT NULL, + block_number bigint NOT NULL, + block_time timestamptz NOT NULL, + block_hash text, + payload jsonb NOT NULL, + usd double precision, + priced_by text, + PRIMARY KEY (chain_id, kind, tx_hash, log_index, block_number) +); +CREATE TABLE IF NOT EXISTS bb_launch_reorgs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + chain_id integer NOT NULL, + from_block bigint NOT NULL, + old_cursor bigint NOT NULL, + observed_head bigint NOT NULL, + reason text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS reorg_invalidated_at timestamptz; +ALTER TABLE bb_pair_applications ADD COLUMN IF NOT EXISTS payment_reorg boolean NOT NULL DEFAULT false; +ALTER TABLE bb_pair_burns ADD COLUMN IF NOT EXISTS valuation_pending boolean NOT NULL DEFAULT false; diff --git a/app/scripts/posts-db-test-chain.ts b/app/scripts/posts-db-test-chain.ts new file mode 100644 index 00000000..4618dd4a --- /dev/null +++ b/app/scripts/posts-db-test-chain.ts @@ -0,0 +1,13 @@ +// The integration tests use real signatures, replacing only the external RPC. +import type { Address, Hex } from "viem"; + +type Client = { + readContract: () => Promise; + verifyMessage: (args: { address: Address; message: string; signature: Hex }) => Promise; +}; +let client: Client | null = null; +export function setPostsTestClient(value: Client) { client = value; } +export function publicClient() { + if (!client) throw new Error("posts integration-test client not configured"); + return client; +} diff --git a/app/scripts/posts-db-test-db.ts b/app/scripts/posts-db-test-db.ts new file mode 100644 index 00000000..55b68746 --- /dev/null +++ b/app/scripts/posts-db-test-db.ts @@ -0,0 +1,8 @@ +// Explicit integration-test adapter. Never imported by the application build. +import type { Db } from "../src/lib/db.ts"; + +let connection: Db | null = null; + +export function setPostsTestDb(value: Db | null) { connection = value; } +export function maybeDb(): Db | null { return connection; } +export type { Db }; diff --git a/app/scripts/reorg-db-test-chain.ts b/app/scripts/reorg-db-test-chain.ts new file mode 100644 index 00000000..9f85193e --- /dev/null +++ b/app/scripts/reorg-db-test-chain.ts @@ -0,0 +1,11 @@ +// Test-only RPC adapter: all reads use the synthetic viem transport. +export * from "../src/lib/chainPublic"; +import type { ChainModel } from "./reorg-db-test-model"; +let active: ReturnType | null = null; +export function setClient(value: ReturnType) { active = value; } +export function publicClient() { + if (!active) + throw new Error("reorg test chain not configured"); + return active; +} +export function b20Client() { throw new Error("External B20 RPC disabled in reorg tests"); } diff --git a/app/scripts/reorg-db-test-db.ts b/app/scripts/reorg-db-test-db.ts new file mode 100644 index 00000000..f398f6e6 --- /dev/null +++ b/app/scripts/reorg-db-test-db.ts @@ -0,0 +1,13 @@ +// Test-only DB adapter. The opted-in suite owns random schemas on a loopback DB. +export { errMessage, uniqueViolation } from "../src/lib/pg"; +import type { Db } from "../src/lib/db"; +let active: Db | null = null; +export function setDb(value: Db | null) { active = value; } +export function maybeDb() { return active; } +export function db() { + if (!active) + throw new Error("reorg test database not configured"); + return active; +} +export function dbConfigured() { return active !== null; } +export type { Db }; diff --git a/app/scripts/reorg-db-test-direct.ts b/app/scripts/reorg-db-test-direct.ts new file mode 100644 index 00000000..5a36da6c --- /dev/null +++ b/app/scripts/reorg-db-test-direct.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { rollbackChain, replayValue, type ReplayKind, type ReplayPayload } from '../src/lib/launchpad/indexer-reorg-db'; +import { recordPairBurn } from '../src/lib/launchpad/pairApplyServer'; +import { A, B, ALICE, CREATOR, PM, DEAD, ZERO, hash } from './reorg-db-test-model'; +import type { Db } from './reorg-db-test-db'; +import type { ReorgCheckContext } from './reorg-db-test-suite'; +type ArchiveRow = { + kind: ReplayKind; + payload: ReplayPayload; + block_number: bigint; + block_time: string; + block_hash: string; + tx_hash: string; + log_index: number; + usd: number | null; + priced_by: string | null; +}; +export async function directChecks({ check, indexedOld }: ReorgCheckContext) { + async function rollback(db: Db) { + await db.begin(async (t) => { + await t `SELECT pg_advisory_xact_lock(hashtext('launch-index:base'))`; + await rollbackChain(t as unknown as Db, 'base', 101n, 'reorg', 104n); + }); + } + await check('direct rollback reconstructs empty market state and retained fees and holders', async () => { + const { db } = await indexedOld('directtotals'); + const time = new Date(1700000200 * 1000).toISOString(); + await db `UPDATE bb_launches SET holders_synced_block=102 WHERE token=${A}`; + await db `INSERT INTO bb_token_transfers (chain_id,token,tx_hash,log_index,from_addr,to_addr,value,block_number,block_time) + VALUES(8453,${A},${hash('retained-holder')},0,${PM},${ALICE},9,100,${time}), + (8453,${A},${hash('retained-dead')},0,${ALICE},${DEAD},1,100,${time})`; + await db `INSERT INTO bb_launch_fee_events (chain_id,tx_hash,log_index,kind,token_id,token,currency,quote_amount,token_amount,amount,block_number,block_time,usd) + VALUES(8453,${hash('retained-fees')},0,'collected',1,${A},NULL,4,2,NULL,100,${time},8), + (8453,${hash('retained-fees')},1,'burned',1,${A},${ZERO},NULL,NULL,1,100,${time},2), + (8453,${hash('orphan-claim')},0,'claimed',NULL,NULL,${ZERO},NULL,NULL,3,102,${time},NULL), + (8453,${hash('orphan-claim')},1,'credited',NULL,NULL,${ZERO},NULL,NULL,4,102,${time},NULL)`; + await rollback(db); + const [l] = await db `SELECT sqrt_price_x96,tick,start_tick,last_swap_block,last_swap_log,volume_quote::text,volume_usd,buys,sells, + fees_quote_collected::text,fees_token_collected::text,fees_quote_burned::text,fees_collected_usd,fees_burned_usd,holders,holders_synced_block + FROM bb_launches WHERE token=${A}`; + assert.equal(l.sqrt_price_x96, null); + assert.equal(l.tick, l.start_tick); + assert.equal(BigInt(l.last_swap_block), 0n); + assert.equal(l.last_swap_log, 0); + assert.equal(l.volume_quote, '0'); + assert.equal(l.volume_usd, 0); + assert.equal(l.buys, 0); + assert.equal(l.sells, 0); + assert.equal(l.fees_quote_collected, '4'); + assert.equal(l.fees_token_collected, '2'); + assert.equal(l.fees_quote_burned, '1'); + assert.equal(l.fees_collected_usd, 8); + assert.equal(l.fees_burned_usd, 2); + assert.equal(l.holders, 1); + assert.equal(BigInt(l.holders_synced_block), 100n); + assert.equal((await db `SELECT * FROM bb_launch_fee_events WHERE token IS NULL`).length, 0); + const rows = await db `SELECT holder,balance::text,first_block,last_block FROM bb_token_holders WHERE token=${A} ORDER BY holder`; + assert.equal(rows.find(x => x.holder === ALICE)?.balance, '8'); + assert.equal(rows.find(x => x.holder === DEAD)?.balance, '1'); + for (const r of rows) { + assert.equal(BigInt(r.first_block), 100n); + assert.equal(BigInt(r.last_block), 100n); + } + assert.equal((await db `SELECT * FROM bb_launches WHERE token=${B}`).length, 0); + return { retainedHolders: rows.length, launch: l }; + }); + await check('valuation archive preserves large integer identity and rejects changed event placement or payload', async () => { + const { db, model } = await indexedOld('journal'); + const huge = '900719925474099312345678901234567890'; + await db `UPDATE bb_launch_swaps SET amount0=${'-' + huge},amount1=${huge},sqrt_price_x96=${huge},usd=12.75 WHERE true`; + await db `UPDATE bb_launch_fee_events SET quote_amount=${huge},token_amount=${huge},usd=3.25 WHERE kind='collected'`; + await db `INSERT INTO bb_pair_burns (chain_id,tx_hash,log_index,application_id,sender,twig,usd,priced_by,block_number,block_time) + VALUES(8453,${hash('journal-pair')},0,9007199254740993,${ALICE},${huge},200,'quote',102,${new Date(1700000204 * 1000).toISOString()})`; + await rollback(db); + const archive = await db `SELECT * FROM bb_launch_replay_values ORDER BY kind,log_index`; + const swap = archive.find(r => r.kind === 'swap'); + const fee = archive.find(r => r.kind === 'fee' && r.payload.kind === 'collected'); + const pair = archive.find(r => r.kind === 'pair'); + assert.ok(swap && fee && pair, 'all three valuation kinds were archived'); + assert.equal(swap.payload.amount0, '-' + huge); + assert.equal(swap.payload.amount1, huge); + assert.equal(swap.payload.sqrt_price_x96, huge); + assert.equal(fee.payload.quote_amount, huge); + assert.equal(pair.payload.application_id, '9007199254740993'); + assert.equal(pair.payload.twig, huge); + for (const row of [swap, fee, pair]) { + const invoke = (bn = row.block_number, time: string | Date = row.block_time, bh = row.block_hash, payload = row.payload) => + replayValue(db, 'base', row.kind, row.tx_hash, row.log_index, bn, time, bh, payload); + assert.deepEqual(await invoke(), { usd: row.usd, priced_by: row.priced_by, ...(row.kind === 'pair' ? { valuation_pending: false } : {}) }); + assert.equal(await invoke(BigInt(row.block_number) + 1n), null); + assert.equal(await invoke(row.block_number, new Date(new Date(row.block_time).getTime() + 2000)), null); + assert.equal(await invoke(row.block_number, row.block_time, hash('changed block')), null); + const key = row.kind === 'swap' ? 'amount1' : row.kind === 'fee' ? 'quote_amount' : 'twig'; + assert.equal(await invoke(row.block_number, row.block_time, row.block_hash, { ...row.payload, [key]: '1' }), null); + if (row.kind === 'fee') + assert.equal(await invoke(row.block_number, row.block_time, row.block_hash, { ...row.payload, quote: A }), null, 'fee valuation cannot be reused under a different quote-token context'); + } + await db `UPDATE bb_launch_replay_values SET block_hash=NULL WHERE kind='swap'`; + assert.equal((await replayValue(db, 'base', 'swap', swap.tx_hash, swap.log_index, 101n, swap.block_time, model.blockHash(101), swap.payload))?.usd, 12.75); + const changed = { tx_hash: hash('journal-pair'), log_index: 0, application_id: 1, sender: ALICE, whole: '200', block_number: '104' }; + const changedTime = new Date(1700000208 * 1000), changedHash = hash('re-included-pair-block'); + await db.begin(async (t) => { + await t `SELECT pg_advisory_xact_lock(hashtext('launch-index:base'))`; + await recordPairBurn('base', changed, changedTime, t as unknown as Db, changedHash); + }); + assert.equal((await db `SELECT valuation_pending FROM bb_pair_burns WHERE tx_hash=${changed.tx_hash}`)[0].valuation_pending, true); + await db `INSERT INTO bb_launch_blocks (chain_id,block_number,block_hash) VALUES(8453,104,${changedHash})`; + await rollback(db); + await db.begin(async (t) => { + await t `SELECT pg_advisory_xact_lock(hashtext('launch-index:base'))`; + await recordPairBurn('base', changed, changedTime, t as unknown as Db, changedHash); + }); + const [again] = await db `SELECT usd,priced_by,valuation_pending FROM bb_pair_burns WHERE tx_hash=${changed.tx_hash}`; + assert.equal(again.usd, null); + assert.equal(again.priced_by, null); + assert.equal(again.valuation_pending, true, 'repeated rollback/replay cannot clear a previously unresolved historical valuation'); + return { checkedKinds: 3, largeInteger: huge, archiveRows: archive.length }; + }); + await check('rollback invalidates season snapshots and recomputes cross-chain application payment without overwriting decisions', async () => { + const { db } = await indexedOld('dependents'); + const statuses = ['paid', 'approved', 'changes', 'rejected', 'open', 'paid']; + for (let i = 0; i < statuses.length; i++) { + const id = i + 1, address = '0x' + String(id).padStart(40, '0'); + await db `INSERT INTO bb_pair_applications (id,chain_id,address,applicant,code,symbol,name,decimals,price_kind,price_pool,price_quote,precheck,status,fee_usd,paid_usd,paid_at,reviews_used,reason,decided_by,created_at) + VALUES(${id},4663,${address},${CREATOR},${'AUD-' + id},'AUD','Authored application',18,'v4',${hash('pair-pool')},${ZERO},'{}',${statuses[i]},200,250,'2026-01-01',2,'Keep decision',${CREATOR},'2026-01-01')`; + if (i < 4) + await db `INSERT INTO bb_pair_burns (chain_id,tx_hash,log_index,application_id,sender,twig,usd,priced_by,block_number,block_time) + VALUES(8453,${hash('orphan-app-' + id)},0,${id},${ALICE},200,200,'live',102,'2026-01-01'), + (4663,${hash('retained-app-' + id)},0,${id},${ALICE},50,50,'live',99,'2026-01-01')`; + if (i === 5) + await db `INSERT INTO bb_pair_burns (chain_id,tx_hash,log_index,application_id,sender,twig,usd,priced_by,block_number,block_time,valuation_pending) + VALUES(8453,${hash('orphan-pending-app')},0,${id},${ALICE},1,1,'live',102,'2026-01-01',false), + (8453,${hash('retained-pending-app')},0,${id},${ALICE},1,NULL,NULL,100,'2026-01-01',true), + (4663,${hash('retained-funded-app')},0,${id},${ALICE},250,250,'live',99,'2026-01-01',false)`; + } + await db `INSERT INTO bb_seasons (id,slug,name,starts_at,ends_at,public,computed_at,computed_until,published_at,rules_version,created_at) + VALUES(1,'old','Finished season','2025-01-01','2025-02-01',true,'2025-02-02','2025-02-01','2025-01-01',2,'2025-01-01'), + (2,'draft','Untouched draft','2026-01-01','2026-02-01',false,NULL,NULL,NULL,1,'2026-01-01')`; + await db `INSERT INTO bb_points (season_id,wallet,creator,scout,total,eligible,computed_at) VALUES(1,${ALICE},20,30,50,true,'2025-02-02')`; + await rollback(db); + const apps = await db `SELECT id,status,paid_usd,paid_at,reviews_used,reason,decided_by,payment_reorg FROM bb_pair_applications ORDER BY id`; + for (let i = 0; i < 4; i++) { + const a = apps[i]; + assert.equal(a.status, i === 0 ? 'open' : statuses[i]); + assert.equal(a.paid_usd, 50); + assert.equal(a.payment_reorg, true); + assert.equal(a.reviews_used, 2); + assert.equal(a.reason, 'Keep decision'); + assert.equal(a.decided_by, CREATOR); + if (i === 0) + assert.equal(a.paid_at, null); + else + assert.notEqual(a.paid_at, null); + } + assert.equal(apps[4].paid_usd, 250); + assert.equal(apps[4].payment_reorg, false); + assert.equal(apps[4].status, 'open'); + assert.equal(apps[5].paid_usd, 250); + assert.equal(apps[5].payment_reorg, true); + assert.equal(apps[5].status, 'paid', 'a pending historical valuation remains flagged even when other retained burns cover the fee'); + assert.equal((await db `SELECT * FROM bb_points`).length, 0); + const seasons = await db `SELECT * FROM bb_seasons ORDER BY id`; + assert.notEqual(seasons[0].reorg_invalidated_at, null); + assert.equal(seasons[0].computed_at, null); + assert.equal(seasons[0].computed_until, null); + assert.equal(seasons[0].rules_version, 2); + assert.equal(seasons[0].public, true); + assert.equal(seasons[0].slug, 'old'); + assert.notEqual(seasons[0].published_at, null); + assert.equal(seasons[1].reorg_invalidated_at, null); + assert.equal(seasons[1].public, false); + return { applications: apps.length, invalidatedSeasons: 1 }; + }); +} diff --git a/app/scripts/reorg-db-test-model.ts b/app/scripts/reorg-db-test-model.ts new file mode 100644 index 00000000..4bfd3168 --- /dev/null +++ b/app/scripts/reorg-db-test-model.ts @@ -0,0 +1,162 @@ +import { createHash } from 'node:crypto'; +import { createPublicClient, custom, encodeAbiParameters, encodeEventTopics, encodeFunctionResult, toHex, type Abi, type AbiEvent, type Hex } from 'viem'; +import { base } from 'viem/chains'; +import { LAUNCH_FACTORY_ABI, LAUNCH_LOCKER_ABI, POOL_MANAGER_ABI, ERC20_MIN_ABI, ERC20_TRANSFER_EVENT } from '../src/lib/launchpad/abi'; +import { launchpad } from '../src/lib/launchpad/config'; +import { TWIG_ADDRESS } from '../src/lib/launchpad/twig'; +import { boostAmount } from '../src/lib/launchpad/boost'; +export const F = process.env.NEXT_PUBLIC_LAUNCH_FACTORY!; +export const L = process.env.NEXT_PUBLIC_LAUNCH_LOCKER!; +export const PM = launchpad('base').v4.poolManager.toLowerCase(); +export const A = '0x2222222222222222222222222222222222222222'; +export const B = '0x6666666666666666666666666666666666666666'; +export const CREATOR = '0x3333333333333333333333333333333333333333'; +export const ALICE = '0x7777777777777777777777777777777777777777'; +export const BOB = '0x8888888888888888888888888888888888888888'; +export const ZERO = '0x0000000000000000000000000000000000000000'; +export const DEAD = '0x000000000000000000000000000000000000dead'; +export const hash = (label: string) => `0x${createHash('sha256').update(label).digest('hex')}` as Hex; +export const POOL = hash('pool-a'); +export const POOL_B = hash('pool-b'); +type EventArgs = Record; +type LogFilter = { + fromBlock?: Hex; + toBlock?: Hex; + address?: string | string[]; + topics?: readonly (Hex | readonly Hex[] | null)[]; +}; +export type EventInput = { + address: string; + abi: Abi; + eventName: string; + args: EventArgs; + height: number; + tx: string; + index: number; + from?: string; + to?: string; +}; +export function launched(height = 100, token = A, tx = 'common-launch', id = 1n): EventInput { + return { address: F, abi: LAUNCH_FACTORY_ABI, eventName: 'Launched', height, tx, index: 0, from: CREATOR, to: F, + args: { token, tokenId: id, launcher: CREATOR, quote: ZERO, poolId: token === A ? POOL : POOL_B, startTick: 0, lpFee: 10000, supply: 1000000n, metadataURI: 'ipfs://audit' } }; +} +export function transfer(height: number, token: string, from: string, to: string, value: bigint, tx: string, index: number): EventInput { + return { address: token, abi: [ERC20_TRANSFER_EVENT], eventName: 'Transfer', height, tx, index, from, to: token, args: { from, to, value } }; +} +export function swap(height: number, amount: bigint, tx: string, token = A): EventInput { + return { address: PM, abi: POOL_MANAGER_ABI, eventName: 'Swap', height, tx, index: 2, from: ALICE, to: PM, + args: { id: token === A ? POOL : POOL_B, sender: ALICE, amount0: -amount, amount1: amount * 10n, sqrtPriceX96: 100n + amount, liquidity: 1000n, tick: Number(amount), fee: 10000 } }; +} +export function fee(height: number, eventName: 'Collected' | 'Burned' | 'Paid' | 'Credited' | 'Claimed', args: EventArgs, index: number): EventInput { + return { address: L, abi: LAUNCH_LOCKER_ABI, eventName, height, tx: `old-fees`, index, from: ALICE, to: L, args }; +} +export function boost(height: number, tx = 'old-boost'): EventInput { + return transfer(height, TWIG_ADDRESS, ALICE, DEAD, boostAmount(100000n, 1n), tx, 0); +} +export class ChainModel { + branch = 'old'; + fork = 101; + head = 104; + finalized = 100; + events: Record = {}; + calls: string[] = []; + failMethod: string | null = null; + beforeRequest: ((method: string, params: readonly unknown[]) => Promise) | null = null; + blockHash(height: number, branch = this.branch) { return hash(height < this.fork ? `common-block-${height}` : `${branch}-block-${height}`); } + block(height: number, branch = this.branch) { return { number: toHex(height), hash: this.blockHash(height, branch), parentHash: this.blockHash(height - 1, branch), timestamp: toHex(1700000000 + height * 2), transactions: [], gasLimit: '0x1000000', gasUsed: '0x0', baseFeePerGas: '0x1', difficulty: '0x0', totalDifficulty: '0x0', extraData: '0x', size: '0x1', nonce: '0x0000000000000000', miner: ZERO, receiptsRoot: hash('receipts'), stateRoot: hash('state'), transactionsRoot: hash('transactions'), logsBloom: `0x${'00'.repeat(256)}`, sha3Uncles: hash('uncles'), uncles: [] }; } + log(e: EventInput, branch = this.branch) { + const event = e.abi.find((item): item is AbiEvent => item.type === 'event' && item.name === e.eventName); + if (!event) throw new Error(`Unknown fixture event ${e.eventName}`); + const inputs = event.inputs.filter(input => !input.indexed); + return { + address: e.address, + topics: encodeEventTopics({ abi: e.abi, eventName: e.eventName, args: e.args }), + data: encodeAbiParameters(inputs, inputs.map(input => e.args[input.name!])), + blockNumber: toHex(e.height), + blockHash: this.blockHash(e.height, branch), + transactionHash: hash(e.tx), + transactionIndex: '0x0', + logIndex: toHex(e.index), + removed: false, + }; + } + current() { return this.events[this.branch] ?? []; } + receipt(tx: string) { + const events = this.current().filter(e => hash(e.tx).toLowerCase() === tx.toLowerCase()); + if (!events.length) + return null; + const e = events[0]; + return { transactionHash: hash(e.tx), status: '0x1', blockHash: this.blockHash(e.height), blockNumber: toHex(e.height), transactionIndex: '0x0', from: e.from ?? ALICE, to: e.to ?? e.address, cumulativeGasUsed: '0x1', gasUsed: '0x1', logsBloom: `0x${'00'.repeat(256)}`, logs: events.map(x => this.log(x)), type: '0x2', effectiveGasPrice: '0x1', contractAddress: null }; + } + async request(method: string, params: readonly unknown[]) { + this.calls.push(method); + if (this.beforeRequest) + await this.beforeRequest(method, params); + if (this.failMethod === method) { + this.failMethod = null; + throw Error('Injected local RPC failure'); + } + if (method === 'eth_chainId') + return '0x2105'; + if (method === 'eth_blockNumber') + return toHex(this.head); + if (method === 'eth_getBlockByNumber') { + const tag = params[0] as string; + const n = tag === 'latest' ? this.head : tag === 'safe' || tag === 'finalized' ? this.finalized : Number(BigInt(tag)); + return n > this.head ? null : this.block(n); + } + if (method === 'eth_getBlockByHash') { + for (let n = 99; n <= this.head; n++) + if (this.blockHash(n) === params[0]) + return this.block(n); + return null; + } + if (method === 'eth_getTransactionReceipt') + return this.receipt(params[0] as string); + if (method === 'eth_getTransactionByHash') { + const r = this.receipt(params[0] as string); + return r ? { hash: r.transactionHash, from: r.from, to: r.to, blockHash: r.blockHash, blockNumber: r.blockNumber, transactionIndex: '0x0', input: '0x', value: '0x0', nonce: '0x0', gas: '0x5208', gasPrice: '0x1', type: '0x2', chainId: '0x2105', v: '0x1', r: hash('r'), s: hash('s') } : null; + } + if (method === 'eth_getLogs') { + const f = params[0] as LogFilter; + const lo = f.fromBlock ? Number(BigInt(f.fromBlock)) : 0; + const hi = f.toBlock ? Number(BigInt(f.toBlock)) : this.head; + const addresses = f.address ? (Array.isArray(f.address) ? f.address : [f.address]).map((s: string) => s.toLowerCase()) : null; + return this.current().filter(e => e.height >= lo && e.height <= hi).map(e => this.log(e)).filter(log => + (!addresses || addresses.includes(log.address.toLowerCase())) && + (!f.topics || f.topics.every((want, i) => want == null || + (typeof want === 'string' ? [want] : want).some(topic => { + const observed = log.topics[i]; + return typeof observed === 'string' && topic.toLowerCase() === observed.toLowerCase(); + })))); + } + if (method === 'eth_call') { + const data = (params[0] as { data: string }).data; + if (data === '0x06fdde03') + return encodeFunctionResult({ abi: ERC20_MIN_ABI, functionName: 'name', result: 'Audit Token' }); + if (data === '0x95d89b41') + return encodeFunctionResult({ abi: ERC20_MIN_ABI, functionName: 'symbol', result: 'AUD' }); + return encodeFunctionResult({ abi: LAUNCH_LOCKER_ABI, functionName: 'recipientsOf', result: [] }); + } + throw Error('Unexpected synthetic RPC ' + method); + } + client() { + return createPublicClient({ + chain: base, + cacheTime: 0, + transport: custom({ request: ({ method, params }) => this.request(method, params ?? []) }, { retryCount: 0 }), + }); + } +} +export function standardModel() { + const m = new ChainModel(); + const common = [launched(), transfer(100, A, ZERO, PM, 1000000n, 'common-launch', 1)]; + m.events.old = [...common, transfer(101, A, PM, ALICE, 50n, 'old-swap', 1), swap(101, 5n, 'old-swap'), + fee(102, 'Collected', { tokenId: 1n, token: A, quoteAmount: 7n, tokenAmount: 3n }, 0), + fee(102, 'Burned', { tokenId: 1n, currency: ZERO, amount: 2n }, 1), + fee(102, 'Burned', { tokenId: 1n, currency: A, amount: 1n }, 2), + fee(102, 'Paid', { tokenId: 1n, account: ALICE, currency: ZERO, amount: 5n }, 3), boost(102), + launched(102, B, 'orphan-launch', 2n), transfer(102, B, ZERO, PM, 1000000n, 'orphan-launch', 1)]; + m.events.new = [...common, transfer(101, A, PM, BOB, 20n, 'new-swap', 1), swap(101, 2n, 'new-swap')]; + return m; +} diff --git a/app/scripts/reorg-db-test-suite.ts b/app/scripts/reorg-db-test-suite.ts new file mode 100644 index 00000000..8f5e1032 --- /dev/null +++ b/app/scripts/reorg-db-test-suite.ts @@ -0,0 +1,491 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import type { TestContext } from 'node:test'; +import path from 'node:path'; +import postgres from 'postgres'; +import { setDb, type Db } from './reorg-db-test-db'; +import { setClient } from './reorg-db-test-chain'; +import * as indexer from '../src/lib/launchpad/indexer'; +import { withCanonicalIndex } from '../src/lib/launchpad/indexer-canonical'; +import { IndexLockBusyError } from '../src/lib/launchpad/indexer-lock'; +import { A, B, CREATOR, ALICE, BOB, ZERO, standardModel, hash, type ChainModel } from './reorg-db-test-model'; +import { BEFORE_EXECUTION_EVENT, USER_OPERATION_EVENT, ENTRY_POINTS } from '../src/lib/launchpad/attribution'; +import { directChecks } from './reorg-db-test-direct'; +type Snapshot = Record; +export type ReorgCheckContext = { + check: (label: string, run: () => Promise) => Promise; + indexedOld: (label: string) => Promise<{ db: Db; model: ChainModel }>; +}; +export async function runReorgDbTests(t: TestContext, endpoint: string) { + const admin = postgres(endpoint, { max: 1, onnotice: () => { } }); + const resources: { + name: string; + db: Db; + }[] = []; + const schemaNames = new WeakMap(); + // Scope legacy catalog existence checks to the owned schema. All actual table + // definitions and migrations still come from the application's schema.sql. + const schema = readFileSync(path.join(process.cwd(), 'db/schema.sql'), 'utf8') + .replaceAll('FROM pg_constraint WHERE', 'FROM pg_constraint WHERE connamespace = current_schema()::regnamespace AND') + .replaceAll('WHERE t.relname =', 'WHERE c.connamespace = current_schema()::regnamespace AND t.relname ='); + // Compare durable facts and derived accounting. Recovery journals/cursor status + // intentionally record the replay journey, so their invariants are checked separately. + const tables = ['bb_launches', 'bb_launch_swaps', 'bb_launch_fee_events', 'bb_token_transfers', 'bb_token_holders', 'bb_boosts', 'bb_pair_burns', 'bb_launch_meta', 'bb_posts']; + async function database(label: string) { + const name = 'reorg_security_' + label + '_' + randomBytes(8).toString('hex'); + assert.match(name, /^reorg_security_[a-z0-9_]+_[0-9a-f]{16}$/); + await admin.unsafe(`CREATE SCHEMA "${name}"`); + const db = postgres(endpoint, { max: 8, connection: { search_path: name, application_name: name }, types: { bigint: postgres.BigInt }, onnotice: () => { } }) as unknown as Db; + resources.push({ name, db }); + schemaNames.set(db, name); + await db.unsafe(schema); + // One application process normally keeps one DB. Test fixtures switch DBs, so + // give each an independent generation namespace for process-local caches. + await db `UPDATE bb_launch_sync_cursor SET reorg_generation=${BigInt(resources.length) * 1000n} WHERE true`; + await db `INSERT INTO bb_launch_meta (chain_id,token,launcher,name,symbol,description,created_at) VALUES(8453,${B},${CREATOR},'Authored name','USER','Keep this user content','2026-01-01T00:00:00Z')`; + await db `INSERT INTO bb_posts (chain_id,token,wallet,body,signature,created_at) VALUES(8453,${B},${CREATOR},'Keep this signed post','synthetic-test-signature','2026-01-01T00:00:00Z')`; + return db; + } + function activate(db: Db, model: ChainModel) { + setDb(db); + setClient(model.client()); + } + async function snapshot(db: Db): Promise { + const out: Snapshot = {}; + for (const table of tables) { + const rows = await db.unsafe<{ value: unknown }[]>(`SELECT to_jsonb(t) AS value FROM ${table} t`); + out[table] = rows.map(r => r.value).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + } + return out; + } + function differing(a: Snapshot, b: Snapshot) { + return tables.filter(table => JSON.stringify(a[table]) !== JSON.stringify(b[table])); + } + async function check(label: string, run: () => Promise) { + await t.test(label, async () => { await run(); }); + } + async function compareOracle(label: string, observed: Db, model: ChainModel) { + const actual = await snapshot(observed), oracle = await database('oracle'); + activate(oracle, model); + let sync: indexer.LaunchSyncResult | undefined; + for (let i = 0; i < 6; i++) { + sync = await indexer.pollLaunches('base'); + if (sync.caught_up) + break; + } + assert.equal(sync?.caught_up, true, 'clean rebuild oracle must finish'); + const expected = await snapshot(oracle), differences = differing(actual, expected); + assert.deepEqual(differences, [], `${label} differs from clean canonical rebuild`); + return { differences }; + } + async function indexedOld(label: string) { + const db = await database(label), model = standardModel(); + activate(db, model); + await indexer.pollLaunches('base'); + return { db, model }; + } + async function main() { + try { + const observed = await database('observed'), oracle = await database('oracle'); + const model = standardModel(); + activate(observed, model); + await check('full schema and normal canonical ingestion', async () => { + const r = await indexer.pollLaunches('base'); + const [a] = await observed `SELECT volume_quote::text,buys,fees_quote_collected::text,fees_token_collected::text,fees_quote_burned::text,fees_token_burned::text,holders FROM bb_launches WHERE token=${A}`; + assert.equal(a.volume_quote, '5'); + assert.equal(a.buys, 1); + assert.equal(a.fees_quote_collected, '7'); + assert.equal(a.fees_quote_burned, '2'); + assert.equal((await observed `SELECT * FROM bb_boosts`).length, 1); + assert.equal((await observed `SELECT * FROM bb_launches WHERE token=${B}`).length, 1); + return { sync: r, launch: a }; + }); + const original = await snapshot(observed); + await check('canonical replay is idempotent across real SQL aggregates', async () => { + await indexer.pollLaunches('base'); + assert.deepEqual(await snapshot(observed), original); + return {}; + }); + model.branch = 'new'; + await check('canonical replacement matches clean rebuild across swaps fees holders orphan launches and boosts', async () => { + activate(observed, model); + const result = await indexer.pollLaunches('base'); + const actual = await snapshot(observed); + activate(oracle, model); + await indexer.pollLaunches('base'); + const expected = await snapshot(oracle); + const differences = differing(actual, expected); + assert.deepEqual(actual, expected); + assert.deepEqual(actual.bb_launch_meta, expected.bb_launch_meta); + assert.deepEqual(actual.bb_posts, expected.bb_posts); + return { expectedMode: 'fixed-equality', differences, sync: result }; + }); + const [{ ready }] = await observed `SELECT to_regclass('bb_launch_blocks') IS NOT NULL AS ready`; + assert.equal(ready, true, 'reconciliation migration must be present'); + await check('orphan launch records recovery without deleting user-authored metadata or posts', async () => { + const f = await indexedOld('tombstone'); + f.model.branch = 'new'; + activate(f.db, f.model); + await indexer.pollLaunches('base'); + const recovery = await f.db `SELECT * FROM bb_launch_recovery WHERE chain_id=8453 AND token=${B}`; + assert.equal(recovery.length, 1); + assert.equal((await f.db `SELECT * FROM bb_launches WHERE token=${B}`).length, 0); + assert.equal((await f.db `SELECT description FROM bb_launch_meta WHERE token=${B}`)[0].description, 'Keep this user content'); + assert.equal((await f.db `SELECT body FROM bb_posts WHERE token=${B}`)[0].body, 'Keep this signed post'); + return { recoveryReason: recovery[0].reason, ...await compareOracle('tombstone', f.db, f.model) }; + }); + await check('reorg older than overlap reconciles through retained checkpoints', async () => { + const db = await database('deep'), m = standardModel(); + m.head = 200; + activate(db, m); + await indexer.pollLaunches('base'); + const [before] = await db `SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id=8453`; + assert.ok(BigInt(before.cursor_block) - 101n > 50n); + m.branch = 'new'; + await indexer.pollLaunches('base'); + return compareOracle('deep', db, m); + }); + await check('a changed earliest checkpoint replays from deployment without retaining orphan facts', async () => { + const f = await indexedOld('firstanchor'); + f.model.fork = 100; + f.model.branch = 'new'; + activate(f.db, f.model); + await indexer.pollLaunches('base'); + return compareOracle('firstanchor', f.db, f.model); + }); + await check('same transaction re-included at changed block and log indices counts only canonical position', async () => { + const f = await indexedOld('moved'); + f.model.events.new = [...f.model.events.new.filter(e => e.height === 100), ...f.model.events.old.filter(e => e.tx === 'old-swap').map(e => ({ ...e, height: 104, index: e.index + 3 }))]; + f.model.head = 106; + f.model.branch = 'new'; + activate(f.db, f.model); + await indexer.pollLaunches('base'); + const rows = await f.db `SELECT block_number,log_index FROM bb_launch_swaps WHERE tx_hash=${hash('old-swap')}`; + assert.equal(rows.length, 1); + assert.equal(BigInt(rows[0].block_number), 104n); + assert.equal(rows[0].log_index, 5); + return compareOracle('moved', f.db, f.model); + }); + await check('successful provisional receipt is reconciled when its branch is orphaned', async () => { + const db = await database('receipt'), m = standardModel(); + m.head = 101; + activate(db, m); + await indexer.applyLaunchTx('base', hash('common-launch')); + await indexer.applyLaunchTx('base', hash('old-swap')); + assert.equal((await db `SELECT * FROM bb_launch_swaps`).length, 1); + m.branch = 'new'; + m.head = 104; + await indexer.pollLaunches('base'); + return compareOracle('receipt', db, m); + }); + await check('legacy hashless history is rebuilt rather than certified against present hashes', async () => { + const f = await indexedOld('legacy'); + await f.db `DELETE FROM bb_launch_blocks WHERE chain_id=8453`; + await f.db `UPDATE bb_launch_sync_cursor SET reorg_version=0,cursor_block=198,head_block=200 WHERE chain_id=8453`; + f.model.branch = 'new'; + f.model.head = 200; + activate(f.db, f.model); + await indexer.pollLaunches('base'); + const [cursor] = await f.db `SELECT reorg_version,rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id=8453`; + assert.ok(cursor.reorg_version > 0); + return { cursor, ...await compareOracle('legacy', f.db, f.model) }; + }); + await check('bounded legacy rebuild resumes across calls and never stamps stale history as complete', async () => { + const f = await indexedOld('bounded'); + await f.db `DELETE FROM bb_launch_blocks WHERE chain_id=8453`; + await f.db `UPDATE bb_launch_sync_cursor SET reorg_version=0,cursor_block=49998,head_block=50000 WHERE chain_id=8453`; + f.model.branch = 'new'; + f.model.head = 50000; + activate(f.db, f.model); + const first = await indexer.pollLaunches('base'); + const [intermediate] = await f.db `SELECT cursor_block,rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id=8453`; + assert.equal(first.caught_up, false); + assert.ok(intermediate.rebuilding_until !== null && BigInt(intermediate.cursor_block) < BigInt(intermediate.rebuilding_until)); + f.model.head = 50002; + let final: indexer.LaunchSyncResult | undefined; + for (let i = 0; i < 5; i++) { + final = await indexer.pollLaunches('base'); + if (final.caught_up) + break; + } + assert.equal(final?.caught_up, true); + const [done] = await f.db `SELECT rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id=8453`; + assert.equal(done.rebuilding_until, null); + return { first, intermediate, final, ...await compareOracle('bounded', f.db, f.model) }; + }); + await check('injected SQL failure rolls back reconciliation and retries to canonical oracle', async () => { + const f = await indexedOld('sqlfailure'), before = await snapshot(f.db); + const blocks = await f.db `SELECT * FROM bb_launch_blocks ORDER BY chain_id,block_number`; + await f.db.unsafe(`CREATE FUNCTION audit_fail_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic rollback fault'; END $$; CREATE TRIGGER audit_fail_delete AFTER DELETE ON bb_launch_swaps FOR EACH ROW EXECUTE FUNCTION audit_fail_delete()`); + f.model.branch = 'new'; + activate(f.db, f.model); + await assert.rejects(indexer.pollLaunches('base'), /synthetic rollback fault/); + assert.deepEqual(await snapshot(f.db), before); + assert.deepEqual(await f.db `SELECT * FROM bb_launch_blocks ORDER BY chain_id,block_number`, blocks); + await f.db.unsafe('DROP TRIGGER audit_fail_delete ON bb_launch_swaps; DROP FUNCTION audit_fail_delete()'); + await indexer.pollLaunches('base'); + return compareOracle('sqlfailure', f.db, f.model); + }); + await check('RPC outage during replacement does not leave partial derived accounting', async () => { + const f = await indexedOld('rpcfailure'), before = await snapshot(f.db); + f.model.branch = 'new'; + f.model.failMethod = 'eth_getLogs'; + activate(f.db, f.model); + await assert.rejects(indexer.pollLaunches('base'), /Injected local RPC failure/); + assert.deepEqual(await snapshot(f.db), before); + await indexer.pollLaunches('base'); + return compareOracle('rpcfailure', f.db, f.model); + }); + await check('receipt and poller serialize across PostgreSQL connections on the shared chain lock', async () => { + const f = await indexedOld('race'); + f.model.branch = 'new'; + activate(f.db, f.model); + let release!: () => void, entered!: () => void; + const blocked = new Promise(r => release = r), inside = new Promise(r => entered = r); + f.model.beforeRequest = async (method) => { if (method === 'eth_getLogs') { + f.model.beforeRequest = null; + entered(); + await blocked; + } }; + const first = indexer.pollLaunches('base'); + let timer: ReturnType | undefined; + try { + await Promise.race([inside, first.then(() => { throw Error('poller completed without entering synthetic getLogs'); }), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('poller did not reach synthetic getLogs within 10 seconds')), 10000); })]); + } + catch (e) { + release(); + await first.catch(() => { }); + throw e; + } + finally { + if (timer) + clearTimeout(timer); + } + const concurrent = indexer.applyLaunchTx('base', hash('new-swap')); + void concurrent.catch(() => {}); // observed below, including the pre-fix 5s timeout + let waiting = 0; + try { + for (let i = 0; i < 20; i++) { + const [r] = await admin `SELECT count(*)::int AS n FROM pg_stat_activity WHERE application_name=${schemaNames.get(f.db)!} AND wait_event='advisory'`; + waiting = r.n; + if (waiting > 0) + break; + await new Promise(r => setTimeout(r, 50)); + } + // A normal RPC stall may outlive the independent 5s table-lock + // timeout. The chain wait budget must permit this handoff. + await new Promise(r => setTimeout(r, 5500)); + } + finally { + release(); + } + await Promise.all([first, concurrent]); + assert.ok(waiting > 0, 'second PostgreSQL transaction should visibly wait on the shared chain lock'); + return { waitingWorkers: waiting, ...await compareOracle('race', f.db, f.model) }; + }); + await check('advisory timeout changes no canonical state and the same receipt can be retried', async () => { + const f = await indexedOld('locktimeout'); + f.model.branch = 'new'; + activate(f.db, f.model); + const before = await snapshot(f.db); + const cursor = await f.db`SELECT * FROM bb_launch_sync_cursor ORDER BY chain_id`; + let release!: () => void, entered!: () => void; + const hold = new Promise(resolve => { release = resolve; }); + const inside = new Promise(resolve => { entered = resolve; }); + const blocker = f.db.begin(async locked => { + await locked`SELECT pg_advisory_xact_lock(hashtext('launch-index:base'))`; + entered(); + await hold; + }); + try { + await Promise.race([inside, blocker.then(() => { throw Error('blocking transaction ended before its lock'); })]); + await assert.rejects(indexer.applyLaunchTx('base', hash('new-swap')), IndexLockBusyError); + assert.deepEqual(await snapshot(f.db), before); + assert.deepEqual(await f.db`SELECT * FROM bb_launch_sync_cursor ORDER BY chain_id`, cursor); + } finally { + release(); + await blocker; + } + const retried = await indexer.applyLaunchTx('base', hash('new-swap')); + assert.equal(retried.status, 'applied'); + await indexer.pollLaunches('base'); + return compareOracle('locktimeout', f.db, f.model); + }); + await check('holder backfill stops after one empty chunk ahead of the poller cursor', async () => { + const f = await indexedOld('emptybackfill'); + activate(f.db, f.model); + await f.db`UPDATE bb_launches SET holders_synced_block=9223372036854775807 WHERE chain_id=8453`; + await f.db`UPDATE bb_launches SET holders_synced_block=NULL,block_number=103 WHERE chain_id=8453 AND token=${B}`; + const [cursor] = await f.db`SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id=8453`; + assert.equal(BigInt(cursor.cursor_block), 102n); + f.model.calls.length = 0; + assert.equal(await indexer.backfillHolders('base'), null); + assert.equal(f.model.calls.filter(method => method === 'eth_blockNumber').length, 1, 'one canonical transaction, not 25 empty repeats'); + assert.equal(f.model.calls.filter(method => method === 'eth_getLogs').length, 0); + const [pending] = await f.db`SELECT holders_synced_block FROM bb_launches WHERE chain_id=8453 AND token=${B}`; + assert.equal(pending.holders_synced_block, null); + }); + await check('fork arriving between range observation and commit rolls back the entire unit', async () => { + const db = await database('midfork'), m = standardModel(); + activate(db, m); + const before = await snapshot(db); + m.beforeRequest = async (method) => { if (method === 'eth_getLogs') { + m.beforeRequest = null; + m.branch = 'new'; + } }; + await assert.rejects(indexer.pollLaunches('base'), /chain changed|canonical block/); + assert.deepEqual(await snapshot(db), before); + assert.equal((await db `SELECT * FROM bb_launch_blocks`).length, 0); + await indexer.pollLaunches('base'); + return compareOracle('midfork', db, m); + }); + await check('a mismatched log hash cannot commit raw events or checkpoints', async () => { + const db = await database('badlog'), m = standardModel(); + activate(db, m); + const before = await snapshot(db); + const original = m.log.bind(m); + m.log = (event, branch) => ({ ...original(event, branch), blockHash: event.eventName === 'Swap' ? hash('wrong-block') : m.blockHash(event.height, branch) }); + await assert.rejects(indexer.pollLaunches('base'), /not in the canonical block/); + assert.deepEqual(await snapshot(db), before); + assert.equal((await db `SELECT * FROM bb_launch_blocks`).length, 0); + }); + await check('a stale successful receipt is rejected against the current canonical block', async () => { + const db = await database('stalereceipt'), m = standardModel(); + activate(db, m); + const stale = m.receipt(hash('old-swap')), original = m.receipt.bind(m); + m.branch = 'new'; + await indexer.applyLaunchTx('base', hash('common-launch')); + const before = await snapshot(db); + m.receipt = tx => tx === hash('old-swap') ? stale : original(tx); + await assert.rejects(indexer.applyLaunchTx('base', hash('old-swap')), /not in the canonical block/); + assert.deepEqual(await snapshot(db), before); + }); + await check('AA attribution ignores stale receipt evidence and heals from a matching canonical receipt', async () => { + const db = await database('aareceipt'), m = standardModel(); + m.branch = 'new'; + activate(db, m); + const original = m.receipt.bind(m), entryPoint = [...ENTRY_POINTS][0]; + let stale = true; + m.receipt = tx => { + const r = original(tx); + if (!r || tx !== hash('new-swap')) + return r; + const sample = r.logs[0], topic = (address: string) => '0x' + address.slice(2).padStart(64, '0'); + return { ...r, from: ALICE, to: entryPoint, blockHash: stale ? m.blockHash(101, 'old') : m.blockHash(101), logs: [ + { ...sample, address: entryPoint, logIndex: '0x0', topics: [BEFORE_EXECUTION_EVENT], data: '0x' }, + ...r.logs, + { ...sample, address: entryPoint, logIndex: '0x3', topics: [USER_OPERATION_EVENT, hash('userop'), topic(BOB), topic(ZERO)], data: '0x' }, + ] } as typeof r; + }; + await indexer.pollLaunches('base'); + const [pending] = await db `SELECT trader,trader_via,via_app FROM bb_launch_swaps WHERE tx_hash=${hash('new-swap')}`; + assert.equal(pending.trader, ALICE); + assert.equal(pending.trader_via, 'receipt_pending'); + assert.equal(pending.via_app, null); + stale = false; + await indexer.pollLaunches('base'); + await indexer.attributeBacklog('base'); + const [healed] = await db `SELECT trader,trader_via FROM bb_launch_swaps WHERE tx_hash=${hash('new-swap')}`; + assert.equal(healed.trader, BOB); + assert.equal(healed.trader_via, 'userop'); + }); + await check('capped transfer logs without receipt fallback never advance partial holder accounting', async () => { + const db = await database('capped'), m = standardModel(); + activate(db, m); + const before = await snapshot(db); + const original = m.request.bind(m); + m.request = async (method, params) => { + if (method === 'eth_getLogs') { + const addresses = (params[0] as { address: string | string[] }).address; + const values = Array.isArray(addresses) ? addresses : [addresses]; + if (values.some((a: string) => a?.toLowerCase() === A)) + throw Error('query returned more than 10000 results'); + } + return original(method, params); + }; + await assert.rejects(indexer.pollLaunches('base'), /Transfer logs.*receipts are unavailable/); + assert.deepEqual(await snapshot(db), before); + assert.equal((await db `SELECT * FROM bb_launch_blocks`).length, 0); + assert.equal(BigInt((await db `SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id=8453`)[0].cursor_block), 0n); + m.request = original; + await indexer.pollLaunches('base'); + return compareOracle('capped', db, m); + }); + await check('a lagging RPC head fails closed without erasing previously indexed history', async () => { + const f = await indexedOld('shorthead'); + const before = await snapshot(f.db); + f.model.head = 101; + activate(f.db, f.model); + await assert.rejects(indexer.pollLaunches('base'), /RPC head is behind/); + assert.deepEqual(await snapshot(f.db), before); + assert.equal((await f.db `SELECT count(*)::int AS n FROM bb_launch_blocks`)[0].n, 3); + }); + await check('receipt-triggered reorg retains a rebuild target until the poller restores old coverage', async () => { + const db = await database('receiptcoverage'), m = standardModel(); + m.head = 200; + activate(db, m); + await indexer.pollLaunches('base'); + const [old] = await db `SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id=8453`; + m.branch = 'new'; + await indexer.applyLaunchTx('base', hash('new-swap')); + const [pending] = await db `SELECT cursor_block,rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id=8453`; + assert.equal(BigInt(pending.rebuilding_until), BigInt(old.cursor_block)); + assert.ok(BigInt(pending.cursor_block) < BigInt(pending.rebuilding_until)); + await indexer.pollLaunches('base'); + assert.equal((await db `SELECT rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id=8453`)[0].rebuilding_until, null); + return compareOracle('receiptcoverage', db, m); + }); + await check('generated sparse checkpoints locate exactly the last matching ancestor', async () => { + let seed = 0x4f50454e; + const random = (n: number) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed % n; }; + for (let sample = 0; sample < 24; sample++) { + const db = await database('sparse'), m = standardModel(); + m.head = 260; + const heights = [...new Set([100, 220, ...Array.from({ length: random(9) }, () => 101 + random(119))])].sort((a, b) => a - b); + m.fork = sample === 0 ? 100 : sample === 1 ? 221 : 100 + random(122); + await db `UPDATE bb_launch_sync_cursor SET reorg_version=1,cursor_block=220,head_block=260 WHERE chain_id=8453`; + for (const height of heights) + await db `INSERT INTO bb_launch_blocks (chain_id,block_number,block_hash) VALUES(8453,${height},${m.blockHash(height, 'old')})`; + m.branch = 'new'; + activate(db, m); + let clears = 0; + await withCanonicalIndex('base', 100n, () => { clears++; }, async () => { }); + const matching = heights.filter(h => h < m.fork), ancestor = matching.at(-1) ?? 99; + const reorg = m.fork <= 220; + const [state] = await db `SELECT cursor_block,rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id=8453`; + const audit = await db `SELECT from_block FROM bb_launch_reorgs`; + assert.equal(BigInt(state.cursor_block), BigInt(reorg ? ancestor : 220), `sample ${sample}: sparse boundary`); + assert.equal(audit.length, reorg ? 1 : 0); + if (reorg) { + assert.equal(BigInt(audit[0].from_block), BigInt(ancestor + 1)); + assert.equal(BigInt(state.rebuilding_until), 220n); + } + assert.equal(clears, 1, 'generation transition must clear the local caches before work'); + await withCanonicalIndex('base', 100n, () => { clears++; }, async () => { }); + assert.equal(clears, 1, 'unchanged generation keeps normal retry throttling and caches'); + } + }); + await check('failed canonical work clears process caches and retries from persisted generation', async () => { + const { db, model } = await indexedOld('cachefailure'); + activate(db, model); + let clears = 0; + await assert.rejects(withCanonicalIndex('base', 100n, () => { clears++; }, async () => { throw Error('synthetic after-read failure'); }), /synthetic after-read failure/); + assert.equal(clears, 1, 'failure clears data populated by the failed transaction'); + await withCanonicalIndex('base', 100n, () => { clears++; }, async () => { }); + assert.equal(clears, 2, 'retry recognizes the forgotten local generation and clears again'); + }); + await directChecks({ check, indexedOld }); + } + finally { + setDb(null); + for (const r of resources) { + await r.db.end({ timeout: 5 }); + assert.match(r.name, /^reorg_security_[a-z0-9_]+_[0-9a-f]{16}$/); + await admin.unsafe(`DROP SCHEMA "${r.name}" CASCADE`); + } + await admin.end({ timeout: 5 }); + } + } + await main(); +} diff --git a/app/src/app/api/admin/session/route.test.ts b/app/src/app/api/admin/session/route.test.ts new file mode 100644 index 00000000..a2281f3d --- /dev/null +++ b/app/src/app/api/admin/session/route.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import test from "node:test"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { getMock, resetMock } from "../../../../lib/db-mock.ts"; +import { buildAdminSignInMessage } from "../../../../lib/admin/signIn.ts"; +import { POST } from "./route.ts"; + +let sequence = 0; +function fixture() { + resetMock(); + const account = privateKeyToAccount(generatePrivateKey()); + const nonces = new Set(); + let sessions = 0; + let onRead: (() => Promise) | undefined; + getMock().query = async (sql, values) => { + const nonce = String(values[0]); + if (sql.includes("SELECT 1 FROM bb_admin_nonces")) { + const open = nonces.has(nonce); + await onRead?.(); + return open ? [{}] : []; + } + if (sql.includes("UPDATE bb_admin_nonces SET used_at")) { + if (!nonces.delete(nonce)) return []; + return [{ nonce }]; + } + if (sql.includes("INSERT INTO bb_admin_sessions")) sessions++; + return []; + }; + async function signed() { + const nonce = randomBytes(16).toString("hex"); + nonces.add(nonce); + const message = buildAdminSignInMessage({ origin: "https://openlaunch.lol", address: account.address, nonce, issuedAt: new Date() }); + return { message, signature: await account.signMessage({ message }), nonce }; + } + function submit(body: unknown) { + return POST(new Request("http://localhost/api/admin/session", { + method: "POST", headers: { "content-type": "application/json", origin: "https://openlaunch.lol", "fly-client-ip": `admin-quota-${++sequence}` }, body: JSON.stringify(body), + })); + } + function barrier(count: number) { + let arrived = 0; + let release!: () => void; + const ready = new Promise((resolve) => { release = resolve; }); + onRead = async () => { if (++arrived === count) { onRead = undefined; release(); } await ready; }; + } + return { account, nonces, sessions: () => sessions, signed, submit, barrier }; +} + +test("concurrent admin sign-in replays cannot consume the wallet's sign-in allowance", async (t) => { + const f = fixture(); + const previous = process.env.ADMIN_WALLETS; + process.env.ADMIN_WALLETS = f.account.address; + t.after(() => { if (previous === undefined) delete process.env.ADMIN_WALLETS; else process.env.ADMIN_WALLETS = previous; }); + const original = await f.signed(); + f.barrier(12); // Every precheck sees the nonce open before any contender consumes it. + const replies = await Promise.all(Array.from({ length: 12 }, () => f.submit(original))); + assert.equal(replies.filter((r) => r.status === 200).length, 1); + assert.equal(replies.filter((r) => r.status === 401).length, 11); + assert.equal(f.sessions(), 1); + assert.equal((await f.submit(await f.signed())).status, 200, "fresh login retains its allowance"); + assert.equal(f.sessions(), 2); +}); + +test("fresh signed admin logins retain the cap and over-quota nonces cannot be replayed", async (t) => { + const f = fixture(); + const previous = process.env.ADMIN_WALLETS; + process.env.ADMIN_WALLETS = f.account.address; + t.after(() => { if (previous === undefined) delete process.env.ADMIN_WALLETS; else process.env.ADMIN_WALLETS = previous; }); + for (let i = 0; i < 10; i++) assert.equal((await f.submit(await f.signed())).status, 200); + const excess = await f.signed(); + assert.equal((await f.submit(excess)).status, 429); + assert.equal(f.nonces.has(excess.nonce), false); + assert.equal((await f.submit(excess)).status, 401); + assert.equal(f.sessions(), 10); +}); diff --git a/app/src/app/api/admin/session/route.ts b/app/src/app/api/admin/session/route.ts index 94bce097..ded09d99 100644 --- a/app/src/app/api/admin/session/route.ts +++ b/app/src/app/api/admin/session/route.ts @@ -42,10 +42,12 @@ export async function POST(req: Request) { if (v === "down") return json({ error: "signature check unavailable, try again" }, 503); if (v === "bad") return json({ error: "signature does not match (with a smart wallet, switch it to Base and sign again)" }, 401); if (typeof v === "object") return json({ error: "switch your wallet to Base and sign again" }, 409); - // the wallet bucket is spent only after the wallet is proven (a stranger cannot lock an admin out) - if (rateLimited(`admin-signin:wallet:${c.wallet}`, 10)) return json({ error: "slow down" }, 429); try { if (!(await consumeAdminNonce(c.nonce))) return json({ error: "sign-in expired or already used, try again" }, 401); + // Concurrent copies can all pass the earlier read. Only the atomic nonce + // winner spends a wallet allowance; a fresh over-quota attempt consumes its + // nonce and the sign-in UI obtains another on retry. + if (rateLimited(`admin-signin:wallet:${c.wallet}`, 10)) return json({ error: "slow down" }, 429); const device = deviceLabel(req.headers.get("user-agent")); const s = await createAdminSession(c.wallet, device); console.info(`[admin] signed in: ${c.wallet} (${device})`); diff --git a/app/src/app/api/health/route.ts b/app/src/app/api/health/route.ts index 6248216b..f6395942 100644 --- a/app/src/app/api/health/route.ts +++ b/app/src/app/api/health/route.ts @@ -38,7 +38,8 @@ export async function GET() { ); chains = rows.flatMap((r) => { const chain = chainKeyOf(r.chain_id); - return chain && launchpad(chain).configured ? [{ chain, cursor_block: blockNumber(r.cursor_block), head_block: blockNumber(r.head_block), last_run_at: r.last_run_at, last_error: r.last_error }] : []; + // Stored diagnostics (including older rows) may contain credentials outside URLs. + return chain && launchpad(chain).configured ? [{ chain, cursor_block: blockNumber(r.cursor_block), head_block: blockNumber(r.head_block), last_run_at: r.last_run_at, last_error: r.last_error ? "sync_failed" : null }] : []; }); // a configured chain with no cursor row has never been indexed (its deploy block is unset, or the loop is off): say so for (const k of CONFIGURED_CHAINS) if (!chains.some((c) => c.chain === k)) chains.push({ chain: k, cursor_block: null, head_block: null, last_run_at: null, last_error: `never indexed: the sync loop has not run for ${k} (LAUNCH_DEPLOY_BLOCK for ${k} unset, or LAUNCH_SYNC_LOOP off)` }); diff --git a/app/src/app/api/launch/chart-provider/route.ts b/app/src/app/api/launch/chart-provider/route.ts index 9265b69c..d2461de2 100644 --- a/app/src/app/api/launch/chart-provider/route.ts +++ b/app/src/app/api/launch/chart-provider/route.ts @@ -1,3 +1,4 @@ +import { clientIp } from "@/lib/profiles/http"; import { isChartPool } from "@/lib/launchpad/chart-pool"; import { lookupGeckoPool } from "@/lib/launchpad/geckoterminal"; import { createGeckoCache } from "@/lib/launchpad/gecko-cache"; @@ -8,7 +9,7 @@ const lookup = createGeckoCache(lookupGeckoPool, () => Date.now()); export async function GET(request: Request) { const headers = { "cache-control": "no-store" }; - const ip = (request.headers.get("fly-client-ip") || request.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(request); if (rateLimited(`chart:ip:${ip}`, 60)) return Response.json({ error: "Too many chart requests. Try again shortly." }, { status: 429, headers: { ...headers, "retry-after": "60" } }); const query = new URL(request.url).searchParams; const pool = { chain: query.get("chain"), token: query.get("token"), poolId: query.get("pool"), quote: query.get("quote") }; diff --git a/app/src/app/api/launch/edit/nonce/route.ts b/app/src/app/api/launch/edit/nonce/route.ts index 32d7a287..4596cd37 100644 --- a/app/src/app/api/launch/edit/nonce/route.ts +++ b/app/src/app/api/launch/edit/nonce/route.ts @@ -1,3 +1,4 @@ +import { clientIp, readBody } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { isAddress } from "viem"; import { isChainKey } from "@/lib/chainPublic"; @@ -7,11 +8,14 @@ export const dynamic = "force-dynamic"; /** POST {chain, token, wallet} → {nonce, expiresAt}. Only the token's creator gets one. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`nonce:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + const raw = await readBody(req, 64 * 1024); + if (!raw.ok) return NextResponse.json({ error: raw.reason === "too_large" ? "body too large" : "bad json" }, { status: raw.reason === "too_large" ? 413 : 400 }); let b: { chain?: unknown; token?: unknown; wallet?: unknown }; try { - b = (await req.json()) as typeof b; + b = JSON.parse(new TextDecoder().decode(raw.bytes)) as typeof b; + if (!b || typeof b !== "object" || Array.isArray(b)) return NextResponse.json({ error: "bad json" }, { status: 400 }); } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } diff --git a/app/src/app/api/launch/edit/route.ts b/app/src/app/api/launch/edit/route.ts index 830141b3..cf584b8e 100644 --- a/app/src/app/api/launch/edit/route.ts +++ b/app/src/app/api/launch/edit/route.ts @@ -1,3 +1,4 @@ +import { clientIp, readBody } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { isChainKey } from "@/lib/chainPublic"; import { applySignedEdit, rateLimited } from "@/lib/launchpad/editServer"; @@ -6,11 +7,15 @@ export const dynamic = "force-dynamic"; /** POST {chain, token, wallet, nonce, expiresAt, signature, fields} → applies a creator-signed metadata edit. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`edit:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + // Smart-wallet signatures can include ERC-6492 deployment calldata. + const raw = await readBody(req, 256 * 1024); + if (!raw.ok) return NextResponse.json({ error: raw.reason === "too_large" ? "body too large" : "bad json" }, { status: raw.reason === "too_large" ? 413 : 400 }); let b: Record; try { - b = (await req.json()) as Record; + b = JSON.parse(new TextDecoder().decode(raw.bytes)) as Record; + if (!b || typeof b !== "object" || Array.isArray(b)) return NextResponse.json({ error: "bad json" }, { status: 400 }); } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } diff --git a/app/src/app/api/launch/error-disclosure.test.ts b/app/src/app/api/launch/error-disclosure.test.ts new file mode 100644 index 00000000..2ffb2cdd --- /dev/null +++ b/app/src/app/api/launch/error-disclosure.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; +import { HttpRequestError } from "viem"; +import { getMock, resetMock } from "../../../lib/db-mock.ts"; + +const token = "0x2222222222222222222222222222222222222222"; +const launcher = "0x1111111111111111111111111111111111111111"; +const tx = `0x${"ab".repeat(32)}`; +const input = { chain: "base", launcher, salt: `0x${"cd".repeat(32)}`, name: "Test launch", symbol: "TEST" }; +const secret = "synthetic-rpc-key-do-not-disclose"; +const diagnostic = `Authorization: Bearer ${secret}; database host: private-db.internal`; +const failures = [ + ["viem transport error", new HttpRequestError({ url: `https://rpc.example.test/v2/${secret}`, details: diagnostic })], + ["plain Error", new Error(diagnostic)], + ["thrown string", diagnostic], + ["thrown object", { toString: () => diagnostic }], +] as const; + +let metaPost: typeof import("./meta/route.ts").POST; +let syncPost: typeof import("./sync/route.ts").POST; +let healthGet: typeof import("../health/route.ts").GET; +let configuredChains: typeof import("../../../lib/launchpad/config.ts").CONFIGURED_CHAINS; +let lastError: string | null = null; +let sequence = 0; +const previousEnv = new Map(); + +before(async () => { + // Configuration is read at import time. Only the test's Base chain is enabled. + const env: Record = { + NEXT_PUBLIC_LAUNCH_FACTORY: launcher, + NEXT_PUBLIC_LAUNCH_LOCKER: token, + LAUNCH_DEPLOY_BLOCK: "10", + LAUNCH_SYNC_CONFIRMATIONS: "2", + LAUNCH_SYNC_CONFIRMATIONS_BASE: "2", + }; + for (const chain of ["ROBINHOOD", "ARC", "ELYSIUM"]) { + env[`NEXT_PUBLIC_LAUNCH_FACTORY_${chain}`] = undefined; + env[`NEXT_PUBLIC_LAUNCH_LOCKER_${chain}`] = undefined; + } + for (const [key, value] of Object.entries(env)) { + previousEnv.set(key, process.env[key]); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + [{ POST: metaPost }, { POST: syncPost }, { GET: healthGet }, { CONFIGURED_CHAINS: configuredChains }] = await Promise.all([ + import("./meta/route.ts"), import("./sync/route.ts"), import("../health/route.ts"), import("../../../lib/launchpad/config.ts"), + ]); +}); + +after(() => { + resetMock(); + for (const [key, value] of previousEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +async function query(sql: string, values: unknown[]): Promise { + if (sql.includes("INSERT INTO bb_launch_meta")) return [{ token }]; + if (sql.includes("SELECT cursor_block")) return [{ cursor_block: 0n, reorg_version: 1, reorg_generation: 0n, rebuilding_until: null }]; + if (sql.includes("last_error = ?")) lastError = String(values[1]); + if (sql.includes("last_error = NULL")) lastError = null; + if (sql.includes("select chain_id, cursor_block, head_block")) return [{ chain_id: 8453, cursor_block: 10n, head_block: 12n, last_run_at: "2026-01-01T00:00:00Z", last_error: lastError }]; + if (sql.includes("pg_try_advisory_lock")) return [{ ok: false }]; + return []; +} + +beforeEach(() => { + resetMock(); + lastError = null; + process.env.LAUNCH_DEPLOY_BLOCK = "10"; + getMock().query = query; + getMock().publicClient = { + readContract: async () => token, + getBlockNumber: async () => 12n, + getBlock: async ({ blockNumber }: { blockNumber: bigint }) => ({ number: blockNumber, hash: tx, timestamp: 1_700_000_000n }), + getLogs: async () => [], + // Main reads calldata alongside the receipt to attribute in-app swaps. + getTransaction: async () => ({ from: launcher, to: token, input: "0x" }), + getTransactionReceipt: async () => ({ status: "success", transactionHash: tx, blockNumber: 10n, blockHash: tx, from: launcher, to: token, logs: [] }), + }; +}); + +function request(path: string, body?: unknown): Request { + return new Request(`http://localhost/api/launch/${path}`, { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": `disclosure-test-${++sequence}` }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +for (const [kind, failure] of failures) { + test(`metadata hides ${kind} from the real prediction/save chain`, async () => { + getMock().publicClient = { readContract: async () => { throw failure; } }; + const response = await metaPost(request("meta", input)); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "metadata save failed" }); + }); + + test(`sync polling hides ${kind} in per-chain results and health`, async (t) => { + t.mock.method(console, "error", () => {}); + getMock().publicClient = { getBlockNumber: async () => { throw failure; } }; + const response = await syncPost(request("sync")); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { base: { status: "skipped", reason: "sync_failed" } }); + assert.ok(lastError, "the indexer still records a failure for health monitoring"); + const health = await healthGet(); + assert.equal(health.status, 200); + const body = await health.json(); + assert.equal(body.ok, false); + assert.deepEqual(body.alerts, ["sync_error"]); + assert.equal(body.sync.last_error, "sync_failed"); + assert.equal(body.chains[0].last_error, "sync_failed"); + assert.doesNotMatch(JSON.stringify(body), /synthetic-rpc-key|Authorization|private-db/); + }); + + test(`receipt sync hides ${kind} after reading a valid receipt`, async () => { + getMock().query = async () => { throw failure; }; + const response = await syncPost(request(`sync?chain=base&tx=${tx}`)); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "sync failed" }); + }); + + test(`sync route hides ${kind} if the overall poll rejects`, async (t) => { + Object.defineProperty(configuredChains, "map", { configurable: true, value: () => { throw failure; } }); + t.after(() => { Reflect.deleteProperty(configuredChains, "map"); }); + const response = await syncPost(request("sync")); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "sync failed" }); + }); +} + +test("receipt sync reports advisory contention as pending without exposing database diagnostics", async () => { + const queries: string[] = []; + getMock().query = async (sql, values) => { + queries.push(sql); + if (sql.includes("pg_advisory_xact_lock")) throw Object.assign(new Error(diagnostic), { code: "55P03" }); + return query(sql, values); + }; + const response = await syncPost(request(`sync?chain=base&tx=${tx}`)); + assert.equal(response.status, 202); + assert.equal(response.headers.get("retry-after"), "2"); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { status: "pending" }); + assert.equal(queries.some((sql) => sql.includes("LOCK TABLE") || sql.includes("INSERT INTO bb_launch_sync_cursor")), false); +}); + +test("metadata success preserves the URI and predicted token", async () => { + const response = await metaPost(request("meta", input)); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.token, token); + assert.ok(body.uri.endsWith(`/api/launch/meta/${launcher}/${input.salt}`)); +}); + +test("metadata retains actionable key and token conflict messages", async () => { + for (const ownerExists of [true, false]) { + getMock().query = async (sql) => { + if (sql.includes("SELECT launcher") && (ownerExists || sql.includes("AND token ="))) { + return [{ launcher, name: "Different launch", symbol: "OTHER" }]; + } + return []; + }; + const response = await metaPost(request("meta", input)); + assert.equal(response.status, 409); + assert.match((await response.json()).error, /already registered/); + } +}); + +test("metadata retains validation and malformed JSON errors", async () => { + const invalid = await metaPost(request("meta", { ...input, name: "" })); + assert.equal(invalid.status, 400); + assert.match((await invalid.json()).error, /^name:/); + const malformed = await metaPost(new Request("http://localhost/api/launch/meta", { method: "POST", body: "{" })); + assert.equal(malformed.status, 400); + assert.deepEqual(await malformed.json(), { error: "bad json" }); +}); + +test("sync success still advances a range and returns its counters", async () => { + lastError = "previous failure"; + const response = await syncPost(request("sync")); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { base: { + status: "synced", from: "11", to: "10", head: "12", launches: 0, swaps: 0, fees: 0, boosts: 0, caught_up: true, + } }); + assert.equal(lastError, null, "successful indexing clears the previous failure"); +}); + +test("receipt sync preserves applied, missing and reverted outcomes", async () => { + const response = await syncPost(request(`sync?chain=base&tx=${tx}`)); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: "applied", launches: 0, swaps: 0, fees: 0, boosts: 0, tokens: [] }); + getMock().publicClient = { ...getMock().publicClient, getTransactionReceipt: async () => { throw new Error(diagnostic); } }; + assert.deepEqual(await (await syncPost(request(`sync?tx=${tx}`))).json(), { status: "not_found" }); + getMock().publicClient = { ...getMock().publicClient, getTransactionReceipt: async () => ({ status: "reverted" }) }; + assert.deepEqual(await (await syncPost(request(`sync?tx=${tx}`))).json(), { status: "reverted" }); +}); + +test("sync keeps validation and configuration skip reasons", async () => { + for (const [path, error] of [["sync?tx=bad", "bad tx"], [`sync?chain=bad&tx=${tx}`, "bad chain"]]) { + const response = await syncPost(request(path)); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error }); + } + delete process.env.LAUNCH_DEPLOY_BLOCK; + const response = await syncPost(request("sync")); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { base: { status: "skipped", reason: "deploy_block_unset" } }); +}); + +test("health hides previously stored diagnostics in both response representations", async (t) => { + t.mock.method(console, "error", () => {}); + lastError = diagnostic; + const response = await healthGet(); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.sync.last_error, "sync_failed"); + assert.equal(body.chains[0].last_error, "sync_failed"); + assert.deepEqual(body.alerts, ["sync_error"]); + assert.equal(body.sync.cursor_block, 10); + assert.equal(body.sync.head_block, 12); + assert.equal(body.sync.lag_blocks, 2); +}); diff --git a/app/src/app/api/launch/image/route.test.ts b/app/src/app/api/launch/image/route.test.ts new file mode 100644 index 00000000..44af451c --- /dev/null +++ b/app/src/app/api/launch/image/route.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import sharp from "sharp"; +import { BANNER_MAX_BYTES, IMAGE_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR } from "@/lib/launchpad/images"; +import { POST } from "./route.ts"; + +const wallet = (n: number) => `0x${n.toString(16).padStart(40, "0")}`; +const garbage = new Uint8Array(32); +const ipLimit = IMAGE_UPLOADS_PER_HOUR * 2; + +function request(ip: string, owner: string, bytes: Uint8Array, kind?: string, headers?: HeadersInit): Request { + const form = new FormData(); + form.set("wallet", owner); + // Intentionally misleading extension/MIME: the route must validate the actual bytes. + form.set("file", new Blob([new Uint8Array(bytes)], { type: "image/png" }), "upload.png"); + if (kind) form.set("kind", kind); + return new Request("http://localhost/api/launch/image", { + method: "POST", headers: { "x-forwarded-for": ip, ...headers }, body: form, + }); +} + +test("image upload route validates images and limits anonymous callers by trusted IP", async (t) => { + const env = { + BUCKET_NAME: "image-route-test", + AWS_ENDPOINT_URL_S3: "https://image-store.invalid", + AWS_ACCESS_KEY_ID: "test-key", + AWS_SECRET_ACCESS_KEY: "test-secret", + IMAGE_PUBLIC_BASE: "https://images.example/api/launch/image", + }; + const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]])); + Object.assign(process.env, env); + t.after(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + // Exercise the real multipart parser, limiter, decoder and storage command without doing I/O. + const stored: PutObjectCommand[] = []; + t.mock.method(S3Client.prototype, "send", async (command: PutObjectCommand) => { + assert.ok(command instanceof PutObjectCommand); + stored.push(command); + return {}; + }); + const png = await sharp({ create: { width: 600, height: 240, channels: 4, background: "#0052ff" } }) + .withMetadata({ exif: { IFD0: { Copyright: "private metadata" } } }).png().toBuffer(); + + await t.test("invalid uploads naming another wallet cannot spend that wallet's allowance", async () => { + for (let i = 0; i < IMAGE_UPLOADS_PER_HOUR; i++) { + const denied = await POST(request("192.0.2.1", wallet(1), garbage)); + assert.equal(denied.status, 415); + } + const response = await POST(request("192.0.2.2", wallet(1), png)); + assert.equal(response.status, 200, "the named wallet can still upload from its own IP"); + assert.match((await response.json()).url, /\/t\/[0-9a-f]{48}\.webp$/); + }); + + await t.test("valid uploads naming another wallet cannot spend that wallet's allowance", async () => { + for (let i = 0; i < IMAGE_UPLOADS_PER_HOUR; i++) { + const response = await POST(request("192.0.2.3", wallet(2), png)); + assert.equal(response.status, 200); + } + const response = await POST(request("192.0.2.4", wallet(2), png)); + assert.equal(response.status, 200, "valid bytes still do not prove ownership of the wallet field"); + }); + + await t.test("rotating wallet fields cannot bypass the existing IP allowance", async () => { + const before = stored.length; + for (let i = 0; i < ipLimit; i++) { + const response = await POST(request("192.0.2.5", wallet(100 + i), i % 2 ? garbage : png)); + assert.equal(response.status, i % 2 ? 415 : 200); + } + const denied = await POST(request("192.0.2.5", wallet(999), png)); + assert.equal(denied.status, 429); + assert.deepEqual(await denied.json(), { error: "slow down" }); + assert.equal(stored.length - before, ipLimit / 2, "invalid and over-budget requests never reach storage"); + }); + + await t.test("spoofing forwarded prefixes cannot buy new IP allowances", async () => { + for (let i = 0; i < ipLimit; i++) { + const response = await POST(request(`198.51.100.${i}, 192.0.2.6`, wallet(200 + i), garbage)); + assert.equal(response.status, 415); + } + const denied = await POST(request("198.51.100.254, 192.0.2.6", wallet(998), png)); + assert.equal(denied.status, 429); + assert.deepEqual(await denied.json(), { error: "slow down" }); + }); + + await t.test("Fly's client IP takes precedence over forwarded values", async () => { + for (let i = 0; i < ipLimit; i++) { + const response = await POST(request(`198.51.100.${i}`, wallet(300 + i), garbage, undefined, { "fly-client-ip": "192.0.2.7" })); + assert.equal(response.status, 415); + } + assert.equal((await POST(request("198.51.100.254", wallet(997), png, undefined, { "fly-client-ip": "192.0.2.7" }))).status, 429); + }); + + await t.test("wallet shape, format, decoder and size failures retain their responses", async () => { + const before = stored.length; + const disguised = new Uint8Array(32); + disguised.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const tiny = await sharp({ create: { width: 8, height: 8, channels: 4, background: "#0052ff" } }).png().toBuffer(); + for (const [req, status, error] of [ + [request("192.0.2.8", "not-a-wallet", png), 400, "connect a wallet first"], + [request("192.0.2.8", wallet(3), garbage), 415, "PNG, JPEG, WebP or GIF only"], + [request("192.0.2.8", wallet(3), disguised), 415, "not a valid image"], + [request("192.0.2.8", wallet(3), tiny), 415, "image too small (min 16×16)"], + [request("192.0.2.8", wallet(3), new Uint8Array(IMAGE_MAX_BYTES + 1)), 413, "image must be ≤ 2 MB"], + [request("192.0.2.8", wallet(3), new Uint8Array(BANNER_MAX_BYTES + 1), "banner"), 413, "image must be ≤ 4 MB"], + [request("192.0.2.8", wallet(3), png, undefined, { "content-length": String(BANNER_MAX_BYTES + 16 * 1024 + 1) }), 413, "image must be ≤ 4 MB"], + ] as const) { + const response = await POST(req); + assert.equal(response.status, status); + assert.deepEqual(await response.json(), { error }); + } + assert.equal(stored.length, before, "rejected requests are never stored"); + }); + + await t.test("logos and banners still store stripped WebP bytes with random keys", async () => { + const keys: string[] = []; + for (const [kind, width, height] of [[undefined, 512, 512], ["banner", 1500, 500]] as const) { + const response = await POST(request("192.0.2.9", wallet(4), png, kind)); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + const body = await response.json(); + const command = stored.at(-1)!; + assert.match(command.input.Key!, /^t\/[0-9a-f]{48}\.webp$/); + keys.push(command.input.Key!); + assert.equal(body.url, `${env.IMAGE_PUBLIC_BASE}/${command.input.Key}`); + assert.equal(command.input.ContentType, "image/webp"); + const output = command.input.Body as Buffer; + assert.equal(body.bytes, output.length); + const metadata = await sharp(output).metadata(); + assert.equal(metadata.format, "webp"); + assert.equal(metadata.width, width); + assert.equal(metadata.height, height); + assert.equal(metadata.exif, undefined); + assert.equal(output.includes("private metadata"), false); + } + assert.notEqual(keys[0], keys[1]); + }); + + for (const declared of [undefined, "10"]) { + await t.test(`stops reading oversized multipart streams with ${declared === undefined ? "no" : "an understated"} size header`, async () => { + const before = stored.length; + const chunk = new Uint8Array(64 * 1024); + const availableChunks = 80; + let chunksRead = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + if (chunksRead === availableChunks) controller.close(); + else { chunksRead++; controller.enqueue(chunk); } + }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }); + const req = new Request("http://localhost/api/launch/image", { + method: "POST", body, duplex: "half", + headers: { "content-type": "multipart/form-data; boundary=test", "x-forwarded-for": "192.0.2.10", ...(declared === undefined ? {} : { "content-length": declared }) }, + } as RequestInit & { duplex: "half" }); + const response = await POST(req); + assert.equal(response.status, 413, `read ${chunksRead}/${availableChunks} chunks; cancelled=${cancelled}`); + assert.deepEqual(await response.json(), { error: "image must be ≤ 4 MB" }); + assert.equal(chunksRead, Math.floor((BANNER_MAX_BYTES + 16 * 1024) / chunk.length) + 1); + assert.ok(chunksRead < availableChunks, "the parser must not drain the rest of the request"); + assert.equal(cancelled, true); + assert.equal(body.locked, false); + assert.equal(stored.length, before); + }); + } +}); diff --git a/app/src/app/api/launch/image/route.ts b/app/src/app/api/launch/image/route.ts index 456b3af1..3e11010e 100644 --- a/app/src/app/api/launch/image/route.ts +++ b/app/src/app/api/launch/image/route.ts @@ -3,6 +3,7 @@ import { rateLimited } from "@/lib/launchpad/editServer"; import { BANNER_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, imageRole, isWalletParam, maxBytesFor, randomImageKey } from "@/lib/launchpad/images"; import { toBannerWebp, toLogoWebp } from "@/lib/launchpad/imageProcess"; import { imageUploadsEnabled, putImage } from "@/lib/launchpad/imageStore"; +import { clientIp, readBody } from "@/lib/profiles/http"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -11,22 +12,24 @@ const HOUR = 60 * 60_000; /** * POST multipart {wallet, file, kind?} → {url}. Token logo (or, with kind=banner, banner) upload for the launch - * form / edit sheet. Order: wallet + rate limits → size (Content-Length, then real bytes) → magic bytes → sharp + * form / edit sheet. Order: IP rate limit → size (Content-Length, then real bytes) + wallet shape → magic bytes → sharp * re-encode to a 512² logo or a 1500×500 banner WebP (metadata stripped; the decoder is the validator) → random key - * → public bucket. Logos and banners share the upload limits. + * → public bucket. Logos and banners share the IP upload limit. The wallet field is unsigned, so it cannot + * spend a wallet-scoped quota: anyone could otherwise exhaust another creator's allowance by naming their address. * The URL then flows through the normal metadata path (https-only rule, signed edits) unchanged. */ export async function POST(req: Request) { if (!imageUploadsEnabled()) return NextResponse.json({ error: "uploads not configured" }, { status: 503 }); - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`img:ip:${ip}`, IMAGE_UPLOADS_PER_HOUR * 2, HOUR)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - const declared = Number(req.headers.get("content-length") || 0); - // the role is inside the body, so the early check allows the larger (banner) cap; the real cap is applied below - if (declared > BANNER_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 4 MB" }, { status: 413 }); + // The role is inside multipart data. Bound the whole stream before parsing, allowing + // the larger banner cap plus form overhead; a missing/false size header is not trusted. + const body = await readBody(req, BANNER_MAX_BYTES + 16 * 1024); + if (!body.ok) return NextResponse.json({ error: body.reason === "too_large" ? "image must be ≤ 4 MB" : "bad form" }, { status: body.reason === "too_large" ? 413 : 400 }); let form: FormData; try { - form = await req.formData(); + form = await new Response(new Blob([body.bytes as Uint8Array]), { headers: { "content-type": req.headers.get("content-type") ?? "" } }).formData(); } catch { return NextResponse.json({ error: "bad form" }, { status: 400 }); } @@ -36,7 +39,6 @@ export async function POST(req: Request) { const max = maxBytesFor(role); if (!isWalletParam(wallet)) return NextResponse.json({ error: "connect a wallet first" }, { status: 400 }); if (!(file instanceof Blob)) return NextResponse.json({ error: "file required" }, { status: 400 }); - if (rateLimited(`img:wallet:${wallet.toLowerCase()}`, IMAGE_UPLOADS_PER_HOUR, HOUR)) return NextResponse.json({ error: "upload limit reached, try later" }, { status: 429 }); if (file.size > max) return NextResponse.json({ error: `image must be ≤ ${max / 1024 / 1024} MB` }, { status: 413 }); const bytes = new Uint8Array(await file.arrayBuffer()); diff --git a/app/src/app/api/launch/meta/route.ts b/app/src/app/api/launch/meta/route.ts index 5c5fe2e5..7b08fc35 100644 --- a/app/src/app/api/launch/meta/route.ts +++ b/app/src/app/api/launch/meta/route.ts @@ -1,8 +1,8 @@ +import { clientIp, readBody } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { MetaConflict, saveMeta, validateMeta } from "@/lib/launchpad/meta"; import { LAUNCHPAD_CONFIGURED } from "@/lib/launchpad/config"; -import { publicError } from "@/lib/launchpad/redact"; export const dynamic = "force-dynamic"; @@ -11,11 +11,13 @@ export async function POST(req: Request) { if (!LAUNCHPAD_CONFIGURED) return NextResponse.json({ error: "launchpad unconfigured" }, { status: 503 }); // Unsigned by design (salt secrecy decides conflicts), so the IP bucket is // the only thing stopping junk-row sprays and predictToken RPC burn. - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`meta:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + const raw = await readBody(req, 64 * 1024); + if (!raw.ok) return NextResponse.json({ error: raw.reason === "too_large" ? "body too large" : "bad json" }, { status: raw.reason === "too_large" ? 413 : 400 }); let body: unknown; try { - body = await req.json(); + body = JSON.parse(new TextDecoder().decode(raw.bytes)); } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } @@ -25,7 +27,7 @@ export async function POST(req: Request) { return NextResponse.json(await saveMeta(v.value)); } catch (err) { if (err instanceof MetaConflict) return NextResponse.json({ error: err.message }, { status: 409 }); - // never the raw message: predictToken is an RPC read, and its failure quotes the upstream URL (a keyed one carries its API key) - return NextResponse.json({ error: publicError(err) }, { status: 502 }); + // RPC/DB diagnostics can contain credentials, including keyed provider URLs. + return NextResponse.json({ error: "metadata save failed" }, { status: 502 }); } } diff --git a/app/src/app/api/launch/sync/route.ts b/app/src/app/api/launch/sync/route.ts index aa2b8385..5a5ed81b 100644 --- a/app/src/app/api/launch/sync/route.ts +++ b/app/src/app/api/launch/sync/route.ts @@ -1,9 +1,10 @@ +import { clientIp } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; import { rateLimited } from "@/lib/launchpad/editServer"; import { applyLaunchTx, pollAll } from "@/lib/launchpad/indexer"; +import { IndexLockBusyError } from "@/lib/launchpad/indexer-lock"; import { forgetBoostLane } from "@/lib/launchpad/boostServer"; -import { publicError } from "@/lib/launchpad/redact"; export const dynamic = "force-dynamic"; @@ -12,7 +13,7 @@ export async function POST(req: Request) { // Unauthenticated and the most expensive route in the app (a tx-less call // fans out over every configured chain: log ranges, heals and backfills), // so it gets the same per-IP bucket as the other write endpoints. - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`sync:ip:${ip}`, 10)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const u = new URL(req.url); const tx = u.searchParams.get("tx"); @@ -25,14 +26,14 @@ export async function POST(req: Request) { // a boost just indexed: this machine's next lane read is fresh, so the booster sees their place at once if (r.boosts) forgetBoostLane(); return NextResponse.json(r); - } catch (err) { - // never the raw message: a failed RPC read quotes the upstream URL, and a keyed provider URL carries its API key - return NextResponse.json({ error: publicError(err, "sync failed") }, { status: 502 }); + } catch (error) { + if (error instanceof IndexLockBusyError) return NextResponse.json({ status: "pending" }, { status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" } }); + return NextResponse.json({ error: "sync failed" }, { status: 502 }); } } try { return NextResponse.json(await pollAll()); - } catch (err) { - return NextResponse.json({ error: publicError(err, "sync failed") }, { status: 502 }); + } catch { + return NextResponse.json({ error: "sync failed" }, { status: 502 }); } } diff --git a/app/src/app/api/posts/mod/route.ts b/app/src/app/api/posts/mod/route.ts index 6b3133dc..037e2100 100644 --- a/app/src/app/api/posts/mod/route.ts +++ b/app/src/app/api/posts/mod/route.ts @@ -1,3 +1,4 @@ +import { clientIp, readBody } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { moderate } from "@/lib/launchpad/postsServer"; @@ -11,11 +12,15 @@ export const dynamic = "force-dynamic"; * session of the same wallet (moderate checks it), sent from the site's own pages. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`mod:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + // Smart-wallet signatures can include ERC-6492 deployment calldata. + const raw = await readBody(req, 256 * 1024); + if (!raw.ok) return NextResponse.json({ error: raw.reason === "too_large" ? "body too large" : "bad json" }, { status: raw.reason === "too_large" ? 413 : 400 }); let b: Record; try { - b = (await req.json()) as Record; + b = JSON.parse(new TextDecoder().decode(raw.bytes)) as Record; + if (!b || typeof b !== "object" || Array.isArray(b)) return NextResponse.json({ error: "bad json" }, { status: 400 }); } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } diff --git a/app/src/app/api/posts/report/route.test.ts b/app/src/app/api/posts/report/route.test.ts new file mode 100644 index 00000000..4c5413cc --- /dev/null +++ b/app/src/app/api/posts/report/route.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import test from "node:test"; +import { verifyMessage } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { getMock, resetMock } from "../../../../lib/db-mock.ts"; +import { buildReportMessage } from "../../../../lib/launchpad/posts.ts"; +import { POST } from "./route.ts"; + +let requestSequence = 0; +function fixture() { + resetMock(); + const signer = privateKeyToAccount(generatePrivateKey()); + const nonces = new Set(); + let writes = 0; + getMock().publicClient = { verifyMessage }; + getMock().query = async (sql, values) => { + if (sql.includes("SELECT chain_id, wallet FROM bb_posts")) return [{ chain_id: 8453, wallet: "0x1111111111111111111111111111111111111111" }]; + if (sql.includes("SELECT (SELECT count(*)")) return [{ n: 1n }]; + if (sql.includes("INSERT INTO bb_post_nonces")) { + const nonce = String(values[0]); + if (nonces.has(nonce)) throw new Error("duplicate nonce"); + nonces.add(nonce); + } + if (sql.includes("INSERT INTO bb_post_reports")) writes++; + if (sql.includes("SELECT count(*)::bigint")) return [{ n: 1n }]; + return []; + }; + async function signed(postId = 1) { + const input = { postId, wallet: signer.address, reason: "spam", nonce: randomBytes(16).toString("hex"), ts: Date.now() }; + return { ...input, signature: await signer.signMessage({ message: buildReportMessage(input) }) }; + } + function submit(input: unknown, ip = `report-test-${++requestSequence}`) { + // Calls the real route with a local Request; no HTTP/RPC traffic is sent. + return POST(new Request("http://localhost/api/posts/report", { + method: "POST", headers: { "content-type": "application/json", "fly-client-ip": ip }, body: JSON.stringify(input), + })); + } + return { signed, submit, nonces, writes: () => writes }; +} + +test("invalid reports cannot consume the named wallet's reporting allowance", async () => { + const f = fixture(); + const input = await f.signed(); + for (let i = 0; i < 21; i++) assert.equal((await f.submit({ ...input, reason: "invalid" })).status, 400); + for (let i = 0; i < 21; i++) assert.equal((await f.submit({ ...input, signature: "0x1234" })).status, 401); + assert.equal(f.nonces.size, 0); + assert.equal(f.writes(), 0); + assert.equal((await f.submit(input)).status, 200); + assert.equal(f.writes(), 1); +}); + +test("replays do not spend allowance; twenty fresh signed actions retain the wallet cap", async () => { + const f = fixture(); + const first = await f.signed(); + assert.equal((await f.submit(first)).status, 200); + for (let i = 0; i < 21; i++) assert.equal((await f.submit(first)).status, 401); + for (let i = 1; i < 20; i++) { + const input = await f.signed(i + 1); + // Case changes must not create a second wallet allowance. + assert.equal((await f.submit({ ...input, wallet: input.wallet.toLowerCase() })).status, 200); + } + assert.equal((await f.submit(await f.signed(21))).status, 429); + assert.equal(f.writes(), 20); +}); + +test("the IP limit still rejects excess invalid requests before verification", async () => { + const f = fixture(); + const ip = `report-limit-${++requestSequence}`; + const input = await f.signed(); + for (let i = 0; i < 20; i++) assert.equal((await f.submit({ ...input, signature: "0x1234" }, ip)).status, 401); + assert.equal((await f.submit(input, ip)).status, 429); + assert.equal(f.nonces.size, 0); + assert.equal(f.writes(), 0); +}); + +test("client-supplied forwarding prefixes cannot reset a proxy-appended IP bucket", async () => { + fixture(); + const proxyClient = `proxy-client-${++requestSequence}`; + for (let i = 0; i <= 20; i++) { + const response = await POST(new Request("http://localhost/api/posts/report", { + method: "POST", headers: { "content-type": "application/json", "x-forwarded-for": `untrusted-prefix-${i}, ${proxyClient}` }, body: "{}", + })); + assert.equal(response.status, i < 20 ? 400 : 429); + } +}); diff --git a/app/src/app/api/posts/report/route.ts b/app/src/app/api/posts/report/route.ts index e37c72b2..56cd0ef2 100644 --- a/app/src/app/api/posts/report/route.ts +++ b/app/src/app/api/posts/report/route.ts @@ -1,3 +1,4 @@ +import { clientIp, readBody } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { reportPost } from "@/lib/launchpad/postsServer"; @@ -6,16 +7,19 @@ export const dynamic = "force-dynamic"; /** POST {postId, wallet, reason, nonce, ts, signature} */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`report:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + // Smart-wallet signatures can include ERC-6492 deployment calldata. + const raw = await readBody(req, 256 * 1024); + if (!raw.ok) return NextResponse.json({ error: raw.reason === "too_large" ? "body too large" : "bad json" }, { status: raw.reason === "too_large" ? 413 : 400 }); let b: Record; try { - b = (await req.json()) as Record; + b = JSON.parse(new TextDecoder().decode(raw.bytes)) as Record; + if (!b || typeof b !== "object" || Array.isArray(b)) return NextResponse.json({ error: "bad json" }, { status: 400 }); } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } if (typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); - if (rateLimited(`report:wallet:${b.wallet.toLowerCase()}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const r = await reportPost({ postId: b.postId, wallet: b.wallet, reason: b.reason, nonce: b.nonce, ts: b.ts, signature: b.signature }); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); return NextResponse.json(r); diff --git a/app/src/app/api/posts/route.ts b/app/src/app/api/posts/route.ts index b8b35cec..4a63767d 100644 --- a/app/src/app/api/posts/route.ts +++ b/app/src/app/api/posts/route.ts @@ -1,3 +1,4 @@ +import { clientIp, readBody } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { isAddress } from "viem"; import { isChainKey } from "@/lib/chainPublic"; @@ -34,11 +35,15 @@ export async function GET(req: Request) { /** POST {chain, token, wallet, parentId?, body, nonce, ts, signature} → new post. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`post:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + // Smart-wallet signatures can include ERC-6492 deployment calldata. + const raw = await readBody(req, 256 * 1024); + if (!raw.ok) return NextResponse.json({ error: raw.reason === "too_large" ? "body too large" : "bad json" }, { status: raw.reason === "too_large" ? 413 : 400 }); let b: Record; try { - b = (await req.json()) as Record; + b = JSON.parse(new TextDecoder().decode(raw.bytes)) as Record; + if (!b || typeof b !== "object" || Array.isArray(b)) return NextResponse.json({ error: "bad json" }, { status: 400 }); } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } diff --git a/app/src/app/api/presence/route.ts b/app/src/app/api/presence/route.ts index 3f2d29a4..02f24007 100644 --- a/app/src/app/api/presence/route.ts +++ b/app/src/app/api/presence/route.ts @@ -1,3 +1,4 @@ +import { clientIp } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { looksLikeBot, prunePresence, readPulse, recordBeacon, visitorHash } from "@/lib/launchpad/presence"; @@ -16,7 +17,7 @@ let lastPrune = 0; export async function POST(req: Request) { const ua = req.headers.get("user-agent") ?? ""; if (looksLikeBot(ua)) return NextResponse.json(await readPulse(), { headers: { "cache-control": "no-store" } }); - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); // The visitor hash mixes in the UA, which the caller fully controls: without // a bucket, rotating UAs mints a fresh bb_presence row per request and // inflates visits. 30/min leaves normal beaconing (~every few seconds) alone. diff --git a/app/src/app/api/request-body.test.ts b/app/src/app/api/request-body.test.ts new file mode 100644 index 00000000..20136108 --- /dev/null +++ b/app/src/app/api/request-body.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +type Route = { path: string; cap: number; post: (req: Request) => Promise }; +let routes: Route[]; +let sequence = 0; +const previousFactory = process.env.NEXT_PUBLIC_LAUNCH_FACTORY; +const previousLocker = process.env.NEXT_PUBLIC_LAUNCH_LOCKER; +before(async () => { + process.env.NEXT_PUBLIC_LAUNCH_FACTORY = "0x1111111111111111111111111111111111111111"; + process.env.NEXT_PUBLIC_LAUNCH_LOCKER = "0x2222222222222222222222222222222222222222"; + const [posts, report, mod, edit, nonce, meta, rpc] = await Promise.all([ + import("./posts/route.ts"), import("./posts/report/route.ts"), import("./posts/mod/route.ts"), + import("./launch/edit/route.ts"), import("./launch/edit/nonce/route.ts"), import("./launch/meta/route.ts"), import("./rpc/route.ts"), + ]); + routes = [ + { path: "posts", cap: 256 * 1024, post: posts.POST }, { path: "posts/report", cap: 256 * 1024, post: report.POST }, + { path: "posts/mod", cap: 256 * 1024, post: mod.POST }, { path: "launch/edit", cap: 256 * 1024, post: edit.POST }, + { path: "launch/edit/nonce", cap: 64 * 1024, post: nonce.POST }, { path: "launch/meta", cap: 64 * 1024, post: meta.POST }, + { path: "rpc", cap: 256 * 1024, post: rpc.POST }, + ]; +}); +after(() => { + if (previousFactory === undefined) delete process.env.NEXT_PUBLIC_LAUNCH_FACTORY; + else process.env.NEXT_PUBLIC_LAUNCH_FACTORY = previousFactory; + if (previousLocker === undefined) delete process.env.NEXT_PUBLIC_LAUNCH_LOCKER; + else process.env.NEXT_PUBLIC_LAUNCH_LOCKER = previousLocker; +}); + +function request(path: string, body: BodyInit, headers: Record = {}) { + return new Request(`http://localhost/api/${path}`, { + method: "POST", body, duplex: "half", headers: { "content-type": "application/json", "fly-client-ip": `bounded-body-${++sequence}`, ...headers }, + } as RequestInit); +} + +function oversized(path: string, length?: string) { + const state = { pulled: 0, cancelled: 0 }; + const stream = new ReadableStream({ + pull(controller) { + if (state.pulled === 20) { controller.close(); return; } + state.pulled++; + controller.enqueue(new Uint8Array(32 * 1024).fill(32)); + }, + cancel() { state.cancelled++; }, + }, { highWaterMark: 0 }); + return { req: request(path, stream, length === undefined ? {} : { "content-length": length }), state }; +} + +test("JSON write and RPC routes reject finite oversized streams before buffering the remainder", async (t) => { + const network = t.mock.method(globalThis, "fetch", async () => { throw new Error("no network expected"); }); + for (const route of routes) { + for (const declared of [undefined, "1", String(route.cap + 1)]) { + await t.test(`${route.path}: Content-Length ${declared ?? "absent"}`, async () => { + const { req, state } = oversized(route.path, declared); + const response = await route.post(req); + assert.equal(response.status, 413); + assert.deepEqual(await response.json(), { error: "body too large" }); + assert.equal(state.cancelled, 1); + assert.equal(state.pulled, declared === String(route.cap + 1) ? 0 : route.cap / (32 * 1024) + 1); + assert.equal(req.body?.locked, false); + }); + } + } + assert.equal(network.mock.callCount(), 0); +}); + +test("bounded JSON routes retain malformed-body errors and small valid JSON reaches field validation", async () => { + for (const route of routes) { + const malformed = await route.post(request(route.path, "{broken")); + assert.equal(malformed.status, 400, route.path); + assert.deepEqual(await malformed.json(), { error: "bad json" }); + if (route.path !== "rpc") { + const empty = await route.post(request(route.path, "{}")); + assert.equal(empty.status, 400, route.path); + assert.notEqual((await empty.json()).error, "body too large"); + } + } +}); + +test("signed routes leave space for smart-wallet signature envelopes without weakening field checks", async () => { + // Representative encoded envelope size, not a valid signature: field validation must still run. + const body = JSON.stringify({ signature: `0x${"ab".repeat(64 * 1024)}` }); + for (const route of routes.filter((route) => ["posts", "posts/report", "posts/mod", "launch/edit"].includes(route.path))) { + const response = await route.post(request(route.path, body)); + assert.equal(response.status, 400, route.path); + assert.deepEqual(await response.json(), { error: "bad params" }); + } +}); + +test("RPC preserves ordinary objects and arrays of allowed calls after the bounded read", async (t) => { + const forwarded: unknown[] = []; + t.mock.method(globalThis, "fetch", async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { id: number } | { id: number }[]; + forwarded.push(body); + const answer = (call: { id: number }) => ({ jsonrpc: "2.0", id: call.id, result: "0x1" }); + return Response.json(Array.isArray(body) ? body.map(answer) : answer(body)); + }); + const rpc = routes.find((route) => route.path === "rpc")!; + const single = { jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }; + const batch = [single, { ...single, id: 2, method: "eth_call", params: [{ data: `0x${"ab".repeat(64 * 1024)}` }, "latest"] }]; + for (const body of [single, batch]) { + const response = await rpc.post(request("rpc", JSON.stringify(body))); + assert.equal(response.status, 200); + assert.equal(Array.isArray(await response.json()), Array.isArray(body)); + } + assert.deepEqual(forwarded, [single, batch]); +}); diff --git a/app/src/app/api/rpc/route.ts b/app/src/app/api/rpc/route.ts index 547bd818..95631914 100644 --- a/app/src/app/api/rpc/route.ts +++ b/app/src/app/api/rpc/route.ts @@ -1,3 +1,4 @@ +import { clientIp, readBody } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { b20RpcUrl, rpcUrl } from "@/lib/chain"; import { responseHasB20Error } from "@/lib/launchpad/baseStocks"; @@ -68,11 +69,14 @@ export async function POST(req: Request) { if (!isChainKey(c)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); const upstream = rpcUrl(c) ?? CHAINS[c].rpcUrls.default.http[0]; if (!upstream) return NextResponse.json({ error: "rpc unconfigured" }, { status: 503 }); - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (!take(ip)) return NextResponse.json({ error: "rate limited" }, { status: 429, headers: { "retry-after": "2" } }); + // Keep contract calldata and the existing 20-call batches, but never buffer an unbounded request. + const raw = await readBody(req, 256 * 1024); + if (!raw.ok) return NextResponse.json({ error: raw.reason === "too_large" ? "body too large" : "bad json" }, { status: raw.reason === "too_large" ? 413 : 400 }); let body: Req | Req[]; try { - body = (await req.json()) as Req | Req[]; + body = JSON.parse(new TextDecoder().decode(raw.bytes)) as Req | Req[]; } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } diff --git a/app/src/app/api/write-limits.test.ts b/app/src/app/api/write-limits.test.ts index 1fa58d2f..3aa0176d 100644 --- a/app/src/app/api/write-limits.test.ts +++ b/app/src/app/api/write-limits.test.ts @@ -17,7 +17,7 @@ function source(path: string): string { for ( const { file, bucket, mustPrecede } of [ { file: "./launch/sync/route.ts", bucket: "sync:ip", mustPrecede: "pollAll()" }, - { file: "./launch/meta/route.ts", bucket: "meta:ip", mustPrecede: "req.json()" }, + { file: "./launch/meta/route.ts", bucket: "meta:ip", mustPrecede: "readBody(" }, { file: "./presence/route.ts", bucket: "presence:ip", mustPrecede: "recordBeacon(" }, { file: "./boost/seen/route.ts", bucket: "boost-seen:ip", mustPrecede: "readJson(" }, ] diff --git a/app/src/app/t/[chain]/[token]/layout.tsx b/app/src/app/t/[chain]/[token]/layout.tsx index 8e186017..e380d98d 100644 --- a/app/src/app/t/[chain]/[token]/layout.tsx +++ b/app/src/app/t/[chain]/[token]/layout.tsx @@ -1,8 +1,7 @@ import { notFound } from "next/navigation"; import { isAddress } from "viem"; import { isChainKey } from "@/lib/chainPublic"; -import { getLaunch } from "@/lib/launchpad/queries"; -import { memo } from "@/lib/launchpad/memo"; +import { isKnownLaunchRoute } from "@/lib/launchpad/launch-recovery"; export const dynamic = "force-dynamic"; @@ -14,7 +13,7 @@ export const dynamic = "force-dynamic"; export default async function TokenLayout({ params, children }: { params: Promise<{ chain: string; token: string }>; children: React.ReactNode }) { const { chain, token } = await params; if (!isChainKey(chain) || !isAddress(token)) notFound(); - const exists = await memo(`launch-exists:${chain}:${token.toLowerCase()}`, 3_000, async () => Boolean(await getLaunch(chain, token))); - if (!exists) notFound(); + // Do not cache a miss: receipt ingestion/reinclusion can make this route ready immediately. + if (!await isKnownLaunchRoute(chain, token)) notFound(); return children; } diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index d0b1628e..e9cf460d 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -58,6 +58,8 @@ import { BOOST_CHAIN } from "@/lib/launchpad/boost"; import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer"; import { twigUsdFromGitlawb } from "@/lib/launchpad/twig"; import type { ProofKey } from "@/lib/launchpad/proof"; +import { getLaunchRecovery } from "@/lib/launchpad/launch-recovery"; +import TokenRecovery from "@/components/launchpad/TokenRecovery"; /** Where GITLAWB lives, as said beside a GITLAWB-quoted pool. */ const GITLAWB_ORIGIN: Record = { base: " on Base", robinhood: " (bridged 1:1 from Base over LayerZero; one supply, two chains)", arc: "" /* not bridged to Arc */, elysium: "" /* not bridged to Elysium */ }; @@ -68,7 +70,9 @@ export const dynamic = "force-dynamic"; export async function generateMetadata({ params }: { params: Promise<{ chain: string; token: string }> }): Promise { const { chain, token } = await params; const l = isChainKey(chain) && isAddress(token) ? await getLaunch(chain, token) : null; - if (!l) return { title: "Token not found" }; + if (!l) return isChainKey(chain) && isAddress(token) && await getLaunchRecovery(chain, token) + ? { title: "Checking token history", robots: { index: false, follow: false } } + : { title: "Token not found" }; const title = `${l.name} (${l.symbol})`; const description = clampSocial(l.description ?? `${l.name} launched on openlaunch.lol. 100% of supply locked as Uniswap v4 liquidity on ${CHAIN_LABELS[l.chain]}, ${l.lp_fee === 0 ? "0% trading fee" : "no platform fee"}.`, 155); // Next replaces nested metadata objects rather than merging them, so repeat the site-level fields here: @@ -87,8 +91,14 @@ export default async function TokenPage({ params, searchParams }: { params: Prom const { chain, token } = await params; if (!isChainKey(chain) || !isAddress(token)) notFound(); const usd = await ethUsd(); - const l = await getLaunch(chain, token, usd); - if (!l) notFound(); + let l = await getLaunch(chain, token, usd); + if (!l) { + const recovery = await getLaunchRecovery(chain, token); + if (recovery) return
; + // Reinclusion may have atomically replaced the recovery row since the first launch read. + l = await getLaunch(chain, token, usd); + if (!l) notFound(); + } // the key the server resolved (a registry stock, an unlisted ERC-20), not the static list's const quote: Quote = { key: l.quote_key, diff --git a/app/src/components/launchpad/BoostDialog.tsx b/app/src/components/launchpad/BoostDialog.tsx index 8f3c98bc..afaf127c 100644 --- a/app/src/components/launchpad/BoostDialog.tsx +++ b/app/src/components/launchpad/BoostDialog.tsx @@ -1,10 +1,14 @@ "use client"; +import { syncTransaction, INDEX_PENDING_COPY } from "@/lib/launchpad/sync-client"; +import { waitForTrackedReceipt } from "@/lib/launchpad/transaction-receipt"; +import { boostReceiptAmount } from "@/lib/launchpad/boost-receipt"; + import Link from "next/link"; import { useMemo, useRef, useState } from "react"; import { useConfig, useReadContract, useSwitchChain } from "wagmi"; import { getPublicClient, getWalletClient } from "wagmi/actions"; -import type { Hex } from "viem"; +import type { Hex, TransactionReceipt } from "viem"; import { ArrowUpRight } from "lucide-react"; import Sheet from "@/components/Sheet"; import ConnectWallet from "@/components/ConnectWallet"; @@ -12,7 +16,7 @@ import { btn } from "@/components/ui"; import { Spinner } from "@/components/Skeleton"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { ERC20_MIN_ABI, ERC20_TRANSFER_ABI } from "@/lib/launchpad/abi"; -import { DEAD } from "@/lib/launchpad/config"; +import { DEAD, launchpad } from "@/lib/launchpad/config"; import { TWIG_WRAP_URL } from "@/lib/launchpad/twig"; import { fmtUsd } from "@/lib/launchpad/math"; import { friendlyError } from "@/lib/errors"; @@ -27,6 +31,7 @@ import { boostAmountProblem, boostConfirmLabel, boostShowsNow, + boostWholeOf, fmtBoostTwig, type LaneEntry, groupWhole, @@ -39,15 +44,15 @@ import { toast } from "./TxToasts"; import { TwigMark } from "./TwigBadge"; /** A boost the wallet has sent whose outcome is not known yet; the panel keeps it while the dialog is closed. */ -export type PendingBoost = { hash: Hex; whole: bigint }; +export type PendingBoost = { hash: Hex; whole: bigint; replaced?: boolean }; type Phase = | { k: "idle" } | { k: "preparing" } | { k: "signing" } - | { k: "sent"; hash: Hex; whole: bigint } - | { k: "unconfirmed"; hash: Hex; whole: bigint; checking: boolean; note: string | null } - | { k: "done"; hash: Hex; whole: bigint; shows: boolean } - | { k: "error"; message: string }; + | ({ k: "sent" } & PendingBoost) + | ({ k: "unconfirmed"; checking: boolean; note: string | null } & PendingBoost) + | { k: "done"; hash: Hex; whole: bigint; shows: boolean; raw?: bigint } + | { k: "error"; message: string; hash?: Hex }; const BASE = CHAINS[BOOST_CHAIN]; @@ -97,18 +102,32 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur const wallet = await getWalletClient(config, { chainId: BASE.id }); const { request } = await pub.simulateContract({ address: BOOST_TOKEN as `0x${string}`, abi: ERC20_TRANSFER_ABI, functionName: "transfer", args: [DEAD, raw], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); setPhase({ k: "signing" }); - const hash = await wallet.writeContract(request); + let hash = await wallet.writeContract(request); onPending({ hash, whole }); setPhase({ k: "sent", hash, whole }); - let rc; + let replaced = false; + let result; try { - rc = await pub.waitForTransactionReceipt({ hash }); + result = await waitForTrackedReceipt(pub, hash, (replacement) => { + hash = replacement; + onPending({ hash, whole }); + setPhase({ k: "sent", hash, whole }); + }, (replacement) => { + hash = replacement; + replaced = true; + onPending({ hash, whole, replaced }); + setPhase({ k: "sent", hash, whole, replaced }); + }); } catch { // sent, outcome unknown: never an error that would let the same TWIG be sent twice - setPhase({ k: "unconfirmed", hash, whole, checking: false, note: null }); + setPhase({ k: "unconfirmed", hash, whole, replaced, checking: false, note: null }); return; } - await settle(hash, whole, rc.status === "success", showsAfter(whole)); + try { + await settleReceipt(result.receipt, { hash, whole, replaced }); + } catch { + setPhase({ k: "unconfirmed", hash, whole, replaced, checking: false, note: "The transaction outcome could not be verified. Check it before sending again." }); + } } catch (err) { setPhase({ k: "error", message: friendlyError(err) }); } finally { @@ -117,29 +136,55 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur } /** A final answer for a sent boost: indexed and done, or reverted (sending again is then safe). */ - async function settle(hash: Hex, whole: bigint, ok: boolean, shows: boolean) { - onPending(null); + async function settle(hash: Hex, whole: bigint, ok: boolean, shows: boolean, raw?: bigint, replaced = false) { if (!ok) { - setPhase({ k: "error", message: "The transfer reverted on-chain. No TWIG was sent; you can try again." }); + onPending(null); + setPhase({ k: "error", hash, message: "The transfer reverted on-chain. No TWIG was sent; you can try again." }); return; } // index it now rather than at the next poll: the lane and this page show it straight away - await fetch(`/api/launch/sync?chain=${BOOST_CHAIN}&tx=${hash}`, { method: "POST" }).catch(() => {}); - setPhase({ k: "done", hash, whole, shows }); + const synced = await syncTransaction(BOOST_CHAIN, hash); + if (synced.status !== "applied") { + setPhase({ k: "unconfirmed", hash, whole, replaced, checking: false, note: INDEX_PENDING_COPY }); + return; + } + onPending(null); + setPhase({ k: "done", hash, whole, shows, raw }); toast({ kind: "info", title: `${symbol} is boosted`, sub: `${groupWhole(whole)} TWIG sent to 0x…dEaD`, chain: BOOST_CHAIN, token, symbol }); void bal.refetch(); onBoosted?.(); } + /** A changed call can still burn TWIG: resolve its receipt, not the original requested amount. */ + async function settleReceipt(rc: TransactionReceipt, attempt: PendingBoost) { + const { hash, whole, replaced = false } = attempt; + if (rc.transactionHash.toLowerCase() !== hash.toLowerCase()) throw new Error("Receipt hash mismatch"); + if (rc.status !== "success" && rc.status !== "reverted") throw new Error("Receipt status unknown"); + if (!replaced || rc.status === "reverted") { + await settle(hash, whole, rc.status === "success", showsAfter(whole)); + return; + } + const actual = boostReceiptAmount(rc, tag, launchpad(BOOST_CHAIN).locker); + if (actual === null) { + onPending(null); + setPhase({ k: "error", hash, message: `The replacement confirmed without a qualifying TWIG boost for ${symbol}. Review its transaction before starting another boost.` }); + void bal.refetch(); + return; + } + const actualWhole = boostWholeOf(actual); + onPending({ hash, whole: actualWhole, replaced }); + await settle(hash, actualWhole, true, boostShowsNow(entry, current, actual, inLane), actual, replaced); + } + async function checkAgain() { if (phase.k !== "unconfirmed" || phase.checking) return; - const { hash, whole } = phase; + const { hash, whole, replaced } = phase; setPhase({ ...phase, checking: true, note: null }); try { const rc = await getPublicClient(config, { chainId: BASE.id })!.getTransactionReceipt({ hash }); - await settle(hash, whole, rc.status === "success", showsAfter(whole)); + await settleReceipt(rc, { hash, whole, replaced }); } catch { - setPhase({ k: "unconfirmed", hash, whole, checking: false, note: "Not confirmed yet. Check your wallet's activity before sending anything again." }); + setPhase({ k: "unconfirmed", hash, whole, replaced, checking: false, note: "Not confirmed yet. Check your wallet's activity before sending anything again." }); } } @@ -148,11 +193,11 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur {phase.k === "done" ? (

{place !== null ? `${name} is #${place} in the Boosted lane.` : phase.shows ? `${name} is in the Boosted lane.` : `${name} is not in the lane yet: the 5th place still holds more.`}

-

{fmtBoostTwig(boostAmount(phase.whole, tag))} went to 0x…dEaD. It counts for the next 24 hours and adds up with any other boost on {symbol}; the lane shows the 5 largest totals.

+

{fmtBoostTwig(phase.raw ?? boostAmount(phase.whole, tag))} went to 0x…dEaD. It counts for the next 24 hours and adds up with any other boost on {symbol}; the lane shows the 5 largest totals.

View transaction {/* the booster's own post: what was sent and where it went, the token's page as its link */} - Share on X + Share on X See the lane
@@ -232,7 +277,7 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur {phase.k === "sent" ? View the transaction ↗ : null} {phase.k === "unconfirmed" ? (
-

Your wallet sent {groupWhole(phase.whole)} TWIG, but Base has not confirmed it to this page yet. Sending is locked until it does, so the same boost is never paid twice.

+

{phase.replaced ? "Your wallet replaced this transaction. Its burn amount and boost outcome must be checked." : `Your wallet sent ${groupWhole(phase.whole)} TWIG, but Base has not confirmed it to this page yet.`} Sending is locked until its outcome is resolved, so the same boost is never paid twice.

{phase.note ?

{phase.note}

: null}
@@ -240,7 +285,7 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur
) : null} - {phase.k === "error" ?

{phase.message}

: null} + {phase.k === "error" ?

{phase.message}

{phase.hash ? View the transaction ↗ : null}
: null}
)} diff --git a/app/src/components/launchpad/CollectPanel.tsx b/app/src/components/launchpad/CollectPanel.tsx index 47532372..3b4279a6 100644 --- a/app/src/components/launchpad/CollectPanel.tsx +++ b/app/src/components/launchpad/CollectPanel.tsx @@ -1,5 +1,7 @@ "use client"; +import { syncTransaction, INDEX_PENDING_COPY } from "@/lib/launchpad/sync-client"; + import { useState } from "react"; import { useRouter } from "next/navigation"; import { useConfig, useReadContracts } from "wagmi"; @@ -103,10 +105,10 @@ export default function CollectPanel({ 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(() => {}); + const synced = await syncTransaction(chain, hash); void mine.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: synced.status !== "applied" ? INDEX_PENDING_COPY : isBurnOnly ? "Burned on the spot" : "Paid to the beneficiaries" }); setPhase({ k: "idle" }); } catch (err) { setPhase({ k: "error", message: friendlyError(err) }); diff --git a/app/src/components/launchpad/ImageUpload.tsx b/app/src/components/launchpad/ImageUpload.tsx index 91e332fb..b0674726 100644 --- a/app/src/components/launchpad/ImageUpload.tsx +++ b/app/src/components/launchpad/ImageUpload.tsx @@ -25,7 +25,7 @@ function previewUrl(value: string): string | null { /** * Token image picker: drop zone / tap-to-browse → POST /api/launch/image → https URL into `value`. * The URL field stays available for people who already host the image. Wallet must be connected - * (uploads are rate-limited per wallet server-side). `kind="banner"` picks the wide banner instead of + * (uploads are rate-limited per IP server-side). `kind="banner"` picks the wide banner instead of * the square logo: a 3:1 drop zone that previews the banner across its full width. `stacked` (from sm up) * fills the height it is given, so a logo and a banner side by side end level: the logo's tile sits above * its words, and the banner zone drops its fixed 3:1 for the row's height. diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index b30c7856..72a6e4d1 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -34,6 +34,10 @@ import { Spinner } from "@/components/Skeleton"; import { startNav } from "@/components/RouteProgress"; import WalletPicker from "@/components/WalletPicker"; import { randomHex } from "@/lib/nonce"; +import { syncTransaction, type TransactionSync } from "@/lib/launchpad/sync-client"; +import { archivePendingLaunch, captureLaunchIdentity, launchNonceFinalized, pendingLaunch, pendingLaunchServer, recentLaunches, recentLaunchesServer, rememberFirstBuy, rememberPendingLaunch, subscribePendingLaunch, type FirstBuyOutcome } from "@/lib/launchpad/pending-launch"; +import { waitForTrackedReceipt } from "@/lib/launchpad/transaction-receipt"; +import TokenRecovery from "./TokenRecovery"; /** * Launch flow — honest states, nothing claimed before the chain says so: @@ -50,6 +54,7 @@ type Phase = | { k: "buying"; hash: Hex; step: "quote" | "approve" | "sign" } // hash = the confirmed launch | { k: "buying"; hash: Hex; step: "sent"; buyHash: Hex } | { k: "indexing"; hash: Hex } + | { k: "pending" } | { k: "done"; hash: Hex; token: string } | { k: "error"; message: string }; @@ -97,7 +102,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; feePips: number; CHAIN: (typeof CHAINS)[ChainKey]; setPhase: (p: Phase) => void; recordBuy: (buy: Omit) => void }; function parseBuyAmount(v: string, decimals: number): bigint | null | undefined { try { @@ -156,15 +161,23 @@ async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, a dataSuffix: BUILDER_DATA_SUFFIX, }); setPhase({ k: "buying", hash: launchHash, step: "sign" }); - const h = await wallet.writeContract(request); + let h = await wallet.writeContract(request); + ctx.recordBuy({ hash: h, status: "pending" }); setPhase({ k: "buying", hash: launchHash, step: "sent", buyHash: h }); - const rc = await pub.waitForTransactionReceipt({ hash: h }); - if (rc.status !== "success") throw new Error("The buy reverted on-chain."); + const { receipt: rc, changed } = await waitForTrackedReceipt(pub, h, (hash) => { + h = hash; + ctx.recordBuy({ hash: h, status: "pending" }); + setPhase({ k: "buying", hash: launchHash, step: "sent", buyHash: h }); + }); + if (changed) { ctx.recordBuy({ hash: rc.transactionHash, status: "replaced" }); throw new Error("The first buy was replaced by a different transaction. Check its outcome in your wallet."); } + if (rc.status !== "success") { ctx.recordBuy({ hash: h, status: "reverted" }); throw new Error("The buy reverted on-chain."); } // the executed amount can be below the quote (down to the slippage floor): read it off the receipt const received = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: rc.logs }) .filter((l) => l.address.toLowerCase() === tokenAddr.toLowerCase() && l.args.to.toLowerCase() === address.toLowerCase()) .reduce((sum, l) => sum + l.args.value, 0n); - return received > 0n ? { hash: h, out: received, exact: true } : { hash: h, out, exact: false }; + const bought = received > 0n ? { hash: h, out: received, exact: true } : { hash: h, out, exact: false }; + ctx.recordBuy({ hash: h, status: "confirmed", amount: bought.out.toString(), exact: bought.exact }); + return bought; } /** `hypeUsd`: HYPE's live price (server, hypePrice.ts), filled into the static HYPE quote the way the server fills GITLAWB's. */ @@ -256,6 +269,9 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA // 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 [phase, setPhase] = useState({ k: "idle" }); + const sentAttempt = useSyncExternalStore(subscribePendingLaunch, pendingLaunch, pendingLaunchServer); + const savedLaunches = useSyncExternalStore(subscribePendingLaunch, recentLaunches, recentLaunchesServer); + const launchLock = useRef(false); // Starting cap: entered in dollars whenever the quote has a USD price, else in quote units (lib/launchpad/market-cap.ts). // The tick is always computed from the quote-denominated cap; every figure shown leads with dollars. @@ -349,7 +365,8 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA const hasRow = (payout: string) => rows.some((r) => r.payout.trim().toLowerCase() === payout.toLowerCase()); async function launch() { - if (!valid || !address || !cfg.factory || startTick === null) return; + if (!valid || !address || !cfg.factory || startTick === null || launchLock.current || pendingLaunch()) return; + launchLock.current = true; const FACTORY_ADDRESS = cfg.factory; let salt = saltRef.current ?? (saltRef.current = randomSalt()); let metaKey = metaKeyRef.current ?? (metaKeyRef.current = randomSalt()); @@ -418,10 +435,22 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA setPhase({ k: "signing" }); const wallet = await getWalletClient(config, { chainId: CHAIN.id }); - const hash = await wallet.writeContract(request); + let hash = await wallet.writeContract(request); + rememberPendingLaunch({ chain, hash, token: meta.token ?? null }); setPhase({ k: "sent", hash }); - const receipt = await pub.waitForTransactionReceipt({ hash }); - if (receipt.status !== "success") throw new Error("Transaction reverted on-chain."); + const identity = await captureLaunchIdentity(pub, chain, hash, address); + if (identity && pendingLaunch()?.hash === hash) rememberPendingLaunch({ ...pendingLaunch()!, identity }); + const { receipt, changed } = await waitForTrackedReceipt(pub, hash, (replacement) => { + const attempt = pendingLaunch(); + if (attempt?.hash === hash) rememberPendingLaunch({ ...attempt, hash: replacement, ...(attempt.identity ? { identity: { ...attempt.identity, hash: replacement } } : {}) }); + hash = replacement; + setPhase({ k: "sent", hash }); + }); + if (changed) throw new Error("The launch was replaced. Keep its reference until the replacement is finalized."); + if (receipt.status !== "success") { + archivePendingLaunch(hash, "reverted"); + throw new Error("Transaction reverted on-chain."); + } const [ev] = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs }); const token = (ev?.args.token ?? meta.token ?? "").toLowerCase(); @@ -431,7 +460,7 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA 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); + const r = await firstBuy({ pub, wallet, address, V4: cfg.v4, quote, feePips, CHAIN, setPhase, recordBuy: (buy) => rememberFirstBuy(hash, { ...buy, symbol: symbolClean }) }, ev.args.token, hash, initialBuyRaw); buyHash = r.hash; bought = { out: r.out, exact: r.exact }; } catch (err) { @@ -439,20 +468,59 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA } } + // A confirmed purchase is independent of indexer availability, and its journal survives this view and reloads. + if (bought !== null) toast({ kind: "buy", title: `You bought ${bought.exact ? "" : "about "}${fmtCompact(Number(bought.out) / 1e18)} ${symbolClean}`, sub: "Buy confirmed on " + CHAIN_LABEL, chain, token, symbol: symbolClean }); + if (buyProblem) toast({ kind: "info", title: "Your launch was sent; the first buy needs attention", sub: buyProblem, chain, token, symbol: symbolClean }); setPhase({ k: "indexing", hash }); - await fetch(`/api/launch/sync?chain=${chain}&tx=${hash}`, { method: "POST" }).catch(() => {}); - if (buyHash) await fetch(`/api/launch/sync?chain=${chain}&tx=${buyHash}`, { method: "POST" }).catch(() => {}); + const synced = await syncTransaction(chain, hash); + if (buyHash) await syncTransaction(chain, buyHash); + if (synced.status !== "applied" || !synced.tokens.includes(token)) { + setPhase({ k: "pending" }); + return; + } + archivePendingLaunch(hash, "indexed", token); setPhase({ k: "done", hash, token }); toast({ kind: "launch", title: `${name.trim()} is live on ${CHAIN_LABEL}`, sub: "Liquidity locked forever. Taking you to your token.", chain, token, symbol: symbolClean, celebrate: true }); - if (bought !== null) toast({ kind: "buy", title: `You bought ${bought.exact ? "" : "about "}${fmtCompact(Number(bought.out) / 1e18)} ${symbolClean}`, sub: "Buy confirmed on " + CHAIN_LABEL, chain, token, symbol: symbolClean }); - if (buyProblem) toast({ kind: "info", title: "Launched, but the first buy did not go through", sub: `${buyProblem} You can buy on the token page.`, chain, token, symbol: symbolClean }); startNav(); router.push(`/t/${chain}/${token}`); } catch (err) { - setPhase({ k: "error", message: friendlyError(err) }); + // Once a hash exists, a timeout or indexing failure is recovery, never permission to send a second launch. + setPhase(pendingLaunch() ? { k: "pending" } : { k: "error", message: friendlyError(err) }); + } finally { + launchLock.current = false; } } + function recoverLaunch(hash: Hex, result: TransactionSync) { + const attempt = pendingLaunch(); + if (!attempt || attempt.hash !== hash) return; + if (result.status === "reverted") { + archivePendingLaunch(hash, "reverted"); + setPhase({ k: "error", message: "The launch transaction reverted on-chain. You can review the form and try again." }); + return; + } + if (result.status !== "applied") return; + const token = attempt.token ? result.tokens.find((t) => t === attempt.token?.toLowerCase()) : result.tokens[0]; + if (!token) return; + archivePendingLaunch(hash, "indexed", token); + setPhase({ k: "done", hash: attempt.hash, token }); + startNav(); + router.push(`/t/${attempt.chain}/${token}`); + } + + async function resolveLaunch(hash: Hex): Promise { + const attempt = pendingLaunch(); + if (!attempt || attempt.hash !== hash || !address || launchLock.current) return false; + const pub = getPublicClient(config, { chainId: CHAINS[attempt.chain].id }); + if (!pub || !await launchNonceFinalized(pub, attempt, address)) return false; + // Re-read after the RPC: a stale check cannot archive a different active attempt. + if (pendingLaunch() !== attempt || !archivePendingLaunch(hash, "nonce-finalized")) return false; + saltRef.current = null; + metaKeyRef.current = null; + setPhase({ k: "idle" }); + return true; + } + // the launch action: what is still missing, the button and the transaction's progress. The sticky console shows it on // wide screens and the last step shows it on phones; CSS draws one copy at a time. @@ -491,8 +559,15 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA // what a preset opens at once the price snaps to the pool's tick spacing, in the quote: "$25K" opens at 9.433 ETH const presetOpens = (v: number) => cap(fdvForStartTick(startTickForFdv(capToQuote(v, entry), quote.decimals), quote.decimals)).detail; + if (sentAttempt && (phase.k === "idle" || phase.k === "pending")) return recoverLaunch(sentAttempt.hash, result)} onArchive={sentAttempt.identity ? () => resolveLaunch(sentAttempt.hash) : undefined} />; + return (
+ {savedLaunches.length ?
+ Recent launch transactions +

The last ten references in this browser session stay available for chain-history checks. They do not block a different launch.

+ {savedLaunches.map((attempt) => )} +
: null}
{}); - 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 }); + const synced = await syncTransaction(l.chain, hash); + toast({ kind: "collect", title: `Fees collected for ${l.symbol}`, sub: synced.status !== "applied" ? INDEX_PENDING_COPY : isBurnOnly(l.recipients) ? "Burned on the spot" : "Paid 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) }); diff --git a/app/src/components/launchpad/PairApplication.tsx b/app/src/components/launchpad/PairApplication.tsx index bc653b7f..d5cc6405 100644 --- a/app/src/components/launchpad/PairApplication.tsx +++ b/app/src/components/launchpad/PairApplication.tsx @@ -1,5 +1,7 @@ "use client"; +import { syncTransaction, INDEX_PENDING_COPY } from "@/lib/launchpad/sync-client"; + import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import { useConfig, useChainId, useReadContract, useSwitchChain } from "wagmi"; @@ -43,6 +45,7 @@ export default function PairApplication({ initial, open }: { initial: PairApplic // a transfer this browser sent for this application: until the indexer records it, nothing offers to send again const sentKey = `pair-fee-sent:${initial.id}`; const [sent, setSent] = useState(null); + const [sentReady, setSentReady] = useState(false); // read after mount (the server render has no storage): a reload mid-indexing still shows "Sent", never the button useEffect(() => { const id = setTimeout(() => { @@ -52,6 +55,7 @@ export default function PairApplication({ initial, open }: { initial: PairApplic } catch { // storage unavailable } + setSentReady(true); }, 0); return () => clearTimeout(id); }, [sentKey]); @@ -77,16 +81,8 @@ export default function PairApplication({ initial, open }: { initial: PairApplic // keep what is shown } }, [app.id]); - useEffect(() => { - // the sent transfer is in the application's burns: the guard has done its job - if (sent && app.burns.some((b) => b.tx_hash === sent.toLowerCase())) { - try { - window.sessionStorage.removeItem(sentKey); - } catch { - // nothing stored - } - } - }, [app.burns, sent, sentKey]); + // Keep the sent hash after provisional indexing: a later reorg can remove the burn, + // and a reload must still offer Check again instead of another payment. useEffect(() => { const t = setInterval(() => { setNow(nowMs()); @@ -125,7 +121,8 @@ export default function PairApplication({ initial, open }: { initial: PairApplic if (!sent || busy) return; setBusy("recheck"); try { - await fetch(`/api/launch/sync?chain=${FEE_CHAIN}&tx=${sent}`, { method: "POST" }).catch(() => {}); + const synced = await syncTransaction(FEE_CHAIN, sent); + if (synced.status !== "applied") setMsg({ ok: false, text: INDEX_PENDING_COPY }); await reload(); } finally { setBusy(null); @@ -150,7 +147,7 @@ export default function PairApplication({ initial, open }: { initial: PairApplic async function pay() { const q = app.quote; - if (!address || !q || lock.current || waiting) return; + if (!address || !q || lock.current || !sentReady || waiting || app.payment_reorg) return; // the amount must name THIS application in its last digits: never send TWIG that would pay for another one if (pairApplicationOf(BigInt(q.amount)) !== app.id) { setMsg({ ok: false, text: "That amount does not name this application. Nothing was sent." }); @@ -177,8 +174,8 @@ export default function PairApplication({ initial, open }: { initial: PairApplic setMsg({ ok: false, text: e instanceof Error && /reverted/.test(e.message) ? e.message : "Sent. Its confirmation is not in yet: this page updates when it is indexed." }); return; } - await fetch(`/api/launch/sync?chain=${FEE_CHAIN}&tx=${hash}`, { method: "POST" }).catch(() => {}); - setMsg({ ok: true, text: "Sent. It counts once indexed (seconds)." }); + const synced = await syncTransaction(FEE_CHAIN, hash); + setMsg({ ok: synced.status === "applied", text: synced.status === "applied" ? "Sent. It counts once indexed (seconds)." : INDEX_PENDING_COPY }); await reload(); void bal.refetch(); } catch (e) { @@ -226,7 +223,9 @@ export default function PairApplication({ initial, open }: { initial: PairApplic

2 · The fee: ${app.fee_usd} in TWIG

{PAIR_APPLY_COPY.feeRule(app.fee_usd)}

- {app.status === "open" && waiting ? ( + {app.payment_reorg ? ( +

A fee payment needs another check. Its payment history is being checked. Check the original transaction before paying again.

{sent ? : null}
+ ) : app.status === "open" && waiting ? (

Sent. Waiting for it to be indexed, usually seconds: this page updates by itself.

@@ -246,7 +245,7 @@ export default function PairApplication({ initial, open }: { initial: PairApplic

) : null}

{PAIR_APPLY_COPY.sentFinal}

- +
) ) : ( diff --git a/app/src/components/launchpad/TokenRecovery.tsx b/app/src/components/launchpad/TokenRecovery.tsx new file mode 100644 index 00000000..40ebfc09 --- /dev/null +++ b/app/src/components/launchpad/TokenRecovery.tsx @@ -0,0 +1,98 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Hex } from "viem"; +import { CHAIN_LABELS, explorerTx, type ChainKey } from "@/lib/chainPublic"; +import { syncTransaction, type TransactionSync } from "@/lib/launchpad/sync-client"; +import type { FirstBuyOutcome } from "@/lib/launchpad/pending-launch"; +import { fmtCompact } from "@/lib/launchpad/math"; +import { btn, card } from "@/components/ui"; + +/** Recovery checks an existing hash only. There is deliberately no wallet dependency. */ +export default function TokenRecovery({ chain, hash, reason, firstBuy, token, onResult, onArchive }: { + chain: ChainKey; hash: Hex; reason: "pending" | "reorg" | "rebuilding" | "reference"; + firstBuy?: FirstBuyOutcome; token?: string | null; + onResult?: (result: TransactionSync) => void; + onArchive?: () => Promise; +}) { + const router = useRouter(); + const checking = useRef(false); + const alive = useRef(true); + const request = useRef(null); + const lastCheck = useRef(-Infinity); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState(null); + const [indexedToken, setIndexedToken] = useState(null); + useEffect(() => { + alive.current = true; + // Read-only route refreshes let the indexer's normal poll recover the page. Sync is manual, bounded to one in flight. + const timer = reason === "reference" ? undefined : setInterval(() => { if (document.visibilityState === "visible" && !checking.current) router.refresh(); }, 15_000); + return () => { alive.current = false; clearInterval(timer); request.current?.abort(); }; + }, [router, reason]); + + async function check() { + if (checking.current) return; + if (Date.now() - lastCheck.current < 15_000) { + setNote("Please wait a few seconds before checking this transaction again."); + return; + } + lastCheck.current = Date.now(); + checking.current = true; + setBusy(true); + request.current = new AbortController(); + const result = await syncTransaction(chain, hash, request.current.signal); + checking.current = false; + if (!alive.current) return; + setBusy(false); + if (result.status === "applied") setIndexedToken(token ? result.tokens.find((t) => t === token) ?? null : result.tokens[0] ?? null); + setNote(result.status === "reverted" ? "This transaction reverted. Check the transaction details before starting another launch." + : result.status === "error" ? "The check is unavailable right now. Your transaction has not been sent again." + : result.status === "applied" ? "This transaction is indexed. Keep its reference in case the chain history changes." + : "Checked the existing transaction. Waiting for the token page to update."); + onResult?.(result); + router.refresh(); + } + + async function archive() { + if (!onArchive || checking.current) return; + checking.current = true; + setBusy(true); + let archived = false; + try { archived = await onArchive(); } catch { /* Unavailable evidence keeps the active journal. */ } + checking.current = false; + if (!alive.current) return; + setBusy(false); + if (!archived) setNote("The chain has not proved this transaction can no longer execute. Connect the sending wallet and check again later; the saved launch stays protected."); + } + + const Heading = reason === "pending" || reason === "reference" ? "h2" : "h1"; + const titleId = `token-recovery-${chain}-${hash}`; + return
+
+ {reason === "pending" ? "Your launch was sent" : reason === "reference" ? "Saved launch reference" : "Checking this token’s chain history"} +

{reason === "pending" + ? `The token page is not ready yet on ${CHAIN_LABELS[chain]}. Keep this transaction to check its progress.` + : reason === "reference" ? `Keep this ${CHAIN_LABELS[chain]} transaction for later checks. An earlier confirmation or indexing result does not guarantee its block can never change.` + : reason === "rebuilding" ? "We are rebuilding this token’s history from the chain. Its market and trading controls will return when its launch is indexed." + : "The block containing this launch changed. We are checking whether the transaction returns to the chain before showing its market again."}

+

Checking again only checks the existing transaction. It never sends another launch.

+ View transaction {hash} + {firstBuy ?
+

{firstBuy.status === "confirmed" ? `First buy confirmed${firstBuy.amount ? `: ${firstBuy.exact ? "" : "about "}${fmtCompact(Number(firstBuy.amount) / 1e18)} ${firstBuy.symbol}` : ""}.` + : firstBuy.status === "reverted" ? "The first buy reverted on-chain." + : firstBuy.status === "replaced" ? "The first buy was replaced by another transaction. Review its outcome in your wallet." + : "The first buy was sent; its outcome is not confirmed here. Check it before buying again."}

+ View first-buy transaction {firstBuy.hash} +
: null} +
Back to tokens
+ {indexedToken ? Open token page : null} + {onArchive ?
+

You can start a different launch only after this wallet’s finalized transaction count proves the saved transaction cannot execute in the future. This does not mean it failed; its reference and any first-buy result stay saved.

+ +
: null} + {note ?

{note}

: null} +
+
; +} diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index d5b2dbbd..eecade11 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -10,6 +10,7 @@ import { ArrowDown, ArrowRight, Wallet } from "lucide-react"; import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; import { btn } from "@/components/ui"; import { toast } from "./TxToasts"; +import { syncTransaction, INDEX_PENDING_COPY } from "@/lib/launchpad/sync-client"; import { ERC20_MIN_ABI, PERMIT2_ABI, STATE_VIEW_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI, V4_QUOTER_EXACT_INPUT_ABI } from "@/lib/launchpad/abi"; import { fmtImpact, impactLevel, priceImpact } from "@/lib/launchpad/price-impact"; import { BUY_PRESETS, NATIVE, SWAP_GAS_RESERVE_WEI, launchpad, quoteUsdOf, sharesGasBalance, type Quote } from "@/lib/launchpad/config"; @@ -232,9 +233,9 @@ export default function TradePanel({ chain, token, symbol, poolKey, poolId, feeR setPhase({ k: "sent", hash }); const rc = await pub.waitForTransactionReceipt({ hash }); if (rc.status !== "success") throw new Error("Transaction reverted on-chain."); - await fetch(`/api/launch/sync?chain=${chain}&tx=${hash}`, { method: "POST" }).catch(() => {}); + const synced = await syncTransaction(chain, hash); setPhase({ k: "done", hash, side }); - toast({ kind: side, title: side === "buy" ? `You bought ${quote_ ? fmtCompact(Number(quote_.out) / 1e18) : ""} ${symbol}` : `You sold ${fmtCompact(Number(amountIn) / 1e18)} ${symbol}`, sub: `Confirmed on ${CHAIN_LABEL}`, chain, token, symbol, celebrate: side === "buy" }); + toast({ kind: side, title: side === "buy" ? `You bought ${quote_ ? fmtCompact(Number(quote_.out) / 1e18) : ""} ${symbol}` : `You sold ${fmtCompact(Number(amountIn) / 1e18)} ${symbol}`, sub: synced.status === "applied" ? `Confirmed on ${CHAIN_LABEL}` : INDEX_PENDING_COPY, chain, token, symbol, celebrate: side === "buy" }); setAmount(""); setQuote(null); void eth.refetch(); diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index 3b8c474f..34fd4d3e 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -4,7 +4,8 @@ import path from "node:path"; import test from "node:test"; // Source contracts for Boost (like earn-ui.test.ts): they hold the rules that keep a paid lane honest in place. -const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8"); +// Git may check these sources out with CRLF; the contracts below describe code, not checkout line endings. +const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8").replace(/\r\n/g, "\n"); const dialog = read("./BoostDialog.tsx"); const panel = read("./BoostPanel.tsx"); const lane = read("./BoostedLane.tsx"); @@ -75,14 +76,14 @@ test("the send locks before its first await and simulates before the wallet is a test("sent means sent: an unread receipt locks the dialog instead of inviting a second burn", () => { const body = dialog.slice(dialog.indexOf("async function send()"), dialog.indexOf("async function settle(")); // the hash is handed to the panel before the receipt wait, and a failed wait is "unconfirmed", never "error" - assert.ok(body.indexOf("onPending({ hash, whole })") < body.indexOf("waitForTransactionReceipt")); - assert.match(body, /catch \{\n\s*\/\/ sent, outcome unknown[^\n]*\n\s*setPhase\(\{ k: "unconfirmed", hash, whole, checking: false, note: null \}\);\n\s*return;/); + assert.ok(body.indexOf("onPending({ hash, whole })") < body.indexOf("waitForTrackedReceipt")); + assert.match(body, /catch \{\n\s*\/\/ sent, outcome unknown[^\n]*\n\s*setPhase\(\{ k: "unconfirmed", hash, whole, replaced, checking: false, note: null \}\);\n\s*return;/); assert.match(dialog, /const busy = phase\.k === "preparing" \|\| phase\.k === "signing" \|\| phase\.k === "sent" \|\| phase\.k === "unconfirmed";/); assert.match(dialog, /useState\(pending \? \{ k: "unconfirmed", \.\.\.pending, checking: false, note: null \} : \{ k: "idle" \}\)/); // the panel owns the pending hash, so closing and reopening keeps the lock assert.match(panel, /pending=\{pending\} onPending=\{setPending\}/); // only a receipt settles it, and the sync call indexes it at once - assert.match(dialog, /await fetch\(`\/api\/launch\/sync\?chain=\$\{BOOST_CHAIN\}&tx=\$\{hash\}`, \{ method: "POST" \}\)/); + assert.match(dialog, /await syncTransaction\(BOOST_CHAIN, hash\)/); }); test("the dialog says where the TWIG goes, that it is final, and that the ranking is untouched", () => { @@ -186,7 +187,9 @@ test("the lane moves only when something happens, and reduced motion keeps only test("CodeRabbit round 1: the done screen, admin errors and the agent formula", () => { // both ways a receipt can arrive settle with the same rule: never a hard-coded "it shows" - assert.equal((dialog.match(/await settle\(hash, whole, rc\.status === "success", showsAfter\(whole\)\);/g) ?? []).length, 2); + assert.equal((dialog.match(/await settleReceipt\(/g) ?? []).length, 2, "send and recheck share receipt settlement"); + assert.match(dialog, /await settle\(hash, whole, rc\.status === "success", showsAfter\(whole\)\);/); + assert.match(dialog, /boostShowsNow\(entry, current, actual, inLane\)/, "replacement lane result uses actual receipt amount"); assert.doesNotMatch(dialog, /settle\([^)]*, true\)/); assert.match(dialog, /const showsAfter = \(w: bigint\): boolean => !tagOk \|\| boostShowsNow\(entry, current, boostAmount\(w, tag\), inLane\);/); // an error page that is not JSON still reports the HTTP failure; a bad lane read never throws from the effect @@ -407,7 +410,7 @@ test("the token page shows what the lane brought: clicks in the same 24 hours, f test("after a confirmed boost the booster can share it on X: its own post, in a new tab", () => { const done = dialog.slice(dialog.indexOf('{phase.k === "done" ? ('), dialog.indexOf(") : (")); - assert.match(done, /Share on X/); + assert.match(done, /Share on X/); assert.equal((dialog.match(/boostShareUrl\(/g) ?? []).length, 1, "only on the done screen"); }); diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 37bebcc2..ad5866af 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -5,6 +5,7 @@ import { runInNewContext } from "node:vm"; import ts from "typescript"; import { minOut } from "../../lib/launchpad/math.ts"; import { NATIVE, isNativeQuote } from "../../lib/launchpad/config.ts"; +import { waitForTrackedReceipt } from "../../lib/launchpad/transaction-receipt.ts"; const source = readFileSync(new URL("./LaunchForm.tsx", import.meta.url), "utf8"); const ast = ts.createSourceFile("LaunchForm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); @@ -27,8 +28,8 @@ test("launch merge preserves optional funding checks and post-launch buy isolati assert.ok(receiptCheck >= 0, "Launch receipt status check must remain"); assert.ok(buyCall >= 0, "Optional first buy call must remain"); assert.ok(receiptCheck < buyCall, "The receipt status check must run before the first buy"); - assert.match(source, /if \(buyHash\) await fetch\(`\/api\/launch\/sync\?chain=\$\{chain\}&tx=\$\{buyHash\}/); - assert.match(source, /Launched, but the first buy did not go through/); + assert.match(source, /if \(buyHash\) await syncTransaction\(chain, buyHash\)/); + assert.match(source, /Your launch was sent; the first buy needs attention/); assert.match(source, /buying: "Launched! Buying your first tokens…"/); }); @@ -80,7 +81,7 @@ for (const status of ["success", "reverted"]) { // 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, isNativeQuote, + FIRST_BUY_SLIPPAGE_BPS: 300, BUILDER_DATA_SUFFIX: "0x", minOut, isNativeQuote, waitForTrackedReceipt, encodeV4ExactInSingle: (input: { minOut: bigint }) => { encodedMinimum = input.minOut; return { commands: "0x10", inputs: [] }; }, parseEventLogs: () => [ { address: "token", args: { to: "wallet", value: 975n } }, @@ -95,11 +96,11 @@ for (const status of ["success", "reverted"]) { sentValue = input.value; return { request: {} }; }, - waitForTransactionReceipt: async () => ({ status, logs: [] }), + waitForTransactionReceipt: async () => ({ status, logs: [], transactionHash: "buy-hash" }), }, wallet: { writeContract: async () => "buy-hash" }, address: "wallet", V4: { quoter: "quoter", universalRouter: "router", swapLayout: "v1" }, quote: { key: "eth", address: NATIVE }, - feePips: 0, CHAIN: { id: 8453 }, setPhase: () => {}, + feePips: 0, CHAIN: { id: 8453 }, setPhase: () => {}, recordBuy: () => {}, }, "token", "launch-hash", 100n); if (status === "success") { const bought = await result; diff --git a/app/src/components/launchpad/me-dashboard.test.ts b/app/src/components/launchpad/me-dashboard.test.ts index 820c1ebe..c8837cdf 100644 --- a/app/src/components/launchpad/me-dashboard.test.ts +++ b/app/src/components/launchpad/me-dashboard.test.ts @@ -56,7 +56,8 @@ test("fee collection and full signed metadata editing keep their existing transa assert.match(source, /functionName: "collect", args: \[BigInt\(l\.token_id\)\], account: address, 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\}/); + assert.match(source, /const synced = await syncTransaction\(l\.chain, hash\);/); + assert.match(source, /synced\.status !== "applied" \? INDEX_PENDING_COPY/, "a sent collection keeps honest feedback while its index is pending"); assert.match(source, /if \(hasFees\(pending\[key\(l\)\]\)\) await collect\(l\)/); assert.match(source, / phases.push(phase), getPublicClient: () => ({ simulateContract: async () => ({ request: {} }), waitForTransactionReceipt: async () => ({ status }) }), getWalletClient: async () => ({ writeContract: async () => "0xhash" }), - fetch: async () => { effects.push("sync"); }, + syncTransaction: async () => { effects.push("sync"); return { status: "applied", tokens: [] }; }, mine: { refetch: () => effects.push("balance") }, router: { refresh: () => effects.push("refresh") }, toast: () => effects.push("success toast"), friendlyError: (error: Error) => error.message, }); @@ -117,7 +123,7 @@ for (const status of ["reverted", "success"]) { setBusy: (value: string | null) => busyStates.push(value), getPublicClient: () => ({ simulateContract: async () => ({ request: {} }), waitForTransactionReceipt: async () => ({ status }) }), getWalletClient: async () => ({ writeContract: async () => "0xhash" }), - fetch: async () => { effects.push("sync"); }, load: async () => { effects.push("reload"); }, + syncTransaction: async () => { effects.push("sync"); return { status: "applied", tokens: [] }; }, load: async () => { effects.push("reload"); }, isBurnOnly: () => false, friendlyError: (error: Error) => error.message, toast: (message: { kind: string }) => effects.push(message.kind === "collect" ? "success toast" : "failure toast"), }); @@ -134,3 +140,244 @@ test("preparing a trade disables the same controls as signing and confirming", ( assert.match(source, /]*disabled=\{busy\}/); assert.match(source, /if \(!v\[0\] \|\| busy\) return/); }); + +for (const status of ["pending", "error", "reverted", "applied"]) { + test(`boost ${status} indexing retains its sent guard until indexing succeeds`, async () => { + const pending: unknown[] = []; + const phases: { k: string }[] = []; + const effects: string[] = []; + const settle = handler("./BoostDialog.tsx", "settle", { + onPending: (value: unknown) => pending.push(value), + setPhase: (value: { k: string }) => phases.push(value), + syncTransaction: async () => ({ status }), INDEX_PENDING_COPY: "Still indexing", BOOST_CHAIN: "base", + symbol: "TEST", token: "token", groupWhole: String, + toast: () => effects.push("toast"), bal: { refetch: () => effects.push("balance") }, onBoosted: () => effects.push("boost"), + }); + await settle("0xhash", 100n, true, true); + if (status === "applied") { + assert.deepEqual(pending, [null]); + assert.equal(phases.at(-1)?.k, "done"); + assert.deepEqual(effects, ["toast", "balance", "boost"]); + } else { + assert.deepEqual(pending, [], "an indexing result cannot release the existing sent hash"); + assert.equal(phases.at(-1)?.k, "unconfirmed"); + assert.deepEqual(effects, [], "do not claim a lane change before the indexer applies it"); + } + }); +} + +for (const state of [{ sentReady: false, waiting: false, payment_reorg: false }, { sentReady: true, waiting: true, payment_reorg: false }, { sentReady: true, waiting: false, payment_reorg: true }]) { + test(`pair fee recovery blocks a new wallet action: ${JSON.stringify(state)}`, async () => { + let started = false; + const pay = handler("./PairApplication.tsx", "pay", { + app: { quote: {}, payment_reorg: state.payment_reorg }, address: "wallet", lock: { current: false }, + sentReady: state.sentReady, waiting: state.waiting, + pairApplicationOf: () => { started = true; throw Error("must not reach preflight"); }, + }); + await pay(); + assert.equal(started, false); + }); +} + +const launchHash = `0x${"ab".repeat(32)}` as const; +const secondHash = `0x${"cd".repeat(32)}` as const; +const launcher = `0x${"12".repeat(20)}` as const; +const launchedToken = `0x${"34".repeat(20)}` as const; +function journalStorage(t: TestContext) { + const disk = new Map([["ol:pending-launch", "null "]]); + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: { getItem: (k: string) => disk.get(k) ?? null, setItem: (k: string, v: string) => disk.set(k, v), removeItem: (k: string) => disk.delete(k) } }); + launchJournal.pendingLaunch(); + t.after(() => { disk.set("ol:pending-launch", "null "); launchJournal.pendingLaunch(); launchJournal.rememberPendingLaunch(null); Reflect.deleteProperty(globalThis, "sessionStorage"); }); + return disk; +} +function launchHarness(t: TestContext, syncStatus: string) { + const disk = journalStorage(t); + const effects: string[] = []; + const phases: { k: string }[] = []; + let sends = 0; + const pub = { + getChainId: async () => 8453, + getTransaction: async () => ({ hash: launchHash, from: launcher, nonce: 7 }), + getTransactionCount: async () => 8, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ transactionHash: launchHash, status: "success", logs: [] }), + }; + const bindings = { + ...launchJournal, waitForTrackedReceipt, valid: true, address: launcher, cfg: { factory: launcher, v4: {} }, startTick: 1, + launchLock: { current: false }, saltRef: { current: null }, metaKeyRef: { current: null }, randomSalt: () => launchHash, + chain: "base", name: "Test", symbolClean: "TEST", description: "", image: "", banner: "", website: "", x: "", onChain: true, + CHAIN: { id: 8453 }, CHAIN_LABEL: "Base", CHAINS: { base: { id: 8453 } }, config: {}, quote: { address: launcher }, + fetch: async () => ({ status: 200, json: async () => ({ uri: "https://synthetic.invalid/meta", token: launchedToken }) }), + getPublicClient: () => pub, getWalletClient: async () => ({ writeContract: async () => { sends++; return launchHash; } }), + setPhase: (phase: { k: string }) => phases.push(phase), isNativeQuote: () => true, + DEFAULT_SUPPLY: 1n, feePips: 0, recipients: [], LAUNCH_FACTORY_ABI: [], BUILDER_DATA_SUFFIX: "0x", FIRST_BUY_SLIPPAGE_BPS: 300, + parseEventLogs: () => [{ args: { token: launchedToken } }], initialBuyRaw: 1n, + firstBuy: async (ctx: { recordBuy: (buy: object) => void }) => { ctx.recordBuy({ hash: secondHash, status: "confirmed", amount: "10", exact: true }); return { hash: secondHash, out: 10n, exact: true }; }, + syncTransaction: async (_chain: string, hash: string) => { effects.push(`sync:${hash}`); return { status: syncStatus, tokens: [launchedToken] }; }, + toast: (toast: { kind: string }) => effects.push(`toast:${toast.kind}`), fmtCompact: String, + friendlyError: (error: Error) => error.message, startNav: () => {}, router: { push: (url: string) => effects.push(`navigate:${url}`) }, + }; + return { bindings, disk, effects, phases, pub, sends: () => sends }; +} + +for (const status of ["pending", "error", "applied"]) test(`actual launch retains confirmed first buy across ${status} indexing`, async (t) => { + const h = launchHarness(t, status); + const launch = handler("./LaunchForm.tsx", "launch", h.bindings); + await launch(); + assert.equal(h.sends(), 1); + assert.ok(h.effects.indexOf("toast:buy") < h.effects.indexOf(`sync:${launchHash}`), "confirmed buy is reported before launch sync can fail"); + h.disk.set("ol:pending-launch", h.disk.get("ol:pending-launch")! + " "); + const retained = status === "applied" ? launchJournal.recentLaunches()[0] : launchJournal.pendingLaunch(); + assert.equal(retained?.firstBuy?.hash, secondHash); + assert.equal(retained?.firstBuy?.status, "confirmed"); + assert.equal(retained?.hash, launchHash); + if (status !== "applied") { await launch(); assert.equal(h.sends(), 1, "pending outcome never starts a second wallet send"); } + else assert.equal(launchJournal.pendingLaunch(), null, "indexed reference does not block unrelated new launches"); +}); + +test("explicit finalized-nonce resolution archives only the checked active launch", async (t) => { + const h = launchHarness(t, "pending"); + await handler("./LaunchForm.tsx", "launch", h.bindings)(); + h.pub.getTransactionCount = async () => 7; + const resolve = handler("./LaunchForm.tsx", "resolveLaunch", h.bindings); + assert.equal(await resolve(launchHash), false); + assert.equal(launchJournal.pendingLaunch()?.hash, launchHash); + h.pub.getTransactionCount = async () => 8; + assert.equal(await resolve(launchHash), true); + assert.equal(launchJournal.pendingLaunch(), null); + assert.equal(launchJournal.recentLaunches()[0].outcome, "nonce-finalized"); + assert.equal(launchJournal.recentLaunches()[0].firstBuy?.hash, secondHash); + assert.equal(h.sends(), 1, "resolution has no wallet write"); +}); + +test("a later launch revert preserves its independent first-buy reference", async (t) => { + const h = launchHarness(t, "pending"); + await handler("./LaunchForm.tsx", "launch", h.bindings)(); + await handler("./LaunchForm.tsx", "recoverLaunch", h.bindings)(launchHash, { status: "reverted" }); + assert.equal(launchJournal.pendingLaunch(), null); + assert.equal(launchJournal.recentLaunches()[0].outcome, "reverted"); + assert.equal(launchJournal.recentLaunches()[0].firstBuy?.hash, secondHash); + assert.equal(h.sends(), 1); +}); + +const boostTag = 123n; +const boostLocker = `0x${"44".repeat(20)}`; +function boostReceipt(raw = boostAmount(300_000n, boostTag)): TransactionReceipt { + return { status: "success", transactionHash: secondHash, logs: [{ address: BOOST_TOKEN, transactionHash: secondHash, blockNumber: 1n, logIndex: 0, removed: false, + data: encodeAbiParameters([{ type: "uint256" }], [raw]), + topics: encodeEventTopics({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", args: { from: launcher, to: BOOST_DEAD } }), + }] } as unknown as TransactionReceipt; +} +function boostHarness(reason: string, receipt: TransactionReceipt, options: { waitError?: boolean; syncStatus?: string; syncWait?: Promise } = {}) { + const pending: ({ hash: string; whole: bigint; replaced?: boolean } | null)[] = []; + const phases: { k: string; hash?: string; whole?: bigint; raw?: bigint; shows?: boolean }[] = []; + const synced: string[] = []; + const effects: string[] = []; + let sends = 0; + const original = { hash: launchHash, from: launcher, to: launcher, nonce: 7, input: "0xab", value: 0n }; + const pub = { + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async ({ onReplaced }: { onReplaced: (event: unknown) => void }) => { + onReplaced({ reason: reason === "invalid" ? "repriced" : reason, replacedTransaction: original, + transaction: { ...original, hash: secondHash, nonce: reason === "invalid" ? 8 : 7, input: reason === "repriced" || reason === "invalid" ? original.input : "0xcd" }, transactionReceipt: { transactionHash: secondHash } }); + if (options.waitError) throw new Error("Receipt timeout"); + return receipt; + }, + getTransactionReceipt: async () => receipt, + }; + const bindings: Record = { + address: launcher, raw: boostAmount(100_000n, boostTag), whole: 100_000n, busy: false, lock: { current: false }, chainId: 8453, BASE: { id: 8453 }, config: {}, + getPublicClient: () => pub, getWalletClient: async () => ({ writeContract: async () => { sends++; return launchHash; } }), waitForTrackedReceipt, + BOOST_TOKEN, ERC20_TRANSFER_ABI: [], DEAD: BOOST_DEAD, BUILDER_DATA_SUFFIX: "0x", BOOST_CHAIN: "base", tag: boostTag, + setPhase: (p: { k: string }) => { phases.push(p); bindings.phase = p; bindings.busy = ["preparing", "signing", "sent", "unconfirmed"].includes(p.k); }, + onPending: (value: typeof pending[number]) => pending.push(value), showsAfter: () => false, friendlyError: (error: Error) => error.message, + boostReceiptAmount, boostWholeOf, boostShowsNow, launchpad: () => ({ locker: boostLocker }), entry: { full: true, cutoff: String(boostAmount(200_000n, boostTag)) }, current: 0n, inLane: false, + syncTransaction: async (_chain: string, hash: string) => { synced.push(hash); await options.syncWait; return { status: options.syncStatus ?? "applied" }; }, INDEX_PENDING_COPY: "Still indexing", + symbol: "TEST", token: "token", groupWhole: String, toast: () => effects.push("toast"), bal: { refetch: () => effects.push("balance") }, onBoosted: () => effects.push("boost"), + }; + bindings.settle = handler("./BoostDialog.tsx", "settle", bindings); + bindings.settleReceipt = handler("./BoostDialog.tsx", "settleReceipt", bindings); + return { send: handler("./BoostDialog.tsx", "send", bindings), checkAgain: handler("./BoostDialog.tsx", "checkAgain", bindings), bindings, pending, phases, synced, effects, pub, sends: () => sends }; +} + +test("actual changed boost retains its hash through indexing and settles the actual burn amount", async () => { + let finish!: () => void; + const h = boostHarness("replaced", boostReceipt(), { syncWait: new Promise((resolve) => { finish = resolve; }) }); + const sending = h.send(); + while (!h.synced.length) await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual({ ...h.pending.at(-1) }, { hash: secondHash, whole: 300_000n, replaced: true }); + await h.send(); + assert.equal(h.sends(), 1, "the replacement cannot invite a second wallet send while indexing"); + finish(); await sending; + assert.deepEqual(h.synced, [secondHash]); + assert.equal(h.pending.at(-1), null); + assert.equal(h.phases.at(-1)?.k, "done"); + assert.equal(h.phases.at(-1)?.whole, 300_000n); + assert.equal(h.phases.at(-1)?.raw, boostAmount(300_000n, boostTag)); + assert.equal(h.phases.at(-1)?.shows, true, "the original amount would not enter this lane"); +}); + +test("changed boost totals actual fractional burns for lane eligibility", async () => { + const raw = boostAmount(100_000n, boostTag) + 10n ** 12n; + const receipt = boostReceipt(raw); + receipt.logs.push({ ...receipt.logs[0], logIndex: 1 }); + const h = boostHarness("replaced", receipt); + h.bindings.entry = { full: true, cutoff: String(2n * raw) }; + await h.send(); + assert.equal(h.phases.at(-1)?.raw, 2n * raw); + assert.equal(h.phases.at(-1)?.shows, true, "do not drop fractional TWIG or a second matching burn"); +}); + +for (const syncStatus of ["pending", "error"]) test(`changed boost survives ${syncStatus} indexing and close/reopen without resending`, async () => { + const options = { syncStatus }; + const h = boostHarness("replaced", boostReceipt(), options); + await h.send(); + assert.equal(h.phases.at(-1)?.k, "unconfirmed"); + assert.deepEqual({ ...h.pending.at(-1) }, { hash: secondHash, whole: 300_000n, replaced: true }); + h.bindings.phase = { k: "unconfirmed", ...h.pending.at(-1), checking: false, note: null }; // the dialog's actual reopen initializer + await h.send(); assert.equal(h.sends(), 1); + options.syncStatus = "applied"; + await h.checkAgain(); + assert.deepEqual(h.synced, [secondHash, secondHash]); + assert.equal(h.phases.at(-1)?.whole, 300_000n); + assert.equal(h.pending.at(-1), null); +}); + +test("changed callback followed by timeout retains the replacement for a later receipt recheck", async () => { + const h = boostHarness("replaced", boostReceipt(), { waitError: true }); + await h.send(); + assert.equal(h.phases.at(-1)?.k, "unconfirmed"); + assert.equal(h.pending.at(-1)?.hash, secondHash); + assert.equal(h.pending.at(-1)?.replaced, true); + await h.send(); assert.equal(h.sends(), 1); + await h.checkAgain(); + assert.deepEqual(h.synced, [secondHash]); + assert.equal(h.phases.at(-1)?.whole, 300_000n); +}); + +for (const reason of ["repriced", "cancelled", "reverted", "invalid", "unknown", "wrong-hash", "malformed"]) test(`actual boost resolves ${reason} only with matching receipt evidence`, async () => { + const rc = boostReceipt(); + if (reason === "cancelled") rc.logs = []; + if (reason === "reverted") rc.status = "reverted"; + if (reason === "unknown") rc.status = "unknown" as TransactionReceipt["status"]; + if (reason === "wrong-hash") rc.transactionHash = launchHash; + if (reason === "malformed") rc.logs[0].data = "0x"; + const h = boostHarness(["repriced", "cancelled", "invalid"].includes(reason) ? reason : "replaced", rc); + await h.send(); + if (reason === "repriced") { + assert.deepEqual(h.synced, [secondHash]); + assert.equal(h.phases.at(-1)?.k, "done"); + assert.equal(h.phases.at(-1)?.whole, 100_000n, "unchanged repricing keeps the submitted amount"); + } else if (reason === "cancelled" || reason === "reverted") { + assert.equal(h.pending.at(-1), null, "known receipt outcome resolves the guard"); + assert.equal(h.phases.at(-1)?.k, "error"); + assert.equal(h.phases.at(-1)?.hash, secondHash, "the resolved replacement keeps its explorer reference"); + assert.deepEqual(h.synced, []); + assert.ok(!h.effects.includes("toast")); + } else { + assert.equal(h.phases.at(-1)?.k, "unconfirmed"); + assert.equal(h.pending.at(-1)?.hash, reason === "invalid" ? launchHash : secondHash); + assert.deepEqual(h.synced, []); + await h.send(); assert.equal(h.sends(), 1); + } +}); diff --git a/app/src/lib/admin/admin.test.ts b/app/src/lib/admin/admin.test.ts index 7496aa89..c6ff2ba6 100644 --- a/app/src/lib/admin/admin.test.ts +++ b/app/src/lib/admin/admin.test.ts @@ -165,7 +165,7 @@ const SIGN_IN = new Set(["admin/session:POST", "admin/nonce:POST"]); test("every handler of every admin route requires the admin session", () => { const found: string[] = []; for (const file of routes()) { - const rel = path.relative(path.join(SRC, "app/api"), path.dirname(file)); + const rel = path.relative(path.join(SRC, "app/api"), path.dirname(file)).split(path.sep).join("/"); const s = readFileSync(file, "utf8"); if (!ADMIN_MARKERS.test(s) && !rel.startsWith("admin")) continue; found.push(rel); @@ -206,7 +206,7 @@ test("admin actions: the session is checked before the action, against the walle test("sign-in: same-origin, rate-limited, nonce spent only after the signature checks out", () => { const s = src("app/api/admin/session/route.ts"); const post = s.slice(s.indexOf("export async function POST("), s.indexOf("export async function DELETE(")); - const order = ["fromAdminOrigin(req)", "rateLimited(`admin-signin:ip:", "checkAdminSignIn(", "adminWallets().has(c.wallet)", "adminNonceOpen(c.nonce)", "verifySig(c.wallet, [b.message as string], b.signature, \"base\")", "rateLimited(`admin-signin:wallet:", "consumeAdminNonce(c.nonce)", "createAdminSession(", "res.cookies.set(ADMIN_COOKIE, s.token, adminCookieOptions(s.expiresAt))"]; + const order = ["fromAdminOrigin(req)", "rateLimited(`admin-signin:ip:", "checkAdminSignIn(", "adminWallets().has(c.wallet)", "adminNonceOpen(c.nonce)", "verifySig(c.wallet, [b.message as string], b.signature, \"base\")", "consumeAdminNonce(c.nonce)", "rateLimited(`admin-signin:wallet:", "createAdminSession(", "res.cookies.set(ADMIN_COOKIE, s.token, adminCookieOptions(s.expiresAt))"]; let at = -1; for (const step of order) { const i = post.indexOf(step); diff --git a/app/src/lib/chain-mock.ts b/app/src/lib/chain-mock.ts index aeaf1f61..67bc2427 100644 --- a/app/src/lib/chain-mock.ts +++ b/app/src/lib/chain-mock.ts @@ -42,6 +42,7 @@ const DEAD_RPC = "http://127.0.0.1:1"; export function publicClient(_key: ChainKey): any { const m = getMock(); + if (m.publicClient) return m.publicClient; if (m.transportOutage) { return createPublicClient({ chain: base, diff --git a/app/src/lib/db-mock.ts b/app/src/lib/db-mock.ts index f87c87c7..ab6bbfb1 100644 --- a/app/src/lib/db-mock.ts +++ b/app/src/lib/db-mock.ts @@ -12,6 +12,9 @@ type NonceRow = { }; type MockState = { + // Optional route-test dependencies; the defaults below keep existing edit tests unchanged. + query?: (sql: string, values: unknown[]) => Promise; + publicClient?: object; nonceRows: Record; launcherRows: Record; metaInserted: boolean; @@ -74,6 +77,8 @@ function fakeDb(strings: TemplateStringsArray, ...values: unknown[]): Promise now(), and the exact signed expiry. @@ -144,6 +149,9 @@ function fakeDb(strings: TemplateStringsArray, ...values: unknown[]): Promise fakeDb; +(fakeDb as any).release = () => {}; + export function maybeDb(): any | null { return fakeDb as any; } @@ -157,3 +165,4 @@ export function db(): any { } export type { Db }; +export { errMessage } from "./pg.ts"; diff --git a/app/src/lib/launchpad/boost-receipt.test.ts b/app/src/lib/launchpad/boost-receipt.test.ts new file mode 100644 index 00000000..68773976 --- /dev/null +++ b/app/src/lib/launchpad/boost-receipt.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { encodeAbiParameters, encodeEventTopics, type TransactionReceipt } from "viem"; +import { ERC20_TRANSFER_EVENT } from "./abi"; +import { BOOST_TOKEN, BOOST_DEAD, boostAmount, TAG_SPACE } from "./boost"; +import { boostReceiptAmount } from "./boost-receipt"; + +const hash = `0x${"ab".repeat(32)}` as const; +const sender = `0x${"11".repeat(20)}` as const; +const locker = `0x${"22".repeat(20)}` as const; +const tag = 123n; +function burn(value = boostAmount(300_000n, tag), from = sender, to = BOOST_DEAD) { + return { address: BOOST_TOKEN, transactionHash: hash, blockNumber: 1n, logIndex: 0, removed: false, + data: encodeAbiParameters([{ type: "uint256" }], [value]), + topics: encodeEventTopics({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", args: { from, to: to as `0x${string}` } }), + } as TransactionReceipt["logs"][number]; +} +function receipt(logs = [burn()]) { return { transactionHash: hash, logs }; } + +test("replacement boost amount follows actual Transfer facts, including fractional amounts and multiple qualifying burns", () => { + const actual = boostAmount(300_000n, tag) + TAG_SPACE; + assert.equal(boostReceiptAmount(receipt([burn(actual)]), tag, locker), actual); + assert.equal(boostReceiptAmount(receipt([burn(), { ...burn(), logIndex: 1 }]), tag, locker), 2n * boostAmount(300_000n, tag)); +}); + +test("cancellation, wrong token, recipient, launch tag, floor, and locker burns never become this token's boost", () => { + for (const logs of [[], [{ ...burn(), address: sender }], [burn(undefined, sender, sender)], [burn(boostAmount(300_000n, tag + 1n))], [burn(boostAmount(99_999n, tag))], [burn(undefined, locker as typeof sender)]]) { + assert.equal(boostReceiptAmount(receipt(logs), tag, locker), null); + } +}); + +test("malformed, removed, duplicate, or mismatched receipt logs cannot resolve a replacement", () => { + for (const logs of [[{ ...burn(), transactionHash: `0x${"cd".repeat(32)}` as const }], [{ ...burn(), removed: true }], [burn(), burn()], [{ ...burn(), data: "0x" as const }], [{ ...burn(), logIndex: null }]]) { + assert.throws(() => boostReceiptAmount(receipt(logs), tag, locker)); + } +}); diff --git a/app/src/lib/launchpad/boost-receipt.ts b/app/src/lib/launchpad/boost-receipt.ts new file mode 100644 index 00000000..a588396d --- /dev/null +++ b/app/src/lib/launchpad/boost-receipt.ts @@ -0,0 +1,23 @@ +import { decodeEventLog, toEventSelector, type TransactionReceipt } from "viem"; +import { ERC20_TRANSFER_EVENT } from "./abi"; +import { BOOST_TOKEN, boostFromTransfer } from "./boost"; + +const TRANSFER_TOPIC = toEventSelector(ERC20_TRANSFER_EVENT); + +/** Receipt facts use the indexer's boost rules, including locker exclusion. Never infer a replacement's amount. */ +export function boostReceiptAmount(receipt: Pick, tag: bigint, locker: string | null): bigint | null { + let amount = 0n; + const seen = new Set(); + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== BOOST_TOKEN || log.topics[0]?.toLowerCase() !== TRANSFER_TOPIC) continue; + if (log.removed || log.transactionHash?.toLowerCase() !== receipt.transactionHash.toLowerCase() + || log.logIndex === null || !Number.isSafeInteger(log.logIndex) || log.logIndex < 0 || log.blockNumber === null || seen.has(log.logIndex)) { + throw new Error("The replacement burn could not be verified. Check its transaction before sending again."); + } + seen.add(log.logIndex); + const { args } = decodeEventLog({ abi: [ERC20_TRANSFER_EVENT], data: log.data, topics: log.topics, strict: true }); + const row = boostFromTransfer({ address: log.address, ...args, txHash: receipt.transactionHash, logIndex: log.logIndex, blockNumber: log.blockNumber }, locker ? [locker] : []); + if (row?.tag === tag.toString()) amount += args.value; + } + return amount > 0n ? amount : null; +} diff --git a/app/src/lib/launchpad/editServer.test.ts b/app/src/lib/launchpad/editServer.test.ts index 8a5599f0..43312bff 100644 --- a/app/src/lib/launchpad/editServer.test.ts +++ b/app/src/lib/launchpad/editServer.test.ts @@ -228,6 +228,48 @@ test("applySignedEdit: already-used nonce is rejected (401)", async () => { assert.equal(r.status, 401, "reused nonce should be 401"); }); +test("applySignedEdit: replayed valid signatures cannot exhaust the creator's edit allowance", async () => { + resetMock(); + const m = getMock(); + const wallet = "0x" + "ac".repeat(20); + m.launcherRows[TOKEN] = { launcher: wallet, name: "Replay fixture", symbol: "RPL" }; + m.verifyResult = true; + const first = "b".repeat(32); + const fresh = "c".repeat(32); + for (const nonce of [first, fresh]) m.nonceRows[nonce] = { + nonce, used_at: null, wallet, token: TOKEN, chain_id: 8453, expires_at: EXPIRES_AT_ISO, + }; + assert.equal((await ed.applySignedEdit(editRequest({ wallet, nonce: first }))).ok, true); + for (let i = 0; i < 10; i++) { + const replay = await ed.applySignedEdit(editRequest({ wallet, nonce: first })); + assert.equal(replay.ok, false); + assert.equal(replay.status, 401, "a consumed nonce cannot spend a wallet allowance"); + } + assert.equal((await ed.applySignedEdit(editRequest({ wallet, nonce: fresh }))).ok, true); +}); + +test("applySignedEdit: ten fresh signed edits retain the wallet limit and over-quota nonces are consumed", async () => { + resetMock(); + const m = getMock(); + const wallet = "0x" + "ad".repeat(20); + m.launcherRows[TOKEN] = { launcher: wallet, name: "Quota fixture", symbol: "QTA" }; + m.verifyResult = true; + for (let i = 0; i < 11; i++) { + const nonce = i.toString(16).padStart(32, "d"); + m.nonceRows[nonce] = { nonce, used_at: null, wallet, token: TOKEN, chain_id: 8453, expires_at: EXPIRES_AT_ISO }; + const response = await ed.applySignedEdit(editRequest({ wallet, nonce })); + if (i < 10) assert.equal(response.ok, true); + else { + assert.equal(response.ok, false); + assert.equal(response.status, 429); + assert.notEqual(m.nonceRows[nonce].used_at, null); + const replay = await ed.applySignedEdit(editRequest({ wallet, nonce })); + assert.equal(replay.ok, false); + assert.equal(replay.status, 401); + } + } +}); + // ── Finding 1: rateLimited keyed on unproven wallet ── test("rateLimited: 10 hits on a wallet key 429s the 11th (the DoS mechanism)", () => { diff --git a/app/src/lib/launchpad/editServer.ts b/app/src/lib/launchpad/editServer.ts index b998372e..5ef9157d 100644 --- a/app/src/lib/launchpad/editServer.ts +++ b/app/src/lib/launchpad/editServer.ts @@ -88,11 +88,6 @@ export async function applySignedEdit(r: EditRequest): Promise<{ ok: true } | { return { ok: false, error: "signature does not match", status: 401 }; } - // wallet rate limit is applied AFTER the signature is verified, so an - // attacker cannot freeze a creator's edit budget by sending requests with - // the creator's public address (the wallet is proven before the bucket is spent) - if (rateLimited(`edit:wallet:${wallet}`, 10)) return { ok: false, error: "slow down", status: 429 }; - // nonce: exists, matches, unexpired, unused — consumed atomically, only // after the signature is proven, inside a transaction with the metadata // write so a failed INSERT rolls the nonce back @@ -105,6 +100,11 @@ export async function applySignedEdit(r: EditRequest): Promise<{ ok: true } | { AND expires_at = ${new Date(expiresAt).toISOString()} RETURNING nonce`; if (consumed.length === 0) return { ok: false as const, error: "nonce invalid or already used", status: 401 }; + // A signature proves who signed it, not that this submission is fresh. Charge + // the wallet only after atomic nonce consumption so replays cannot spend its + // allowance. A valid over-quota attempt consumes its nonce; the UI requests a + // fresh nonce on retry, as it already does for every edit submission. + if (rateLimited(`edit:wallet:${wallet}`, 10)) return { ok: false as const, error: "slow down", status: 429 }; await t` INSERT INTO bb_launch_meta (chain_id, token, launcher, name, symbol, description, image_url, banner_url, website, x_handle, updated_at) VALUES (${cid}, ${token}, ${wallet}, ${row[0].name}, ${row[0].symbol}, ${v.value.description || null}, ${v.value.image_url || null}, ${v.value.banner_url || null}, ${v.value.website || null}, ${v.value.x_handle || null}, now()) diff --git a/app/src/lib/launchpad/images.ts b/app/src/lib/launchpad/images.ts index c7ebf1ca..9256b2e3 100644 --- a/app/src/lib/launchpad/images.ts +++ b/app/src/lib/launchpad/images.ts @@ -18,7 +18,7 @@ export function imageRole(v: unknown): ImageRole { export function maxBytesFor(role: ImageRole): number { return role === "banner" ? BANNER_MAX_BYTES : IMAGE_MAX_BYTES; } -export const IMAGE_UPLOADS_PER_HOUR = 10; // per wallet and per IP +export const IMAGE_UPLOADS_PER_HOUR = 10; // the anonymous route's IP allowance is twice this (20/hour) export const IMAGE_KEY_BYTES = 24; export type ImageKind = "png" | "jpeg" | "webp" | "gif"; @@ -116,7 +116,7 @@ export function isOwnImageUrl(url: string | null | undefined, publicBase: string return rel !== null && new RegExp(`^t/[0-9a-f]{${IMAGE_KEY_BYTES * 2}}\\.webp$`).test(rel); } -/** Validate a client-declared wallet before it's used as a rate-limit key. */ +/** Validate the client-declared wallet's shape; this does not prove ownership. */ export function isWalletParam(v: unknown): v is string { return typeof v === "string" && /^0x[0-9a-fA-F]{40}$/.test(v); } diff --git a/app/src/lib/launchpad/indexer-canonical.ts b/app/src/lib/launchpad/indexer-canonical.ts new file mode 100644 index 00000000..9506529c --- /dev/null +++ b/app/src/lib/launchpad/indexer-canonical.ts @@ -0,0 +1,146 @@ +import "server-only"; +import { AsyncLocalStorage } from "node:async_hooks"; +import type { Log } from "viem"; +import { publicClient } from "@/lib/chain"; +import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; +import type { Db } from "@/lib/db"; +import { withIndexLock } from "./indexer-lock"; +import { rollbackChain } from "./indexer-reorg-db"; + +type Block = { number: bigint; hash: string; timestamp: bigint }; +type State = { cursor_block: bigint; reorg_version: number; reorg_generation: bigint; rebuilding_until: bigint | null }; +type Observation = { chain: ChainKey; db: Db; blocks: Map }; +const observations = new AsyncLocalStorage(); +const seenGenerations = new Map(); + +async function readBlock(chain: ChainKey, number: bigint): Promise { + const b = await publicClient(chain).getBlock({ blockNumber: number }); + if (b.number !== number || !b.hash || !/^0x[0-9a-f]{64}$/i.test(b.hash)) throw new Error("canonical block unavailable"); + return { number, hash: b.hash.toLowerCase(), timestamp: b.timestamp }; +} + +/** A block is checked once per ingestion transaction, never cached across forks. */ +export async function observeBlock(chain: ChainKey, number: bigint): Promise { + const context = observations.getStore(); + if (!context || context.chain !== chain) throw new Error("canonical index context required"); + const found = context.blocks.get(number); + if (found) return found; + const block = await readBlock(chain, number); + const [previous] = await context.db<{ block_hash: string }[]>` + SELECT block_hash FROM bb_launch_blocks WHERE chain_id = ${chainIdOf(chain)} AND block_number = ${number}`; + if (previous && previous.block_hash !== block.hash) throw new Error("chain changed during indexing"); + context.blocks.set(number, block); + return block; +} + +export async function observeLog(chain: ChainKey, log: Pick): Promise { + if (log.removed || log.blockNumber === null || !log.blockHash) throw new Error("unmined or removed index event"); + const block = await observeBlock(chain, log.blockNumber); + if (block.hash !== log.blockHash.toLowerCase()) throw new Error("event is not in the canonical block"); + return block; +} + +async function state(db: Db, cid: number): Promise { + const [row] = await db` + SELECT cursor_block, reorg_version, reorg_generation, rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id = ${cid} FOR UPDATE`; + if (!row) throw new Error("index state unavailable"); + return row; +} + +/** Find the last checkpoint whose ancestry still exists. Sparse checkpoints include every event-bearing block. */ +async function reconcile(db: Db, chain: ChainKey, deploy: bigint, head: bigint): Promise { + const cid = chainIdOf(chain); + const [tip] = await db<{ block_number: bigint; block_hash: string }[]>` + SELECT block_number, block_hash FROM bb_launch_blocks WHERE chain_id = ${cid} ORDER BY block_number DESC LIMIT 1`; + if (!tip) return false; + let candidate = tip; + if (tip.block_number > head) { + const [earlier] = await db` + SELECT block_number, block_hash FROM bb_launch_blocks WHERE chain_id = ${cid} AND block_number <= ${head} ORDER BY block_number DESC LIMIT 1`; + // A lagging node is not evidence that previously observed blocks were removed. + if (!earlier || (await readBlock(chain, earlier.block_number)).hash === earlier.block_hash) throw new Error("RPC head is behind indexed history"); + candidate = earlier; + } else if ((await readBlock(chain, tip.block_number)).hash === tip.block_hash) return false; + + let low = deploy - 1n; + let high = candidate.block_number - 1n; + // A matching hash commits to its ancestors. Binary search checkpoint heights, + // including forks deeper than the normal log overlap, without a linear RPC walk. + while (low < high) { + const mid = (low + high + 1n) / 2n; + const [row] = await db` + SELECT block_number, block_hash FROM bb_launch_blocks + WHERE chain_id = ${cid} AND block_number > ${low} AND block_number <= ${mid} ORDER BY block_number DESC LIMIT 1`; + if (!row) { + const [next] = await db` + SELECT block_number, block_hash FROM bb_launch_blocks + WHERE chain_id = ${cid} AND block_number > ${mid} AND block_number <= ${high} ORDER BY block_number ASC LIMIT 1`; + if (!next) break; + if ((await readBlock(chain, next.block_number)).hash === next.block_hash) low = next.block_number; + else high = next.block_number - 1n; + } else if ((await readBlock(chain, row.block_number)).hash === row.block_hash) low = row.block_number; + else high = row.block_number - 1n; + } + await rollbackChain(db, chain, low + 1n, "reorg", head); + return true; +} + +/** One atomic unit: reconcile, apply facts, verify the observed ancestry again, then checkpoint and commit. */ +export async function withCanonicalIndex(chain: ChainKey, deploy: bigint, clearCaches: () => void, + work: (db: Db, head: bigint, current: State) => Promise): Promise { + return withIndexLock(chain, async (db) => { + const cid = chainIdOf(chain); + await db`INSERT INTO bb_launch_sync_cursor (chain_id) VALUES (${cid}) ON CONFLICT DO NOTHING`; + let current = await state(db, cid); + const head = await publicClient(chain).getBlockNumber(); + if (head < deploy) throw new Error("RPC head is behind indexed history"); + if (current.reorg_version !== 0 && current.reorg_version !== 1) throw new Error("unsupported index recovery version"); + if (current.reorg_version === 0) { + const [old] = await db<{ n: bigint }[]>` + SELECT GREATEST(${current.cursor_block}, + COALESCE((SELECT max(block_number) FROM bb_launches WHERE chain_id = ${cid}), 0), + COALESCE((SELECT max(block_number) FROM bb_launch_swaps WHERE chain_id = ${cid}), 0), + COALESCE((SELECT max(block_number) FROM bb_launch_fee_events WHERE chain_id = ${cid}), 0), + COALESCE((SELECT max(block_number) FROM bb_token_transfers WHERE chain_id = ${cid}), 0), + COALESCE((SELECT max(block_number) FROM bb_boosts WHERE chain_id = ${cid}), 0), + COALESCE((SELECT max(block_number) FROM bb_pair_burns WHERE chain_id = ${cid}), 0)) AS n`; + if (old.n > head) throw new Error("RPC head is behind legacy indexed history"); + if (old.n >= deploy) { + await rollbackChain(db, chain, deploy, "rebuilding", head); + await db`UPDATE bb_launch_sync_cursor SET cursor_block = ${deploy - 1n}, rebuilding_until = ${head} WHERE chain_id = ${cid}`; + } + await db`UPDATE bb_launch_sync_cursor SET reorg_version = 1 WHERE chain_id = ${cid}`; + current = await state(db, cid); + } + if (await reconcile(db, chain, deploy, head)) current = await state(db, cid); + if (current.cursor_block > head) throw new Error("RPC head is behind indexed history"); + // Process-local transaction/receipt/height caches cannot survive another + // machine's rollback. Comparing the persisted generation avoids relying on local notifications. + if (seenGenerations.get(chain) !== current.reorg_generation) { + clearCaches(); + seenGenerations.set(chain, current.reorg_generation); + } + return observations.run({ chain, db, blocks: new Map() }, async () => { + const [anchor] = await db<{ block_number: bigint }[]>` + SELECT block_number FROM bb_launch_blocks WHERE chain_id = ${cid} ORDER BY block_number DESC LIMIT 1`; + if (anchor) await observeBlock(chain, anchor.block_number); + const value = await work(db, head, current); + const blocks = [...observations.getStore()!.blocks.values()].sort((a, b) => a.number < b.number ? -1 : 1); + if (blocks.length) { + // Recheck all observed hashes, not only the tip: reject inconsistent RPC + // responses as well as a fork arriving while the range was being applied. + for (const b of blocks) if ((await readBlock(chain, b.number)).hash !== b.hash) throw new Error("chain changed before index commit"); + for (const b of blocks) await db` + INSERT INTO bb_launch_blocks (chain_id, block_number, block_hash) VALUES (${cid}, ${b.number}, ${b.hash}) + ON CONFLICT (chain_id, block_number) DO NOTHING`; + } + return value; + }); + }).catch((error) => { + // SQL rolls back, but process-local receipt/transaction caches do not. A + // failed range may have populated them from a fork that never committed. + clearCaches(); + seenGenerations.delete(chain); + throw error; + }); +} diff --git a/app/src/lib/launchpad/indexer-lock.test.ts b/app/src/lib/launchpad/indexer-lock.test.ts new file mode 100644 index 00000000..bf9b3a0d --- /dev/null +++ b/app/src/lib/launchpad/indexer-lock.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getMock, resetMock } from "@/lib/db-mock"; +import { IndexLockBusyError, withIndexLock } from "./indexer-lock"; + +test("the chain wait budget is separate from the table lock budget", async () => { + resetMock(); + const calls: string[] = []; + getMock().query = async (sql) => { calls.push(sql); return []; }; + assert.equal(await withIndexLock("base", async () => { calls.push("work"); return 42; }), 42); + assert.match(calls[0], /lock_timeout = '8s'/); + assert.match(calls[1], /pg_advisory_xact_lock/); + assert.match(calls[2], /lock_timeout = '5s'/); + assert.match(calls[3], /LOCK TABLE bb_launch_swaps, bb_launch_fee_events, bb_launches IN ROW EXCLUSIVE MODE/); + assert.equal(calls[4], "work"); +}); + +test("an advisory timeout never enters work and is retryable", async () => { + resetMock(); + let worked = false; + getMock().query = async (sql) => { + if (sql.includes("pg_advisory_xact_lock")) throw { code: "55P03", detail: "private database diagnostic" }; + assert.doesNotMatch(sql, /LOCK TABLE/); + return []; + }; + await assert.rejects(withIndexLock("base", async () => { worked = true; }), IndexLockBusyError); + assert.equal(worked, false); +}); + +test("other database failures are not misreported as advisory contention", async () => { + for (const [stage, code] of [["advisory", "08006"], ["table", "55P03"]]) { + resetMock(); + const failure = Object.assign(new Error("synthetic failure"), { code }); + getMock().query = async (sql) => { + if (stage === "advisory" ? sql.includes("pg_advisory_xact_lock") : sql.includes("LOCK TABLE")) throw failure; + return []; + }; + await assert.rejects(withIndexLock("base", async () => assert.fail("failed lock entered work")), error => error === failure); + } +}); diff --git a/app/src/lib/launchpad/indexer-lock.ts b/app/src/lib/launchpad/indexer-lock.ts new file mode 100644 index 00000000..b8c68226 --- /dev/null +++ b/app/src/lib/launchpad/indexer-lock.ts @@ -0,0 +1,40 @@ +import "server-only"; +import { maybeDb, type Db } from "@/lib/db"; +import type { ChainKey } from "@/lib/chainPublic"; + +/** Retryable writer contention, distinct from a failed RPC or database operation. */ +export class IndexLockBusyError extends Error { + constructor() { super("canonical index is busy"); this.name = "IndexLockBusyError"; } +} + +/** All canonical event writers use one transaction/lock per chain, across processes. */ +export async function withIndexLock(chain: ChainKey, work: (db: Db) => Promise): Promise { + const pool = maybeDb(); + if (!pool) throw new Error("index database unavailable"); + return pool.begin(async (sql) => { + const db = sql as unknown as Db; + // RPC work can hold this lock beyond the table-lock budget. Bound only the + // wait to leave room within the sync client's 15s request deadline; the + // transaction's table/RPC work can still take longer, so a timeout is pending. + await db`SET LOCAL lock_timeout = '8s'`; + try { + await db`SELECT pg_advisory_xact_lock(hashtext(${`launch-index:${chain}`}))`; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "55P03") throw new IndexLockBusyError(); + throw error; + } + await db`SET LOCAL lock_timeout = '5s'`; + // Match the existing totals/USD maintenance scripts' table lock order. + // Compatible across chains, but wait before touching launches if a rebuild + // already owns SHARE locks on raw events. + await db`LOCK TABLE bb_launch_swaps, bb_launch_fee_events, bb_launches IN ROW EXCLUSIVE MODE`; + return work(db); + }) as Promise; +} + +/** postgres.js transactions expose savepoint, not begin. Retain per-event atomicity. */ +export async function indexAtomic(db: Db, work: (db: Db) => Promise): Promise { + const tx = db as unknown as { savepoint?: (fn: (sql: Db) => Promise) => Promise }; + if (tx.savepoint) return tx.savepoint(work); + return db.begin((sql) => work(sql as unknown as Db)) as Promise; +} diff --git a/app/src/lib/launchpad/indexer-reorg-db.test.ts b/app/src/lib/launchpad/indexer-reorg-db.test.ts new file mode 100644 index 00000000..a9db9daf --- /dev/null +++ b/app/src/lib/launchpad/indexer-reorg-db.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getMock, maybeDb, resetMock } from "@/lib/db-mock"; +import { replayPayloadMatches, replayValue, type ReplayKind, type ReplayPayload } from "./indexer-reorg-db"; + +const identities: Record = { + swap: { token: "0xtoken", pool_id: "0xpool", amount0: "-1000000000000000000000001", amount1: "2000000000000000000000003", sqrt_price_x96: "79228162514264337593543950337", tick: 4, is_buy: true }, + fee: { kind: "collected", token_id: "9007199254740993", token: "0xtoken", quote: "0xquote", currency: null, account: null, amount: null, quote_amount: "1000000000000000000000001", token_amount: "2000000000000000000000003" }, + pair: { application_id: "9007199254740993", sender: "0xsender", twig: "1000000000000000000000001" }, +}; + +for (const kind of ["swap", "fee", "pair"] as const) { + test(`replay valuation: ${kind} requires every event field, including explicit nulls`, () => { + const original = identities[kind]; + assert.equal(replayPayloadMatches(kind, { ...original, usd: 7, trader: "an enrichment" }, original), true); + for (const key of Object.keys(original)) { + const missing = { ...original }; + delete missing[key]; + assert.equal(replayPayloadMatches(kind, original, missing), false, `missing ${key}`); + assert.equal(replayPayloadMatches(kind, original, { ...original, [key]: original[key] === null ? "changed" : null }), false, `changed ${key}`); + } + }); +} + +test("replay identity preserves integers beyond JS precision and accepts exact bigint/string equivalents", () => { + const pair = identities.pair; + assert.equal(replayPayloadMatches("pair", pair, { ...pair, application_id: 9007199254740993n, twig: BigInt(String(pair.twig)) }), true); + assert.equal(replayPayloadMatches("pair", pair, { ...pair, application_id: Number(pair.application_id) }), false); + assert.equal(replayPayloadMatches("swap", identities.swap, { ...identities.swap, sqrt_price_x96: Number(identities.swap.sqrt_price_x96) }), false); +}); + +test("replaying an already pending pair valuation preserves the reconciliation requirement", async () => { + resetMock(); + getMock().query = async () => [{ payload: { ...identities.pair, valuation_pending: true }, usd: null, priced_by: null }]; + const replayed = await replayValue(maybeDb()!, "base", "pair", "0xtx", 1, 100n, "2026-11-12T00:00:00Z", "0xblock", identities.pair); + assert.deepEqual(replayed, { usd: null, priced_by: null, valuation_pending: true }); +}); diff --git a/app/src/lib/launchpad/indexer-reorg-db.ts b/app/src/lib/launchpad/indexer-reorg-db.ts new file mode 100644 index 00000000..8e556a01 --- /dev/null +++ b/app/src/lib/launchpad/indexer-reorg-db.ts @@ -0,0 +1,152 @@ +import "server-only"; +import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; +import type { Db } from "@/lib/db"; +import { launchpad } from "./config"; +import { DEAD_ADDR, ZERO_ADDR } from "./holders"; +import { PAIR_FEE_TOLERANCE } from "./pairApply"; + +export type ReplayKind = "swap" | "fee" | "pair"; +export type ReplayPayload = Record; +export type ReplayValue = { usd: number | null; priced_by: string | null; valuation_pending?: boolean }; +const identityFields: Record = { + swap: ["token", "pool_id", "amount0", "amount1", "sqrt_price_x96", "tick", "is_buy"], + fee: ["kind", "token_id", "token", "quote", "currency", "account", "amount", "quote_amount", "token_amount"], + pair: ["application_id", "sender", "twig"], +}; + +/** All decoded event fields must agree; a same transaction/log key alone does not identify a re-included event. */ +export function replayPayloadMatches(kind: ReplayKind, stored: Record, current: ReplayPayload): boolean { + return identityFields[kind].every((key) => Object.hasOwn(current, key) && Object.hasOwn(stored, key) + && (stored[key] === null || current[key] === null ? stored[key] === current[key] : String(stored[key]) === String(current[key]))); +} + +/** + * Reuse an observed valuation only for an unchanged event at the same canonical placement. The caller supplies + * fields decoded from a freshly validated canonical log. Legacy rows have no hash; their complete payload, + * height and timestamp must still match. Large numeric fields in the archive are text, never JSON doubles. + */ +export async function replayValue(db: Db, chain: ChainKey, kind: ReplayKind, txHash: string, logIndex: number, + blockNumber: bigint, blockTime: string | Date, blockHash: string, payload: ReplayPayload): Promise { + const rows = await db<{ payload: Record; usd: number | null; priced_by: string | null }[]>` + SELECT payload, usd, priced_by FROM bb_launch_replay_values + WHERE chain_id = ${chainIdOf(chain)} AND kind = ${kind} AND tx_hash = ${txHash.toLowerCase()} AND log_index = ${logIndex} + AND block_number = ${blockNumber} AND block_time = ${new Date(blockTime).toISOString()}::timestamptz + AND (block_hash IS NULL OR block_hash = ${blockHash.toLowerCase()})`; + const row = rows.find((r) => replayPayloadMatches(kind, r.payload, payload)); + return row ? { usd: row.usd, priced_by: row.priced_by, + ...(kind === "pair" ? { valuation_pending: row.payload.valuation_pending === true } : {}), + } : null; +} + +/** + * Remove a noncanonical suffix and rebuild every dependent total from retained facts. `db` MUST be the caller's + * transaction, under the common per-chain advisory lock. No helper obtains another connection or commits here. + * Cursor rows precede season rows in the lock order shared with points publication. Raw swaps precede fees and + * launches in the mutation order shared with the offline totals/USD maintenance scripts. + */ +export async function rollbackChain(db: Db, chain: ChainKey, from: bigint, reason: "reorg" | "rebuilding", head: bigint): Promise { + if (from < 0n) throw new Error("invalid reorg boundary"); + const cid = chainIdOf(chain); + const before = from > 0n ? from - 1n : 0n; + const [cursor] = await db<{ cursor_block: bigint }[]>`SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id = ${cid} FOR UPDATE`; + if (!cursor) throw new Error("missing chain cursor for rollback"); + // Preserve full raw evidence plus exact numeric strings before removing anything. A future rollback at the + // same height replaces this slot with the most recently observed event, and replay still checks its identity. + await db` + INSERT INTO bb_launch_replay_values (chain_id, kind, tx_hash, log_index, block_number, block_time, block_hash, payload, usd, priced_by) + SELECT s.chain_id, 'swap', s.tx_hash, s.log_index, s.block_number, s.block_time, b.block_hash, + to_jsonb(s) || jsonb_build_object('amount0', s.amount0::text, 'amount1', s.amount1::text, 'sqrt_price_x96', s.sqrt_price_x96::text), s.usd, NULL + FROM bb_launch_swaps s LEFT JOIN bb_launch_blocks b ON b.chain_id = s.chain_id AND b.block_number = s.block_number + WHERE s.chain_id = ${cid} AND (s.block_number >= ${from} OR s.token IN (SELECT token FROM bb_launches WHERE chain_id = ${cid} AND block_number >= ${from})) + ON CONFLICT (chain_id, kind, tx_hash, log_index, block_number) DO UPDATE + SET block_time = EXCLUDED.block_time, block_hash = EXCLUDED.block_hash, payload = EXCLUDED.payload, usd = EXCLUDED.usd, priced_by = EXCLUDED.priced_by`; + await db` + INSERT INTO bb_launch_replay_values (chain_id, kind, tx_hash, log_index, block_number, block_time, block_hash, payload, usd, priced_by) + SELECT f.chain_id, 'fee', f.tx_hash, f.log_index, f.block_number, f.block_time, b.block_hash, + to_jsonb(f) || jsonb_build_object('token_id', f.token_id::text, 'quote', l.quote, 'amount', f.amount::text, 'quote_amount', f.quote_amount::text, 'token_amount', f.token_amount::text), f.usd, NULL + FROM bb_launch_fee_events f LEFT JOIN bb_launch_blocks b ON b.chain_id = f.chain_id AND b.block_number = f.block_number + LEFT JOIN bb_launches l ON l.chain_id = f.chain_id AND l.token = f.token + WHERE f.chain_id = ${cid} AND (f.block_number >= ${from} OR f.token IN (SELECT token FROM bb_launches WHERE chain_id = ${cid} AND block_number >= ${from})) + ON CONFLICT (chain_id, kind, tx_hash, log_index, block_number) DO UPDATE + SET block_time = EXCLUDED.block_time, block_hash = EXCLUDED.block_hash, payload = EXCLUDED.payload, usd = EXCLUDED.usd, priced_by = EXCLUDED.priced_by`; + await db` + INSERT INTO bb_launch_replay_values (chain_id, kind, tx_hash, log_index, block_number, block_time, block_hash, payload, usd, priced_by) + SELECT p.chain_id, 'pair', p.tx_hash, p.log_index, p.block_number, p.block_time, b.block_hash, + to_jsonb(p) || jsonb_build_object('application_id', p.application_id::text, 'twig', p.twig::text), p.usd, p.priced_by + FROM bb_pair_burns p LEFT JOIN bb_launch_blocks b ON b.chain_id = p.chain_id AND b.block_number = p.block_number + WHERE p.chain_id = ${cid} AND p.block_number >= ${from} + ON CONFLICT (chain_id, kind, tx_hash, log_index, block_number) DO UPDATE + SET block_time = EXCLUDED.block_time, block_hash = EXCLUDED.block_hash, payload = EXCLUDED.payload, usd = EXCLUDED.usd, priced_by = EXCLUDED.priced_by`; + const apps = await db<{ application_id: string }[]>`SELECT DISTINCT application_id::text FROM bb_pair_burns WHERE chain_id = ${cid} AND block_number >= ${from}`; + await db` + INSERT INTO bb_launch_recovery (chain_id, token, tx_hash, reason) + SELECT chain_id, token, tx_hash, ${reason} FROM bb_launches WHERE chain_id = ${cid} AND block_number >= ${from} + ON CONFLICT (chain_id, token) DO UPDATE SET tx_hash = EXCLUDED.tx_hash, reason = EXCLUDED.reason, created_at = now()`; + await db`DELETE FROM bb_launch_swaps WHERE chain_id = ${cid} AND (block_number >= ${from} OR token IN (SELECT token FROM bb_launches WHERE chain_id = ${cid} AND block_number >= ${from}))`; + await db`DELETE FROM bb_launch_fee_events WHERE chain_id = ${cid} AND (block_number >= ${from} OR token IN (SELECT token FROM bb_launches WHERE chain_id = ${cid} AND block_number >= ${from}))`; + await db`DELETE FROM bb_token_transfers WHERE chain_id = ${cid} AND (block_number >= ${from} OR token IN (SELECT token FROM bb_launches WHERE chain_id = ${cid} AND block_number >= ${from}))`; + await db`DELETE FROM bb_boosts WHERE chain_id = ${cid} AND block_number >= ${from}`; + await db`DELETE FROM bb_pair_burns WHERE chain_id = ${cid} AND block_number >= ${from}`; + await db`DELETE FROM bb_launches WHERE chain_id = ${cid} AND block_number >= ${from}`; + // Start with the empty-event state; the latest swap below fills it only where one actually remains. + await db` + UPDATE bb_launches l SET + buys = (SELECT count(*) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token AND s.is_buy), + sells = (SELECT count(*) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token AND NOT s.is_buy), + volume_quote = COALESCE((SELECT sum(abs(s.amount0)) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0), + volume_usd = COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0), + last_trade_at = (SELECT max(s.block_time) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), + sqrt_price_x96 = NULL, tick = l.start_tick, last_swap_block = 0, last_swap_log = 0, + fees_quote_collected = COALESCE((SELECT sum(f.quote_amount) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0), + fees_token_collected = COALESCE((SELECT sum(f.token_amount) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0), + fees_quote_burned = COALESCE((SELECT sum(f.amount) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0), + fees_token_burned = COALESCE((SELECT sum(f.amount) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency <> l.quote), 0), + fees_collected_usd = COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0), + fees_burned_usd = COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0), + holders = 0, holders_synced_block = CASE WHEN holders_synced_block IS NULL THEN NULL ELSE LEAST(holders_synced_block, ${before}) END + WHERE l.chain_id = ${cid}`; + await db` + UPDATE bb_launches l SET sqrt_price_x96 = s.sqrt_price_x96, tick = s.tick, last_swap_block = s.block_number, last_swap_log = s.log_index + FROM (SELECT DISTINCT ON (token) token, sqrt_price_x96, tick, block_number, log_index FROM bb_launch_swaps + WHERE chain_id = ${cid} ORDER BY token, block_number DESC, log_index DESC) s + WHERE l.chain_id = ${cid} AND l.token = s.token`; + await db`DELETE FROM bb_token_holders WHERE chain_id = ${cid}`; + await db` + INSERT INTO bb_token_holders (chain_id, token, holder, balance, first_block, last_block) + SELECT ${cid}, token, holder, sum(delta), min(block_number), max(block_number) FROM ( + SELECT token, from_addr AS holder, -value AS delta, block_number FROM bb_token_transfers WHERE chain_id = ${cid} AND from_addr <> ${ZERO_ADDR} + UNION ALL + SELECT token, to_addr AS holder, value AS delta, block_number FROM bb_token_transfers WHERE chain_id = ${cid} AND to_addr <> ${ZERO_ADDR} + ) legs GROUP BY token, holder ORDER BY token, holder`; + const cfg = launchpad(chain); + const excluded = [cfg.v4.poolManager, cfg.v4.positionManager, cfg.locker!, cfg.factory!, cfg.v4.universalRouter, cfg.v4.permit2, ZERO_ADDR, DEAD_ADDR].map((a) => a.toLowerCase()); + await db` + UPDATE bb_launches l SET holders = h.n FROM ( + SELECT token, count(*)::int AS n FROM bb_token_holders WHERE chain_id = ${cid} AND balance > 0 AND NOT (holder = ANY(${excluded}::text[])) GROUP BY token + ) h WHERE l.chain_id = ${cid} AND l.token = h.token`; + if (apps.length) { + await db` + UPDATE bb_pair_applications a SET paid_usd = s.total, + status = CASE WHEN a.status = 'paid' AND s.total < a.fee_usd * ${1 - PAIR_FEE_TOLERANCE} THEN 'open' ELSE a.status END, + paid_at = CASE WHEN a.status = 'paid' AND s.total < a.fee_usd * ${1 - PAIR_FEE_TOLERANCE} THEN NULL ELSE a.paid_at END, + payment_reorg = s.total < a.fee_usd * ${1 - PAIR_FEE_TOLERANCE} + OR EXISTS (SELECT 1 FROM bb_pair_burns pending WHERE pending.application_id = a.id AND pending.valuation_pending) + FROM (SELECT a2.id, COALESCE((SELECT sum(b.usd) FROM bb_pair_burns b WHERE b.application_id = a2.id), 0) AS total + FROM bb_pair_applications a2 WHERE a2.id = ANY(${apps.map((a) => a.application_id)}::bigint[])) s + WHERE a.id = s.id`; + } + await db`DELETE FROM bb_launch_blocks WHERE chain_id = ${cid} AND block_number >= ${from}`; + // A receipt can discover a reorg before the poller runs. Keep points/payment consumers in recovery until the + // old fully scanned cursor is replayed, even if the replacement head temporarily lies below it. + await db` + UPDATE bb_launch_sync_cursor SET + rebuilding_until = CASE WHEN cursor_block >= ${from} THEN GREATEST(rebuilding_until, cursor_block) ELSE rebuilding_until END, + cursor_block = LEAST(cursor_block, ${before}), reorg_generation = reorg_generation + 1 + WHERE chain_id = ${cid}`; + // Earlier transfers can change later-season holdings/first buys. Invalidate every computed season, preserving + // its authored rules and publication window. These locks follow chain-cursor locks, as in computeSeason. + await db`SELECT id FROM bb_seasons ORDER BY id FOR UPDATE`; + await db`UPDATE bb_seasons SET reorg_invalidated_at = now(), computed_at = NULL, computed_until = NULL WHERE computed_at IS NOT NULL OR computed_until IS NOT NULL OR reorg_invalidated_at IS NOT NULL`; + await db`DELETE FROM bb_points WHERE season_id IN (SELECT id FROM bb_seasons WHERE reorg_invalidated_at IS NOT NULL)`; + await db`INSERT INTO bb_launch_reorgs (chain_id, from_block, old_cursor, observed_head, reason) VALUES (${cid}, ${from}, ${cursor.cursor_block}, ${head}, ${reason})`; +} diff --git a/app/src/lib/launchpad/indexer-text.test.ts b/app/src/lib/launchpad/indexer-text.test.ts new file mode 100644 index 00000000..e3bf73c1 --- /dev/null +++ b/app/src/lib/launchpad/indexer-text.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { decodeFunctionResult, encodeAbiParameters, encodeEventTopics, encodeFunctionResult, parseEventLogs, type Hex, type Log } from "viem"; +import { getMock, maybeDb, resetMock } from "@/lib/db-mock"; +import { ERC20_MIN_ABI, LAUNCH_FACTORY_ABI } from "./abi"; + +const FACTORY = "0x1111111111111111111111111111111111111111"; +const TOKEN = "0x2222222222222222222222222222222222222222"; +const LAUNCHER = "0x3333333333333333333333333333333333333333"; +const HASH = `0x${"55".repeat(32)}` as Hex; +const ZERO = "0x0000000000000000000000000000000000000000"; +const POOL = `0x${"66".repeat(32)}` as Hex; +type Row = { token: string; name: string; symbol: string; metadata_uri: string; token_id: bigint; pool_id: string; quote: string; lp_fee: number; recipients: unknown[] }; + +function launched(metadataURI: string, token = TOKEN, logIndex = 0): Log { + const event = LAUNCH_FACTORY_ABI.find((x) => x.type === "event" && x.name === "Launched")!; + return { + address: FACTORY, + topics: encodeEventTopics({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", args: { token: token as `0x${string}`, tokenId: BigInt(logIndex + 1), launcher: LAUNCHER } }), + data: encodeAbiParameters(event.inputs.filter((x) => !x.indexed), [ZERO, POOL, 0, 0, 1000n, metadataURI]), + blockNumber: 100n, blockHash: HASH, transactionHash: HASH, transactionIndex: 0, logIndex, removed: false, + }; +} + +async function fixture(t: { after: (fn: () => void) => void }) { + resetMock(); + const previous = { factory: process.env.NEXT_PUBLIC_LAUNCH_FACTORY, locker: process.env.NEXT_PUBLIC_LAUNCH_LOCKER, deploy: process.env.LAUNCH_DEPLOY_BLOCK }; + process.env.NEXT_PUBLIC_LAUNCH_FACTORY = FACTORY; + process.env.NEXT_PUBLIC_LAUNCH_LOCKER = "0x4444444444444444444444444444444444444444"; + process.env.LAUNCH_DEPLOY_BLOCK = "100"; + const db = maybeDb() as { json?: (value: unknown) => unknown }; + const previousJson = db.json; + const previousFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new Error("external fetch disabled in indexer test"); }; + db.json = (value) => value; + t.after(() => { + for (const [key, value] of [["NEXT_PUBLIC_LAUNCH_FACTORY", previous.factory], ["NEXT_PUBLIC_LAUNCH_LOCKER", previous.locker], ["LAUNCH_DEPLOY_BLOCK", previous.deploy]] as const) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + if (previousJson) db.json = previousJson; else delete db.json; + globalThis.fetch = previousFetch; + }); + const rows = new Map(); + const state = { name: "Name", symbol: "SYM", logs: [launched("ipfs://metadata")], cursor: 0n, failReads: false }; + getMock().publicClient = { + getTransactionReceipt: async () => ({ status: "success", transactionHash: HASH, blockNumber: 100n, blockHash: HASH, from: LAUNCHER, to: FACTORY, logs: state.logs }), + getTransaction: async () => ({ from: LAUNCHER, to: FACTORY }), + getBlock: async ({ blockNumber }: { blockNumber: bigint }) => ({ number: blockNumber, hash: HASH, timestamp: 1_700_000_000n }), + getBlockNumber: async () => 102n, + getLogs: async ({ address }: { address: string | string[] }) => address === FACTORY ? parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: state.logs }) : [], + readContract: async ({ functionName }: { functionName: string }) => { + if (state.failReads) throw new Error("synthetic RPC failure"); + if (functionName === "recipientsOf") return []; + if (functionName !== "name" && functionName !== "symbol") throw new Error("unexpected read"); + // A genuine ABI round trip verifies that the upstream decoder keeps NUL. + return decodeFunctionResult({ abi: ERC20_MIN_ABI, functionName, data: encodeFunctionResult({ abi: ERC20_MIN_ABI, functionName, result: state[functionName] }) }); + }, + }; + getMock().query = async (sql, values) => { + for (const value of values) if (typeof value === "string" && value.includes("\0")) throw Object.assign(new Error("invalid byte sequence for encoding UTF8: 0x00"), { code: "22021" }); + if (sql.includes("INSERT INTO bb_launches")) { + const token = String(values[1]); + if (rows.has(token)) return []; + rows.set(token, { token, token_id: BigInt(values[2] as bigint), quote: String(values[4]), pool_id: String(values[5]), lp_fee: Number(values[7]), metadata_uri: String(values[9]), name: String(values[10]), symbol: String(values[11]), recipients: [] }); + return [{ token }]; + } + if (sql.includes("FROM bb_launches")) return [...rows.values()].filter((r) => !sql.includes("name = '?'") || r.name === "?" || r.symbol === "?"); + if (sql.includes("UPDATE bb_launches SET") && sql.includes("name = COALESCE")) { + const row = rows.get(String(values[4]))!; + if (values[0] !== null) row.name = String(values[0]); + if (values[1] !== null) row.symbol = String(values[1]); + } + if (sql.includes("SELECT cursor_block")) return [{ cursor_block: state.cursor, reorg_version: 1, reorg_generation: 0n, rebuilding_until: null }]; + if (sql.includes("SET cursor_block =")) state.cursor = BigInt(values[0] as bigint); + return []; + }; + const indexer = await import("./indexer"); + return { indexer, state, rows }; +} + +test("launch receipts retain NUL-bearing launches with PostgreSQL-safe chain text", async (t) => { + const f = await fixture(t); + for (const field of ["name", "symbol", "metadataURI"] as const) { + f.rows.clear(); f.state.name = "Name"; f.state.symbol = "SYM"; + const value = "before\0after"; + if (field !== "metadataURI") f.state[field] = value; + f.state.logs = [launched(field === "metadataURI" ? value : "ipfs://metadata")]; + const result = await f.indexer.applyLaunchTx("base", HASH); + assert.equal(result.launches, 1, field); + assert.equal(f.rows.get(TOKEN)![field === "metadataURI" ? "metadata_uri" : field], "beforeafter", field); + assert.equal((await f.indexer.applyLaunchTx("base", HASH)).launches, 0, "receipt replay remains idempotent"); + } +}); + +test("a NUL-bearing launch does not block later launches or the range cursor", async (t) => { + const f = await fixture(t); + f.state.name = "bad\0name"; + f.state.logs = [launched("bad\0uri"), launched("ordinary", "0x7777777777777777777777777777777777777777", 1)]; + const result = await f.indexer.pollLaunches("base"); + assert.equal(result.launches, 2); + assert.equal(f.rows.size, 2, "both events are retained, not caught and skipped"); + assert.equal(f.state.cursor, 100n); + assert.equal((await f.indexer.pollLaunches("base")).launches, 0); +}); + +test("healing failed token reads removes NUL and preserves existing text limits", async (t) => { + const f = await fixture(t); + f.state.failReads = true; + await f.indexer.applyLaunchTx("base", HASH); + assert.equal(f.rows.get(TOKEN)!.name, "?"); + f.state.failReads = false; + f.state.name = "é\0" + "n".repeat(80); + f.state.symbol = "Σ\0" + "s".repeat(25); + assert.equal(await f.indexer.healLaunchReads("base"), 1); + assert.equal(f.rows.get(TOKEN)!.name, "é" + "n".repeat(63)); + assert.equal(f.rows.get(TOKEN)!.symbol, "Σ" + "s".repeat(15)); + assert.equal(await f.indexer.healLaunchReads("base"), 0); +}); diff --git a/app/src/lib/launchpad/indexer.reorg.db.test.ts b/app/src/lib/launchpad/indexer.reorg.db.test.ts new file mode 100644 index 00000000..016ee31e --- /dev/null +++ b/app/src/lib/launchpad/indexer.reorg.db.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Opt-in: real PostgreSQL, actual indexer/SQL, synthetic canonical branches. +// No real RPC, wallets, transactions, environment files or external price calls. +// From app/: RUN_REORG_DB_TESTS=1 REORG_TEST_DATABASE_URL=postgres://...@127.0.0.1:PORT/audit +// npx tsx --conditions=react-server --tsconfig tsconfig.reorg-db-test.json --test src/lib/launchpad/indexer.reorg.db.test.ts +test("canonical event indexing and reorg recovery match a clean PostgreSQL rebuild", { + skip: process.env.RUN_REORG_DB_TESTS !== "1", timeout: 120_000, +}, async (t) => { + const endpoint = process.env.REORG_TEST_DATABASE_URL; + assert.ok(endpoint, "REORG_TEST_DATABASE_URL is required for this opt-in test"); + const url = new URL(endpoint); + assert.ok(["postgres:", "postgresql:"].includes(url.protocol) + && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) + && url.port && !url.search && !url.hash, + "reorg tests require an explicit loopback PostgreSQL host and port, without URL options"); + const settings = { + NEXT_PUBLIC_LAUNCH_FACTORY: "0x1111111111111111111111111111111111111111", + NEXT_PUBLIC_LAUNCH_LOCKER: "0x4444444444444444444444444444444444444444", + LAUNCH_DEPLOY_BLOCK: "100", + LAUNCH_SYNC_CONFIRMATIONS_BASE: "2", + }; + const previous = new Map(Object.keys(settings).map((key) => [key, process.env[key]])); + Object.assign(process.env, settings); + const fetchBefore = globalThis.fetch; + globalThis.fetch = async () => { throw new Error("External fetch disabled in reorg integration tests"); }; + t.after(() => { + globalThis.fetch = fetchBefore; + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + }); + const { runReorgDbTests } = await import("../../../scripts/reorg-db-test-suite.ts"); + await runReorgDbTests(t, endpoint); +}); diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index 1bd38a97..17987a5f 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -9,7 +9,7 @@ import { validDecimals } from "./unlisted-quote"; import { DEAD_ADDR, ZERO_ADDR } from "./holders"; import { SYNC_CHUNK_BLOCKS, SYNC_MAX_CHUNKS_PER_CALL, syncOverlapBlocks } from "@/lib/config"; import { byChainOrder, fetchBySelectors, fetchLogsSplit, isRangeTooLarge } from "./log-range"; -import { publicError, redactUrls } from "./redact"; +import { redactUrls } from "./redact"; import { ENTRY_POINTS, attributeSwap, type ReceiptLog } from "./attribution"; import { appTagOf, swapInApp, type AppTag } from "./appTag"; import { BUILDER_DATA_SUFFIX } from "@/lib/chainPublic"; @@ -19,6 +19,9 @@ import { pairBurnFromTransfer } from "./pairApply"; import { recordPairBurn } from "./pairApplyServer"; import { captureLiveUsd, healUnpricedUsd } from "./usdLedger"; import { hourIso } from "./usd-history"; +import { indexAtomic } from "./indexer-lock"; +import { observeBlock, observeLog, withCanonicalIndex } from "./indexer-canonical"; +import { replayValue } from "./indexer-reorg-db"; /** * Launchpad chain → Postgres indexer. @@ -82,16 +85,9 @@ function skipReason(chain: ChainKey): string | null { } // ── caches (per process; cheap) ────────────────────────────────────────────── -const blockTime = new Map(); async function timeOf(chain: ChainKey, block: bigint): Promise { - const k = `${chain}:${block}`; - const hit = blockTime.get(k); - if (hit) return hit; - const b = await publicClient(chain).getBlock({ blockNumber: block }); - const iso = new Date(Number(b.timestamp) * 1000).toISOString(); - if (blockTime.size > 5000) blockTime.clear(); - blockTime.set(k, iso); - return iso; + const b = await observeBlock(chain, block); + return new Date(Number(b.timestamp) * 1000).toISOString(); } /** Sender, target and app tag (appTag.ts: null = the input was not read) of one transaction. Only the tag of the input is kept. */ type TxFacts = { from: string; to: string | null; tag: AppTag | null }; @@ -143,6 +139,8 @@ async function traderOf(chain: ChainKey, hash: Hex, swapLogIndex: number, token: if (!logs) { try { const r = await publicClient(chain).getTransactionReceipt({ hash }); + if (r.transactionHash.toLowerCase() !== hash.toLowerCase() || r.status !== "success") throw new Error("attribution receipt identity mismatch"); + await observeLog(chain, { blockNumber: r.blockNumber, blockHash: r.blockHash, removed: false }); logs = r.logs.map((l) => ({ address: l.address, topics: l.topics as readonly string[], logIndex: Number(l.logIndex) })); } catch { return { trader: tx.from, tx_from: tx.from, via: RECEIPT_PENDING, app: null }; @@ -155,10 +153,21 @@ async function traderOf(chain: ChainKey, hash: Hex, swapLogIndex: number, token: return { trader: a.trader, tx_from: tx.from, via: a.via, app: swapInApp(tx.tag, a.trader, a.via) }; } +function clearIndexCaches(): void { + txFacts.clear(); receiptLogs.clear(); + attributionTries.clear(); attributionIdleUntil.clear(); healRestUntil.clear(); +} + // ── apply ──────────────────────────────────────────────────────────────────── type Launched = { token: Address; tokenId: bigint; launcher: Address; quote: Address; poolId: Hex; startTick: number; lpFee: number; supply: bigint; metadataURI: string }; +// Solidity strings may contain NUL, which Viem preserves but PostgreSQL text +// cannot store. Remove only that character before writing chain-owned text; +// a valid launch must not stall every subsequent range or be skipped. +const postgresText = (value: string) => value.replaceAll("\0", ""); + async function applyLaunched(db: Db, chain: ChainKey, log: Log & { args: Launched }): Promise { + await observeLog(chain, log); const a = log.args; const token = a.token.toLowerCase(); const client = publicClient(chain); @@ -176,10 +185,11 @@ async function applyLaunched(db: Db, chain: ChainKey, log: Log & { args: Launche 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}, - ${a.supply.toString()}, ${a.metadataURI}, ${name.slice(0, 64)}, ${symbol.slice(0, 16)}, + ${a.supply.toString()}, ${postgresText(a.metadataURI)}, ${postgresText(name).slice(0, 64)}, ${postgresText(symbol).slice(0, 16)}, ${log.blockNumber!}, ${time}, ${log.transactionHash!.toLowerCase()}, ${log.logIndex!}, ${a.startTick}, ${db.json(recipients)}) ON CONFLICT (chain_id, token) DO NOTHING RETURNING token`; + await db`DELETE FROM bb_launch_recovery WHERE chain_id = ${chainIdOf(chain)} AND token = ${token}`; return rows.length > 0; } @@ -191,6 +201,7 @@ async function applySwap(db: Db, chain: ChainKey, log: Log & { args: Swap }, poo const poolId = a.id.toLowerCase(); const token = poolToToken.get(poolId); if (!token) return false; + const block = await observeLog(chain, log); const cid = chainIdOf(chain); const isBuy = a.amount0 < 0n; // swapper paid quote (currency0) const absQuote = a.amount0 < 0n ? -a.amount0 : a.amount0; @@ -199,16 +210,20 @@ async function applySwap(db: Db, chain: ChainKey, log: Log & { args: Swap }, poo const li = log.logIndex!; // the trade's USD value at its own hour (bb_quote_usd_hourly; usd-history.ts), fixed once written; no price for that hour = NULL const quote = tokenQuote.get(token) ?? ""; + const replay = await replayValue(db, chain, "swap", log.transactionHash!, Number(li), bn, time, block.hash, { + token, pool_id: poolId, amount0: a.amount0.toString(), amount1: a.amount1.toString(), + sqrt_price_x96: a.sqrtPriceX96.toString(), tick: a.tick, is_buy: isBuy, + }); // One transaction: the swap row and the launch totals commit together or not at all (a failure between // the two used to leave a recorded trade whose totals were never applied — and the dedupe then skips it). - return db.begin(async (tx) => { - const t = tx as unknown as Db; + return indexAtomic(db, async (t) => { const inserted = await t` INSERT INTO bb_launch_swaps (chain_id, tx_hash, log_index, token, pool_id, trader, tx_from, trader_via, via_app, amount0, amount1, sqrt_price_x96, tick, is_buy, block_number, block_time, usd) VALUES (${cid}, ${log.transactionHash!.toLowerCase()}, ${log.logIndex!}, ${token}, ${poolId}, ${who.trader}, ${who.tx_from}, ${who.via}, ${who.app}, ${a.amount0.toString()}, ${a.amount1.toString()}, ${a.sqrtPriceX96.toString()}, ${a.tick}, ${isBuy}, ${log.blockNumber!}, ${time}, - (SELECT ${absQuote.toString()}::numeric / power(10::numeric, p.decimals) * p.usd FROM bb_quote_usd_hourly p - WHERE p.chain_id = ${cid} AND p.quote = ${quote} AND p.hour = ${hourIso(time)}::timestamptz)) + CASE WHEN ${replay !== null} THEN ${replay?.usd ?? null}::float8 ELSE + (SELECT ${absQuote.toString()}::numeric / power(10::numeric, p.decimals) * p.usd FROM bb_quote_usd_hourly p + WHERE p.chain_id = ${cid} AND p.quote = ${quote} AND p.hour = ${hourIso(time)}::timestamptz) END) ON CONFLICT DO NOTHING RETURNING tx_hash, usd`; if (inserted.length === 0) { @@ -245,6 +260,7 @@ async function applyBoost(db: Db, chain: ChainKey, log: Log & { args: TransferAr [locker], ); if (!row) return false; + await observeLog(chain, log); const time = await timeOf(chain, log.blockNumber!); const rows = await db` INSERT INTO bb_boosts (chain_id, tx_hash, log_index, booster, amount, tag, block_number, block_time) @@ -255,7 +271,7 @@ async function applyBoost(db: Db, chain: ChainKey, log: Log & { args: TransferAr } /** Record one TWIG transfer to 0x…dEaD as a pair-application fee when its amount names an application (pairApply.ts, tag kind 3). */ -async function applyPairBurn(chain: ChainKey, log: Log & { args: TransferArgs }, locker: string): Promise { +async function applyPairBurn(db: Db, chain: ChainKey, log: Log & { args: TransferArgs }, locker: string): Promise { const twig = TWIG_ADDRESSES[chain]; if (!twig) return false; const row = pairBurnFromTransfer( @@ -265,7 +281,8 @@ async function applyPairBurn(chain: ChainKey, log: Log & { args: TransferArgs }, [locker], ); if (!row) return false; - return recordPairBurn(chain, row, new Date(await timeOf(chain, log.blockNumber!))); + const block = await observeLog(chain, log); + return recordPairBurn(chain, row, new Date(await timeOf(chain, log.blockNumber!)), db, block.hash); } type FeeLog = Log & @@ -277,10 +294,11 @@ type FeeLog = Log & ); async function applyFee(pool: Db, chain: ChainKey, log: FeeLog, tokenIdToToken: Map): Promise { + await observeLog(chain, log); const cid = chainIdOf(chain); const time = await timeOf(chain, log.blockNumber!); // event row + launch fee totals in one transaction (same reasoning as applySwap) - return pool.begin(async (tx) => applyFeeIn(tx as unknown as Db, chain, cid, time, log, tokenIdToToken)) as Promise; + return indexAtomic(pool, (db) => applyFeeIn(db, chain, cid, time, log, tokenIdToToken)); } async function applyFeeIn(db: Db, chain: ChainKey, cid: number, time: string, log: FeeLog, tokenIdToToken: Map): Promise { @@ -290,12 +308,17 @@ async function applyFeeIn(db: Db, chain: ChainKey, cid: number, time: string, lo if (log.eventName === "Collected") { const t = log.args.token.toLowerCase(); const quote = tokenIdToToken.get(log.args.tokenId.toString())?.quote ?? ""; + const replay = await replayValue(db, chain, "fee", tx, Number(li), bn, time, log.blockHash!, { + kind: "collected", token_id: log.args.tokenId.toString(), token: t, quote: quote || null, currency: null, account: null, + amount: null, quote_amount: log.args.quoteAmount.toString(), token_amount: log.args.tokenAmount.toString(), + }); // the quote side's USD value at the collect's own hour (usd-history.ts), as for trades 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, usd) VALUES (${cid}, ${tx}, ${li}, 'collected', ${log.args.tokenId}, ${t}, ${log.args.quoteAmount.toString()}, ${log.args.tokenAmount.toString()}, ${bn}, ${time}, - (SELECT ${log.args.quoteAmount.toString()}::numeric / power(10::numeric, p.decimals) * p.usd FROM bb_quote_usd_hourly p - WHERE p.chain_id = ${cid} AND p.quote = ${quote} AND p.hour = ${hourIso(time)}::timestamptz)) + CASE WHEN ${replay !== null} THEN ${replay?.usd ?? null}::float8 ELSE + (SELECT ${log.args.quoteAmount.toString()}::numeric / power(10::numeric, p.decimals) * p.usd FROM bb_quote_usd_hourly p + WHERE p.chain_id = ${cid} AND p.quote = ${quote} AND p.hour = ${hourIso(time)}::timestamptz) END) ON CONFLICT DO NOTHING RETURNING tx_hash, usd`; if (r.length === 0) return false; const usd = (r[0] as { usd: number | null }).usd ?? 0; @@ -313,11 +336,16 @@ async function applyFeeIn(db: Db, chain: ChainKey, cid: number, time: string, lo const cur = log.args.currency.toLowerCase(); // a burn in the launch's quote gets its USD value at its own hour; a burn of the launched token stays NULL (quote side only) const priceIn = cur === info.quote ? cur : ""; + const replay = await replayValue(db, chain, "fee", tx, Number(li), bn, time, log.blockHash!, { + kind: "burned", token_id: log.args.tokenId.toString(), token: info.token, quote: info.quote, currency: cur, account: null, + amount: log.args.amount.toString(), quote_amount: null, token_amount: null, + }); 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, usd) VALUES (${cid}, ${tx}, ${li}, 'burned', ${log.args.tokenId}, ${info.token}, ${cur}, ${log.args.amount.toString()}, ${bn}, ${time}, - (SELECT ${log.args.amount.toString()}::numeric / power(10::numeric, p.decimals) * p.usd FROM bb_quote_usd_hourly p - WHERE p.chain_id = ${cid} AND p.quote = ${priceIn} AND p.hour = ${hourIso(time)}::timestamptz)) + CASE WHEN ${replay !== null} THEN ${replay?.usd ?? null}::float8 ELSE + (SELECT ${log.args.amount.toString()}::numeric / power(10::numeric, p.decimals) * p.usd FROM bb_quote_usd_hourly p + WHERE p.chain_id = ${cid} AND p.quote = ${priceIn} AND p.hour = ${hourIso(time)}::timestamptz) END) ON CONFLICT DO NOTHING RETURNING tx_hash, usd`; if (r.length === 0) return false; const usd = (r[0] as { usd: number | null }).usd ?? 0; @@ -378,7 +406,8 @@ async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint): Pr const { poolToToken, tokenIdToToken, tokenQuote } = await poolMaps(db, chain); // this hour's live USD for every quote in use, before the range's trades are priced at their hours (never throws) - await captureLiveUsd(db, chain, tokenQuote.values()); + // Price capture happens before the canonical transaction: its price sources + // may use this pool too, and must not starve writers holding its connections. if (poolToToken.size > 0) { 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) @@ -397,7 +426,7 @@ async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint): Pr const burnLogs = await fetchLogsSplit((f, t) => client.getLogs({ address: twig as Address, event: ERC20_TRANSFER_EVENT, args: { to: DEAD }, fromBlock: f, toBlock: t }), from, to); for (const l of burnLogs) { if (await applyBoost(db, chain, l as Log & { args: TransferArgs }, locker.toLowerCase())) boosts++; - else await applyPairBurn(chain, l as Log & { args: TransferArgs }, locker.toLowerCase()); + else await applyPairBurn(db, chain, l as Log & { args: TransferArgs }, locker.toLowerCase()); } } // holder balances: every Transfer of every launched token (idempotent; backfill covers history) @@ -426,8 +455,11 @@ const healRestUntil = new Map(); // `${chain}:${tx}` whose trans * UPDATEs re-test that the row is still open, so two machines running the same batch change each row once. */ export async function attributeBacklog(chain: ChainKey, batch = 100): Promise { - const db = maybeDb(); - if (!db || skipReason(chain)) return 0; + if (skipReason(chain)) return 0; + return withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, (db) => attributeBacklogIn(db, chain, batch)); +} + +async function attributeBacklogIn(db: Db, chain: ChainKey, batch: number): Promise { // after the deploy day there is almost never anything to check: an empty pass rests a few minutes instead of // querying every 15 s poll on every machine if (Date.now() < (attributionIdleUntil.get(chain) ?? 0)) return 0; @@ -501,7 +533,7 @@ export async function attributeBacklog(chain: ChainKey, batch = 100): Promise[]) { - if (typeof l?.address !== "string" || !Array.isArray(l.topics) || typeof l.data !== "string" || typeof l.blockNumber !== "string" || typeof l.transactionHash !== "string" || typeof l.logIndex !== "string") return null; + if (typeof l?.address !== "string" || !Array.isArray(l.topics) || typeof l.data !== "string" || typeof l.blockNumber !== "string" || typeof l.blockHash !== "string" || typeof l.transactionHash !== "string" || typeof l.logIndex !== "string") return null; if (l.address.toLowerCase() === want && !l.removed) raw.push(l as RawReceiptLog); } } @@ -548,10 +580,8 @@ async function applyTransfers(db: Db, chain: ChainKey, tokens: string[], from: b const cid = chainIdOf(chain); // one address list for every launched token, bisected by block like every other fetch; only when a SINGLE block is over // the node's result cap does the token list split, and one token still over the cap in one block (a contract spamming - // Transfer events; a few USDC of gas on Arc) has that block's holder update skipped rather than the chain's indexing - // wedging on it. Launches, swaps and fees are fetched separately and unaffected. That token's holder balances stay off - // by that block's transfers (balances are accumulated deltas, so nothing recomputes them), which is why the skip is - // logged as an alert rather than a warning: it is a deliberate degradation to look at, not a transient. + // Transfer events) falls back to full block receipts. If neither RPC method can supply the complete block, + // fail the atomic range and leave its cursor unchanged so the next attempt can recover every balance delta. const oneBlockOrRange = async (addrs: string[], a: bigint, b: bigint): Promise => { try { return (await client.getLogs({ address: addrs as Address[], event: ERC20_TRANSFER_EVENT, fromBlock: a, toBlock: b })) as TransferLog[]; @@ -559,11 +589,10 @@ async function applyTransfers(db: Db, chain: ChainKey, tokens: string[], from: b if (a !== b || !isRangeTooLarge(err)) throw err; // a range: let fetchLogsSplit halve the blocks if (addrs.length <= 1) { // one token, one block, over the cap: the block's receipts are not capped, so read the token's Transfer logs from - // them; only if the node has no receipts method is the block skipped (an alert, since the balances stay off by it) + // them. Without that method, retry the range instead of checkpointing incomplete holder balances. const recovered = await transfersFromBlockReceipts(chain, addrs[0], a); if (recovered !== null) return recovered; - console.error(`[alert] launch-sync ${chain}: Transfer logs of ${addrs[0]} in block ${a} exceed the node's result cap and eth_getBlockReceipts is unavailable; that block's holder update for the token is skipped and its holder balances are off by it from now on`); - return []; + throw new Error(`Transfer logs in block ${a} exceed the RPC cap and block receipts are unavailable; retry without advancing the canonical cursor`); } const mid = Math.ceil(addrs.length / 2); return [...(await oneBlockOrRange(addrs.slice(0, mid), a, b)), ...(await oneBlockOrRange(addrs.slice(mid), a, b))]; @@ -572,6 +601,7 @@ async function applyTransfers(db: Db, chain: ChainKey, tokens: string[], from: b // every token address is one selector: past the node's cap the address list goes out in slices (log-range.ts) const logs = byChainOrder((await fetchBySelectors(SELECTOR_CAPS, chain, tokens, (slice) => fetchLogsSplit((a, b) => oneBlockOrRange(slice, a, b), from, to))) as TransferLog[]); if (logs.length === 0) return 0; + for (const log of logs) await observeLog(chain, log); const rows = logs.map((l) => ({ chain_id: cid, tx_hash: l.transactionHash!.toLowerCase(), @@ -644,19 +674,15 @@ const BACKFILL_CHUNKS_PER_CALL = 25; * Runs after every poll; costs a bounded number of getLogs calls per run. */ export async function backfillHolders(chain: ChainKey): Promise<{ tokens: number; chunks: number; transfers: number } | null> { - const pool = maybeDb(); - if (!pool || skipReason(chain)) return null; - // one backfill per chain across all machines: session-level advisory lock on a reserved connection - const reserved = await pool.reserve(); - const db = reserved as unknown as Db; - try { - const [{ ok }] = await db<{ ok: boolean }[]>`SELECT pg_try_advisory_lock(hashtext(${`holders-backfill-${chain}`})) AS ok`; - if (!ok) return null; - return await backfillHoldersLocked(db, chain); - } finally { - await db`SELECT pg_advisory_unlock(hashtext(${`holders-backfill-${chain}`}))`.catch(() => {}); - reserved.release(); + if (skipReason(chain)) return null; + const total = { tokens: 0, chunks: 0, transfers: 0 }; + for (let i = 0; i < BACKFILL_CHUNKS_PER_CALL; i++) { + const r = await withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, + (db) => backfillHoldersLocked(db, chain)); + if (!r || r.chunks === 0) break; + total.tokens = r.tokens; total.chunks += r.chunks; total.transfers += r.transfers; } + return total.chunks ? total : null; } async function backfillHoldersLocked(db: Db, chain: ChainKey): Promise<{ tokens: number; chunks: number; transfers: number } | null> { @@ -676,9 +702,10 @@ async function backfillHoldersLocked(db: Db, chain: ChainKey): Promise<{ tokens: }, cursor + 1n); let chunks = 0; let transfers = 0; - while (from <= cursor && chunks < BACKFILL_CHUNKS_PER_CALL) { + while (from <= cursor && chunks < 1) { let to = from + SYNC_CHUNK_BLOCKS - 1n; if (to > cursor) to = cursor; + await observeBlock(chain, to); transfers += await applyTransfers(db, chain, tokens, from, to); const mark = to >= cursor ? SYNCED_FOREVER : to; await db`UPDATE bb_launches SET holders_synced_block = ${mark} WHERE chain_id = ${cid} AND token = ANY(${tokens})`; @@ -708,11 +735,10 @@ export async function pollAll(): Promise> { const out: Record = {}; await Promise.all( CONFIGURED_CHAINS.map(async (c) => { - // the reason is returned by POST /api/launch/sync, unauthenticated: the first line alone, never the request or the - // node's own answer (the whole message, without URLs, goes to the log here) + // The result is public through /api/launch/sync; keep diagnostics in redacted server logs. out[c] = await pollLaunches(c).catch((e): LaunchSyncResult => { console.warn(`[launch-sync] poll ${c}:`, redactUrls(errMessage(e))); - return { status: "skipped", reason: publicError(e, "sync failed") }; + return { status: "skipped", reason: "sync_failed" }; }); await healLaunchReads(c).catch((e) => console.warn(`[launch-sync] heal ${c}:`, redactUrls(errMessage(e)))); await healQuoteTokens(c).catch((e) => console.warn(`[launch-sync] quote tokens ${c}:`, redactUrls(errMessage(e)))); @@ -731,8 +757,11 @@ export async function pollAll(): Promise> { * Re-reads a few of the newest such rows on every poll and rewrites them once the reads succeed. */ export async function healLaunchReads(chain: ChainKey): Promise { - const db = maybeDb(); - if (!db || skipReason(chain)) return 0; + if (skipReason(chain)) return 0; + return withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, (db) => healLaunchReadsIn(db, chain)); +} + +async function healLaunchReadsIn(db: Db, chain: ChainKey): Promise { 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 @@ -754,8 +783,8 @@ export async function healLaunchReads(chain: ChainKey): Promise { .catch(() => null) : Promise.resolve(null), ]); - const setName = typeof name === "string" && name !== "?" && name !== r.name ? name.slice(0, 64) : null; - const setSymbol = typeof symbol === "string" && symbol !== "?" && symbol !== r.symbol ? symbol.slice(0, 16) : null; + const setName = typeof name === "string" && name !== "?" && name !== r.name ? postgresText(name).slice(0, 64) : null; + const setSymbol = typeof symbol === "string" && symbol !== "?" && symbol !== r.symbol ? postgresText(symbol).slice(0, 16) : null; const setRecipients = recipients && recipients.length > 0 ? recipients : null; if (!setName && !setSymbol && !setRecipients) continue; await db` @@ -776,8 +805,11 @@ export async function healLaunchReads(chain: ChainKey): Promise { * symbol is stored: what may be shown is decided at read time, so a change to the reserved list applies to old rows. */ export async function healQuoteTokens(chain: ChainKey): Promise { - const db = maybeDb(); - if (!db || skipReason(chain)) return 0; + if (skipReason(chain)) return 0; + return withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, (db) => healQuoteTokensIn(db, chain)); +} + +async function healQuoteTokensIn(db: Db, chain: ChainKey): Promise { const cid = chainIdOf(chain); const rows = await db<{ quote: string }[]>` SELECT l.quote FROM bb_launches l @@ -815,8 +847,11 @@ export async function healQuoteTokens(chain: ChainKey): Promise { * ranking cannot tell whether they were outside trades. Re-reads a bounded batch of the newest ones per poll. */ export async function healSwapTraders(chain: ChainKey): Promise { - const db = maybeDb(); - if (!db || skipReason(chain)) return 0; + if (skipReason(chain)) return 0; + return withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, (db) => healSwapTradersIn(db, chain)); +} + +async function healSwapTradersIn(db: Db, chain: ChainKey): Promise { const cid = chainIdOf(chain); const rows = await db<{ tx_hash: string; log_index: number; token: string }[]>` SELECT tx_hash, log_index, token FROM bb_launch_swaps @@ -860,8 +895,11 @@ const appTagIdleUntil = new Map(); * older than a day (and every one from before the column) keep NULL, which never counts as in-app. */ export async function healAppTags(chain: ChainKey): Promise { - const db = maybeDb(); - if (!db || skipReason(chain)) return 0; + if (skipReason(chain)) return 0; + return withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, (db) => healAppTagsIn(db, chain)); +} + +async function healAppTagsIn(db: Db, chain: ChainKey): Promise { if (Date.now() < (appTagIdleUntil.get(chain) ?? 0)) return 0; const cid = chainIdOf(chain); // transactions still resting after a failed read are left out of the query, so a run of unreadable ones at the head @@ -911,42 +949,45 @@ export async function healAppTags(chain: ChainKey): Promise { async function run(chain: ChainKey): Promise { const reason = skipReason(chain); if (reason) return { status: "skipped", reason }; - const db = maybeDb()!; + const pool = maybeDb()!; const cid = chainIdOf(chain); - // the chain head as read this run: recorded even when the run then fails, so /api/health shows a stuck chain's real lag - // (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 { - 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); + // Price providers can consult the DB pool. Do this before holding a canonical + // transaction, not while four chains each hold a connection waiting for it. + const quotes = await pool<{ quote: string }[]>`SELECT DISTINCT quote FROM bb_launches WHERE chain_id = ${cid}`; + await captureLiveUsd(pool, chain, quotes.map((r) => r.quote)); 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, boosts: 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; - totals.boosts += r.boosts; - 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++; + let nextFrom: bigint | null = null; + let result: LaunchSyncResult = { status: "synced", ...totals }; + for (let chunk = 0; chunk < SYNC_MAX_CHUNKS_PER_CALL; chunk++) { + const r = await withCanonicalIndex(chain, deploy, clearIndexCaches, async (db, head, current) => { + knownHead = head; + const confirmed = head - confirmations(chain); + let from = nextFrom === null ? current.cursor_block - syncOverlapBlocks() : + (nextFrom > current.cursor_block + 1n ? current.cursor_block + 1n : nextFrom); + if (from < deploy) from = deploy; + if (from > confirmed) return { from, to: current.cursor_block, head, done: true, launches: 0, swaps: 0, fees: 0, boosts: 0 }; + const to = from + SYNC_CHUNK_BLOCKS - 1n < confirmed ? from + SYNC_CHUNK_BLOCKS - 1n : confirmed; + await observeBlock(chain, to); + const applied = await applyRange(db, chain, from, to); + await db`UPDATE bb_launch_sync_cursor SET cursor_block = GREATEST(cursor_block, ${to}), head_block = ${head}, + last_run_at = now(), last_error = NULL, + rebuilding_until = CASE WHEN rebuilding_until <= ${to} THEN NULL ELSE rebuilding_until END WHERE chain_id = ${cid}`; + return { from, to, head, done: to >= confirmed, ...applied }; + }); + totals.launches += r.launches; totals.swaps += r.swaps; totals.fees += r.fees; totals.boosts += r.boosts; + nextFrom = r.to + 1n; + result = { status: "synced", from: nextFrom.toString(), to: r.to.toString(), head: r.head.toString(), ...totals, caught_up: r.done }; + if (r.done) break; } - // trades and fees whose hour has a price by now but had none when they were written (usdLedger.ts; throttled, never throws) - await healUnpricedUsd(db, chain); - return { status: "synced", from: from.toString(), to: to.toString(), head: head.toString(), ...totals, caught_up: from > confirmed }; + await withCanonicalIndex(chain, deploy, clearIndexCaches, (db) => healUnpricedUsd(db, chain)); + return result; } 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) - await db`UPDATE bb_launch_sync_cursor SET last_run_at = now(), head_block = COALESCE(${knownHead}::bigint, head_block), last_error = ${redactUrls(msg).slice(0, 500)} WHERE chain_id = ${cid}`.catch(() => {}); + await pool`UPDATE bb_launch_sync_cursor SET last_run_at = now(), head_block = COALESCE(${knownHead}::bigint, head_block), + last_error = ${redactUrls(msg).slice(0, 500)} WHERE chain_id = ${cid}`.catch(() => {}); throw err; } } @@ -958,67 +999,70 @@ export type ApplyLaunchTxResult = { status: "applied" | "not_found" | "reverted" export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise { const reason = skipReason(chain); if (reason) return { status: "skipped", reason }; - const db = maybeDb()!; - // the receipt has no input: the transaction is read for the app tag at the same time, once, so the wallet waits no - // longer for it. If that read fails, the trade is still credited from the receipt with its tag unknown (NULL), and - // the tag is not cached: the log poll reads the transaction again and fills it in (applySwap, on the conflict) - const [receipt, sent] = await Promise.all([ - publicClient(chain).getTransactionReceipt({ hash }).catch(() => null), - publicClient(chain).getTransaction({ hash }).catch(() => null), - ]); - if (!receipt) return { status: "not_found" }; - if (receipt.status !== "success") return { status: "reverted" }; - // the receipt in hand is what attribution needs (sender, destination, logs): give it to traderOf, so crediting a - // smart wallet's swap here never depends on reading the same receipt a second time from a node that may lag - const k = `${chain}:${receipt.transactionHash}`; // the same hash the receipt's logs carry, so traderOf finds it - const to = receipt.to ? receipt.to.toLowerCase() : null; - const input = sent?.input ?? null; - if (txFacts.size > 5000) txFacts.clear(); - const seeded: TxFacts = { from: receipt.from.toLowerCase(), to, tag: input === null ? null : tagOf(chain, to, input) }; - txFacts.set(k, seeded); - if (receiptLogs.size > 500) receiptLogs.clear(); - receiptLogs.set(k, receipt.logs.map((l) => ({ address: l.address, topics: l.topics as readonly string[], logIndex: Number(l.logIndex) }))); - try { - const cfg = launchpad(chain); - const factory = cfg.factory!.toLowerCase(); - const locker = cfg.locker!.toLowerCase(); - const pm = cfg.v4.poolManager.toLowerCase(); + return withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, async (db) => { + // the receipt has no input: the transaction is read for the app tag at the same time, once, so the wallet waits no + // longer for it. If that read fails, the trade is still credited from the receipt with its tag unknown (NULL), and + // the tag is not cached: the log poll reads the transaction again and fills it in (applySwap, on the conflict) + const [receipt, sent] = await Promise.all([ + publicClient(chain).getTransactionReceipt({ hash }).catch(() => null), + publicClient(chain).getTransaction({ hash }).catch(() => null), + ]); + if (!receipt) return { status: "not_found" }; + if (receipt.status !== "success") return { status: "reverted" }; + if (receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) throw new Error("receipt identity mismatch"); + await observeLog(chain, { blockNumber: receipt.blockNumber, blockHash: receipt.blockHash, removed: false }); + // the receipt in hand is what attribution needs (sender, destination, logs): give it to traderOf, so crediting a + // smart wallet's swap here never depends on reading the same receipt a second time from a node that may lag + const k = `${chain}:${receipt.transactionHash}`; // the same hash the receipt's logs carry, so traderOf finds it + const to = receipt.to ? receipt.to.toLowerCase() : null; + const input = sent?.input ?? null; + if (txFacts.size > 5000) txFacts.clear(); + const seeded: TxFacts = { from: receipt.from.toLowerCase(), to, tag: input === null ? null : tagOf(chain, to, input) }; + txFacts.set(k, seeded); + if (receiptLogs.size > 500) receiptLogs.clear(); + receiptLogs.set(k, receipt.logs.map((l) => ({ address: l.address, topics: l.topics as readonly string[], logIndex: Number(l.logIndex) }))); + try { + const cfg = launchpad(chain); + const factory = cfg.factory!.toLowerCase(); + const locker = cfg.locker!.toLowerCase(); + const pm = cfg.v4.poolManager.toLowerCase(); - let launches = 0; - let swaps = 0; - let fees = 0; - const tokens: string[] = []; - const launched = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs.filter((l) => 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()); - } - const { poolToToken, tokenIdToToken, tokenQuote } = await poolMaps(db, chain); - // no price capture here: this path runs while the user waits, and a capture can wait on price sources. Its trades take - // the hour's price the poller already captured, or are priced by the poller's heal pass a few minutes later. - 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, tokenQuote)) { - swaps++; - const t = poolToToken.get(l.args.id.toLowerCase()); - if (t && !tokens.includes(t)) tokens.push(t); + let launches = 0; + let swaps = 0; + let fees = 0; + const tokens: string[] = []; + const launched = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs.filter((l) => 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()); } - } - 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++; - let boosts = 0; - const twig = TWIG_ADDRESSES[chain]; - if (twig) { - const burnLogs = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: receipt.logs.filter((l) => l.address.toLowerCase() === twig) }); - for (const l of burnLogs) { - if (await applyBoost(db, chain, l as unknown as Log & { args: TransferArgs }, locker)) boosts++; - else await applyPairBurn(chain, l as unknown as Log & { args: TransferArgs }, locker); + const { poolToToken, tokenIdToToken, tokenQuote } = await poolMaps(db, chain); + // no price capture here: this path runs while the user waits, and a capture can wait on price sources. Its trades take + // the hour's price the poller already captured, or are priced by the poller's heal pass a few minutes later. + 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, tokenQuote)) { + swaps++; + const t = poolToToken.get(l.args.id.toLowerCase()); + if (t && !tokens.includes(t)) tokens.push(t); + } } + 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++; + let boosts = 0; + const twig = TWIG_ADDRESSES[chain]; + if (twig) { + const burnLogs = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: receipt.logs.filter((l) => l.address.toLowerCase() === twig) }); + for (const l of burnLogs) { + if (await applyBoost(db, chain, l as unknown as Log & { args: TransferArgs }, locker)) boosts++; + else await applyPairBurn(db, chain, l as unknown as Log & { args: TransferArgs }, locker); + } + } + return { status: "applied", launches, swaps, fees, boosts, tokens }; + } finally { + // unknown: the log poll (or healAppTags) reads the transaction again. Only this call's own entry is removed: a sync + // of the same transaction running alongside may have cached the real tag meanwhile + if (input === null && txFacts.get(k) === seeded) txFacts.delete(k); } - return { status: "applied", launches, swaps, fees, boosts, tokens }; - } finally { - // unknown: the log poll (or healAppTags) reads the transaction again. Only this call's own entry is removed: a sync - // of the same transaction running alongside may have cached the real tag meanwhile - if (input === null && txFacts.get(k) === seeded) txFacts.delete(k); - } + }); } diff --git a/app/src/lib/launchpad/launch-recovery.ts b/app/src/lib/launchpad/launch-recovery.ts new file mode 100644 index 00000000..74e0c243 --- /dev/null +++ b/app/src/lib/launchpad/launch-recovery.ts @@ -0,0 +1,24 @@ +import "server-only"; +import { maybeDb } from "@/lib/db"; +import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; +import type { Hex } from "viem"; + +export type LaunchRecovery = { tx_hash: Hex; reason: "reorg" | "rebuilding" }; + +/** Only the indexer creates these records; arbitrary token URLs remain genuine 404s. */ +export async function getLaunchRecovery(chain: ChainKey, token: string): Promise { + const db = maybeDb(); + if (!db) return null; + const rows = await db`SELECT tx_hash, reason FROM bb_launch_recovery WHERE chain_id=${chainIdOf(chain)} AND token=${token.toLowerCase()}`; + return rows[0] ?? null; +} + +/** One database snapshot avoids a false 404 while reinclusion replaces a tombstone with a launch. */ +export async function isKnownLaunchRoute(chain: ChainKey, token: string): Promise { + const db = maybeDb(); + if (!db) return false; + const [row] = await db<{ known: boolean }[]>` + SELECT EXISTS(SELECT 1 FROM bb_launches WHERE chain_id=${chainIdOf(chain)} AND token=${token.toLowerCase()}) + OR EXISTS(SELECT 1 FROM bb_launch_recovery WHERE chain_id=${chainIdOf(chain)} AND token=${token.toLowerCase()}) AS known`; + return row?.known ?? false; +} diff --git a/app/src/lib/launchpad/pair-reorg.test.ts b/app/src/lib/launchpad/pair-reorg.test.ts new file mode 100644 index 00000000..8881d434 --- /dev/null +++ b/app/src/lib/launchpad/pair-reorg.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import { getMock, resetMock } from "@/lib/db-mock"; +import { applicantAction, expireStaleApplications, reviewApplication } from "./pairApplyServer"; +import { pairsTick } from "./pairsServer"; + +const WALLET = "0x1111111111111111111111111111111111111111"; +const TOKEN = "0x2222222222222222222222222222222222222222"; +const ready = { reorg_version: 1, reorg_generation: 0n, rebuilding_until: null }; +function offline(t: TestContext) { + resetMock(); + t.mock.method(globalThis, "fetch", async () => { throw new Error("offline pair recovery test"); }); + t.mock.method(console, "warn", () => {}); + t.mock.method(console, "log", () => {}); +} + +test("payment expiry takes the index lock and refuses partially rebuilt payment history", async (t) => { + offline(t); + const queries: string[] = []; + getMock().query = async (sql) => { + queries.push(sql); + if (sql.includes("bb_launch_sync_cursor")) return [{ ...ready, rebuilding_until: 200n }]; + return []; + }; + assert.equal(await expireStaleApplications(), 0); + assert.ok(queries.findIndex((q) => q.includes("pg_advisory_xact_lock")) < queries.findIndex((q) => q.includes("bb_launch_sync_cursor"))); + assert.equal(queries.some((q) => q.includes("SET status = 'expired'")), false); +}); + +test("payment expiry resumes under the lock while preserving payment reconciliation rows", async (t) => { + offline(t); + let expired = false; + getMock().query = async (sql) => { + if (sql.includes("bb_launch_sync_cursor")) return [ready]; + if (sql.includes("SET status = 'expired'")) { + assert.match(sql, /WHERE NOT a\.payment_reorg/); + expired = true; + return [{ id: 8 }]; + } + return []; + }; + assert.equal(await expireStaleApplications(), 1); + assert.equal(expired, true); +}); + +async function againFixture(t: TestContext, changed: boolean) { + offline(t); + const previous = process.env.PAIRS_APPLY; + process.env.PAIRS_APPLY = "on"; + t.after(() => { if (previous === undefined) delete process.env.PAIRS_APPLY; else process.env.PAIRS_APPLY = previous; }); + const app = { id: "7", chain_id: 8453, address: TOKEN, applicant: WALLET, code: "OL-7", status: "changes", reviews_used: 1, + paid_at: new Date(Date.now() - 86_400_000), paid_usd: 200, fee_usd: 200, payment_reorg: false }; + const queries: string[] = []; + getMock().query = async (sql) => { + queries.push(sql); + if (sql.includes("bb_launch_sync_cursor")) return [ready]; + if (sql.includes("SELECT * FROM bb_pair_applications")) return [{ ...app, + ...(changed && sql.includes("FOR UPDATE") ? { paid_usd: 0, payment_reorg: true } : {}) }]; + if (sql.includes("SET status = 'paid'")) return [{ id: 7 }]; + return []; + }; + const result = await applicantAction({ action: "again", id: 7, chain: "base", address: TOKEN, wallet: WALLET, nonce: "ab".repeat(16), ts: Date.now(), signature: "0x11" }); + return { result, queries }; +} + +test("another-review request rechecks payments after network precheck and rejects an intervening reorg", async (t) => { + const { result, queries } = await againFixture(t, true); + assert.equal(result.ok, false); + assert.equal(!result.ok && result.status, 409); + assert.ok(queries.some((q) => q.includes("FOR UPDATE"))); + assert.equal(queries.some((q) => q.includes("SET status = 'paid'")), false); +}); + +test("another-review request succeeds only after the chain and application locks", async (t) => { + const { result, queries } = await againFixture(t, false); + assert.deepEqual(result, { ok: true, id: 7, code: "OL-7" }); + const chainLock = queries.findIndex((q) => q.includes("pg_advisory_xact_lock")); + const appLock = queries.findIndex((q) => q.includes("FOR UPDATE")); + const change = queries.findIndex((q) => q.includes("SET status = 'paid'")); + assert.ok(chainLock >= 0 && appLock > chainLock && change > appLock); +}); + +let monitorClock = Date.now(); +test("launch-pool monitor discards a health result when recovery changed its source generation", async (t) => { + offline(t); + const r = { chain_id: 8453, address: TOKEN, symbol: "TEST", status: "listed", status_by: "admin", status_at: new Date(Date.now() - 3 * 86_400_000), + failing_since: new Date(Date.now() - 3 * 86_400_000), checked_at: new Date(), price_kind: "launch", price_pool: TOKEN, price_quote: WALLET, code_hash: null }; + let snapshots = 0; + const queries: string[] = []; + getMock().publicClient = { getCode: async () => "0x" }; // a failing read would normally start/sustain suspension + getMock().query = async (sql) => { + queries.push(sql); + if (sql.includes("UPDATE bb_pairs SET checked_at = now()")) return [r]; + if (sql.includes("SELECT reorg_generation")) return [{ ...ready, reorg_generation: BigInt(snapshots++) }]; + return []; + }; + await pairsTick(monitorClock += 61_000); + assert.equal(snapshots, 2); + assert.ok(queries.some((q) => q.includes("pg_advisory_xact_lock"))); + assert.ok(queries.some((q) => q.includes("UPDATE bb_pairs SET checked_at = NULL"))); + assert.equal(queries.some((q) => q.includes("UPDATE bb_pairs SET status =")), false); + assert.equal(queries.some((q) => q.includes("INSERT INTO bb_pair_events")), false); +}); + +test("launch-pool monitor avoids price reads while its source is rebuilding", async (t) => { + offline(t); + let rpcReads = 0; + let released = false; + const checkedAt = new Date(); + getMock().publicClient = { getCode: async () => { rpcReads++; return "0x"; } }; + getMock().query = async (sql, values) => { + if (sql.includes("UPDATE bb_pairs SET checked_at = now()")) return [{ chain_id: 8453, address: TOKEN, price_kind: "launch", checked_at: checkedAt }]; + if (sql.includes("SELECT reorg_generation")) return [{ ...ready, rebuilding_until: 200n }]; + if (sql.includes("UPDATE bb_pairs SET checked_at = NULL")) { + assert.match(sql, /date_trunc\('milliseconds', checked_at\)/); + assert.deepEqual(values, [8453, TOKEN, checkedAt]); + released = true; + } + return []; + }; + await pairsTick(monitorClock += 61_000); + assert.equal(rpcReads, 0); + assert.equal(released, true, "the current claim is eligible again after recovery"); +}); + +for (const failure of ["cursor read", "advisory timeout"]) test(`pair monitor releases ${failure} and continues its claimed batch`, async (t) => { + offline(t); + const secondToken = "0x3333333333333333333333333333333333333333"; + const checkedAt = new Date(); + const row = { chain_id: 8453, symbol: "TEST", status: "listed", status_by: "admin", status_at: new Date(Date.now() - 3 * 86_400_000), + failing_since: new Date(Date.now() - 3 * 86_400_000), checked_at: checkedAt, price_kind: "launch", code_hash: null }; + let snapshots = 0, locks = 0; + const released: string[] = [], updated: string[] = []; + getMock().publicClient = { getCode: async () => "0x" }; + getMock().query = async (sql, values) => { + if (sql.includes("UPDATE bb_pairs SET checked_at = now()")) return [{ ...row, address: TOKEN }, { ...row, address: secondToken }]; + if (sql.includes("SELECT reorg_generation")) { + if (++snapshots === 1 && failure === "cursor read") throw Error("synthetic cursor outage"); + return [ready]; + } + if (sql.includes("pg_advisory_xact_lock") && ++locks === 1 && failure === "advisory timeout") throw { code: "55P03" }; + if (sql.includes("UPDATE bb_pairs SET checked_at = NULL")) { + assert.match(sql, /date_trunc\('milliseconds', checked_at\)/); + assert.deepEqual(values, [8453, TOKEN, checkedAt]); + released.push(String(values[1])); + } + if (sql.includes("UPDATE bb_pairs SET status =")) { + assert.match(sql, /date_trunc\('milliseconds', checked_at\)/); + assert.ok(values.includes(secondToken)); + updated.push(secondToken); + return [{ address: secondToken }]; + } + return []; + }; + await pairsTick(monitorClock += 61_000); + assert.deepEqual(released, [TOKEN]); + assert.deepEqual(updated, [secondToken]); +}); + +test("launch-pool monitor still records a sustained failure when its source generation is unchanged", async (t) => { + offline(t); + const r = { chain_id: 8453, address: TOKEN, symbol: "TEST", status: "listed", status_by: "admin", status_at: new Date(Date.now() - 3 * 86_400_000), + failing_since: new Date(Date.now() - 3 * 86_400_000), checked_at: new Date(), price_kind: "launch", code_hash: null }; + let updated = false; + let audited = false; + getMock().publicClient = { getCode: async () => "0x" }; + getMock().query = async (sql, values) => { + if (sql.includes("UPDATE bb_pairs SET checked_at = now()")) return [r]; + if (sql.includes("SELECT reorg_generation")) return [ready]; + if (sql.includes("UPDATE bb_pairs SET status =")) { + assert.equal(values[0], "suspended"); + updated = true; + return [{ address: TOKEN }]; + } + if (sql.includes("INSERT INTO bb_pair_events")) audited = true; + return []; + }; + await pairsTick(monitorClock += 61_000); + assert.equal(updated, true); + assert.equal(audited, true); +}); + +test("a failed listing reacquires the payment lock and restores using current payment facts", async (t) => { + offline(t); + const previous = process.env.ADMIN_WALLETS; + process.env.ADMIN_WALLETS = WALLET; + t.after(() => { if (previous === undefined) delete process.env.ADMIN_WALLETS; else process.env.ADMIN_WALLETS = previous; }); + const app = { id: "7", chain_id: 8453, address: TOKEN, status: "paid", paid_usd: 200, fee_usd: 200, reviews_used: 0, + quote_until: null, last_quote_until: null, payment_reorg: false, paid_at: new Date(), reason: null, decided_by: null, decided_at: null }; + let locks = 0; + let restored = false; + getMock().query = async (sql) => { + if (sql.includes("pg_advisory_xact_lock")) locks++; + if (sql.includes("bb_launch_sync_cursor")) return [ready]; + if (sql.includes("SELECT * FROM bb_pair_applications")) return [app]; + if (sql.includes("RETURNING id") && sql.includes("UPDATE bb_pair_applications")) return [{ id: 7 }]; + if (sql.includes("SELECT 1 FROM bb_pairs")) throw new Error("listing failed after a concurrent rollback"); + if (sql.includes("UPDATE bb_pair_applications SET") && sql.includes("CASE WHEN NOT payment_reorg")) { + assert.equal(locks, 2, "restore holds a fresh chain lock after the asynchronous listing"); + assert.match(sql, /THEN 'paid' ELSE 'open' END/); + assert.match(sql, /payment_reorg OR paid_usd < fee_usd/); + restored = true; + } + return []; + }; + await assert.rejects(reviewApplication({ decision: "approve", id: 7, wallet: WALLET, nonce: "cd".repeat(16), ts: Date.now(), signature: "0x11" }), /listing failed/); + assert.equal(restored, true); +}); diff --git a/app/src/lib/launchpad/pairApply.test.ts b/app/src/lib/launchpad/pairApply.test.ts index b9ee13d0..c0d7ccc0 100644 --- a/app/src/lib/launchpad/pairApply.test.ts +++ b/app/src/lib/launchpad/pairApply.test.ts @@ -31,7 +31,7 @@ import { PAIR_CHAINS, PAIR_CHAINS_TEXT, PAIR_FEE_CHAIN, buildPairAdminMessage, p import { coreQuoteNames } from "./config.ts"; import { hasDelegatecall, implementationOf1167 } from "./pairProbe.ts"; -const src = (rel: string) => readFileSync(path.join(import.meta.dirname, rel), "utf8"); +const src = (rel: string) => readFileSync(path.join(import.meta.dirname, rel), "utf8").replaceAll("\r\n", "\n"); const TWIG = "0x6ac18bcf4ede02591d4917452700ea5eaaea13c1"; const DEAD = "0x000000000000000000000000000000000000dEaD"; const E18 = 10n ** 18n; @@ -172,24 +172,30 @@ test("nothing holds a token's place for good: unpaid applications expire after 4 assert.match(src("loop.ts"), /void revaluePairBurns\(\)/); }); -test("burns: the fact first, then its value, then the total; each step heals on the next pass", () => { +test("burns: atomic facts and replay values, then deferred valuation under the chain lock", () => { const s = src("pairApplyServer.ts"); const rec = s.slice(s.indexOf("export async function recordPairBurn"), s.indexOf("async function valueBurn")); - assert.ok(rec.indexOf("INSERT INTO bb_pair_burns") < rec.indexOf("await valueBurn("), "inserted before any price read"); - assert.match(rec, /if \(cur && cur\.usd === null\) await valueBurn/, "a re-fed burn is only valued if it still has no value"); - assert.match(rec, /await settleApplication\(row\.application_id\);/); - assert.match(s, /AND usd IS NULL RETURNING tx_hash/); + assert.ok(rec.indexOf("INSERT INTO bb_pair_burns") < rec.indexOf("await settleApplication(db, row.application_id)")); + assert.doesNotMatch(rec, /await valueBurn|await gitlawbUsdParts/, "ingestion never waits for an external price while holding its transaction"); + assert.match(rec, /await replayValue\(db, chain, "pair"/); + assert.match(rec, /Boolean\(prior\?\.valuation_pending\)/, "repeated recovery cannot erase pending historical valuation"); + assert.match(s, /AND usd IS NULL AND NOT valuation_pending RETURNING tx_hash/); assert.match(s, /SET quote_twig = NULL, quote_price_usd = NULL, quote_at = NULL, quote_until = NULL WHERE id = \$\{appId\} AND status = 'open'/, "a landed burn spends its lock: the next quote is for what is still due"); assert.match(s, /JOIN bb_pair_applications a ON a\.id = b\.application_id WHERE b\.usd IS NULL/, "unvalued burns are valued later"); - assert.match(s, /precheck = COALESCE\(\$\{pre \? db\.json\(pre\) : null\}, precheck\)/, "another review sees the token as it is now"); + assert.match(s, /precheck = COALESCE\(\$\{pre \? locked\.json\(pre\) : null\}, precheck\)/, "another review sees the token as it is now, after rechecking payment state under the chain lock"); }); test("the fee step never offers to send again while a sent transfer is not indexed", () => { const page = src("../../components/launchpad/PairApplication.tsx"); - assert.match(page, /if \(!address \|\| !q \|\| lock\.current \|\| waiting\) return;/); + assert.match(page, /if \(!address \|\| !q \|\| lock\.current \|\| !sentReady \|\| waiting \|\| app\.payment_reorg\) return;/); assert.match(page, /const waiting = sent !== null && !indexed && app\.status === "open";/); assert.match(page, /window\.sessionStorage\.setItem\(sentKey, h\)/, "kept across a reload"); - assert.match(page, /\{app\.status === "open" && waiting \? \(/); + assert.match(page, /\{app\.payment_reorg \? \(/, "a canonical payment shortfall takes precedence over the fee action"); + assert.match(page, /\) : app\.status === "open" && waiting \? \(/); + assert.equal((page.match(/sessionStorage\.removeItem\(sentKey\)/g) ?? []).length, 1, "only remember(null) may clear the sent hash; provisional indexing must keep it across a later reorg and reload"); + const recheck = page.slice(page.indexOf("async function recheck()"), page.indexOf("async function getQuote()")); + assert.match(recheck, /await syncTransaction\(FEE_CHAIN, sent\)/); + assert.doesNotMatch(recheck, /writeContract|sendTransaction|signMessage/, "rechecking a payment never resubmits it"); // a reverted transfer sent nothing: the guard is lifted so the applicant can try again assert.match(page, /if \(rc\.status !== "success"\) \{\n\s*remember\(null\);/); }); @@ -207,7 +213,7 @@ test("a burn sent before its application existed never counts, and is never retr const s = src("pairApplyServer.ts"); const value = s.slice(s.indexOf("async function valueBurn"), s.indexOf("async function settleApplication")); assert.match(value, /if \(sentAt\.getTime\(\) < new Date\(app\.created_at\)\.getTime\(\)\) return;/); - assert.match(s, /WHERE b\.usd IS NULL AND b\.block_time >= a\.created_at LIMIT 20/); + assert.match(s, /WHERE b\.usd IS NULL AND NOT b\.valuation_pending AND b\.block_time >= a\.created_at LIMIT 20/); }); test("llms.txt explains the application fee only while applications are open", () => { @@ -229,8 +235,9 @@ test("an admin can approve an unpaid application with the fee waived, and an alr // a paid fee is never recorded as waived assert.match(decide, /if \(decision === "waive" && isPaid\(app\.paid_usd, app\.fee_usd\)\) return fail\(/); // and the claim itself re-checks it, so a burn settling between the check and the claim cannot be waived away - assert.match(decide, /WHERE id = \$\{id\} AND status = \$\{app\.status\} AND \(\$\{decision\} <> 'waive' OR \(paid_usd < fee_usd \* \$\{1 - PAIR_FEE_TOLERANCE\}\n\s*AND \(quote_until IS NULL OR quote_until < now\(\) - \$\{`\$\{PAIR_QUOTE_SETTLE_MS \/ 1000\} seconds`\}::interval\)\n\s*AND \(last_quote_until IS NULL OR last_quote_until < now\(\) - \$\{`\$\{PAIR_QUOTE_SETTLE_MS \/ 1000\} seconds`\}::interval\)\)\) RETURNING id/); - assert.match(decide, /const restore = \(\) => db`\s*UPDATE bb_pair_applications SET\s*status = CASE WHEN \$\{app\.status\} = 'open' AND paid_usd >= fee_usd \* \$\{1 - PAIR_FEE_TOLERANCE\} THEN 'paid' ELSE \$\{app\.status\} END,[^`]*reviews_used = \$\{app\.reviews_used\}[^`]*AND status = 'approved'`/, "a failed listing puts the application back where it was (as paid when a burn settled meanwhile)"); + assert.match(decide, /AND \(\$\{decision\} = 'reject' OR NOT payment_reorg\) AND \(\$\{decision\} <> 'waive' OR \(paid_usd < fee_usd \* \$\{1 - PAIR_FEE_TOLERANCE\}\n\s*AND \(quote_until IS NULL OR quote_until < now\(\) - \$\{`\$\{PAIR_QUOTE_SETTLE_MS \/ 1000\} seconds`\}::interval\)\n\s*AND \(last_quote_until IS NULL OR last_quote_until < now\(\) - \$\{`\$\{PAIR_QUOTE_SETTLE_MS \/ 1000\} seconds`\}::interval\)\)\) RETURNING id/); + assert.match(decide, /const restore = \(\) => withIndexLock\(PAIR_FEE_CHAIN/, "restoring a failed listing shares the canonical payment lock"); + assert.match(decide, /CASE WHEN NOT payment_reorg AND paid_usd >= fee_usd \* \$\{1 - PAIR_FEE_TOLERANCE\} THEN 'paid' ELSE 'open' END/, "an intervening reorg cannot restore stale paid status"); assert.match(decide, /\(decision === "changes" \|\| decision === "reject"\) && !reason/, "approving and waiving need no reason"); assert.match(src("../../components/launchpad/PairReviewAdmin.tsx"), /\{!isPaid\(a\.paid_usd, a\.fee_usd\) && !a\.settling_until \?