From a121ff889e53b410204bc929935be6bc7b7351eb Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 11 Nov 2026 13:35:25 +0800 Subject: [PATCH 1/3] fix(points): address CodeRabbit's re-review of #140 - App tags: healAppTags revisits the last day's settled swaps whose app tag is still unknown. That happens when the transaction could not be read when the swap was credited, and again throughout the poll overlap. It fills via_app for the same credited trader. It reads up to 20 transactions a poll; a failed read rests ten minutes, and an empty pass rests five. It runs after the attribution pass. Checked on a throwaway Postgres: in-app and plain swaps are filled; unread, older-than-a-day, pending and already-known swaps are left alone. - Vesting boundary: the moment a week is up maps to the block of the last trade at or before it, not an interpolation that turned into "every block so far" when no trade followed. A sell (a trade) always falls on its exact side. A bare transfer with no later trade to place it counts as after the week, in the holder's favour. New test: a transfer out after the week with no later trade keeps the vesting (it failed before); one placed before the week by a later trade does not. --- app/src/lib/launchpad/indexer.ts | 50 ++++++++++++++++++++++++++++++++ app/src/lib/points/score.test.ts | 16 ++++++++++ app/src/lib/points/score.ts | 16 ++++------ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index df3d978d..a1a833e5 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -713,6 +713,7 @@ export async function pollAll(): Promise> { 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))); await attributeBacklog(c).catch((e) => console.warn(`[launch-sync] attribute swaps ${c}:`, errMessage(e))); + await healAppTags(c).catch((e) => console.warn(`[launch-sync] app tags ${c}:`, errMessage(e))); await backfillHolders(c).catch((e) => console.warn(`[launch-sync] holders backfill ${c}:`, errMessage(e))); }), ); @@ -844,6 +845,55 @@ export async function healSwapTraders(chain: ChainKey): Promise { return healed; } +const appTagIdleUntil = new Map(); +/** + * Fill the app tag (via_app) of the last day's swaps whose transaction could not be read when they were credited + * (applyLaunchTx reads it once beside the receipt; a log poll re-reads it only while the swap is in the overlap). Only + * settled swaps (credited to their sender or smart wallet); the update keeps the same credited trader. Up to 20 + * transaction reads a poll; one that fails rests ten minutes; a pass that finds nothing rests five minutes. Swaps + * 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 (Date.now() < (appTagIdleUntil.get(chain) ?? 0)) return 0; + const cid = chainIdOf(chain); + type Unknown = { tx_hash: string; log_index: number; trader: string; trader_via: string }; + const rows = await db` + SELECT tx_hash, log_index, trader, trader_via FROM bb_launch_swaps + WHERE block_time > now() - interval '1 day' AND chain_id = ${cid} AND via_app IS NULL AND trader IS NOT NULL AND trader_via IN ('tx_from', 'userop') + ORDER BY block_time DESC LIMIT 100`; + if (rows.length === 0) { + appTagIdleUntil.set(chain, Date.now() + ATTRIBUTION_IDLE_MS); + return 0; + } + const byTx = new Map(); + for (const r of rows) byTx.set(r.tx_hash, [...(byTx.get(r.tx_hash) ?? []), r]); + let tagged = 0; + let read = 0; + for (const [hash, swaps] of byTx) { + if (read >= 20) break; + const key = `app:${chain}:${hash}`; + if (Date.now() < (healRestUntil.get(key) ?? 0)) continue; + read++; + const tx = await txOf(chain, hash as Hex); + if (!tx || tx.tag === null) { + if (healRestUntil.size > 5_000) healRestUntil.clear(); + healRestUntil.set(key, Date.now() + ATTRIBUTION_TRY_GAP_MS); + continue; + } + healRestUntil.delete(key); + for (const r of swaps) { + const app = swapInApp(tx.tag, r.trader, r.trader_via); + if (app === null) continue; + const up = await db`UPDATE bb_launch_swaps SET via_app = ${app} WHERE chain_id = ${cid} AND tx_hash = ${r.tx_hash} AND log_index = ${r.log_index} AND via_app IS NULL AND trader = ${r.trader} RETURNING tx_hash`; + tagged += up.length; + } + } + if (tagged) console.log(`[launch-sync] filled the app tag on ${tagged} swap(s) on ${chain}`); + return tagged; +} + async function run(chain: ChainKey): Promise { const reason = skipReason(chain); if (reason) return { status: "skipped", reason }; diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts index 0b3a39ca..b2fe7487 100644 --- a/app/src/lib/points/score.test.ts +++ b/app/src/lib/points/score.test.ts @@ -547,3 +547,19 @@ test("v2 creator volume: round trips count for nothing; a sell of a week-old buy assert.equal(c.creatorWhy.volumeUsd, 6 * 2_000 + 51 * 6, "6 traders × ($1,000 held buy + $1,000 sell of a week-old buy) + the holders'"); assert.equal(c.creatorWhy.volumePts, 100); }); + +test("v2 vesting boundary: a bare transfer after the week keeps the vesting, even with no trade after it; one before it does not", () => { + const buy = trade(w(1), START + DAY + 3_600_000, 0.5, 30_000); // an eligible trader, $10 fee + const eligible = new Set([...gateEligible, w(1)]); + const out = (block: number): Move => ({ key: L.key, wallet: w(1), block, logIndex: 1, tx: `0xout${block}`, delta: -buy.move.delta }); + const vested = (moves: Move[], extra: ReturnType[] = []) => + scoreSeason(build(gate, { swaps: [...gate.map((g) => g.swap), buy.swap, ...extra.map((x) => x.swap)], moves: [...gate.flatMap((g) => g.moves), buy.move, ...extra.map((x) => x.move), ...moves], eligible }), RULES_V2).wallets.get(CREATOR)!.creatorWhy.feesUsd; + const holdersOnly = Math.round(7 * 0.06 * 100) / 100; + // moved out in a block past every trade known (no trade after the week to place it): taken as after the week + assert.equal(vested([out(39_000)]), 10 + 7 * 0.06); + // moved out on day 2, and a later trade (day 3, another wallet) shows that block came before the week was up + const later = trade(w(2), START + 3 * DAY, 0.003, 32_000); + assert.equal(vested([out(31_000)], [later]), holdersOnly); + // a sell is a trade itself: sold on day 5, never vested + assert.equal(vested([], [trade(w(1), START + 5 * DAY, 0.5, 35_000, { isBuy: false })]), holdersOnly); +}); diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts index db573102..c34a0605 100644 --- a/app/src/lib/points/score.ts +++ b/app/src/lib/points/score.ts @@ -317,9 +317,11 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe for (const lot of lotsOfTx.get(`${key}|${w}|${tx}`) ?? []) left += lot.size - lot.outs.reduce((a, o) => (o.block <= block ? a + o.take : a), 0n); return left; }; - // (version 2) the last block of a moment, per chain, from the trades' own (time, block) pairs (transfers carry blocks, - // not times): interpolated between the trades on either side of it, which is exact on a chain with steady block times - // (Base: 2 s) and stays between those two trades on one without; after the last trade, every block so far + // (version 2) where a moment falls among the blocks, per chain, from the trades' own exact (time, block) pairs + // (transfers carry blocks, not times): the block of the last trade at or before the moment. A trade is never placed + // on the wrong side of it (a sell after the moment is in a later block), and no moment ever reaches past the trades + // known. A bare transfer between that trade and the next is taken as after the moment: in the holder's favour, which + // cannot be farmed (the tokens moved out and still unsold sit in the token as long as they would have) const timeline = new Map(); for (const t of [...input.swaps, ...input.firstBuys]) { const c = t.key.split(":")[0]; @@ -337,13 +339,7 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe if (list[mid].time <= ms) lo = mid + 1; else hi = mid; } - if (lo >= list.length) return Number.POSITIVE_INFINITY; // no trade after the moment: everything so far - const after = list[lo]; - if (lo === 0) return after.block - 1; // before the first trade known - const before = list[lo - 1]; - if (after.time === before.time || after.block <= before.block) return before.block; - const guess = before.block + Math.floor(((ms - before.time) / (after.time - before.time)) * (after.block - before.block)); - return Math.min(after.block - 1, Math.max(before.block, guess)); + return lo === 0 ? Number.NEGATIVE_INFINITY : list[lo - 1].block; // before every trade known: no outflow yet }; /** The wallet received at least half of what a buy bought, in that buy's own transaction. */ From a84228e7634b1aeb44aba815ff266d94ddf5cc0f Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 11 Nov 2026 13:48:04 +0800 Subject: [PATCH 2/3] fix(points): unreadable transactions no longer block the app-tag heal CodeRabbit (#142): healAppTags took the newest 100 untagged swaps. If those all belonged to transactions resting after a failed read, every pass picked the same rows. Older swaps were never reached, and lost their in-app tag once a day old. Transactions still resting are now left out of the query, so each pass reaches the swaps behind them. Checked on a throwaway Postgres, with 120 unreadable newest swaps and one older in-app swap: before the fix, 8 passes left it untagged; after, it is tagged. The earlier heal checks are unchanged. --- app/src/lib/launchpad/indexer.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index a1a833e5..e13ec073 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -850,7 +850,8 @@ const appTagIdleUntil = new Map(); * Fill the app tag (via_app) of the last day's swaps whose transaction could not be read when they were credited * (applyLaunchTx reads it once beside the receipt; a log poll re-reads it only while the swap is in the overlap). Only * settled swaps (credited to their sender or smart wallet); the update keeps the same credited trader. Up to 20 - * transaction reads a poll; one that fails rests ten minutes; a pass that finds nothing rests five minutes. Swaps + * transaction reads a poll; one that fails rests ten minutes (left out of the query meanwhile, so the swaps behind it + * are reached); a pass that finds nothing rests five minutes. Swaps * 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 { @@ -858,10 +859,17 @@ export async function healAppTags(chain: ChainKey): Promise { if (!db || skipReason(chain)) return 0; 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 + // never fills every batch and keeps older swaps from their tag until they pass the day + const prefix = `app:${chain}:`; + const now = Date.now(); + const resting: string[] = []; + for (const [k, until] of healRestUntil) if (until > now && k.startsWith(prefix)) resting.push(k.slice(prefix.length)); type Unknown = { tx_hash: string; log_index: number; trader: string; trader_via: string }; const rows = await db` SELECT tx_hash, log_index, trader, trader_via FROM bb_launch_swaps WHERE block_time > now() - interval '1 day' AND chain_id = ${cid} AND via_app IS NULL AND trader IS NOT NULL AND trader_via IN ('tx_from', 'userop') + AND NOT (tx_hash = ANY(${resting}::text[])) ORDER BY block_time DESC LIMIT 100`; if (rows.length === 0) { appTagIdleUntil.set(chain, Date.now() + ATTRIBUTION_IDLE_MS); From 13fffce8a578c60c70cd20ed1a3c2fe7cf19508e Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 11 Nov 2026 13:58:59 +0800 Subject: [PATCH 3/3] fix(points): swaps not credited yet still place transfers for vesting CodeRabbit (#142): the vesting timeline was built only from swaps credited to a trader. So when the only trade that showed a bare transfer happened before the week was up was a swap whose sender had not been read yet, the buy still counted as vested. The compute now reads every swap of the season. Swaps credited to a trader are scored as before; the rest are passed as timelinePoints and only place transfers in time, never scored. New test: an early transfer placed only by an uncredited swap loses the vesting, and the uncredited swap scores no one. Re-checked the v1 and v2 compute runs on a throwaway Postgres, with an uncredited swap added: unchanged. --- app/src/lib/points/score.test.ts | 15 +++++++++++++++ app/src/lib/points/score.ts | 7 ++++++- app/src/lib/points/server.ts | 8 ++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts index b2fe7487..44db4b26 100644 --- a/app/src/lib/points/score.test.ts +++ b/app/src/lib/points/score.test.ts @@ -563,3 +563,18 @@ test("v2 vesting boundary: a bare transfer after the week keeps the vesting, eve // a sell is a trade itself: sold on day 5, never vested assert.equal(vested([], [trade(w(1), START + 5 * DAY, 0.5, 35_000, { isBuy: false })]), holdersOnly); }); + +test("v2 vesting boundary: a swap not credited to anyone yet still places a transfer before the week (and is never scored)", () => { + const buy = trade(w(1), START + DAY + 3_600_000, 0.5, 30_000); // an eligible trader, $10 fee + const eligible = new Set([...gateEligible, w(1)]); + const out: Move = { key: L.key, wallet: w(1), block: 31_000, logIndex: 1, tx: "0xoutday2", delta: -buy.move.delta }; // moved out on day 2 + const run = (timelinePoints?: ScoreInput["timelinePoints"]) => + scoreSeason(build(gate, { swaps: [...gate.map((g) => g.swap), buy.swap], moves: [...gate.flatMap((g) => g.moves), buy.move, out], eligible, timelinePoints }), RULES_V2); + // nothing places the transfer: taken as after the week (in the holder's favour) + assert.equal(run().wallets.get(CREATOR)!.creatorWhy.feesUsd, 10 + 7 * 0.06); + // an uncredited swap on day 3 (block 32,000) shows the transfer came before the week was up + const r = run([{ key: L.key, block: 32_000, time: START + 3 * DAY }]); + assert.equal(r.wallets.get(CREATOR)!.creatorWhy.feesUsd, Math.round(7 * 0.06 * 100) / 100); + assert.equal(r.wallets.get(w(1))?.scout ?? 0, 0, "the buyer loses its own fee points too"); + assert.deepEqual([...r.wallets.keys()].filter((k) => !run().wallets.has(k)), [], "the uncredited swap scores no one"); +}); diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts index c34a0605..51f68672 100644 --- a/app/src/lib/points/score.ts +++ b/app/src/lib/points/score.ts @@ -202,6 +202,11 @@ export type ScoreInput = { * what tells a creator's in-season outflows from earlier ones. A chain missing here counts only swap sells. */ seasonStartBlocks: ReadonlyMap; + /** + * (version 2) more exact (time, block) points that place transfers in time for the week's vesting: the season's + * swaps not credited to anyone yet. Never scored as trades. + */ + timelinePoints?: readonly { key: string; block: number; time: number }[]; }; /** Version 2 adds `volumeUsd` / `volumePts` (creator) and `appFeesUsd` / `sellFeesUsd` (scout); older rows lack them. */ @@ -323,7 +328,7 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe // known. A bare transfer between that trade and the next is taken as after the moment: in the holder's favour, which // cannot be farmed (the tokens moved out and still unsold sit in the token as long as they would have) const timeline = new Map(); - for (const t of [...input.swaps, ...input.firstBuys]) { + for (const t of [...input.swaps, ...input.firstBuys, ...(input.timelinePoints ?? [])]) { const c = t.key.split(":")[0]; const list = timeline.get(c) ?? []; list.push({ time: t.time, block: t.block }); diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts index 329cfbfb..e30d5660 100644 --- a/app/src/lib/points/server.ts +++ b/app/src/lib/points/server.ts @@ -230,9 +230,12 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis const price = await quotePricer(usd); // the season's swaps decide which tokens are in scope - const swapRows = await read<{ chain_id: number; token: string; trader: string; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; log_index: number; tx_hash: string; t: string; usd: number | null; app: boolean }[]>` + // every swap of the season: those credited to a trader are scored; the rest (a sender not read yet) only place + // transfers in time for the week's vesting (score.timelinePoints) + const seasonSwapRows = await read<{ chain_id: number; token: string; trader: string | null; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; log_index: number; tx_hash: string; t: string; usd: number | null; app: boolean }[]>` SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, tx_hash, block_time AS t, usd, via_app IS TRUE AS app - FROM bb_launch_swaps WHERE block_time >= ${season.starts_at} AND block_time < ${until} AND trader IS NOT NULL`; + FROM bb_launch_swaps WHERE block_time >= ${season.starts_at} AND block_time < ${until}`; + const swapRows = seasonSwapRows.filter((s): s is typeof s & { trader: string } => s.trader !== null); const keyOf = (cid: number, token: string) => `${chainKeyOf(cid) ?? cid}:${token}`; const scope = new Map(); for (const s of swapRows) scope.set(keyOf(s.chain_id, s.token), { cid: s.chain_id, token: s.token }); @@ -357,6 +360,7 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis eligible, flagged, seasonStartBlocks, + timelinePoints: seasonSwapRows.filter((s) => s.trader === null).map((s) => ({ key: keyOf(s.chain_id, s.token), block: Number(s.block_number), time: new Date(s.t).getTime() })), }, rulesFor(season.rules_version)); const scores = [...result.wallets.values()];