From bf64f217f4e2c4ea03b7aacc7be60cfda058b018 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Thu, 12 Nov 2026 20:49:39 +0530 Subject: [PATCH 1/4] fix(security): harden request limits and recover canonical index history --- app/db/schema.sql | 46 ++ app/scripts/posts-db-test-chain.ts | 13 + app/scripts/posts-db-test-db.ts | 8 + app/scripts/reorg-db-test-chain.ts | 11 + app/scripts/reorg-db-test-db.ts | 13 + app/scripts/reorg-db-test-direct.ts | 174 +++++++ app/scripts/reorg-db-test-model.ts | 162 +++++++ app/scripts/reorg-db-test-suite.ts | 444 ++++++++++++++++++ app/src/app/api/admin/session/route.test.ts | 76 +++ app/src/app/api/admin/session/route.ts | 6 +- app/src/app/api/health/route.ts | 3 +- .../app/api/launch/chart-provider/route.ts | 3 +- app/src/app/api/launch/edit/nonce/route.ts | 8 +- app/src/app/api/launch/edit/route.ts | 9 +- .../app/api/launch/error-disclosure.test.ts | 208 ++++++++ app/src/app/api/launch/image/route.test.ts | 171 +++++++ app/src/app/api/launch/image/route.ts | 18 +- app/src/app/api/launch/meta/route.ts | 10 +- app/src/app/api/launch/sync/route.ts | 11 +- app/src/app/api/posts/mod/route.ts | 9 +- app/src/app/api/posts/report/route.test.ts | 86 ++++ app/src/app/api/posts/report/route.ts | 10 +- app/src/app/api/posts/route.ts | 9 +- app/src/app/api/presence/route.ts | 3 +- app/src/app/api/request-body.test.ts | 107 +++++ app/src/app/api/rpc/route.ts | 8 +- app/src/app/api/write-limits.test.ts | 2 +- app/src/app/t/[chain]/[token]/layout.tsx | 7 +- app/src/app/t/[chain]/[token]/page.tsx | 16 +- app/src/components/launchpad/BoostDialog.tsx | 11 +- app/src/components/launchpad/CollectPanel.tsx | 6 +- app/src/components/launchpad/ImageUpload.tsx | 2 +- app/src/components/launchpad/LaunchForm.tsx | 49 +- app/src/components/launchpad/MeDashboard.tsx | 6 +- .../components/launchpad/PairApplication.tsx | 31 +- .../components/launchpad/TokenRecovery.tsx | 65 +++ app/src/components/launchpad/TradePanel.tsx | 5 +- app/src/components/launchpad/boost-ui.test.ts | 5 +- .../components/launchpad/launch-form.test.ts | 2 +- .../components/launchpad/me-dashboard.test.ts | 3 +- .../launchpad/transaction-safety.test.ts | 42 +- app/src/lib/admin/admin.test.ts | 4 +- app/src/lib/chain-mock.ts | 1 + app/src/lib/db-mock.ts | 9 + app/src/lib/launchpad/editServer.test.ts | 42 ++ app/src/lib/launchpad/editServer.ts | 10 +- app/src/lib/launchpad/images.ts | 4 +- app/src/lib/launchpad/indexer-canonical.ts | 146 ++++++ app/src/lib/launchpad/indexer-lock.ts | 26 + .../lib/launchpad/indexer-reorg-db.test.ts | 37 ++ app/src/lib/launchpad/indexer-reorg-db.ts | 152 ++++++ app/src/lib/launchpad/indexer-text.test.ts | 118 +++++ .../lib/launchpad/indexer.reorg.db.test.ts | 36 ++ app/src/lib/launchpad/indexer.ts | 338 +++++++------ app/src/lib/launchpad/launch-recovery.ts | 24 + app/src/lib/launchpad/pair-reorg.test.ts | 166 +++++++ app/src/lib/launchpad/pairApply.test.ts | 42 +- app/src/lib/launchpad/pairApplyServer.ts | 147 ++++-- app/src/lib/launchpad/pairs.test.ts | 5 +- app/src/lib/launchpad/pairsServer.ts | 45 +- app/src/lib/launchpad/pending-launch.test.ts | 33 ++ app/src/lib/launchpad/pending-launch.ts | 37 ++ app/src/lib/launchpad/postsServer.db.test.ts | 145 ++++++ app/src/lib/launchpad/postsServer.ts | 33 +- app/src/lib/launchpad/sync-client.test.ts | 40 ++ app/src/lib/launchpad/sync-client.ts | 30 ++ app/src/lib/launchpad/usd-history.test.ts | 18 +- app/src/lib/points/reorg.test.ts | 73 +++ app/src/lib/points/server.ts | 57 ++- app/src/lib/profiles/http.test.ts | 73 +++ app/src/lib/profiles/http.ts | 52 +- app/src/lib/profiles/profile-quota.test.ts | 86 ++++ app/src/lib/profiles/server.ts | 10 +- app/tsconfig.posts-db-test.json | 10 + app/tsconfig.reorg-db-test.json | 10 + .../test/LaunchFactory.assumptions.t.sol | 120 +++++ contracts/test/LaunchLocker.invariant.t.sol | 318 +++++++++++++ .../2026-11-12-contract-verification.md | 129 +++++ docs/security/2026-11-12-deep-review.md | 77 +++ .../2026-11-12-expanded-verification.md | 77 +++ docs/security/2026-11-12-pr-split.md | 33 ++ docs/security/2026-11-12-remediation.md | 52 ++ docs/security/2026-11-12-reorg-recovery.md | 75 +++ 83 files changed, 4464 insertions(+), 354 deletions(-) create mode 100644 app/scripts/posts-db-test-chain.ts create mode 100644 app/scripts/posts-db-test-db.ts create mode 100644 app/scripts/reorg-db-test-chain.ts create mode 100644 app/scripts/reorg-db-test-db.ts create mode 100644 app/scripts/reorg-db-test-direct.ts create mode 100644 app/scripts/reorg-db-test-model.ts create mode 100644 app/scripts/reorg-db-test-suite.ts create mode 100644 app/src/app/api/admin/session/route.test.ts create mode 100644 app/src/app/api/launch/error-disclosure.test.ts create mode 100644 app/src/app/api/launch/image/route.test.ts create mode 100644 app/src/app/api/posts/report/route.test.ts create mode 100644 app/src/app/api/request-body.test.ts create mode 100644 app/src/components/launchpad/TokenRecovery.tsx create mode 100644 app/src/lib/launchpad/indexer-canonical.ts create mode 100644 app/src/lib/launchpad/indexer-lock.ts create mode 100644 app/src/lib/launchpad/indexer-reorg-db.test.ts create mode 100644 app/src/lib/launchpad/indexer-reorg-db.ts create mode 100644 app/src/lib/launchpad/indexer-text.test.ts create mode 100644 app/src/lib/launchpad/indexer.reorg.db.test.ts create mode 100644 app/src/lib/launchpad/launch-recovery.ts create mode 100644 app/src/lib/launchpad/pair-reorg.test.ts create mode 100644 app/src/lib/launchpad/pending-launch.test.ts create mode 100644 app/src/lib/launchpad/pending-launch.ts create mode 100644 app/src/lib/launchpad/postsServer.db.test.ts create mode 100644 app/src/lib/launchpad/sync-client.test.ts create mode 100644 app/src/lib/launchpad/sync-client.ts create mode 100644 app/src/lib/points/reorg.test.ts create mode 100644 app/src/lib/profiles/http.test.ts create mode 100644 app/src/lib/profiles/profile-quota.test.ts create mode 100644 app/tsconfig.posts-db-test.json create mode 100644 app/tsconfig.reorg-db-test.json create mode 100644 contracts/test/LaunchFactory.assumptions.t.sol create mode 100644 contracts/test/LaunchLocker.invariant.t.sol create mode 100644 docs/security/2026-11-12-contract-verification.md create mode 100644 docs/security/2026-11-12-deep-review.md create mode 100644 docs/security/2026-11-12-expanded-verification.md create mode 100644 docs/security/2026-11-12-pr-split.md create mode 100644 docs/security/2026-11-12-remediation.md create mode 100644 docs/security/2026-11-12-reorg-recovery.md 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..f316ce50 --- /dev/null +++ b/app/scripts/reorg-db-test-suite.ts @@ -0,0 +1,444 @@ +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 { 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')); + 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)); + } + } + 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('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..1bed06a3 --- /dev/null +++ b/app/src/app/api/launch/error-disclosure.test.ts @@ -0,0 +1,208 @@ +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("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 7ba815bc..7b08fc35 100644 --- a/app/src/app/api/launch/meta/route.ts +++ b/app/src/app/api/launch/meta/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 { MetaConflict, saveMeta, validateMeta } from "@/lib/launchpad/meta"; @@ -10,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 }); } @@ -24,6 +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 }); - return NextResponse.json({ error: err instanceof Error ? err.message : "failed" }, { 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 6b6b5ef0..2ef56d96 100644 --- a/app/src/app/api/launch/sync/route.ts +++ b/app/src/app/api/launch/sync/route.ts @@ -1,3 +1,4 @@ +import { clientIp } from "@/lib/profiles/http"; import { NextResponse } from "next/server"; import { DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; import { rateLimited } from "@/lib/launchpad/editServer"; @@ -11,7 +12,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"); @@ -24,13 +25,13 @@ 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) { - return NextResponse.json({ error: err instanceof Error ? err.message : "sync failed" }, { status: 502 }); + } catch { + return NextResponse.json({ error: "sync failed" }, { status: 502 }); } } try { return NextResponse.json(await pollAll()); - } catch (err) { - return NextResponse.json({ error: err instanceof Error ? err.message : "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..76f90d39 100644 --- a/app/src/components/launchpad/BoostDialog.tsx +++ b/app/src/components/launchpad/BoostDialog.tsx @@ -1,5 +1,7 @@ "use client"; +import { syncTransaction, INDEX_PENDING_COPY } from "@/lib/launchpad/sync-client"; + import Link from "next/link"; import { useMemo, useRef, useState } from "react"; import { useConfig, useReadContract, useSwitchChain } from "wagmi"; @@ -118,13 +120,18 @@ 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); if (!ok) { + onPending(null); setPhase({ k: "error", 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(() => {}); + const synced = await syncTransaction(BOOST_CHAIN, hash); + if (synced.status !== "applied") { + setPhase({ k: "unconfirmed", hash, whole, checking: false, note: INDEX_PENDING_COPY }); + return; + } + onPending(null); setPhase({ k: "done", hash, whole, shows }); toast({ kind: "info", title: `${symbol} is boosted`, sub: `${groupWhole(whole)} TWIG sent to 0x…dEaD`, chain: BOOST_CHAIN, token, symbol }); void bal.refetch(); 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..5462e446 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -34,6 +34,9 @@ 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 { pendingLaunch, pendingLaunchServer, rememberPendingLaunch, subscribePendingLaunch } from "@/lib/launchpad/pending-launch"; +import TokenRecovery from "./TokenRecovery"; /** * Launch flow — honest states, nothing claimed before the chain says so: @@ -50,6 +53,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 }; @@ -256,6 +260,8 @@ 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 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 +355,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()); @@ -419,9 +426,13 @@ 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); + 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."); + if (receipt.status !== "success") { + rememberPendingLaunch(null); + 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(); @@ -440,8 +451,13 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA } 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; + } + rememberPendingLaunch(null); 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 }); @@ -449,8 +465,28 @@ export default function LaunchForm({ ethUsd, hypeUsd = null, initialChain = DEFA 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(result: TransactionSync) { + const attempt = pendingLaunch(); + if (!attempt) return; + if (result.status === "reverted") { + rememberPendingLaunch(null); + 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; + rememberPendingLaunch(null); + setPhase({ k: "done", hash: attempt.hash, token }); + startNav(); + router.push(`/t/${attempt.chain}/${token}`); } @@ -491,6 +527,8 @@ 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 ; + return (
{}); - 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..a3c3f790 --- /dev/null +++ b/app/src/components/launchpad/TokenRecovery.tsx @@ -0,0 +1,65 @@ +"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 { btn, card } from "@/components/ui"; + +/** Recovery checks an existing hash only. There is deliberately no wallet dependency. */ +export default function TokenRecovery({ chain, hash, reason, onResult }: { + chain: ChainKey; hash: Hex; reason: "pending" | "reorg" | "rebuilding"; + onResult?: (result: TransactionSync) => void; +}) { + 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); + 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 = setInterval(() => { if (document.visibilityState === "visible" && !checking.current) router.refresh(); }, 15_000); + return () => { alive.current = false; clearInterval(timer); request.current?.abort(); }; + }, [router]); + + 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); + 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." + : "Checked the existing transaction. Waiting for the token page to update."); + onResult?.(result); + router.refresh(); + } + + const Heading = reason === "pending" ? "h2" : "h1"; + return
+
+ {reason === "pending" ? "Your launch was sent" : "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 === "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} +
Back to tokens
+ {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..5505f5d9 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"); @@ -82,7 +83,7 @@ test("sent means sent: an unread receipt locks the dialog instead of inviting a // 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", () => { diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 37bebcc2..7236e23a 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -27,7 +27,7 @@ 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, /if \(buyHash\) await syncTransaction\(chain, buyHash\)/); assert.match(source, /Launched, but the first buy did not go through/); assert.match(source, /buying: "Launched! Buying your first tokens…"/); }); 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 +117,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 +134,41 @@ 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); + }); +} 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/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.ts b/app/src/lib/launchpad/indexer-lock.ts new file mode 100644 index 00000000..13e210e5 --- /dev/null +++ b/app/src/lib/launchpad/indexer-lock.ts @@ -0,0 +1,26 @@ +import "server-only"; +import { maybeDb, type Db } from "@/lib/db"; +import type { ChainKey } from "@/lib/chainPublic"; + +/** 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; + await db`SET LOCAL lock_timeout = '5s'`; + await db`SELECT pg_advisory_xact_lock(hashtext(${`launch-index:${chain}`}))`; + // 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 c90cb461..633a7fe2 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -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) 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,7 +735,8 @@ export async function pollAll(): Promise> { const out: Record = {}; await Promise.all( CONFIGURED_CHAINS.map(async (c) => { - out[c] = await pollLaunches(c).catch((e): LaunchSyncResult => ({ status: "skipped", reason: errMessage(e) })); + // The result is public through /api/launch/sync; upstream diagnostics stay private. + out[c] = await pollLaunches(c).catch((): LaunchSyncResult => ({ status: "skipped", reason: "sync_failed" })); await healLaunchReads(c).catch((e) => console.warn(`[launch-sync] heal ${c}:`, errMessage(e))); await healQuoteTokens(c).catch((e) => console.warn(`[launch-sync] quote tokens ${c}:`, errMessage(e))); await healSwapTraders(c).catch((e) => console.warn(`[launch-sync] heal traders ${c}:`, errMessage(e))); @@ -726,8 +754,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 @@ -749,8 +780,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` @@ -771,8 +802,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 @@ -810,8 +844,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 @@ -855,8 +892,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 @@ -906,42 +946,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; } } @@ -953,67 +996,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(); - - 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); + 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 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..58423622 --- /dev/null +++ b/app/src/lib/launchpad/pair-reorg.test.ts @@ -0,0 +1,166 @@ +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; + getMock().publicClient = { getCode: async () => { rpcReads++; return "0x"; } }; + getMock().query = async (sql) => { + if (sql.includes("UPDATE bb_pairs SET checked_at = now()")) return [{ chain_id: 8453, address: TOKEN, price_kind: "launch" }]; + if (sql.includes("SELECT reorg_generation")) return [{ ...ready, rebuilding_until: 200n }]; + return []; + }; + await pairsTick(monitorClock += 61_000); + assert.equal(rpcReads, 0); +}); + +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 \? 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/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index 5505f5d9..2c9f8d33 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -76,7 +76,7 @@ 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.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, 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" \}\)/); @@ -187,7 +187,7 @@ 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 settle\(hash, whole, (?:rc|result\.receipt)\.status === "success", showsAfter\(whole\)\);/g) ?? []).length, 2); 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 diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 7236e23a..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); @@ -28,7 +29,7 @@ test("launch merge preserves optional funding checks and post-launch buy isolati 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 syncTransaction\(chain, buyHash\)/); - assert.match(source, /Launched, but the first buy did not go through/); + 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/transaction-safety.test.ts b/app/src/components/launchpad/transaction-safety.test.ts index 7f5dd00e..e52d3fd5 100644 --- a/app/src/components/launchpad/transaction-safety.test.ts +++ b/app/src/components/launchpad/transaction-safety.test.ts @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import test from "node:test"; +import test, { type TestContext } from "node:test"; import { runInNewContext } from "node:vm"; import ts from "typescript"; +import * as launchJournal from "../../lib/launchpad/pending-launch"; +import { waitForTrackedReceipt } from "../../lib/launchpad/transaction-receipt"; /** * Execute the actual component handlers with inert dependencies. This avoids a @@ -172,3 +174,113 @@ for (const state of [{ sentReady: false, waiting: false, payment_reorg: false }, 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); +}); + +for (const reason of ["repriced", "cancelled", "replaced", "invalid"]) test(`actual boost handler handles ${reason} receipt without syncing the original hash`, async () => { + const pending: unknown[] = []; + const phases: { k: string }[] = []; + const settled: unknown[][] = []; + 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 }, transactionReceipt: { transactionHash: secondHash } }); + return { status: "success", transactionHash: secondHash, logs: [] }; + }, + }; + const send = handler("./BoostDialog.tsx", "send", { + address: launcher, raw: 100n, whole: 100n, busy: false, lock: { current: false }, chainId: 8453, BASE: { id: 8453 }, config: {}, + getPublicClient: () => pub, getWalletClient: async () => ({ writeContract: async () => launchHash }), waitForTrackedReceipt, + BOOST_TOKEN: launcher, ERC20_TRANSFER_ABI: [], DEAD: launcher, BUILDER_DATA_SUFFIX: "0x", setPhase: (p: { k: string }) => phases.push(p), + onPending: (value: unknown) => pending.push(value), showsAfter: () => true, settle: async (...args: unknown[]) => { settled.push(args); }, friendlyError: (error: Error) => error.message, + }); + await send(); + if (reason === "repriced") { assert.equal(settled[0]?.[0], secondHash); assert.deepEqual({ ...pending.at(-1) as object }, { hash: secondHash, whole: 100n }); } + else { + assert.deepEqual(settled, []); + assert.equal(phases.at(-1)?.k, reason === "invalid" ? "unconfirmed" : "error"); + if (reason === "invalid") assert.deepEqual({ ...pending.at(-1) as object }, { hash: launchHash, whole: 100n }); + else assert.equal(pending.at(-1), null); + } +}); 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 index 13e210e5..b8c68226 100644 --- a/app/src/lib/launchpad/indexer-lock.ts +++ b/app/src/lib/launchpad/indexer-lock.ts @@ -2,14 +2,28 @@ 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'`; - await db`SELECT pg_advisory_xact_lock(hashtext(${`launch-index:${chain}`}))`; // 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. diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index 633a7fe2..bf5f9724 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -679,7 +679,7 @@ export async function backfillHolders(chain: ChainKey): Promise<{ tokens: number for (let i = 0; i < BACKFILL_CHUNKS_PER_CALL; i++) { const r = await withCanonicalIndex(chain, launchDeployBlock(chain), clearIndexCaches, (db) => backfillHoldersLocked(db, chain)); - if (!r) break; + if (!r || r.chunks === 0) break; total.tokens = r.tokens; total.chunks += r.chunks; total.transfers += r.transfers; } return total.chunks ? total : null; diff --git a/app/src/lib/launchpad/pair-reorg.test.ts b/app/src/lib/launchpad/pair-reorg.test.ts index 58423622..8881d434 100644 --- a/app/src/lib/launchpad/pair-reorg.test.ts +++ b/app/src/lib/launchpad/pair-reorg.test.ts @@ -105,14 +105,56 @@ test("launch-pool monitor discards a health result when recovery changed its sou 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) => { - if (sql.includes("UPDATE bb_pairs SET checked_at = now()")) return [{ chain_id: 8453, address: TOKEN, price_kind: "launch" }]; + 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) => { diff --git a/app/src/lib/launchpad/pairsServer.ts b/app/src/lib/launchpad/pairsServer.ts index 78d26196..d48a1f37 100644 --- a/app/src/lib/launchpad/pairsServer.ts +++ b/app/src/lib/launchpad/pairsServer.ts @@ -387,42 +387,55 @@ export async function pairsTick(now = Date.now()): Promise { RETURNING *`; let changed = false; for (const r of claimed) { - const chain = chainKeyOf(r.chain_id); - if (!chain) continue; - type IndexState = { reorg_generation: bigint; reorg_version: number; rebuilding_until: bigint | null }; - const sourceIndex = r.price_kind === "launch" ? (await db` - SELECT reorg_generation, reorg_version, rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id = ${r.chain_id}`)[0] : null; - if (r.price_kind === "launch" && (!sourceIndex || sourceIndex.reorg_version !== 1 || sourceIndex.rebuilding_until !== null)) continue; - const { health, code } = await checkPair(r, chain).catch(() => ({ health: null, code: null })); - const step = nextPairStatus({ status: r.status, status_by: r.status_by, status_at: new Date(r.status_at).getTime(), failing_since: r.failing_since ? new Date(r.failing_since).getTime() : null }, health, Date.now()); - const publishCheck = async (locked: Db) => { - if (sourceIndex) { - const [current] = await locked` - SELECT reorg_generation, reorg_version, rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id = ${r.chain_id}`; - if (!current || current.reorg_version !== 1 || current.rebuilding_until !== null || BigInt(current.reorg_generation) !== BigInt(sourceIndex.reorg_generation)) { - // Retry after recovery instead of counting a temporarily absent launch as a failing price source. - await locked`UPDATE bb_pairs SET checked_at = NULL WHERE chain_id = ${r.chain_id} AND address = ${r.address} - AND date_trunc('milliseconds', checked_at) = date_trunc('milliseconds', ${new Date(r.checked_at!)}::timestamptz)`; - return; - } - } - // only if nothing changed the row while it was checked (an admin's suspension must never be written over); a JS Date holds - // milliseconds and Postgres microseconds, so the timestamps are compared at milliseconds - const done = await locked`UPDATE bb_pairs SET status = ${step.status}, status_by = ${step.status_by}, status_at = to_timestamp(${step.status_at / 1000}), failing_since = ${step.failing_since === null ? null : new Date(step.failing_since)}, - status_reason = ${step.changed ? step.reason : r.status_reason}, code_hash = COALESCE(code_hash, ${code}) - WHERE chain_id = ${r.chain_id} AND address = ${r.address} AND status = ${r.status} AND status_by = ${r.status_by} AND date_trunc('milliseconds', status_at) = date_trunc('milliseconds', ${new Date(r.status_at)}::timestamptz) - RETURNING address`; - if (done.length === 0) return; - if (step.changed) { - changed = true; - await locked`INSERT INTO bb_pair_events (chain_id, address, status, reason, actor) VALUES (${r.chain_id}, ${r.address}, ${step.status}, ${step.reason}, 'monitor')`; - console.log(`[pairs] ${r.symbol} ${r.status} → ${step.status}: ${step.reason}`); - } else if (health && !health.ok) { - console.warn(`[pairs] ${r.symbol} failing: ${health.reason}`); + const releaseClaim = (locked: Db = db) => locked`UPDATE bb_pairs SET checked_at = NULL WHERE chain_id = ${r.chain_id} AND address = ${r.address} + AND date_trunc('milliseconds', checked_at) = date_trunc('milliseconds', ${new Date(r.checked_at!)}::timestamptz)`; + try { + const chain = chainKeyOf(r.chain_id); + if (!chain) { await releaseClaim(); continue; } + type IndexState = { reorg_generation: bigint; reorg_version: number; rebuilding_until: bigint | null }; + const sourceIndex = r.price_kind === "launch" ? (await db` + SELECT reorg_generation, reorg_version, rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id = ${r.chain_id}`)[0] : null; + if (r.price_kind === "launch" && (!sourceIndex || sourceIndex.reorg_version !== 1 || sourceIndex.rebuilding_until !== null)) { + await releaseClaim(); + continue; } - }; - if (sourceIndex) await withIndexLock(chain, publishCheck); - else await publishCheck(db); + const { health, code } = await checkPair(r, chain).catch(() => ({ health: null, code: null })); + const step = nextPairStatus({ status: r.status, status_by: r.status_by, status_at: new Date(r.status_at).getTime(), failing_since: r.failing_since ? new Date(r.failing_since).getTime() : null }, health, Date.now()); + const publishCheck = async (locked: Db) => { + if (sourceIndex) { + const [current] = await locked` + SELECT reorg_generation, reorg_version, rebuilding_until FROM bb_launch_sync_cursor WHERE chain_id = ${r.chain_id}`; + if (!current || current.reorg_version !== 1 || current.rebuilding_until !== null || BigInt(current.reorg_generation) !== BigInt(sourceIndex.reorg_generation)) { + // Retry after recovery instead of counting a temporarily absent launch as a failing price source. + await releaseClaim(locked); + return; + } + } + // only if nothing changed the row while it was checked (an admin's suspension must never be written over); a JS Date holds + // milliseconds and Postgres microseconds, so the timestamps are compared at milliseconds + const done = await locked`UPDATE bb_pairs SET status = ${step.status}, status_by = ${step.status_by}, status_at = to_timestamp(${step.status_at / 1000}), failing_since = ${step.failing_since === null ? null : new Date(step.failing_since)}, + status_reason = ${step.changed ? step.reason : r.status_reason}, code_hash = COALESCE(code_hash, ${code}) + WHERE chain_id = ${r.chain_id} AND address = ${r.address} + AND date_trunc('milliseconds', checked_at) = date_trunc('milliseconds', ${new Date(r.checked_at!)}::timestamptz) + AND status = ${r.status} AND status_by = ${r.status_by} AND date_trunc('milliseconds', status_at) = date_trunc('milliseconds', ${new Date(r.status_at)}::timestamptz) + RETURNING address`; + if (done.length === 0) return; + if (step.changed) { + changed = true; + await locked`INSERT INTO bb_pair_events (chain_id, address, status, reason, actor) VALUES (${r.chain_id}, ${r.address}, ${step.status}, ${step.reason}, 'monitor')`; + console.log(`[pairs] ${r.symbol} ${r.status} → ${step.status}: ${step.reason}`); + } else if (health && !health.ok) { + console.warn(`[pairs] ${r.symbol} failing: ${health.reason}`); + } + }; + if (sourceIndex) await withIndexLock(chain, publishCheck); + else await publishCheck(db); + } catch (error) { + // A lock timeout or failed cursor read must not strand this claim, nor + // prevent the rest of this machine's claimed batch from being checked. + await releaseClaim().catch(() => {}); + console.warn(`[pairs] check ${r.symbol}:`, error instanceof Error ? error.message : error); + } } if (changed) await reloadPairs(); } finally { diff --git a/app/src/lib/launchpad/pending-launch.test.ts b/app/src/lib/launchpad/pending-launch.test.ts index b70adad4..9074a96d 100644 --- a/app/src/lib/launchpad/pending-launch.test.ts +++ b/app/src/lib/launchpad/pending-launch.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import test from "node:test"; -import { pendingLaunch, pendingLaunchServer, rememberPendingLaunch, subscribePendingLaunch } from "./pending-launch.ts"; +import test, { type TestContext } from "node:test"; +import type { PublicClient } from "viem"; +import { archivePendingLaunch, captureLaunchIdentity, launchNonceFinalized, pendingLaunch, pendingLaunchServer, recentLaunches, rememberFirstBuy, rememberPendingLaunch, subscribePendingLaunch, type PendingLaunch } from "./pending-launch.ts"; test("sent launch journal restores and validates an existing hash without a wallet action", (t) => { const storage = new Map(); @@ -31,3 +32,73 @@ test("a denied storage write still retains the in-memory send guard", (t) => { assert.equal(pendingLaunch(), attempt); assert.equal(pendingLaunch(), attempt); }); + +const hash = `0x${"ab".repeat(32)}` as const; +const wallet = `0x${"12".repeat(20)}` as const; +function storageFor(t: TestContext) { + const storage = new Map([["ol:pending-launch", " "]]); + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => storage.set(k, v), removeItem: (k: string) => storage.delete(k) } }); + storage.set("ol:pending-launch", "null "); + pendingLaunch(); + t.after(() => { storage.set("ol:pending-launch", "null "); pendingLaunch(); rememberPendingLaunch(null); Reflect.deleteProperty(globalThis, "sessionStorage"); }); + return storage; +} +test("confirmed first buys and indexed references survive a storage reload without blocking an unrelated launch", (t) => { + const storage = storageFor(t); + const attempt: PendingLaunch = { chain: "base", hash, token: wallet }; + rememberPendingLaunch(attempt); + const buy = { hash: `0x${"cd".repeat(32)}` as const, status: "confirmed" as const, amount: "1500000000000000000", exact: true, symbol: "TEST" }; + rememberFirstBuy(hash, buy); + storage.set("ol:pending-launch", storage.get("ol:pending-launch")! + " "); + assert.deepEqual(pendingLaunch()?.firstBuy, buy); + assert.equal(archivePendingLaunch(hash, "indexed", wallet), true); + assert.equal(pendingLaunch(), null); + storage.set("ol:pending-launch", storage.get("ol:pending-launch")! + " "); + assert.deepEqual(recentLaunches()[0], { ...attempt, firstBuy: buy, outcome: "indexed" }); + rememberPendingLaunch({ ...attempt, hash: buy.hash }); + assert.equal(pendingLaunch()?.hash, buy.hash); + assert.equal(recentLaunches()[0].hash, hash); + assert.equal(archivePendingLaunch(hash, "indexed"), false, "stale result cannot release a different active attempt"); +}); + +test("references are bounded and old journals never receive fabricated nonce evidence", (t) => { + const storage = storageFor(t); + storage.set("ol:pending-launch", JSON.stringify({ chain: "base", hash, token: null })); + assert.equal(pendingLaunch()?.identity, undefined); + for (let i = 0; i < 12; i++) { + const next = `0x${i.toString(16).padStart(64, "0")}` as const; + rememberPendingLaunch({ chain: "base", hash: next, token: null }); + archivePendingLaunch(next, "indexed"); + } + assert.equal(recentLaunches().length, 10); + assert.equal(pendingLaunch(), null); +}); + +test("only the exact wallet, chain and finalized nonce can authorize an explicit archive", async () => { + const attempt: PendingLaunch = { chain: "base", hash, token: null, identity: { hash, chainId: 8453, sender: wallet, nonce: 7 } }; + const calls: unknown[] = []; + const pub = { getChainId: async () => 8453, getTransactionCount: async (args: { blockTag: string }) => { calls.push(args); return args.blockTag === "finalized" ? 8 : 100; } } as unknown as Pick; + assert.equal(await launchNonceFinalized(pub, attempt, wallet), true); + assert.deepEqual(calls, [{ address: wallet, blockTag: "finalized" }]); + for (const changed of [ + { ...attempt, identity: undefined }, + { ...attempt, hash: `0x${"cd".repeat(32)}` as const }, + { ...attempt, chain: "arc" as const }, + ]) assert.equal(await launchNonceFinalized(pub, changed, wallet), false); + assert.equal(await launchNonceFinalized(pub, attempt, `0x${"34".repeat(20)}`), false); + assert.equal(await launchNonceFinalized({ ...pub, getChainId: async () => 1 }, attempt, wallet), false); + for (const nonce of [7, 6]) assert.equal(await launchNonceFinalized({ ...pub, getTransactionCount: async () => nonce }, attempt, wallet), false); + assert.equal(await launchNonceFinalized({ ...pub, getTransactionCount: async (args) => args.blockTag === "finalized" ? 7 : 100 }, attempt, wallet), false, "a pending nonce proves nothing"); + assert.equal(await launchNonceFinalized({ ...pub, getTransactionCount: async () => { throw Error("finality unavailable"); } }, attempt, wallet), false); +}); + +test("identity capture requires exact original hash, wallet and chain and fails closed on missing reads", async () => { + const tx = { hash, from: wallet, nonce: 7 }; + const pub = { getChainId: async () => 8453, getTransaction: async () => tx } as unknown as Pick; + assert.deepEqual(await captureLaunchIdentity(pub, "base", hash, wallet), { hash, chainId: 8453, sender: wallet, nonce: 7 }); + for (const changed of [{ ...tx, hash: `0x${"cd".repeat(32)}` }, { ...tx, from: `0x${"34".repeat(20)}` }, { ...tx, nonce: -1 }]) { + assert.equal(await captureLaunchIdentity({ ...pub, getTransaction: async () => changed } as typeof pub, "base", hash, wallet), undefined); + } + assert.equal(await captureLaunchIdentity({ ...pub, getChainId: async () => 1 }, "base", hash, wallet), undefined); + assert.equal(await captureLaunchIdentity({ ...pub, getTransaction: async () => { throw Error("not found"); } }, "base", hash, wallet), undefined); +}); diff --git a/app/src/lib/launchpad/pending-launch.ts b/app/src/lib/launchpad/pending-launch.ts index ef0977ea..d85404f6 100644 --- a/app/src/lib/launchpad/pending-launch.ts +++ b/app/src/lib/launchpad/pending-launch.ts @@ -1,13 +1,34 @@ -import type { Hex } from "viem"; -import { isChainKey, type ChainKey } from "@/lib/chainPublic"; +import type { Address, Hex, PublicClient } from "viem"; +import { chainIdOf, isChainKey, type ChainKey } from "@/lib/chainPublic"; -export type PendingLaunch = { chain: ChainKey; hash: Hex; token: string | null }; +export type LaunchIdentity = { hash: Hex; chainId: number; sender: Address; nonce: number }; +export type FirstBuyOutcome = { hash: Hex; status: "pending" | "confirmed" | "reverted" | "replaced"; symbol: string; amount?: string; exact?: boolean }; +export type PendingLaunch = { chain: ChainKey; hash: Hex; token: string | null; identity?: LaunchIdentity; firstBuy?: FirstBuyOutcome }; +export type SavedLaunch = PendingLaunch & { outcome: "indexed" | "nonce-finalized" | "reverted" }; const KEY = "ol:pending-launch"; +const EMPTY: readonly SavedLaunch[] = []; const listeners = new Set<() => void>(); let current: PendingLaunch | null = null; +let recent: readonly SavedLaunch[] = EMPTY; let stored: string | null = null; let inMemoryOnly = false; +const isHash = (v: unknown): v is Hex => typeof v === "string" && /^0x[0-9a-f]{64}$/i.test(v); +const isAddress = (v: unknown): v is Address => typeof v === "string" && /^0x[0-9a-f]{40}$/i.test(v); +function parseAttempt(v: unknown): PendingLaunch | null { + if (!v || typeof v !== "object") return null; + const a = v as PendingLaunch; + if (!isChainKey(a.chain) || !isHash(a.hash)) return null; + const attempt: PendingLaunch = { chain: a.chain, hash: a.hash, token: isAddress(a.token) ? a.token.toLowerCase() : null }; + const identity = a.identity; + if (identity && identity.hash === a.hash && identity.chainId === chainIdOf(a.chain) && isAddress(identity.sender) && Number.isSafeInteger(identity.nonce) && identity.nonce >= 0) attempt.identity = identity; + const buy = a.firstBuy; + if (buy && isHash(buy.hash) && ["pending", "confirmed", "reverted", "replaced"].includes(buy.status) && typeof buy.symbol === "string" && buy.symbol.length <= 32) { + attempt.firstBuy = { hash: buy.hash, status: buy.status, symbol: buy.symbol, + ...(typeof buy.amount === "string" && /^\d{1,78}$/.test(buy.amount) ? { amount: buy.amount, exact: buy.exact === true } : {}) }; + } + return attempt; +} export function pendingLaunchServer(): null { return null; } export function pendingLaunch(): PendingLaunch | null { if (inMemoryOnly) return current; @@ -16,14 +37,21 @@ export function pendingLaunch(): PendingLaunch | null { if (raw === stored) return current; stored = raw; const v = raw ? JSON.parse(raw) : null; - current = v && isChainKey(v.chain) && typeof v.hash === "string" && /^0x[0-9a-f]{64}$/i.test(v.hash) - ? { chain: v.chain, hash: v.hash, token: typeof v.token === "string" && /^0x[0-9a-f]{40}$/i.test(v.token) ? v.token.toLowerCase() : null } : null; + // Version-one journals are the original single attempt; never invent missing nonce evidence. + current = parseAttempt(v?.version === 2 ? v.active : v); + recent = v?.version === 2 && Array.isArray(v.recent) ? v.recent.slice(0, 10).flatMap((row: SavedLaunch) => { + const attempt = parseAttempt(row); + return attempt && (row.outcome === "indexed" || row.outcome === "nonce-finalized" || row.outcome === "reverted") ? [{ ...attempt, outcome: row.outcome }] : []; + }) : EMPTY; } catch { /* Storage denied: the in-memory attempt still prevents another send. */ } return current; } export function rememberPendingLaunch(attempt: PendingLaunch | null): void { current = attempt; - stored = attempt ? JSON.stringify(attempt) : null; + persist(); +} +function persist(): void { + stored = current || recent.length ? JSON.stringify({ version: 2, active: current, recent }) : null; try { if (stored) sessionStorage.setItem(KEY, stored); else sessionStorage.removeItem(KEY); @@ -31,6 +59,41 @@ export function rememberPendingLaunch(attempt: PendingLaunch | null): void { } catch { inMemoryOnly = true; } for (const listener of listeners) listener(); } +export function recentLaunchesServer(): readonly SavedLaunch[] { return EMPTY; } +export function recentLaunches(): readonly SavedLaunch[] { pendingLaunch(); return recent; } +/** Keep the last ten session references without making an indexed/resolved attempt an active send guard. */ +export function archivePendingLaunch(hash: Hex, outcome: SavedLaunch["outcome"], token?: string): boolean { + const attempt = pendingLaunch(); + if (!attempt || attempt.hash.toLowerCase() !== hash.toLowerCase()) return false; + recent = [{ ...attempt, token: token ?? attempt.token, outcome }, ...recent.filter((a) => a.hash !== attempt.hash || a.chain !== attempt.chain)].slice(0, 10); + current = null; + persist(); + return true; +} +export function rememberFirstBuy(launchHash: Hex, buy: FirstBuyOutcome): void { + const attempt = pendingLaunch(); + if (attempt?.hash === launchHash) rememberPendingLaunch({ ...attempt, firstBuy: buy }); +} + +/** Best-effort evidence capture. Smart-wallet outer senders and unavailable transactions remain unresolvable here. */ +export async function captureLaunchIdentity(pub: Pick, chain: ChainKey, hash: Hex, wallet: Address): Promise { + try { + if (await pub.getChainId() !== chainIdOf(chain)) return; + const tx = await pub.getTransaction({ hash }); + if (tx.hash.toLowerCase() !== hash.toLowerCase() || tx.from.toLowerCase() !== wallet.toLowerCase() || !Number.isSafeInteger(tx.nonce) || tx.nonce < 0) return; + return { hash, chainId: chainIdOf(chain), sender: tx.from, nonce: tx.nonce }; + } catch { return; } +} +/** Only a finalized nonce strictly past this exact saved transaction rules out its future execution. */ +export async function launchNonceFinalized(pub: Pick, attempt: PendingLaunch, wallet: Address): Promise { + const id = attempt.identity; + if (!id || id.hash !== attempt.hash || id.chainId !== chainIdOf(attempt.chain) || id.sender.toLowerCase() !== wallet.toLowerCase() || !Number.isSafeInteger(id.nonce) || id.nonce < 0) return false; + try { + if (await pub.getChainId() !== id.chainId) return false; + const nonce = await pub.getTransactionCount({ address: id.sender, blockTag: "finalized" }); + return Number.isSafeInteger(nonce) && nonce > id.nonce; + } catch { return false; } +} export function subscribePendingLaunch(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; diff --git a/app/src/lib/launchpad/postsServer.db.test.ts b/app/src/lib/launchpad/postsServer.db.test.ts index 1c0aee4e..8b531b30 100644 --- a/app/src/lib/launchpad/postsServer.db.test.ts +++ b/app/src/lib/launchpad/postsServer.db.test.ts @@ -1,12 +1,13 @@ import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; import postgres from "postgres"; import { verifyMessage } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { setPostsTestDb, type Db } from "../../../scripts/posts-db-test-db.ts"; import { setPostsTestClient } from "../../../scripts/posts-db-test-chain.ts"; -import { buildPostMessage } from "./posts.ts"; +import { POST_MIN_GAP_MS, buildPostMessage } from "./posts.ts"; // Opt-in, real PostgreSQL concurrency checks. The URL must be loopback, with a port and no options. Each run owns a // random schema and drops only that schema; it never reads application environment files. @@ -23,7 +24,7 @@ test("posting limits and nonce consumption stay atomic across PostgreSQL connect const schema = `post_security_${randomBytes(8).toString("hex")}`; assert.match(schema, /^post_security_[0-9a-f]{16}$/); const root = postgres(endpoint, { max: 1, onnotice: () => {} }); - const sql = postgres(endpoint, { max: 8, connection: { search_path: schema }, onnotice: () => {} }); + const sql = postgres(endpoint, { max: 8, connection: { search_path: schema, application_name: schema }, onnotice: () => {} }); let schemaCreated = false; t.after(async () => { setPostsTestDb(null); @@ -93,6 +94,38 @@ test("posting limits and nonce consumption stay atomic across PostgreSQL connect assert.equal((await submit(await payload())).status, 429); assert.equal(await postCount(), 1); }); + await t.test("a post queued beyond the gap is timestamped after its wallet lock is released", { timeout: 45_000 }, async () => { + await clear(); + const locker = await sql.reserve(); + let locked = false; + let queued: Promise | undefined; + try { + await locker`SELECT pg_advisory_lock(hashtext(${`post:${wallet}`}))`; + locked = true; + queued = submit(await payload()); + const deadline = Date.now() + 5_000; + for (;;) { + const [state] = await root`SELECT count(*)::int AS waiting FROM pg_stat_activity WHERE application_name = ${schema} AND wait_event = 'advisory'`; + if (state.waiting > 0) break; + assert.ok(Date.now() < deadline, "the actual posting transaction must be waiting on the wallet lock"); + await delay(10); + } + await delay(POST_MIN_GAP_MS + 100); + const [clock] = await root`SELECT clock_timestamp() AS released_at`; + await locker`SELECT pg_advisory_unlock(hashtext(${`post:${wallet}`}))`; + locked = false; + const response = await queued; + assert.equal(response.status, 200); + const result = await response.json(); + assert.equal((await submit(await payload())).status, 429, "lock wait must not backdate a post and permit an immediate second post"); + assert.ok(new Date(result.post.created_at).getTime() >= new Date(clock.released_at).getTime(), "created_at must use insertion time, not transaction-start time"); + assert.equal(await postCount(), 1); + } finally { + if (locked) await locker`SELECT pg_advisory_unlock(hashtext(${`post:${wallet}`}))`; + locker.release(); + await queued?.catch(() => {}); + } + }); await t.test("distinct signed nonces cannot bypass the gap concurrently", async () => { await clear(); barrier(2); diff --git a/app/src/lib/launchpad/postsServer.ts b/app/src/lib/launchpad/postsServer.ts index d9d8de06..95872e5c 100644 --- a/app/src/lib/launchpad/postsServer.ts +++ b/app/src/lib/launchpad/postsServer.ts @@ -163,15 +163,15 @@ export async function createPost(p: { chain: ChainKey; token: string; wallet: st // Distinct signatures can pass the cheap preflight together. Serialize the final // policy read and write for this wallet across every server process and chain. await sql`SELECT pg_advisory_xact_lock(hashtext(${`post:${wallet}`}))`; - const [current] = await sql<{ last: string | null; n24: bigint }[]>`SELECT max(created_at) AS last, count(*) FILTER (WHERE created_at > now() - interval '24 hours')::bigint AS n24 FROM bb_posts WHERE wallet = ${wallet}`; + const [current] = await sql<{ last: string | null; n24: bigint }[]>`SELECT max(created_at) AS last, count(*) FILTER (WHERE created_at > clock_timestamp() - interval '24 hours')::bigint AS n24 FROM bb_posts WHERE wallet = ${wallet}`; const finalGate = canPostNow(current.last ? new Date(current.last).getTime() : null, Number(current.n24), Date.now()); if (!finalGate.ok) return fail(finalGate.error, 429); // A refused quota attempt spends no nonce; a failed INSERT rolls its nonce back. const used = await sql`INSERT INTO bb_post_nonces (nonce, wallet) VALUES (${nonce}, ${wallet}) ON CONFLICT (nonce) DO NOTHING RETURNING nonce`; if (used.length === 0) return fail("nonce already used", 401); const [row] = await sql` - INSERT INTO bb_posts (chain_id, token, wallet, parent_id, body, tag, signature) - VALUES (${cid}, ${token}, ${wallet}, ${parentId}, ${v.body}, ${elig.tag}, ${String(p.signature)}) + INSERT INTO bb_posts (chain_id, token, wallet, parent_id, body, tag, signature, created_at) + VALUES (${cid}, ${token}, ${wallet}, ${parentId}, ${v.body}, ${elig.tag}, ${String(p.signature)}, clock_timestamp()) RETURNING id, chain_id, token, wallet, parent_id, body, tag, created_at, reports, hidden`; return { ok: true as const, post: shape(row) }; }); diff --git a/app/src/lib/launchpad/sync-client.test.ts b/app/src/lib/launchpad/sync-client.test.ts index 2b087d0f..4560e192 100644 --- a/app/src/lib/launchpad/sync-client.test.ts +++ b/app/src/lib/launchpad/sync-client.test.ts @@ -22,6 +22,10 @@ for (const status of [429, 502]) test(`HTTP ${status} cannot throw a sent transa t.mock.method(globalThis, "fetch", async () => new Response("unavailable", { status })); assert.deepEqual(await syncTransaction("base", hash), { status: "error" }); }); +test("retryable index contention preserves the original sent transaction", async (t) => { + t.mock.method(globalThis, "fetch", async () => Response.json({ status: "pending" }, { status: 202, headers: { "Retry-After": "2" } })); + assert.deepEqual(await syncTransaction("base", hash), { status: "pending" }); +}); test("network and malformed JSON failures remain recoverable", async (t) => { const fetch = t.mock.method(globalThis, "fetch", async () => { throw new Error("offline"); }); assert.deepEqual(await syncTransaction("base", hash), { status: "error" }); diff --git a/app/src/lib/launchpad/transaction-receipt.test.ts b/app/src/lib/launchpad/transaction-receipt.test.ts new file mode 100644 index 00000000..47968cf6 --- /dev/null +++ b/app/src/lib/launchpad/transaction-receipt.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { PublicClient } from "viem"; +import { waitForTrackedReceipt } from "./transaction-receipt"; + +const hash = `0x${"ab".repeat(32)}` as const; +const replacement = `0x${"cd".repeat(32)}` as const; +const sender = `0x${"12".repeat(20)}` as const; +const original = { hash, from: sender, nonce: 7, to: sender, input: "0xab", value: 0n }; +const next = { ...original, hash: replacement }; +function reader(event?: object, receiptHash = event ? replacement : hash) { + return { waitForTransactionReceipt: async ({ onReplaced }: { onReplaced: (event: object) => void }) => { + if (event) onReplaced(event); + return { transactionHash: receiptHash, status: "success", logs: [] }; + } } as unknown as Pick; +} +const event = { reason: "repriced", replacedTransaction: original, transaction: next, transactionReceipt: { transactionHash: replacement } }; + +test("receipt tracking adopts an exact repricing and reports changed calls separately", async () => { + const adopted: string[] = []; + assert.equal((await waitForTrackedReceipt(reader(), hash, () => assert.fail())).changed, false); + const repriced = await waitForTrackedReceipt(reader(event), hash, (h) => adopted.push(h)); + assert.equal(repriced.receipt.transactionHash, replacement); + assert.deepEqual(adopted, [replacement]); + assert.equal(repriced.changed, false); + for (const reason of ["cancelled", "replaced"]) { + const changed = await waitForTrackedReceipt(reader({ ...event, reason, transaction: { ...next, input: "0x" } }), hash, () => assert.fail("changed call is not an adopted repricing")); + assert.equal(changed.changed, true); + } +}); + +test("hash, sender, nonce and calldata mismatches never become a successful repricing", async () => { + for (const bad of [ + { ...event, replacedTransaction: { ...original, hash: replacement } }, + { ...event, transaction: { ...next, from: `0x${"34".repeat(20)}` } }, + { ...event, transaction: { ...next, nonce: 8 } }, + { ...event, transaction: { ...next, input: "0xffff" } }, + { ...event, transactionReceipt: { transactionHash: hash } }, + ]) await assert.rejects(waitForTrackedReceipt(reader(bad), hash, () => assert.fail()), /could not be matched/); + await assert.rejects(waitForTrackedReceipt(reader(undefined, replacement), hash, () => assert.fail()), /could not be matched/); +}); + +test("a timeout after a verified repricing has already retained the replacement reference", async () => { + const retained: string[] = []; + const pub = { waitForTransactionReceipt: async ({ onReplaced }: { onReplaced: (event: object) => void }) => { onReplaced(event); throw Error("timeout"); } } as unknown as Pick; + await assert.rejects(waitForTrackedReceipt(pub, hash, (h) => retained.push(h)), /timeout/); + assert.deepEqual(retained, [replacement]); +}); diff --git a/app/src/lib/launchpad/transaction-receipt.ts b/app/src/lib/launchpad/transaction-receipt.ts new file mode 100644 index 00000000..0693c201 --- /dev/null +++ b/app/src/lib/launchpad/transaction-receipt.ts @@ -0,0 +1,25 @@ +import type { Hex, PublicClient } from "viem"; + +/** Adopt only an exact same-sender/nonce repricing; changed calls are a separate outcome. */ +export async function waitForTrackedReceipt(pub: Pick, hash: Hex, onHash: (hash: Hex) => void) { + let expected = hash; + let changed = false; + let invalid = false; + const receipt = await pub.waitForTransactionReceipt({ + hash, + onReplaced: ({ reason, replacedTransaction: previous, transaction: next, transactionReceipt }) => { + if (previous.hash.toLowerCase() !== expected.toLowerCase() || next.hash.toLowerCase() !== transactionReceipt.transactionHash.toLowerCase() + || previous.from.toLowerCase() !== next.from.toLowerCase() || !Number.isSafeInteger(previous.nonce) || previous.nonce < 0 || previous.nonce !== next.nonce) { + invalid = true; + return; + } + const sameCall = previous.to?.toLowerCase() === next.to?.toLowerCase() && previous.input.toLowerCase() === next.input.toLowerCase() && previous.value === next.value; + if (reason === "repriced" && !sameCall) { invalid = true; return; } + expected = next.hash; + changed ||= reason !== "repriced"; + if (!changed) onHash(expected); + }, + }); + if (invalid || receipt.transactionHash.toLowerCase() !== expected.toLowerCase()) throw new Error("The receipt could not be matched to the submitted transaction. Check the saved transaction before sending again."); + return { receipt, changed }; +} diff --git a/app/src/lib/points/reorg.test.ts b/app/src/lib/points/reorg.test.ts index 981f17f1..252704b2 100644 --- a/app/src/lib/points/reorg.test.ts +++ b/app/src/lib/points/reorg.test.ts @@ -14,6 +14,13 @@ test("points refuse unvalidated, rebuilding or missing required chains", () => { assert.equal(indexSnapshotReady([row()], [8453, 5042002]), false); }); +test("an unconfigured cursor cannot block otherwise ready required chains", () => { + const unrelated = { ...row(), chain_id: 5042002, reorg_version: 0, rebuilding_until: 200n }; + assert.equal(indexSnapshotReady([row(), unrelated], [8453]), true); + assert.equal(indexSnapshotReady([row(), unrelated], [8453, 5042002]), false); + assert.equal(indexSnapshotReady([unrelated], [8453]), false); +}); + test("points compare the generation, not whether a replay regained its old height", () => { assert.equal(sameIndexSnapshot([row()], [row()]), true); assert.equal(sameIndexSnapshot([row()], [row(1n)]), false); @@ -21,7 +28,7 @@ test("points compare the generation, not whether a replay regained its old heigh assert.equal(sameIndexSnapshot([row()], [row(), { ...row(), chain_id: 5042002 }]), false); }); -function fixture(t: TestContext, current: IndexSnapshot) { +function fixture(t: TestContext, current: IndexSnapshot, initial: IndexSnapshot = [row()]) { resetMock(); resetEthPriceCache(); t.mock.method(globalThis, "fetch", async (url: string | URL | Request) => { @@ -34,7 +41,7 @@ function fixture(t: TestContext, current: IndexSnapshot) { const queries: string[] = []; getMock().query = async (sql) => { queries.push(sql); - if (sql.includes("reorg_generation")) return sql.includes("FOR UPDATE") ? current : [row()]; + if (sql.includes("reorg_generation")) return sql.includes("FOR UPDATE") ? current : initial; if (sql.includes("FROM bb_seasons") && sql.includes("FOR UPDATE")) return [season]; return []; }; @@ -61,6 +68,14 @@ test("unchanged generation publishes after cursor locks then season lock and cle assert.ok(f.queries.some((q) => q.includes("reorg_invalidated_at = NULL"))); }); +test("season computation can publish with an unchanged unrelated unready cursor", async (t) => { + const snapshot = [row(), { ...row(), chain_id: 5042002, reorg_version: 0, rebuilding_until: 200n }]; + const f = fixture(t, snapshot, snapshot); + const result = await computeSeason(f.db, f.db, f.season); + assert.equal("wallets" in result && result.wallets, 0); + assert.ok(f.queries.some((q) => q.includes("DELETE FROM bb_points"))); +}); + test("historical invalidated seasons are selected independently of the latest season", async () => { resetMock(); const historical = { id: 2, reorg_invalidated_at: "2026-11-12T00:00:00Z", rules_version: 1 }; diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts index e733cdab..915fb7dc 100644 --- a/app/src/lib/points/server.ts +++ b/app/src/lib/points/server.ts @@ -92,8 +92,7 @@ async function indexSnapshot(db: Db, lock = false): Promise { } export function indexSnapshotReady(rows: IndexSnapshot, required: readonly number[]): boolean { - return rows.every((r) => r.reorg_version === 1 && r.rebuilding_until === null) - && required.every((cid) => rows.some((r) => r.chain_id === cid)); + return required.every((cid) => rows.some((r) => r.chain_id === cid && r.reorg_version === 1 && r.rebuilding_until === null)); } /** Generation equality rejects an old computation even if the replay has already reached the old cursor again. */ diff --git a/app/src/lib/profiles/http.test.ts b/app/src/lib/profiles/http.test.ts index 470e7510..efd760d7 100644 --- a/app/src/lib/profiles/http.test.ts +++ b/app/src/lib/profiles/http.test.ts @@ -25,6 +25,41 @@ test("readBody accepts exactly the byte limit and releases its reader", async () assert.deepEqual(await readBody(new Request("http://localhost/"), 4), { ok: true, bytes: new Uint8Array(0) }); }); +test("readBody copies tiny views before a producer reuses their backing storage", async () => { + const backing = new Uint8Array(1024 * 1024); + const view = backing.subarray(100, 101); + let next = 1; + const body = new ReadableStream({ + pull(controller) { + if (next > 4) { controller.close(); return; } + view[0] = next++; + controller.enqueue(view); + }, + }, { highWaterMark: 0 }); + const request = new Request("http://localhost/", { method: "POST", body, duplex: "half" } as RequestInit); + assert.deepEqual(await readBody(request, 4), { ok: true, bytes: Uint8Array.of(1, 2, 3, 4) }); + assert.equal(request.body?.locked, false); +}); + +test("readBody tolerates empty chunks and bounds a stream of single-byte chunks", { timeout: 5_000 }, async () => { + const bytes = Uint8Array.from({ length: 16_384 }, (_, i) => i % 251); + const chunks = Array.from(bytes, (byte) => [new Uint8Array(0), Uint8Array.of(byte)]).flat(); + const { request, state } = streamed(chunks); + assert.deepEqual(await readBody(request, bytes.length), { ok: true, bytes }); + assert.equal(state.cancelled, 0); + assert.equal(request.body?.locked, false); +}); + +test("readBody cancels an overflowing tiny-chunk stream after empty chunks without waiting for cancellation", { timeout: 1_000 }, async () => { + const { request, state } = streamed([ + ...Array.from({ length: 256 }, () => new Uint8Array(0)), + ...Array.from({ length: 256 }, () => Uint8Array.of(1)), + ], undefined, () => new Promise(() => {})); + assert.deepEqual(await readBody(request, 8), { ok: false, reason: "too_large" }); + assert.deepEqual(state, { pulled: 265, cancelled: 1 }); + assert.equal(request.body?.locked, false); +}); + test("readBody rejects declared oversize without pulling any bytes", async () => { const { request, state } = streamed([Uint8Array.of(1), Uint8Array.of(2)], "999999999999999999999999999999"); assert.deepEqual(await readBody(request, 4), { ok: false, reason: "too_large" }); diff --git a/app/src/lib/profiles/http.ts b/app/src/lib/profiles/http.ts index e0cf7c79..44097bdc 100644 --- a/app/src/lib/profiles/http.ts +++ b/app/src/lib/profiles/http.ts @@ -1,7 +1,7 @@ /** * Client IP for rate-limit keys. Fly's proxy sets fly-client-ip; the first x-forwarded-for entry is whatever the * client sent, so the fallback is the last one (the hop the proxy appended). - * Deployment must strip/overwrite fly-client-ip or append the actual client to XFF; + * Ingress must overwrite fly-client-ip with the actual client IP, or strip it and append the actual client to XFF; * directly exposed servers cannot authenticate arbitrary forwarding headers. */ export function clientIp(req: Request): string { @@ -21,7 +21,7 @@ export async function readBody(req: Request, maxBytes: number): Promise; try { reader = req.body.getReader(); } catch { return { ok: false, reason: "invalid" }; } - const chunks: Uint8Array[] = []; + let bytes = new Uint8Array(0); let size = 0; let complete = false; try { @@ -29,18 +29,21 @@ export async function readBody(req: Request, maxBytes: number): Promise maxBytes - size) return { ok: false, reason: "too_large" }; + // Copy each view now: retaining it could pin an arbitrarily large backing buffer, + // and an entry per tiny chunk would add memory beyond the byte budget. + if (size + value.byteLength > bytes.byteLength) { + const capacity = Math.min(maxBytes, Math.max(size + value.byteLength, 8_192, bytes.byteLength * 2)); + const grown = new Uint8Array(capacity); + grown.set(bytes); + bytes = grown; + } + bytes.set(value, size); size += value.byteLength; - chunks.push(value); } complete = true; - const bytes = new Uint8Array(size); - let at = 0; - for (const c of chunks) { - bytes.set(c, at); - at += c.byteLength; - } - return { ok: true, bytes }; + return { ok: true, bytes: bytes.subarray(0, size) }; } catch { return { ok: false, reason: "invalid" }; } finally { diff --git a/contracts/test/LaunchLocker.invariant.t.sol b/contracts/test/LaunchLocker.invariant.t.sol index 7020d626..51a3ed13 100644 --- a/contracts/test/LaunchLocker.invariant.t.sol +++ b/contracts/test/LaunchLocker.invariant.t.sol @@ -9,6 +9,7 @@ import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; import {PositionManager} from "@uniswap/v4-periphery/src/PositionManager.sol"; +import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; import {IPositionDescriptor} from "@uniswap/v4-periphery/src/interfaces/IPositionDescriptor.sol"; import {IWETH9} from "@uniswap/v4-periphery/src/interfaces/external/IWETH9.sol"; import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol"; @@ -210,23 +211,27 @@ contract LaunchLockerHandler is Test { } function rejectWithdrawal(uint256 positionSeed) public { - uint256 id = ids[positionSeed % 4]; + uint256 i = positionSeed % 4; + uint256 id = ids[i]; + PoolKey memory key = factory.poolKeyOf(tokens[i]); (bool moved,) = address(posm) .call(abi.encodeWithSignature("transferFrom(address,address,uint256)", address(locker), address(this), id)); assertFalse(moved, "outsider cannot move the NFT"); (bool approved,) = address(posm).call(abi.encodeWithSignature("approve(address,uint256)", address(this), id)); assertFalse(approved, "outsider cannot approve the NFT"); - bytes[] memory params = new bytes[](1); + bytes[] memory params = new bytes[](2); params[0] = abi.encode(id, uint256(1), uint128(0), uint128(0), bytes("")); - (bool decreased,) = address(posm) + params[1] = abi.encode(key.currency0, key.currency1, address(this)); + (bool decreased, bytes memory result) = address(posm) .call( abi.encodeWithSignature( "modifyLiquidities(bytes,uint256)", - abi.encode(abi.encodePacked(uint8(Actions.DECREASE_LIQUIDITY)), params), + abi.encode(abi.encodePacked(uint8(Actions.DECREASE_LIQUIDITY), uint8(Actions.TAKE_PAIR)), params), block.timestamp ) ); assertFalse(decreased, "outsider cannot remove principal"); + assertEq(result, abi.encodeWithSelector(IPositionManager.NotApproved.selector, address(this))); rejectedWithdrawals++; } diff --git a/docs/security/2026-11-12-pr-split.md b/docs/security/2026-11-12-pr-split.md index 45627255..973ce8aa 100644 --- a/docs/security/2026-11-12-pr-split.md +++ b/docs/security/2026-11-12-pr-split.md @@ -34,6 +34,25 @@ The source-preservation comparison above describes the initial split at `bf64f21 Against disposable PostgreSQL 17.10, the original harness accepted `?search_path=public` and left fixture tables in `public`. The fixed harness rejected both schema-override query forms, a URL fragment and a missing port without fixture writes. The valid suite passed all nine tests and removed its temporary schema. No production database was used. +## Additional review repairs + +The later review follow-up intentionally adds fixes beyond the source-preserving split. It addresses these verified findings: + +- Receipt tracking follows a same-sender, same-nonce repricing only when the recipient, calldata and value are unchanged. Boosts sync the confirmed hash; cancelled or changed calls are reported separately. +- Pending launches retain first-buy hashes and outcomes. The last ten completed or explicitly archived launch references remain in the browser session without blocking unrelated launches. An explicit archive of an uncertain attempt requires its captured sender/nonce and a strictly greater finalized account nonce on the correct chain. Age, a missing receipt and a pending nonce never establish this. Legacy records without captured identity, smart-wallet outer senders and unavailable finality remain protected and may require manual investigation. +- Canonical advisory-lock contention returns retryable pending status rather than a misleading sync failure. Table locks keep their separate timeout. Empty holder backfill stops, failed pair-monitor work releases only its owned claim, and points readiness uses the configured chains. +- Post quota checks use the database's current clock after acquiring the wallet lock; inserted posts no longer inherit a stale transaction-start timestamp. The request reader copies bounded bytes without accumulating empty chunks or retaining oversized backing buffers. Ingress documentation clarifies how the preferred client-IP header must be sanitized. +- The payment-reconciliation runbook now covers archived evidence, canonical receipt and historical-price validation, atomic aggregate repair and preserved review decisions. The locker invariant test submits a complete withdrawal action sequence and checks the precise authorization failure. + +These are bounded correctness, availability and security-hardening repairs, not a claim that every review comment describes a remotely exploitable vulnerability. Recovery depends on honest, consistent RPC responses and the documented deployment procedure. Session storage is not a cross-tab wallet mutex; denied storage retains only the current page's in-memory protection. +### Follow-up verification + +- Full standalone application suite: **1,569 passed, 13 explicit opt-in skips, zero failures**. TypeScript passes; ESLint has zero errors and the same five existing Open Graph image warnings. +- Actual launch, boost and journal checks: 76 focused tests pass. An independent review passes 33 focused checks and caught a later-reverted launch erasing the separate first-buy reference; both reverted paths now retain an archived reference and a regression covers the case. +- Actual launch/recovery components in Chromium: **12 scenarios passed**, including reload, replacement hashes, first-buy visibility and explicit finalized-nonce archive. Zero browser errors and external requests; wallet/RPC boundaries were synthetic, with no funded transaction. +- PostgreSQL 17.10: **28 canonical-recovery checks** and **10 posting checks** pass. Actual lock contention, unchanged facts after an advisory timeout and safe same-receipt retry are exercised. The posting timing regression fails against the original transaction-start timestamp and passes with the post-lock clock. Disposable schemas and containers were removed. +- Bounded-body negative controls reproduce the original retained backing buffers and overwritten reused views; the fixed reader passes. The client-IP runtime precedence is unchanged; this run does not validate production ingress configuration. +- The locker invariant campaign passes with 256 runs of 128 calls, zero handler reverts and 4,665 attempted unauthorized withdrawals, using the existing pinned local dependencies. The deterministic failure-recovery test also passes; no deployed source was changed. ## Rollout The standalone security PR owns the schema and canonical-history rollout. Follow [Canonical index recovery](2026-11-12-reorg-recovery.md): stop old indexer writers before deploying, retain a database backup, and allow the one-time resumable historical rebuild to complete. Market data can temporarily show recovery states. Splitting the PR does not remove these operational requirements or establish a vulnerability-free guarantee. diff --git a/docs/security/2026-11-12-reorg-recovery.md b/docs/security/2026-11-12-reorg-recovery.md index 46af4e87..567fba97 100644 --- a/docs/security/2026-11-12-reorg-recovery.md +++ b/docs/security/2026-11-12-reorg-recovery.md @@ -43,6 +43,47 @@ FROM bb_pair_applications WHERE payment_reorg; A payment marked for reconciliation requires checking the canonical receipt and original application/quote evidence. Do not ask the applicant to burn again, clear the flag merely to unblock review, or substitute a current price without validating its historical basis. Removed payments remain flagged even if the replacement chain never includes them. +### Reconcile a verified re-included payment + +This is an operator procedure for a database maintainer, not a public API or an automatic repricing path. Review the evidence and proposed changes before executing them, and rehearse the transaction on a restored database copy. A valid historical valuation must be established first; the application does not supply missing evidence or grant approval simply because a row is marked pending. + +1. **Find both the current facts and the archive.** A removed burn no longer appears in `bb_pair_burns`. The following read-only queries associate archived evidence with the affected application, including a payment whose transaction never returned to the canonical chain: + + ```sql + SELECT a.id, a.code, a.status, a.fee_usd, a.paid_usd, a.created_at, + a.quote_price_usd, a.quote_at, a.quote_until, a.last_quote_until, + r.chain_id, r.tx_hash, r.log_index, r.block_number, r.block_time, + r.block_hash, r.payload, r.usd, r.priced_by + FROM bb_pair_applications a + JOIN bb_launch_replay_values r + ON r.kind = 'pair' AND r.payload->>'application_id' = a.id::text + WHERE a.payment_reorg + ORDER BY a.id, r.tx_hash, r.log_index, r.block_number; + + SELECT b.*, c.block_hash + FROM bb_pair_burns b + JOIN bb_pair_applications a ON a.id = b.application_id + LEFT JOIN bb_launch_blocks c + ON c.chain_id = b.chain_id AND c.block_number = b.block_number + WHERE a.payment_reorg + ORDER BY b.application_id, b.tx_hash, b.log_index; + ``` + +2. **Verify the payment independently.** Fees are TWIG burns on Base, even when the application lists a pair on another chain. Check the successful canonical receipt, block hash/time, TWIG contract, burn destination, sender, application tag, log index and whole-TWIG amount against `pairBurnFromTransfer` and the original application. Require the canonical burn time to be at or after the application's creation time, and exclude configured locker senders as `pairBurnFromTransfer(skip)` does. Validate the sender against the canonical event; it does not have to be the applicant. The current indexed event must match that receipt. Archive rows describe prior observations; their presence does not prove current canonical inclusion. If the burn is absent, still pending, or has a different application/tag, keep reconciliation blocked and preserve the archive. Never insert an archived event as a new canonical fact. + +3. **Establish the historical USD value.** A locked quote is usable only with retained evidence of its price and validity interval covering the canonical burn time. A `live` valuation needs verifiable evidence of the historical price used under the fee policy. Recalculate from the verified whole-TWIG amount and that price using `pairBurnUsd`'s rules; require a finite positive result. Current application quote fields may have been cleared when the original payment consumed the quote, so they are not a substitute for the original quote evidence. Do not infer a new quote window, copy an archive's USD value solely because its hash matches, or use today's market price. Without sufficient evidence, leave `valuation_pending` and `payment_reorg` set. + +4. **Apply the reviewed repair atomically.** Pause all application/indexer/payment-review writers and maintenance jobs, take a backup, then use one transaction with the same lock order as `withIndexLock`: the `launch-index:base` advisory lock, the raw-event table locks, the Base cursor row, and the affected application row. Confirm recovery version 1, `rebuilding_until IS NULL`, and the reviewed recovery generation/checkpoint. Recheck the canonical receipt/block before committing; abort if identity, placement or recovery generation changed. Within that transaction: + - Update only the verified current burn's `usd` and `priced_by` (`quote` or `live` according to the evidence), and set its `valuation_pending` to false. Match the complete reviewed row identity and prior values, and require exactly one affected row. Retain all archive evidence. + - Recompute the application's `paid_usd` from `COALESCE(sum(usd), 0)` over its **current** `bb_pair_burns`, never from the archive or the previous aggregate. Check every remaining `valuation_pending` row for the application. + - Clear `payment_reorg` only if no pending valuations remain and the current total meets `fee_usd * (1 - PAIR_FEE_TOLERANCE)` (currently 0.98 of the fee). Otherwise keep it true. A verified but insufficient payment does not authorize another burn or an automatic waiver. + - Apply the payment-state transitions used by `settleApplication`/rollback: an `open` application meeting the fee may become `paid`, setting `paid_at` if absent; a `paid` application falling below the fee becomes `open` with `paid_at = NULL`. Preserve authored `changes`, `approved`, `rejected` and `expired` decisions, review counts and decision metadata. Normal review APIs still enforce their existing eligibility checks. + - Using the original application status captured under the row lock, clear an open application's consumed `quote_twig`, `quote_price_usd`, `quote_at` and `quote_until`, before applying any open-to-paid transition, as `valueBurn` does, while preserving `last_quote_until`. Record the before/after rows and evidence in the operator's change record. Roll back the entire transaction on any failed check. + +5. **Verify before resuming normal operation.** Read the repaired burn, the recomputed total, the remaining pending valuations and the application's flags/status in the transaction, then verify them again after commit. Resume the current writer version and confirm the application page no longer reports reconciliation only when the above eligibility conditions hold. Approval/listing still goes through the normal signed review flow; this procedure never directly lists a pair. Do not simply clear `valuation_pending` and wait for the background repricer: that would value the payment with a new price, and `revaluePairBurns` intentionally excludes pending valuations. + +If the payment was not re-included, or its historical price cannot be verified, this procedure has no successful repair branch. Keep the application blocked with its original transaction evidence for an explicit maintainer resolution; do not erase the reconciliation flags to manufacture eligibility. + ## Reproducible verification | Check | Result | From 8b31a63b5af3792364c55362e9e6dfd0c4b69ab2 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Thu, 12 Nov 2026 23:42:49 +0530 Subject: [PATCH 4/4] fix: verify replacement boosts and use database quota time Retain changed replacement hashes until their receipt and actual qualifying TWIG burns are resolved. Keep posting-gap checks on the database clock, and preserve unrelated historical payment quotes during reconciliation. --- app/src/components/launchpad/BoostDialog.tsx | 77 ++++++---- app/src/components/launchpad/boost-ui.test.ts | 8 +- .../launchpad/transaction-safety.test.ts | 135 +++++++++++++++--- app/src/lib/launchpad/boost-receipt.test.ts | 36 +++++ app/src/lib/launchpad/boost-receipt.ts | 23 +++ app/src/lib/launchpad/postsServer.db.test.ts | 19 ++- app/src/lib/launchpad/postsServer.ts | 9 +- .../lib/launchpad/transaction-receipt.test.ts | 11 ++ app/src/lib/launchpad/transaction-receipt.ts | 3 +- docs/security/2026-11-12-pr-split.md | 9 ++ docs/security/2026-11-12-reorg-recovery.md | 2 +- 11 files changed, 278 insertions(+), 54 deletions(-) create mode 100644 app/src/lib/launchpad/boost-receipt.test.ts create mode 100644 app/src/lib/launchpad/boost-receipt.ts diff --git a/app/src/components/launchpad/BoostDialog.tsx b/app/src/components/launchpad/BoostDialog.tsx index dcb9eafa..afaf127c 100644 --- a/app/src/components/launchpad/BoostDialog.tsx +++ b/app/src/components/launchpad/BoostDialog.tsx @@ -2,12 +2,13 @@ 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"; @@ -15,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"; @@ -30,6 +31,7 @@ import { boostAmountProblem, boostConfirmLabel, boostShowsNow, + boostWholeOf, fmtBoostTwig, type LaneEntry, groupWhole, @@ -42,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]; @@ -103,24 +105,29 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur let hash = await wallet.writeContract(request); onPending({ hash, whole }); setPhase({ k: "sent", hash, whole }); + let replaced = false; let result; try { 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; } - if (result.changed) { - onPending(null); - setPhase({ k: "error", message: "This boost was replaced by a different transaction. Review its outcome in your wallet before boosting again." }); - return; + 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." }); } - await settle(hash, whole, result.receipt.status === "success", showsAfter(whole)); } catch (err) { setPhase({ k: "error", message: friendlyError(err) }); } finally { @@ -129,35 +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) { + async function settle(hash: Hex, whole: bigint, ok: boolean, shows: boolean, raw?: bigint, replaced = false) { if (!ok) { onPending(null); - setPhase({ k: "error", message: "The transfer reverted on-chain. No TWIG was sent; you can try again." }); + 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 const synced = await syncTransaction(BOOST_CHAIN, hash); if (synced.status !== "applied") { - setPhase({ k: "unconfirmed", hash, whole, checking: false, note: INDEX_PENDING_COPY }); + setPhase({ k: "unconfirmed", hash, whole, replaced, checking: false, note: INDEX_PENDING_COPY }); return; } onPending(null); - setPhase({ k: "done", hash, whole, shows }); + 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 }); - if (rc.transactionHash.toLowerCase() !== hash.toLowerCase()) throw new Error("Receipt hash mismatch"); - 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." }); } } @@ -166,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
@@ -250,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}
@@ -258,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/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index 2c9f8d33..34fd4d3e 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -77,7 +77,7 @@ test("sent means sent: an unread receipt locks the dialog instead of inviting a 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("waitForTrackedReceipt")); - 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.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 @@ -187,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|result\.receipt)\.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 @@ -408,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/transaction-safety.test.ts b/app/src/components/launchpad/transaction-safety.test.ts index e52d3fd5..05ae7516 100644 --- a/app/src/components/launchpad/transaction-safety.test.ts +++ b/app/src/components/launchpad/transaction-safety.test.ts @@ -5,6 +5,10 @@ import { runInNewContext } from "node:vm"; import ts from "typescript"; import * as launchJournal from "../../lib/launchpad/pending-launch"; import { waitForTrackedReceipt } from "../../lib/launchpad/transaction-receipt"; +import { encodeAbiParameters, encodeEventTopics, type TransactionReceipt } from "viem"; +import { ERC20_TRANSFER_EVENT } from "../../lib/launchpad/abi"; +import { BOOST_TOKEN, BOOST_DEAD, boostAmount, boostWholeOf, boostShowsNow } from "../../lib/launchpad/boost"; +import { boostReceiptAmount } from "../../lib/launchpad/boost-receipt"; /** * Execute the actual component handlers with inert dependencies. This avoids a @@ -256,31 +260,124 @@ test("a later launch revert preserves its independent first-buy reference", asyn assert.equal(h.sends(), 1); }); -for (const reason of ["repriced", "cancelled", "replaced", "invalid"]) test(`actual boost handler handles ${reason} receipt without syncing the original hash`, async () => { - const pending: unknown[] = []; - const phases: { k: string }[] = []; - const settled: unknown[][] = []; +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 }, transactionReceipt: { transactionHash: secondHash } }); - return { status: "success", transactionHash: secondHash, logs: [] }; + 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 send = handler("./BoostDialog.tsx", "send", { - address: launcher, raw: 100n, whole: 100n, busy: false, lock: { current: false }, chainId: 8453, BASE: { id: 8453 }, config: {}, - getPublicClient: () => pub, getWalletClient: async () => ({ writeContract: async () => launchHash }), waitForTrackedReceipt, - BOOST_TOKEN: launcher, ERC20_TRANSFER_ABI: [], DEAD: launcher, BUILDER_DATA_SUFFIX: "0x", setPhase: (p: { k: string }) => phases.push(p), - onPending: (value: unknown) => pending.push(value), showsAfter: () => true, settle: async (...args: unknown[]) => { settled.push(args); }, friendlyError: (error: Error) => error.message, - }); - await send(); - if (reason === "repriced") { assert.equal(settled[0]?.[0], secondHash); assert.deepEqual({ ...pending.at(-1) as object }, { hash: secondHash, whole: 100n }); } - else { - assert.deepEqual(settled, []); - assert.equal(phases.at(-1)?.k, reason === "invalid" ? "unconfirmed" : "error"); - if (reason === "invalid") assert.deepEqual({ ...pending.at(-1) as object }, { hash: launchHash, whole: 100n }); - else assert.equal(pending.at(-1), null); + 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/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/postsServer.db.test.ts b/app/src/lib/launchpad/postsServer.db.test.ts index 8b531b30..14959ac1 100644 --- a/app/src/lib/launchpad/postsServer.db.test.ts +++ b/app/src/lib/launchpad/postsServer.db.test.ts @@ -94,6 +94,21 @@ test("posting limits and nonce consumption stay atomic across PostgreSQL connect assert.equal((await submit(await payload())).status, 429); assert.equal(await postCount(), 1); }); + for (const skewMs of [-60_000, 60_000]) await t.test(`posting gaps use database time when the app clock is skewed by ${skewMs}ms`, async (t) => { + await clear(); + const realNow = Date.now; + t.mock.method(Date, "now", () => realNow() + skewMs); + assert.equal((await submit(await payload())).status, 200); + const immediate = await submit(await payload()); + assert.equal(immediate.status, 429, "an app clock ahead of PostgreSQL must not bypass the posting gap"); + assert.match((await immediate.json()).error, /^wait \d+s between posts$/); + assert.equal(await postCount(), 1); + assert.equal(await nonceCount(), 1); + await sql`UPDATE bb_posts SET created_at = clock_timestamp() - interval '21 seconds'`; + assert.equal((await submit(await payload())).status, 200, "an app clock behind PostgreSQL must not extend the posting gap"); + assert.equal(await postCount(), 2); + assert.equal(await nonceCount(), 2); + }); await t.test("a post queued beyond the gap is timestamped after its wallet lock is released", { timeout: 45_000 }, async () => { await clear(); const locker = await sql.reserve(); @@ -126,8 +141,10 @@ test("posting limits and nonce consumption stay atomic across PostgreSQL connect await queued?.catch(() => {}); } }); - await t.test("distinct signed nonces cannot bypass the gap concurrently", async () => { + for (const skewMs of [0, -60_000, 60_000]) await t.test(`distinct signed nonces cannot bypass the gap concurrently with app clock skew ${skewMs}ms`, async (t) => { await clear(); + const realNow = Date.now; + t.mock.method(Date, "now", () => realNow() + skewMs); barrier(2); const inputs = await Promise.all([payload(), payload()]); const responses = await Promise.all(inputs.map(submit)); diff --git a/app/src/lib/launchpad/postsServer.ts b/app/src/lib/launchpad/postsServer.ts index 95872e5c..bff23454 100644 --- a/app/src/lib/launchpad/postsServer.ts +++ b/app/src/lib/launchpad/postsServer.ts @@ -144,8 +144,9 @@ export async function createPost(p: { chain: ChainKey; token: string; wallet: st // policy before crypto (cheap first) const [st] = await db<{ comments_muted: boolean }[]>`SELECT comments_muted FROM bb_token_settings WHERE chain_id = ${cid} AND token = ${token}`; if (st?.comments_muted) return fail("comments are off for this token", 403); - const [rate] = await db<{ last: string | null; n24: bigint }[]>`SELECT max(created_at) AS last, count(*) FILTER (WHERE created_at > now() - interval '24 hours')::bigint AS n24 FROM bb_posts WHERE wallet = ${wallet}`; - const gate = canPostNow(rate.last ? new Date(rate.last).getTime() : null, Number(rate.n24), now); + // Stored post times and both gap checks use the database clock, regardless of app-host clock skew. + const [rate] = await db<{ last: string | null; n24: bigint; db_now: string }[]>`SELECT max(created_at) AS last, count(*) FILTER (WHERE created_at > clock_timestamp() - interval '24 hours')::bigint AS n24, clock_timestamp() AS db_now FROM bb_posts WHERE wallet = ${wallet}`; + const gate = canPostNow(rate.last ? new Date(rate.last).getTime() : null, Number(rate.n24), new Date(rate.db_now).getTime()); if (!gate.ok) return fail(gate.error, 429); if (parentId !== null) { const [parent] = await db<{ id: bigint; parent_id: bigint | null }[]>`SELECT id, parent_id FROM bb_posts WHERE id = ${parentId} AND chain_id = ${cid} AND token = ${token} AND NOT hidden`; @@ -163,8 +164,8 @@ export async function createPost(p: { chain: ChainKey; token: string; wallet: st // Distinct signatures can pass the cheap preflight together. Serialize the final // policy read and write for this wallet across every server process and chain. await sql`SELECT pg_advisory_xact_lock(hashtext(${`post:${wallet}`}))`; - const [current] = await sql<{ last: string | null; n24: bigint }[]>`SELECT max(created_at) AS last, count(*) FILTER (WHERE created_at > clock_timestamp() - interval '24 hours')::bigint AS n24 FROM bb_posts WHERE wallet = ${wallet}`; - const finalGate = canPostNow(current.last ? new Date(current.last).getTime() : null, Number(current.n24), Date.now()); + const [current] = await sql<{ last: string | null; n24: bigint; db_now: string }[]>`SELECT max(created_at) AS last, count(*) FILTER (WHERE created_at > clock_timestamp() - interval '24 hours')::bigint AS n24, clock_timestamp() AS db_now FROM bb_posts WHERE wallet = ${wallet}`; + const finalGate = canPostNow(current.last ? new Date(current.last).getTime() : null, Number(current.n24), new Date(current.db_now).getTime()); if (!finalGate.ok) return fail(finalGate.error, 429); // A refused quota attempt spends no nonce; a failed INSERT rolls its nonce back. const used = await sql`INSERT INTO bb_post_nonces (nonce, wallet) VALUES (${nonce}, ${wallet}) ON CONFLICT (nonce) DO NOTHING RETURNING nonce`; diff --git a/app/src/lib/launchpad/transaction-receipt.test.ts b/app/src/lib/launchpad/transaction-receipt.test.ts index 47968cf6..61b312a7 100644 --- a/app/src/lib/launchpad/transaction-receipt.test.ts +++ b/app/src/lib/launchpad/transaction-receipt.test.ts @@ -46,3 +46,14 @@ test("a timeout after a verified repricing has already retained the replacement await assert.rejects(waitForTrackedReceipt(pub, hash, (h) => retained.push(h)), /timeout/); assert.deepEqual(retained, [replacement]); }); + +test("changed replacement tracking is opt-in, identity-bound, and survives a receipt timeout", async () => { + const retained: string[] = []; + const changedEvent = { ...event, reason: "replaced", transaction: { ...next, input: "0xffff" } }; + const pub = { waitForTransactionReceipt: async ({ onReplaced }: { onReplaced: (event: object) => void }) => { onReplaced(changedEvent); throw Error("timeout"); } } as unknown as Pick; + await assert.rejects(waitForTrackedReceipt(pub, hash, () => assert.fail("not a repricing"), (h) => retained.push(h)), /timeout/); + assert.deepEqual(retained, [replacement]); + for (const transaction of [{ ...changedEvent.transaction, nonce: 8 }, { ...changedEvent.transaction, from: `0x${"34".repeat(20)}` }]) { + await assert.rejects(waitForTrackedReceipt(reader({ ...changedEvent, transaction }), hash, () => assert.fail(), () => assert.fail("unverified identity")), /could not be matched/); + } +}); diff --git a/app/src/lib/launchpad/transaction-receipt.ts b/app/src/lib/launchpad/transaction-receipt.ts index 0693c201..399e1b1d 100644 --- a/app/src/lib/launchpad/transaction-receipt.ts +++ b/app/src/lib/launchpad/transaction-receipt.ts @@ -1,7 +1,7 @@ import type { Hex, PublicClient } from "viem"; /** Adopt only an exact same-sender/nonce repricing; changed calls are a separate outcome. */ -export async function waitForTrackedReceipt(pub: Pick, hash: Hex, onHash: (hash: Hex) => void) { +export async function waitForTrackedReceipt(pub: Pick, hash: Hex, onHash: (hash: Hex) => void, onChanged?: (hash: Hex) => void) { let expected = hash; let changed = false; let invalid = false; @@ -18,6 +18,7 @@ export async function waitForTrackedReceipt(pub: Pick