Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions app/src/lib/launchpad/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ export async function pollAll(): Promise<Record<string, LaunchSyncResult>> {
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)));
}),
);
Expand Down Expand Up @@ -844,6 +845,63 @@ export async function healSwapTraders(chain: ChainKey): Promise<number> {
return healed;
}

const appTagIdleUntil = new Map<ChainKey, number>();
/**
* 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<number> {
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<Unknown[]>`
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`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (rows.length === 0) {
appTagIdleUntil.set(chain, Date.now() + ATTRIBUTION_IDLE_MS);
return 0;
}
const byTx = new Map<string, Unknown[]>();
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<LaunchSyncResult> {
const reason = skipReason(chain);
if (reason) return { status: "skipped", reason };
Expand Down
31 changes: 31 additions & 0 deletions app/src/lib/points/score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof trade>[] = []) =>
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");
});
23 changes: 12 additions & 11 deletions app/src/lib/points/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
/**
* (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. */
Expand Down Expand Up @@ -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<string, { time: number; block: number }[]>();
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 });
Expand All @@ -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. */
Expand Down
8 changes: 6 additions & 2 deletions app/src/lib/points/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { cid: number; token: string }>();
for (const s of swapRows) scope.set(keyOf(s.chain_id, s.token), { cid: s.chain_id, token: s.token });
Expand Down Expand Up @@ -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()];
Expand Down
Loading