diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index 50b7b254..7ceaca01 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -13,7 +13,7 @@ import LaunchMachine from "@/components/launchpad/LaunchMachine"; import { ChainLogo } from "@/components/launchpad/ChainLogo"; import BoostRulesEntry from "@/components/launchpad/BoostRulesEntry"; import { boostEnabled } from "@/lib/launchpad/boostServer"; -import { RULES, RULES_V2 } from "@/lib/points/score"; +import { RULES, RULES_V2, eligibleNeededFor } from "@/lib/points/score"; /** The trading bonuses of the latest rules (seasons started on version 2 or later). */ const BONUS = RULES_V2.bonus!; @@ -132,7 +132,7 @@ export default function RulesPage() {
What counts as a real buyer?

A wallet whose first buy of a token was worth ${RULES.minTradeUsd} or more when it was made, after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. The wallet must receive the tokens of that buy itself (a relayer or router that only passed them on is not a buyer). Never the launcher, a fee recipient, a wallet that got ${RULES.linkMinUsd} or more of the token from either before its first buy, or a wallet kept off points. A real holder is a real buyer with ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below) of that first buy still in the wallet, valued at the token's steady price (the middle of its last trade price now, 12 hours ago and 24 hours ago, so a last-minute pump or dump moves nothing).

Where a rule needs a number of holders, every holder with a qualifying profile counts, and holders without one count up to twice as many as those (at least {RULES.unverifiedFree}). The same limit applies to what a creator is paid for holders without a qualifying profile.

Holding is judged per buy: whenever tokens leave a wallet (sold, or moved to any other wallet), the most recently received ones go first. So selling a later buy does not touch an earlier one, tokens sent in from another wallet never stand in for a buy, and moving a position out counts the same as selling it. Only tokens priced in a quote we can price count.

How do creators earn?

On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.

Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 for each buy that is still at least half held (a buy sold straight back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold or moved tokens out during the season (to any wallet, including one of your own; burning them is fine) and less than half of your own buys of it is left.

-
How do traders earn?

Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} counted holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.

Every token with {RULES.holdMinHolders}+ counted holders that you first buy this season, with a real first buy, and still half-hold a day later (that buy itself, not a later one; a token you already held before the season does not count): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ counted holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.

+
How do traders earn?

Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} counted holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.

Every token with {RULES.holdMinHolders}+ counted holders that you first buy this season, with a real first buy, and still half-hold a day later (that buy itself, not a later one; a token you already held before the season does not count): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ counted holders (which takes at least {Math.max(RULES.scoutFeeMinEligibleHolders, eligibleNeededFor(RULES.scoutFeeMinHolders))} with a qualifying profile), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.

Trading bonuses

Seasons that include these say so on the leaderboard (a season keeps the rules it started with). Trading in the app: a buy you make on openlaunch, held as above, earns {BONUS.appFeePointsPerUsd} points per $1 of fees instead of {RULES.scoutFeePointsPerUsd}. Selling in the app tokens you bought this season a week or more earlier earns {BONUS.sellFeePointsPerUsd} points per $1 of fees on that part of the sale. In one day, other buys earn up to {RULES.scoutFeeCapPerDay} and trades in the app top that up to {BONUS.scoutFeeCapPerDayApp}. A buy you held at least half of for a week keeps its fee points (yours and the creator's) after you sell it.

Volume milestones for creators: a token's trades this season of ${RULES.minTradeUsd} or more with money at stake (buys still held, or held a week; sells of buys made a week or more before, so buying and selling straight back counts for nothing), counting each trader for at most ${n(BONUS.volumeTraderCapUsd)} a day across all your tokens (${BONUS.volumeUnverifiedTraderCapUsd} without a qualifying profile). Reaching {BONUS.volumeTiers.map((t) => `$${n(t.usd)}`).join(", ")} of that, with {BONUS.volumeTiers.map((t) => t.traders).join(", ")} different traders with a qualifying profile, adds {BONUS.volumeTiers.map((t) => n(t.pts)).join(", ")} points ({n(BONUS.volumeTiers.reduce((a, t) => a + t.pts, 0))} in all), on a token with {BONUS.volumeMinHolders} counted holders that you did not dump.

Who appears on the leaderboard?

Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; while the season runs, they unlock for the whole season so far with the next points update after the profile qualifies (points update about every hour). The post itself earns nothing.

diff --git a/app/src/lib/launchpad/appTag.test.ts b/app/src/lib/launchpad/appTag.test.ts index b4c768d3..22980aaf 100644 --- a/app/src/lib/launchpad/appTag.test.ts +++ b/app/src/lib/launchpad/appTag.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { encodeFunctionData, parseAbi, type Hex } from "viem"; -import { HANDLE_OPS_ABI, appTagOf, endsWithSuffix, swapInApp } from "./appTag.ts"; +import { ACCOUNT_CALL_ABI, HANDLE_OPS_ABI, accountCalls, appTagOf, endsWithSuffix, swapInApp } from "./appTag.ts"; import { BUILDER_DATA_SUFFIX } from "../chainPublic.ts"; const ROUTER = "0x6ff5693b99212da76ad316178a184ab56d299b43"; @@ -52,6 +52,14 @@ test("a smart wallet's operation is in-app only for the account whose own callDa assert.equal(swapInApp(tag, "0x00000000000000000000000000000000000000cc", "tx_from"), false, "credited to the bundler"); } assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, plain)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] }); + // the suffix on an operation that calls something other than the router (a bot contract, an aggregator) is not in-app + const elsewhere = encodeFunctionData({ abi: smartExecute, args: [OTHER as Hex, 0n, (swapCall + tail) as Hex] }); + assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, elsewhere)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] }); + // nor one that only names the router inside its arguments, or a router call with the suffix buried in its arguments + const namesRouter = encodeFunctionData({ abi: smartExecute, args: [OTHER as Hex, 0n, (encodeFunctionData({ abi: parseAbi(["function go(address r, bytes d)"]), args: [ROUTER, swapCall] }) + tail) as Hex] }); + assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, namesRouter)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] }); + const buried = opCall(encodeFunctionData({ abi: execute, args: ["0x10", [`0x${tail}`], 1n] })); + assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, buried)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] }); assert.deepEqual(appTagOf({ to: EP06, input: "0xdeadbeef" + tail, router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] }, "not a bundle"); }); @@ -60,3 +68,15 @@ test("an unread transaction is unknown, never in-app", () => { assert.equal(swapInApp({ direct: true, senders: [] }, null, null), null); assert.equal(swapInApp({ direct: true, senders: [] }, ACCOUNT, "tx_from"), true); }); + +test("smart-account call formats: Coinbase Smart Wallet execute and executeBatch, SimpleAccount batches; others are not read", () => { + const callData = (swapCall + tail) as Hex; + const formats = [ + encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[0]], args: [ROUTER, 0n, callData] }), + encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[1]], args: [[{ target: OTHER as Hex, value: 0n, data: "0x" }, { target: ROUTER, value: 0n, data: callData }]] }), + encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[2]], args: [[OTHER as Hex, ROUTER], ["0x", callData]] }), + encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[3]], args: [[ROUTER], [0n], [callData]] }), + ]; + for (const cd of formats) assert.deepEqual(appTagOf({ to: EP07, input: bundle07([op07(ACCOUNT, cd as Hex)]), router: ROUTER, suffix: SUFFIX }).senders, [ACCOUNT], cd.slice(0, 10)); + assert.deepEqual(accountCalls("0xdeadbeef"), []); +}); diff --git a/app/src/lib/launchpad/appTag.ts b/app/src/lib/launchpad/appTag.ts index 82637011..60c545d9 100644 --- a/app/src/lib/launchpad/appTag.ts +++ b/app/src/lib/launchpad/appTag.ts @@ -6,23 +6,18 @@ * - the transaction was sent to the chain's Universal Router and its input ends with the suffix (a wallet sending * its own transaction), or with the suffix followed only by another ERC-8021 suffix (a wallet that appends a * builder code of its own); the same bytes inside the swap's arguments do not count, or - * - it was sent to an ERC-4337 EntryPoint, and the user operation of the account the swap is credited to carries - * the suffix in its callData (a smart wallet: the app's call, suffix and all, sits inside the operation; ABI - * encoding pads that call, so there the suffix is never last and is looked for anywhere in the callData). + * - it was sent to an ERC-4337 EntryPoint, and the user operation of the account the swap is credited to makes a + * call TO the Universal Router whose data ends with the suffix, read by decoding the account's call + * (execute(address,uint256,bytes) of Coinbase Smart Wallet and SimpleAccount, or one of their executeBatch forms). + * An operation calling a bot contract or an aggregator, one that only names the router in its arguments, or a + * smart account whose call format is not one of those, does not count. * Anything else (an aggregator, a bot contract, another front end's smart-wallet trade) is not in-app. * * The suffix is public, so anyone can copy it: this is a label for a small, capped points bonus, never proof. It is * worked out once per transaction and only its result is kept (the input itself can be kilobytes). */ import { decodeFunctionData, parseAbi, type Hex } from "viem"; - -/** ERC-4337 EntryPoints (same address on every chain): v0.6, v0.7, v0.8, v0.9. Lowercase; kept equal to attribution.ts. */ -const ENTRY_POINTS: ReadonlySet = new Set([ - "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", - "0x0000000071727de22e5e9d8baf0edac6f37da032", - "0x4337084d9e255ff0702461cf8895ce9e3b5ff108", - "0x433709009b8330fda32311df1c2afa402ed8d009", -]); +import { ENTRY_POINTS } from "./attribution.ts"; /** handleOps for v0.6 (UserOperation) and v0.7+ (PackedUserOperation): different tuples, so different selectors. */ export const HANDLE_OPS_ABI = parseAbi([ @@ -30,6 +25,29 @@ export const HANDLE_OPS_ABI = parseAbi([ "function handleOps((address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,bytes signature)[] ops, address beneficiary)", ]); +/** The smart-account call formats read for the app tag (Coinbase Smart Wallet; SimpleAccount v0.6 and v0.7). */ +export const ACCOUNT_CALL_ABI = parseAbi([ + "function execute(address target, uint256 value, bytes data)", + "function executeBatch((address target, uint256 value, bytes data)[] calls)", + "function executeBatch(address[] dest, bytes[] func)", + "function executeBatch(address[] dest, uint256[] value, bytes[] func)", +]); + +/** The (target, data) calls a smart account's operation makes, or [] for a call format not read here. */ +export function accountCalls(callData: string): { target: string; data: string }[] { + try { + const d = decodeFunctionData({ abi: ACCOUNT_CALL_ABI, data: callData as Hex }); + if (d.functionName === "execute") return [{ target: (d.args[0] as string).toLowerCase(), data: (d.args[2] as string).toLowerCase() }]; + const a = d.args as readonly unknown[]; + if (a.length === 1) return (a[0] as readonly { target: string; data: string }[]).map((c) => ({ target: c.target.toLowerCase(), data: c.data.toLowerCase() })); + const targets = a[0] as readonly string[]; + const datas = (a.length === 2 ? a[1] : a[2]) as readonly string[]; + return targets.map((t, i) => ({ target: t.toLowerCase(), data: (datas[i] ?? "0x").toLowerCase() })); + } catch { + return []; + } +} + /** What one transaction says about the app: `direct` for a wallet's own router call, `senders` for smart wallets. */ export type AppTag = { direct: boolean; senders: readonly string[] }; @@ -64,7 +82,9 @@ export function appTagOf(p: { to: string | null; input: string; router: string; if (!ENTRY_POINTS.has(to) || !input.includes(needle)) return NONE; // cheap: no suffix anywhere, no operation has it try { const { args } = decodeFunctionData({ abi: HANDLE_OPS_ABI, data: p.input as Hex }); - const senders = args[0].filter((op) => op.callData.toLowerCase().includes(needle)).map((op) => op.sender.toLowerCase()); + const router = p.router.toLowerCase(); + // the same call must go to the router and carry the suffix at its end, as a wallet's own router call would + const senders = args[0].filter((op) => accountCalls(op.callData).some((c) => c.target === router && endsWithSuffix(c.data, needle))).map((op) => op.sender.toLowerCase()); return { direct: false, senders: [...new Set(senders)] }; } catch { return NONE; // handleAggregatedOps, or an input that is not a bundle: not tagged diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index e13ec073..c90cb461 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -882,6 +882,7 @@ export async function healAppTags(chain: ChainKey): Promise { for (const [hash, swaps] of byTx) { if (read >= 20) break; const key = `app:${chain}:${hash}`; + // checked again here: a pass running alongside (the loop and a manual sync on one machine) may have just put it to rest if (Date.now() < (healRestUntil.get(key) ?? 0)) continue; read++; const tx = await txOf(chain, hash as Hex); @@ -968,7 +969,8 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise 5000) txFacts.clear(); - txFacts.set(k, { from: receipt.from.toLowerCase(), to, tag: input === null ? null : tagOf(chain, to, input) }); + 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 { @@ -1010,6 +1012,8 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise COMPUTE_WARN_MS; + return { level: slow ? "warn" : "log", line: `[points] ${slug}: skipped (${r.skipped}), last board kept${elapsedMs !== undefined ? ` after ${elapsedMs}ms` : ""}${slow ? ` (heavy: over ${COMPUTE_WARN_MS / 1000}s)` : ""}` }; + } const rows = r.read ? r.read.swaps + r.read.firstBuys + r.read.moves + r.read.linked : 0; const heavy = rows > COMPUTE_WARN_ROWS || r.ms > COMPUTE_WARN_MS; const read = r.read ? ` from ${r.read.swaps} swaps, ${r.read.firstBuys} first buys, ${r.read.moves} moves, ${r.read.linked} linked transfers` : ""; diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts index 44db4b26..cf779264 100644 --- a/app/src/lib/points/score.test.ts +++ b/app/src/lib/points/score.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { LATEST_RULES_VERSION, RULES, RULES_V1, RULES_V2, countedHolders, isEligible, rulesFor, notEligibleReason, rankBy, scoreSeason, steadyPrice, whyLine, type FirstBuy, type ScoreInput, type ScoreLaunch, type ScoreSwap } from "./score.ts"; +import { LATEST_RULES_VERSION, RULES, RULES_V1, RULES_V2, countedHolders, eligibleNeededFor, isEligible, rulesFor, notEligibleReason, rankBy, scoreSeason, steadyPrice, whyLine, type FirstBuy, type ScoreInput, type ScoreLaunch, type ScoreSwap } from "./score.ts"; const DAY = 86_400_000; const START = Date.UTC(2026, 9, 1); @@ -578,3 +578,45 @@ test("v2 vesting boundary: a swap not credited to anyone yet still places a tran 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"); }); + +// ── final review 2026-11-11 ────────────────────────────────────────────────── +test("v2 sells: a sale split into many swaps shares one week-old lot between them, it does not count it once per swap", () => { + const old = trade(w(1), START + DAY + 3_600_000, 0.01, 30_000, { app: true }); // $20, held 8 days + const fresh = trade(w(1), START + 9 * DAY, 0.5, 39_000, { app: true }); // $1,000 the same day as the sale + // one transaction selling all of it as 51 swaps of $20: LIFO takes the fresh lot first, so $20 of it is week-old + const unit = tokensFor(0.01); + const sells = Array.from({ length: 51 }, (_, i) => { + const swap: ScoreSwap = { key: L.key, trader: w(1), isBuy: false, quoteRaw: eth(0.01), tokenRaw: unit, block: 39_100, logIndex: 10 + 2 * i, time: START + 9 * DAY + 60_000, tx: "0xsellmany", app: true }; + const move: Move = { key: L.key, wallet: w(1), block: 39_100, logIndex: 11 + 2 * i, tx: "0xsellmany", delta: -unit }; + return { swap, move }; + }); + const r = v2([old, fresh, ...sells]).wallets.get(w(1))!; + assert.ok(Math.abs((r.scoutWhy.sellFeesUsd ?? 0) - 0.2) < 0.011, `sell fees ${r.scoutWhy.sellFeesUsd}: $20 sold at 1%`); +}); + +test("v2 creator volume: a dumped token uses up none of a trader's daily allowance", () => { + const holders = crowd(51, 6000); + const eligibleHolders = wallets(holders.slice(0, 17)); + const A: ScoreLaunch = { ...L, key: "base:0xdumped" }; + // the creator bought 1M of A and moved most of it out this season: A is a dump + const boughtA = new Map([[A.key, [{ tx: "0xcbA", tokenRaw: 1_000_000n * E18 }]]]); + const creatorMoves: Move[] = [ + { key: A.key, wallet: CREATOR, block: 50, logIndex: 1, tx: "0xcbA", delta: 1_000_000n * E18 }, + { key: A.key, wallet: CREATOR, block: 3_000, logIndex: 1, tx: "0xdumpA", delta: -700_000n * E18 }, + ]; + // 10 eligible traders: each spends their day's $1,000 on A first, then $1,000 on L (for a week) + const both = Array.from({ length: 10 }, (_, i) => Array.from({ length: 7 }, (_, d) => [ + trade(w(9600 + i), START + (2 + d) * DAY + i, 0.5, 70_000 + d * 100 + i * 2, { launch: A }), + trade(w(9600 + i), START + (2 + d) * DAY + i + 60_000, 0.5, 70_000 + d * 100 + i * 2 + 1), + ]).flat()).flat(); + const c = scoreSeason(build(holders, { launches: [L, A], swaps: [...holders.map((h) => h.swap), ...both.map((x) => x.swap)], moves: [...holders.flatMap((h) => h.moves), ...both.map((x) => x.move), ...creatorMoves], launcherBuys: boughtA, eligible: new Set([...eligibleHolders, ...both.map((x) => x.swap.trader)]) }), RULES_V2).wallets.get(CREATOR)!; + assert.equal(c.creatorWhy.dumped, 1); + assert.equal(c.creatorWhy.volumeUsd, 70_000 + 51 * 6, "L gets each trader's full $1,000 a day"); + assert.equal(c.creatorWhy.volumePts, 100 + 400); +}); + +test("the eligible holders a counted-holders gate really needs", () => { + assert.equal(eligibleNeededFor(RULES.scoutFeeMinHolders), 7, "20 counted: 6 + 12 = 18 is short, 7 + 14 = 21"); + assert.equal(eligibleNeededFor(RULES.holdMinHolders), 0, "10 counted: ten unverified holders reach it"); + assert.equal(eligibleNeededFor(RULES.earlyHoldersTarget), 17, "50 counted: 16 + 32 = 48 is short"); +}); diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts index 51f68672..e60bfc11 100644 --- a/app/src/lib/points/score.ts +++ b/app/src/lib/points/score.ts @@ -38,8 +38,9 @@ * more while still holding half of that first buy * - 5 per token with 10+ counted holders that the wallet first bought in the season (a real buy) and still * half-held a day later; at most 20 tokens per UTC day - * - 10 per $1 of fees paid on buys ≥ $5 of other people's tokens with 20+ counted holders (5+ of them eligible), - * for each buy half of which has been held ever since; at most 200 a day + * - 10 per $1 of fees paid on buys ≥ $5 of other people's tokens with 20+ counted holders (which takes at least 7 + * eligible ones; the 5+ eligible check is kept but never decides alone), for each buy half of which has been + * held ever since; at most 200 a day * * Version 2 (seasons started on rules_version 2; score.RULES_V2) adds, on top of everything above: * - in-app trading (bb_launch_swaps.via_app; appTag.ts): a held in-app buy earns 15 per $1 of fees instead of 10; @@ -58,9 +59,11 @@ /** * Version 1: every season started before rules had versions, and the base every later version builds on. A season - * keeps the version it was started with (bb_seasons.rules_version), so a rule added later never changes the standings - * of a season already running; fixes that only take farmed points away (PR "close the sock-farm, dust and end-price - * holes") are part of version 1 itself. + * keeps the version it was started with (bb_seasons.rules_version), so a bonus added later never changes the standings + * of a season already running. Fixes to how version 1 judges buyers, holders and prices (2026-11-11: counted holders, + * linked wallets only from before a first buy, trade-time USD, the steady price) are part of version 1 itself and + * rescore a running season on its next run: mostly they take farmed points away, but a wallet wrongly treated as an + * insider, or a trade worth more at its own hour, can gain. */ export const RULES_V1 = { sniperBlocks: 3, @@ -266,6 +269,7 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe const receivedByTx = new Map(); // `${key}|${wallet}|${tx}` → what that tx brought in at all // (version 2) each lot's outflows by block, to tell what was left of it at a moment; and what each outflow used up type Lot = { tx: string; size: bigint; left: bigint; outs: { block: number; take: bigint }[] }; + const history = R.bonus !== undefined; // version 1 needs none of it const lotsOfTx = new Map(); // `${key}|${wallet}|${tx}` → the lots that tx brought in const usedBy = new Map(); // `${key}|${wallet}|${outTx}` → the lots it used up { @@ -294,17 +298,22 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe const take = top.left < out ? top.left : out; top.left -= take; out -= take; - top.outs.push({ block: m.block, take }); - const used = usedBy.get(`${k}|${m.tx}`) ?? []; - used.push({ lotTx: top.tx, take }); - usedBy.set(`${k}|${m.tx}`, used); + if (history) { + top.outs.push({ block: m.block, take }); + const used = usedBy.get(`${k}|${m.tx}`); + if (used) used.push({ lotTx: top.tx, take }); + else usedBy.set(`${k}|${m.tx}`, [{ lotTx: top.tx, take }]); + } if (top.left === 0n) stack.pop(); } } } for (const lot of all) { lotsByTx.set(`${k}|${lot.tx}`, (lotsByTx.get(`${k}|${lot.tx}`) ?? 0n) + lot.left); - lotsOfTx.set(`${k}|${lot.tx}`, [...(lotsOfTx.get(`${k}|${lot.tx}`) ?? []), lot]); + if (!history) continue; + const lots = lotsOfTx.get(`${k}|${lot.tx}`); + if (lots) lots.push(lot); + else lotsOfTx.set(`${k}|${lot.tx}`, [lot]); } } } @@ -328,7 +337,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, ...(input.timelinePoints ?? [])]) { + for (const t of history ? [...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 }); @@ -394,23 +403,35 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe // (the fees coming back to a farmer's own token) earns nothing and washing ties up real capital for the season. // From version 2, a buy half held for a week (buyVestMs) keeps them after it is sold: capital still sits a week const B = R.bonus; + const heldMemo = new Map(); // creator fees, volume and scout fees each ask about the same buys const heldBuy = (s: ScoreSwap) => { - if (!B || end - s.time < B.buyVestMs) return halfLeft(s.key, s.trader, s.tx, s.tokenRaw); - const left = leftAt(s.key, s.trader, s.tx, lastBlockAt(s.key, s.time + B.buyVestMs)); - return left > 0n && left * 2n >= s.tokenRaw; + const hit = heldMemo.get(s); + if (hit !== undefined) return hit; + let held: boolean; + if (!B || end - s.time < B.buyVestMs) held = halfLeft(s.key, s.trader, s.tx, s.tokenRaw); + else { + const left = leftAt(s.key, s.trader, s.tx, lastBlockAt(s.key, s.time + B.buyVestMs)); + held = left > 0n && left * 2n >= s.tokenRaw; + } + heldMemo.set(s, held); + return held; }; // (version 2) the share of a sell that sold the wallet's own in-season buys made a week or more before it (LIFO: - // a round trip uses up the lot it just bought, so it never qualifies) + // a round trip uses up the lot it just bought, so it never qualifies). Worked out for the whole transaction (what it + // sold of week-old buys over everything it moved out) and given to each of its sell swaps alike: a sale split into + // many swaps shares one old lot between them instead of each claiming all of it const buyTime = new Map(); // `${key}|${wallet}|${tx}` → when an in-season buy was made for (const s of input.swaps) if (s.isBuy && inSeason(s.time)) buyTime.set(`${s.key}|${s.trader}|${s.tx}`, s.time); const agedSellShare = (s: ScoreSwap) => { if (!B || s.isBuy || s.tokenRaw <= 0n) return 0; let aged = 0n; + let total = 0n; for (const u of usedBy.get(`${s.key}|${s.trader}|${s.tx}`) ?? []) { + total += u.take; const at = buyTime.get(`${s.key}|${s.trader}|${u.lotTx}`); if (at !== undefined && s.time - at >= B.sellMinLotAgeMs) aged += u.take; } - return Math.min(1, Number((aged * 1_000_000n) / s.tokenRaw) / 1_000_000); + return total > 0n ? Math.min(1, Number((aged * 1_000_000n) / total) / 1_000_000) : 0; }; // ── creator points ── @@ -440,6 +461,30 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe const fee = (tradeUsd(l, s) * l.lpFee) / 1_000_000; if (fee > 0) ensure(l).fees.push({ trader: s.trader, day: utcDay(s.time), usd: fee }); } + // the creator's in-season outflows of each token: a sell, or tokens moved to any other wallet (one that then sells + // included; a burn is not one), told from earlier ones by the season's first block on that chain + const creatorOut = new Set(creatorSold); + for (const m of input.moves) { + const l = launches.get(m.key); + if (!l || m.wallet !== l.launcher || m.delta >= 0n || m.burn) continue; + const from = input.seasonStartBlocks.get(m.key.split(":")[0]); + if (from !== undefined && m.block >= from) creatorOut.add(m.key); + } + /** + * A dump: tokens moved out during the season and less than half of the creator's own buys is left (fee tokens that + * came in after the buys are used up first, so selling those is not one). + */ + const dumpedMemo = new Map(); + const dumped = (l: ScoreLaunch) => { + const hit = dumpedMemo.get(l.key); + if (hit !== undefined) return hit; + const buys = input.launcherBuys.get(l.key) ?? []; + const bought = buys.reduce((a, b) => a + b.tokenRaw, 0n); + const left = buys.reduce((a, b) => a + leftOf(l.key, l.launcher, b.tx), 0n); + const d = bought > 0n && creatorOut.has(l.key) && left * 2n < bought; + dumpedMemo.set(l.key, d); + return d; + }; // (version 2) volume milestones: each token's qualified volume is its outside trades with capital at risk, at their // own hour's value: buys still held (heldBuy: half held throughout, or for a week), and the share of sells that sold // the trader's own buys made a week or more before (so a round trip counts for nothing on either side); each trader @@ -448,8 +493,11 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe if (B) { const used = new Map(); // `${creator}|${trader}|${day}` → qualified volume counted so far const volume = new Map }>(); + const dumpedKeys = new Set(); + for (const l of launches.values()) if (dumped(l)) dumpedKeys.add(l.key); for (const s of outside) { const l = launches.get(s.key)!; + if (dumpedKeys.has(s.key)) continue; // a dumped token scores nothing, and uses up no trader's allowance const eligible = input.eligible.has(s.trader); const atRisk = s.isBuy ? (heldBuy(s) ? 1 : 0) : agedSellShare(s); if (atRisk <= 0) continue; @@ -472,23 +520,7 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe if (pts > 0) ensure(launches.get(key)!).volume = { pts, usd: v.usd }; } } - // the creator's in-season outflows of each token: a sell, or tokens moved to any other wallet (one that then sells - // included; a burn is not one), told from earlier ones by the season's first block on that chain - const creatorOut = new Set(creatorSold); - for (const m of input.moves) { - const l = launches.get(m.key); - if (!l || m.wallet !== l.launcher || m.delta >= 0n || m.burn) continue; - const from = input.seasonStartBlocks.get(m.key.split(":")[0]); - if (from !== undefined && m.block >= from) creatorOut.add(m.key); - } - for (const t of perToken.values()) { - const buys = input.launcherBuys.get(t.l.key) ?? []; - const bought = buys.reduce((a, b) => a + b.tokenRaw, 0n); - const left = buys.reduce((a, b) => a + leftOf(t.l.key, t.l.launcher, b.tx), 0n); - // a dump = tokens moved out during the season and less than half of the creator's own buys is left (fee tokens - // that came in after the buys are used up first, so selling those is not one) - if (bought > 0n && creatorOut.has(t.l.key) && left * 2n < bought) t.dumped = true; - } + for (const t of perToken.values()) if (dumped(t.l)) t.dumped = true; // per creator: each holder once (their best token), fees capped per trader per day, best 3 tokens per launch day const byCreator = new Map(); for (const t of perToken.values()) { @@ -717,6 +749,16 @@ export function unverifiedAllowance(eligible: number, rules: Rules = RULES_V1): return Math.max(rules.unverifiedFree, rules.unverifiedPerVerified * eligible); } +/** + * The fewest eligible holders a token needs to reach `counted` counted holders, however many unverified ones it has: + * the real floor behind a "20+ counted holders" gate (7 there, so its "5+ eligible" never decides on its own). + */ +export function eligibleNeededFor(counted: number, rules: Rules = RULES_V1): number { + let e = 0; + while (e + unverifiedAllowance(e, rules) < counted) e++; + return e; +} + /** Counted holders: every eligible one, and unverified ones up to the allowance (a crowd of fresh wallets reaches no threshold alone). */ export function countedHolders(eligible: number, unverified: number, rules: Rules = RULES_V1): number { return eligible + Math.min(unverified, unverifiedAllowance(eligible, rules)); diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts index 0c92bbcd..6afedf53 100644 --- a/app/src/lib/points/server.ts +++ b/app/src/lib/points/server.ts @@ -9,7 +9,7 @@ import { DEAD_ADDR, ZERO_ADDR } from "@/lib/launchpad/holders"; import { launchDeployBlock, systemAddresses } from "@/lib/launchpad/indexer"; import { quotePerToken } from "@/lib/launchpad/math"; import { quotePricer } from "@/lib/launchpad/queries"; -import { hourIso } from "@/lib/launchpad/usd-history"; +import { HOUR_MS, hourIso, hourStartMs } from "@/lib/launchpad/usd-history"; import { capturable } from "@/lib/launchpad/usdLedger"; import { lastBlockBetween } from "./blocks"; import { computeLogLine, type ComputeResult } from "./log"; @@ -181,9 +181,19 @@ export async function tradePricesBefore(read: Db, cids: readonly number[], toks: return out; } -/** The USD price of every captured quote in the UTC hour holding `ms`: `${chain_id}:${quote}` → usd. */ +/** How far back the final standings look for a quote's last hourly price before the season's end. */ +export const FINAL_PRICE_LOOKBACK_MS = 7 * 24 * 3_600_000; + +/** + * Each captured quote's last hourly USD price at or before the UTC hour holding `ms`, from rows at most + * FINAL_PRICE_LOOKBACK_MS older: `${chain_id}:${quote}` → usd. A quote that traded little near the end keeps the + * price of its last captured hour (never one from after it); one with no row in the window is not in the map. + */ export async function quoteUsdAtHour(read: Db, ms: number): Promise> { - const rows = await read<{ chain_id: number; quote: string; usd: number }[]>`SELECT chain_id, quote, usd FROM bb_quote_usd_hourly WHERE hour = ${hourIso(ms)}`; + const rows = await read<{ chain_id: number; quote: string; usd: number }[]>` + SELECT DISTINCT ON (chain_id, quote) chain_id, quote, usd FROM bb_quote_usd_hourly + WHERE hour <= ${hourIso(ms)} AND hour > ${hourIso(ms - FINAL_PRICE_LOOKBACK_MS)} + ORDER BY chain_id, quote, hour DESC`; return new Map(rows.map((r) => [`${r.chain_id}:${r.quote.toLowerCase()}`, Number(r.usd)])); } @@ -256,13 +266,20 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis // the token's steady price (its trades at the cut-off, 12 h and 24 h before), and once the season is over the // quotes' prices in its last hour: a final run computed late values nothing at a later price const tradePrices = await tradePricesBefore(read, cids, toks, PRICE_SAMPLE_HOURS.map((h) => new Date(dataUntil - h * 3_600_000).toISOString())); - const endHourUsd = dataUntil < now ? await quoteUsdAtHour(read, end - 1) : new Map(); + // (the last hour that closed by the end: the row of the hour holding the end keeps updating after it until that hour + // is over, so it could carry a price from after the season) + const endHourUsd = dataUntil < now ? await quoteUsdAtHour(read, hourStartMs(end) - HOUR_MS) : new Map(); + const noEndPrice = new Set(); const launches: ScoreLaunch[] = []; for (const l of launchRows) { const chain = chainKeyOf(l.chain_id) as ChainKey; const q = price(chain, l.quote); - // community pairs and unlisted quotes are never captured: they keep their price now, which follows their listing - const usdQ = (capturable(q.key) ? endHourUsd.get(`${l.chain_id}:${l.quote.toLowerCase()}`) : undefined) ?? q.usd; + // community pairs and unlisted quotes are never captured: they keep their price now, which follows their listing. + // A captured quote with no hourly price in the week before the end (never captured in that time: a stock the + // backfill cannot fill) falls back to its price now, named in the log rather than silently + const atEnd = capturable(q.key) ? endHourUsd.get(`${l.chain_id}:${l.quote.toLowerCase()}`) : undefined; + if (dataUntil < now && capturable(q.key) && atEnd === undefined && q.usd !== null) noEndPrice.add(`${chain}:${q.key}:${l.quote.toLowerCase()}`); + const usdQ = atEnd ?? q.usd; if (usdQ === null && MAIN_QUOTES.has(q.key)) return { skipped: `no price for ${q.key}` }; if (usdQ === null && q.priced && finalRun && now < end + PRICE_WAIT_MS) return { skipped: `no price for ${q.key} right now; the final standings wait for it` }; if (usdQ === null) { @@ -403,7 +420,8 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis }); if (!wrote) return { skipped: "the season changed while it was being computed (the next run uses the new window)" }; if (dropped) console.log(`[points] ${season.slug}: ${dropped} token(s) without a price left out of this run`); - return { wallets: rows.length, eligible: rows.filter((r) => r.eligible).length, ms: Date.now() - t0, read: { swaps: swapRows.length, firstBuys: firstRows.length, moves: moveRows.length, linked: linkedRows.length } }; + if (noEndPrice.size) console.warn(`[points] ${season.slug}: no hourly price in the week before the end for ${[...noEndPrice].join(", ")}: valued at the price now`); + return { wallets: rows.length, eligible: rows.filter((r) => r.eligible).length, ms: Date.now() - t0, read: { swaps: seasonSwapRows.length, firstBuys: firstRows.length, moves: moveRows.length, linked: linkedRows.length } }; } let running = false; @@ -420,8 +438,9 @@ async function computeLocked(season: Season): Promise { const [{ ok }] = await db<{ ok: boolean }[]>`SELECT pg_try_advisory_lock(hashtext('points-compute')) AS ok`; if (!ok) return null; try { + const t0 = Date.now(); const r = await computeSeason(db, pool, season); - const { level, line } = computeLogLine(season.slug, r); + const { level, line } = computeLogLine(season.slug, r, Date.now() - t0); console[level](line); return r; } finally {