diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index df3d978d..e13ec073 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,63 @@ 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 (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 { + const db = maybeDb(); + 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); + 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..44db4b26 100644 --- a/app/src/lib/points/score.test.ts +++ b/app/src/lib/points/score.test.ts @@ -547,3 +547,34 @@ 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); +}); + +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 db573102..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. */ @@ -317,11 +322,13 @@ 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]) { + 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 }); @@ -337,13 +344,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. */ 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()];