diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index 76713652..f863fac0 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -126,9 +126,9 @@ export default function RulesPage() {

Points for real buyers.
Nothing for noise.

See the leaderboard

Points measure reputation on openlaunch. They have no cash value, are not a token and not a promise of one, cannot be transferred or sold, and the rules can change between seasons. Wallets that game the system lose their points. Everything is computed from the chain every hour; the rules below are the whole of it.

-
What counts as a real buyer?

A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, 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 the token by transfer from either, 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.

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.

+
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} real 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 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}+ real 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 ({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.

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/price-source.test.ts b/app/src/lib/launchpad/price-source.test.ts index 78ff5fb4..14bfc350 100644 --- a/app/src/lib/launchpad/price-source.test.ts +++ b/app/src/lib/launchpad/price-source.test.ts @@ -75,7 +75,8 @@ test("hero totals: volume in a feed-less stock is left out without the undercoun test("the points run waits for a missing price only when the quote has a price source", () => { const points = read("../points/server.ts"); - assert.match(points, /if \(q\.usd === null && q\.priced && finalRun && now < end \+ PRICE_WAIT_MS\) return \{ skipped: `no price for \$\{q\.key\} right now; the final standings wait for it` \};/); + // (usdQ: the quote's price in the season's last hour once it has ended, else its price now) + assert.match(points, /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` \};/); assert.doesNotMatch(points, /q\.key !== "other"/, "the key alone no longer decides: a feed-less stock has the key 'stock'"); // and that answer is the same one the hero totals use const queries = read("./queries.ts"); diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts index de6f2e26..a9d334ff 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 { RULES, isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type FirstBuy, type ScoreInput, type ScoreLaunch, type ScoreSwap } from "./score.ts"; +import { RULES, countedHolders, isEligible, 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); @@ -35,6 +35,11 @@ function buyer(wallet: string, t: number, ethIn: number, keep = 1, launch: Score function build(buyers: ReturnType[], over: Partial = {}) { return input({ swaps: buyers.map((b) => b.swap), firstBuys: buyers.map((b) => b.first), moves: buyers.flatMap((b) => b.moves), ...over }); } +/** `count` other real holders of a token ($6 each, held), from wallet w(from) on: enough to pass a counted-holders gate. */ +function crowd(count: number, from: number, launch: ScoreLaunch = L, t = START + DAY) { + return Array.from({ length: count }, (_, i) => buyer(w(from + i), t + i, 0.003, 1, launch, 20_000 + from + i)); +} +const wallets = (bs: ReturnType[]) => bs.map((b) => b.first.wallet); // ── the rules ──────────────────────────────────────────────────────────────── test("a real holder who bought this season and held a day earns the creator 30 if eligible, 5 if not; half again after a week", () => { @@ -70,7 +75,7 @@ test("the creator, fee recipients, transfer-fed wallets and snipers (by block or const blockSniper = buyer(w(8), START + DAY, 1, 1, L, L.launchBlock + 2); const fresh: ScoreLaunch = { ...L, key: "base:0xfresh", launchTime: START + DAY }; const timeSniper = buyer(w(7), START + DAY + 5_000, 1, 1, fresh, 99_999); - const r = scoreSeason(build([self, recip, sock, blockSniper, timeSniper], { launches: [L, fresh], linked: new Map([[L.key, new Set([w(9)])]]), eligible: new Set([w(7), w(8), w(9)]) })); + const r = scoreSeason(build([self, recip, sock, blockSniper, timeSniper], { launches: [L, fresh], linked: new Map([[L.key, [{ wallet: w(9), block: 10, logIndex: 0, raw: tokensFor(1) }]]]), eligible: new Set([w(7), w(8), w(9)]) })); assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0); assert.equal(r.realHolders.get(L.key) ?? 0, 0); }); @@ -78,9 +83,9 @@ test("the creator, fee recipients, transfer-fed wallets and snipers (by block or test("only the best 3 tokens a wallet launched on one day count, and each holder counts once per creator", () => { const launches = Array.from({ length: 5 }, (_, i) => ({ ...L, key: `base:0xd${i}`, launchTime: START - DAY + i })); const buyers = launches.flatMap((l, i) => Array.from({ length: i + 1 }, (_, j) => buyer(w(100 * (i + 1) + j), START + DAY, 0.003, 1, l))); - const r = scoreSeason(build(buyers, { launches })); + const r = scoreSeason(build(buyers, { launches, eligible: new Set(wallets(buyers)) })); assert.equal(r.wallets.get(CREATOR)!.creatorWhy.tokens, 3); - assert.equal(r.wallets.get(CREATOR)!.creatorWhy.holders, 5 + 4 + 3); + assert.equal(r.wallets.get(CREATOR)!.creatorWhy.verifiedHolders, 5 + 4 + 3); // one eligible fan holding all five tokens counts once const fan = launches.map((l) => buyer(w(1), START + DAY, 0.003, 1, l)); const r2 = scoreSeason(build(fan, { launches, eligible: new Set([w(1)]) })); @@ -100,33 +105,38 @@ test("dump: sold this season and dropped below half of what was bought → 0; se assert.ok((feeSale.wallets.get(CREATOR)?.creator ?? 0) > 0, "sold the fee tokens, kept everything bought"); }); -test("early: first 25 real buyers of a token with 50+ real holders; +50 only if half of that buy was held throughout", () => { +test("early: first 25 real buyers of a token with 50+ counted holders; +50 only if half of that buy was held throughout", () => { const buyers = Array.from({ length: 52 }, (_, i) => buyer(w(i + 1), START + DAY + i * 1000, 0.003, i === 0 ? 0.3 : 1, L, 1000 + i)); - const r = scoreSeason(build(buyers)); + // 20 eligible holders (w(30)…w(49)) let the 31 others count: 20 + min(31, 40) = 51 counted + const r = scoreSeason(build(buyers, { eligible: new Set(wallets(buyers.slice(29, 49))) })); assert.equal(r.realHolders.get(L.key), 52 - 1, "w(1) dropped to $1.80: not a real holder (unverified needs $5)"); assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1); - assert.equal(r.wallets.get(w(2))!.scout - r.wallets.get(w(1))!.scout, RULES.earlyStillHolding + RULES.hold); + // w(2) also earns fee points on its own held $6 buy now the token passes the fee gate: 0.6, rounded with the rest + assert.equal(r.wallets.get(w(2))!.scout - r.wallets.get(w(1))!.scout, Math.round(RULES.earlyStillHolding + RULES.hold + 6 * 0.01 * RULES.scoutFeePointsPerUsd)); assert.equal(r.wallets.get(w(26))?.scoutWhy.early ?? 0, 0); }); test("early slots go to real buyers only: sub-$5 first buys cannot crowd them out", () => { - const crowd = Array.from({ length: 25 }, (_, i) => buyer(w(500 + i), START + DAY + i, 0.001, 1, L, 900 + i)); // $2 each + const small = Array.from({ length: 25 }, (_, i) => buyer(w(500 + i), START + DAY + i, 0.001, 1, L, 900 + i)); // $2 each const real = Array.from({ length: 52 }, (_, i) => buyer(w(i + 1), START + DAY + 100 + i, 0.003, 1, L, 1000 + i)); - const r = scoreSeason(build([...crowd, ...real])); + const r = scoreSeason(build([...small, ...real], { eligible: new Set(wallets(real.slice(29, 49))) })); assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1); }); test("holds: first buy ≥ $5 this season, half held throughout for a day; 20 tokens a day", () => { const launches = Array.from({ length: 25 }, (_, i) => ({ ...L, key: `base:0xh${i}` })); - const r = scoreSeason(build(launches.map((l) => buyer(w(1), START + DAY, 0.003, 1, l)), { launches })); + // 9 other holders on each token: with the wallet's own, the 10 counted holders a hold needs + const others = launches.flatMap((l, i) => crowd(9, 1000 + 10 * i, l, START + 2 * DAY)); + const r = scoreSeason(build([...launches.map((l) => buyer(w(1), START + DAY, 0.003, 1, l)), ...others], { launches })); assert.equal(r.wallets.get(w(1))!.scoutWhy.holds, RULES.holdTokensPerDay); - const soldDown = scoreSeason(build([buyer(w(2), START + DAY, 0.003, 0.4)])); + const soldDown = scoreSeason(build([buyer(w(2), START + DAY, 0.003, 0.4), ...crowd(10, 1000)])); assert.equal(soldDown.wallets.get(w(2))?.scoutWhy.holds ?? 0, 0, "sold down to 40%: no hold"); }); -test("scout fees: held buys on tokens with 20+ real holders (5+ eligible), at most 200 a day; round trips earn nothing", () => { +test("scout fees: held buys on tokens with 20+ counted holders (5+ eligible), at most 200 a day; round trips earn nothing", () => { const holders = Array.from({ length: 20 }, (_, i) => buyer(w(100 + i), START + DAY, 0.003)); - const eligible = new Set(holders.slice(0, 5).map((h) => h.first.wallet)); + // 7 eligible holders let the 13 others count: 7 + min(13, 14) = 20 + const eligible = new Set(holders.slice(0, 7).map((h) => h.first.wallet)); const buys: ScoreSwap[] = Array.from({ length: 30 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: true, quoteRaw: eth(1), tokenRaw: tokensFor(1), block: 6000 + i, logIndex: 10, time: START + 2 * DAY + i, tx: `0xsb${i}` })); const ins: Move[] = buys.map((b) => ({ key: L.key, wallet: w(1), block: b.block, logIndex: 11, tx: b.tx, delta: b.tokenRaw })); const base = { swaps: [...holders.map((h) => h.swap), ...buys], firstBuys: holders.map((h) => h.first), moves: [...holders.flatMap((h) => h.moves), ...ins], eligible }; @@ -209,8 +219,8 @@ test("farm: a small base position held all season does not cover later round tri test("a buy whose token transfer is logged before its swap (router takes first) still counts", () => { const b = buyer(w(1), START + DAY, 0.01); b.moves[0] = { ...b.moves[0], logIndex: b.swap.logIndex - 1 }; - const r = scoreSeason(build([b])); - assert.equal(r.realHolders.get(L.key), 1); + const r = scoreSeason(build([b, ...crowd(9, 1000)])); + assert.equal(r.realHolders.get(L.key), 10); assert.equal(r.wallets.get(w(1))!.scoutWhy.holds, 1); }); @@ -301,7 +311,7 @@ test("a buy whose tokens the wallet never received (a relayer or bundler the swa const b = buyer(`0xrelay${i}`, START + DAY + i, 0.003, 1, L, 1000 + i); return { ...b, moves: b.moves.map((m) => ({ ...m, wallet: `0xuser${i}` })) }; }); - const r = scoreSeason(build([...relayed, ...real])); + const r = scoreSeason(build([...relayed, ...real], { eligible: new Set(wallets(real.slice(29, 49))) })); assert.equal(r.wallets.get("0xrelay0")?.scoutWhy.early ?? 0, 0, "the relayer takes no early slot"); assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1, "the first real buyers keep theirs"); }); @@ -318,3 +328,95 @@ test("best 3 by what each token adds: three tokens sharing one trader's fee cap assert.equal(c.creatorWhy.verifiedHolders, 2, "0xd's holder is counted: 0xd was not cut for a second share of the same cap"); assert.ok(c.creator >= 45 + 50 + 45, `creator ${c.creator}`); }); + +// ── review 2026-11-11: sock farms, dust griefing, prices ───────────────────── +test("counted holders: unverified ones count up to max(10, 2 × eligible)", () => { + assert.equal(countedHolders(0, 49), 10); + assert.equal(countedHolders(1, 49), 11); + assert.equal(countedHolders(17, 33), 17 + 33); + assert.equal(countedHolders(7, 13), 20); + assert.equal(countedHolders(5, 100), 15); +}); + +test("farm: one eligible wallet and 49 fresh ones make no early slot, no hold and only 10 holders for the launcher", () => { + const junk: ScoreLaunch = { ...L, key: "base:0xjunk", launcher: "0xside", launchTime: START + DAY }; + const socks = Array.from({ length: 50 }, (_, i) => buyer(w(i + 1), START + DAY + 60_000 + i, 0.003, 1, junk, 5_000 + i)); + const r = scoreSeason(build(socks, { launches: [junk], eligible: new Set([w(1)]) })); + assert.equal(r.wallets.get(w(1))?.scoutWhy.early ?? 0, 0, "1 + min(49, 10) = 11 counted, not 50"); + assert.equal(r.wallets.get(w(1))?.scoutWhy.holds, 1, "11 counted holders pass the hold gate"); + const side = r.wallets.get("0xside")!; + assert.equal(side.creatorWhy.verifiedHolders, 1); + assert.equal(side.creatorWhy.holders, RULES.unverifiedFree, "the launcher is paid for 10 of the 49"); + // the same token with 17 eligible holders among the 50 does reach the early target + const real = scoreSeason(build(socks, { launches: [junk], eligible: new Set(wallets(socks.slice(0, 17))) })); + assert.equal(real.wallets.get(w(1))!.scoutWhy.early, 1, "17 + min(33, 34) = 50 counted"); + assert.equal(real.wallets.get("0xside")!.creatorWhy.holders, 33); +}); + +test("holds need 10 counted holders: a token only the wallet holds earns nothing", () => { + const alone = scoreSeason(build([buyer(w(1), START + DAY, 0.003), ...crowd(2, 1000)])); + assert.equal(alone.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0); + const ten = scoreSeason(build([buyer(w(1), START + DAY, 0.003), ...crowd(9, 1000)])); + assert.equal(ten.wallets.get(w(1))!.scoutWhy.holds, 1); +}); + +test("a sock-heavy token cannot push a token with eligible holders out of the best 3 of a day", () => { + const launches = ["0xa", "0xb", "0xc", "0xd"].map((t, i) => ({ ...L, key: `base:${t}`, launchTime: START - DAY + i })); + // 0xa: 40 fresh wallets (worth 10 × 7.5 = 75 at most); 0xb..0xd: 2 eligible holders each (2 × 45 = 90) + const socks = crowd(40, 2_000, launches[0]); + const fans = launches.slice(1).flatMap((l, i) => crowd(2, 100 * (i + 1), l)); + const r = scoreSeason(build([...socks, ...fans], { launches, eligible: new Set(wallets(fans)) })); + const c = r.wallets.get(CREATOR)!; + assert.equal(c.creatorWhy.verifiedHolders, 6, "0xb, 0xc and 0xd were kept"); + assert.equal(c.creatorWhy.holders, 0, "0xa was cut"); +}); + +test("dust griefing: tokens from the launcher after a buy, or dust before it, never take a buyer's points", () => { + const b = buyer(w(1), START + DAY, 0.003); + const others = crowd(9, 1000); + const after = { wallet: w(1), block: b.first.block + 10, logIndex: 0, raw: tokensFor(1) }; // $2,000 of tokens, after the buy + const dust = { wallet: w(1), block: 10, logIndex: 0, raw: 1n }; // 1 wei, before it + const r = scoreSeason(build([b, ...others], { linked: new Map([[L.key, [after, dust]]]) })); + assert.equal(r.wallets.get(w(1))!.scoutWhy.holds, 1); + assert.equal(r.realHolders.get(L.key), 10); + // $5 or more before the first buy: an insider, as before + const fed = { wallet: w(1), block: 10, logIndex: 0, raw: tokensFor(0.0025) }; + const r2 = scoreSeason(build([b, ...others], { linked: new Map([[L.key, [fed]]]) })); + assert.equal(r2.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0); + assert.equal(r2.realHolders.get(L.key), 9); + // a wallet that never bought is linked by what it got at any time (it sells only what it was given) + const never = { wallet: w(77), block: 99_999, logIndex: 0, raw: tokensFor(0.0025) }; + const sale: ScoreSwap = { key: L.key, trader: w(77), isBuy: true, quoteRaw: eth(1), tokenRaw: tokensFor(1), block: 100_000, logIndex: 1, time: START + 2 * DAY, tx: "0xn" }; + const r3 = scoreSeason(build([b, ...others], { swaps: [b.swap, ...others.map((o) => o.swap), sale], linked: new Map([[L.key, [never]]]), eligible: new Set([w(77)]) })); + assert.equal(r3.wallets.get(CREATOR)?.creatorWhy.feesUsd ?? 0, 0, "its trades are an insider's"); +}); + +test("trade-time USD: a buy is judged by its value at its own hour, not today's price", () => { + // today ETH is $2,000, so 0.003 ETH reads $6; at the buy's hour it was worth $4 (stored): not a real buy + const cheap = buyer(w(1), START + DAY, 0.003); + cheap.first.usd = 4; + cheap.swap.usd = 4; + const r = scoreSeason(build([cheap, ...crowd(9, 1000)])); + assert.equal(r.realHolders.get(L.key), 9); + // today 0.002 ETH reads $4; at its hour it was $6 (stored): a real buy + const dear = buyer(w(2), START + DAY, 0.002, 1); + dear.first.usd = 6; + dear.swap.usd = 6; + const r2 = scoreSeason(build([dear, ...crowd(9, 1000)], { eligible: new Set([w(2)]) })); // eligible: a $1 floor for what it holds + assert.equal(r2.realHolders.get(L.key), 10); + // fees too: a stored $1,000 buy pays its 1% at $1,000, whatever the quote is worth now + const whale = buyer(w(3), START + DAY, 0.003); + whale.swap.usd = 1_000; + const holders = crowd(20, 3000); + const r3 = scoreSeason(build([whale, ...holders], { eligible: new Set(wallets(holders.slice(0, 7))) })); + assert.equal(r3.wallets.get(w(3))!.scoutWhy.feesUsd, 10); +}); + +test("steady price: median of three, the older of two, the one there is", () => { + assert.equal(steadyPrice([100, 1, 1.1]), 1.1, "a pump at the cut-off moves nothing"); + assert.equal(steadyPrice([0.01, 1, 1.1]), 1, "nor a dump"); + assert.equal(steadyPrice([5, 1, null]), 1, "two: the older"); + assert.equal(steadyPrice([5, null, null]), 5); + assert.equal(steadyPrice([null, null, null]), null); + assert.equal(steadyPrice([0, Number.NaN, 2]), 2); +}); diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts index 12f2cfc3..7b630bdf 100644 --- a/app/src/lib/points/score.ts +++ b/app/src/lib/points/score.ts @@ -4,12 +4,17 @@ * Points are reputation for launching and trading, recomputed from scratch from the chain index. Every rule is * built on two definitions that cost real money to fake: * - * real buyer = a wallet whose FIRST buy of a token was ≥ $5, outside the sniper window (the launch block + 3, and - * the first 12 seconds), that received the tokens of that buy itself (a relayer or bundler a swap was - * credited to, or a router forwarding to someone else, is not a buyer), and that is not the launcher, - * a fee recipient, a protocol address, a wallet that got the token by transfer from the launcher or a - * recipient, or a wallet kept off points - * real holder = a real buyer with ≥ $5 (≥ $1 if their profile is points-eligible) of that first buy's lot still left + * real buyer = a wallet whose FIRST buy of a token was ≥ $5 (valued at the trade's own hour), outside the sniper + * window (the launch block + 3, and the first 12 seconds), that received the tokens of that buy itself + * (a relayer or bundler a swap was credited to, or a router forwarding to someone else, is not a + * buyer), and that is not the launcher, a fee recipient, a protocol address, a wallet that got $5 or + * more of the token by transfer from the launcher or a recipient BEFORE its first buy (a transfer after + * it, or dust, can never take a buyer's points away), or a wallet kept off points + * real holder = a real buyer with ≥ $5 (≥ $1 if their profile is points-eligible) of that first buy's lot still left, + * at the token's steady price (the median of its last trade price at the cut-off, 12 h and 24 h + * before it, so one block's pump or dump at the end moves nothing) + * counted holders = every eligible real holder, and unverified ones up to max(10, 2 × eligible): a crowd of fresh + * wallets with no X account reaches no threshold and pays no creator past that * * "Held" is judged per buy, from the transfer history: every incoming transfer is a lot, and every outgoing one (a sell, * or a transfer out to any wallet) uses up the newest lots first. A buy is held while at least half of its own lot is @@ -20,7 +25,8 @@ * * Creator (any token the wallet launched; only activity inside the season counts) * - each real holder whose first buy was in the season, a day or more ago: 30 if their profile is points-eligible, - * else 5; a week or more ago: half again. A holder counts once per creator (their best token). + * else 5; a week or more ago: half again. A holder counts once per creator (their best token), and a creator is + * paid for at most max(10, 2 × their eligible holders) unverified ones (the most valuable first). * - 10 per $1 of fees on buys ≥ $5 by points-eligible outside traders, for each buy half of which has been held ever * since (a round trip earns nothing); at most 50 per trader per creator per day * - a token scores 0 if its creator moved tokens out during the season (a sell, or a transfer to any wallet, a @@ -28,12 +34,12 @@ * left * - of the tokens a wallet launched on one UTC day, only the best 3 count (ranked with the fee cap applied) * Scout (traders) - * - one of the first 25 real buyers of a token that now has 50+ real holders, buying in the season: 100, and 50 + * - one of the first 25 real buyers of a token that now has 50+ counted holders, buying in the season: 100, and 50 * more while still holding half of that first buy - * - 5 per token 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+ real holders (5+ of them eligible), for - * each buy half of which has been held ever since; at most 200 a day + * - 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 * * Only points-eligible wallets rank; everyone else sees what is waiting for them. */ @@ -60,6 +66,12 @@ export const RULES = { scoutFeeCapPerDay: 200, scoutFeeMinHolders: 20, scoutFeeMinEligibleHolders: 5, + // counted holders: unverified real holders count up to max(unverifiedFree, unverifiedPerVerified × eligible ones) + unverifiedFree: 10, + unverifiedPerVerified: 2, + holdMinHolders: 10, + // tokens from the launcher or a fee recipient before a wallet's first buy make it an insider from this much on + linkMinUsd: 5, // points-eligible profile: X-verified on an account this old with this many followers eligibleMinAgeDays: 30, eligibleMinFollowers: 20, @@ -78,9 +90,12 @@ export type ScoreLaunch = { quoteUsd: number | null; // USD per whole quote unit; null = unpriced → the token is out of scoring tokenUsd: number | null; // USD per whole token now; null → out of scoring }; -export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; logIndex: number; time: number; tx: string }; +/** `usd`: the trade's value at its own hour (bb_launch_swaps.usd); missing or null → the quote's price in this run. */ +export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; logIndex: number; time: number; tx: string; usd?: number | null }; /** A wallet's first buy of a token, all-time (its rank among real buyers decides "early"). */ -export type FirstBuy = { key: string; wallet: string; block: number; logIndex: number; time: number; quoteRaw: bigint; tokenRaw: bigint; tx: string }; +export type FirstBuy = { key: string; wallet: string; block: number; logIndex: number; time: number; quoteRaw: bigint; tokenRaw: bigint; tx: string; usd?: number | null }; +/** One transfer of a token from its launcher or a fee recipient to `wallet`. */ +export type LinkedTransfer = { wallet: string; block: number; logIndex: number; raw: bigint }; export type ScoreInput = { seasonStart: number; @@ -97,8 +112,8 @@ export type ScoreInput = { moves: readonly { key: string; wallet: string; block: number; logIndex: number; tx: string; delta: bigint; burn?: boolean }[]; /** the launcher's own buys of each token, all-time (transaction + tokens bought) */ launcherBuys: ReadonlyMap; - /** per token: wallets that received it by transfer from the launcher or a fee recipient */ - linked: ReadonlyMap>; + /** per token: every transfer of it from the launcher or a fee recipient (only those before a wallet's first buy link it) */ + linked: ReadonlyMap; /** protocol addresses (pool manager, routers, locker, factory, zero, dead) */ system: ReadonlySet; eligible: ReadonlySet; @@ -135,11 +150,31 @@ export function scoreSeason(input: ScoreInput): ScoreResult { } return s; }; - const insider = (l: ScoreLaunch, w: string) => w === l.launcher || l.recipients.includes(w) || input.system.has(w) || input.flagged.has(w) || (input.linked.get(l.key)?.has(w) ?? false); const sniper = (l: ScoreLaunch, block: number, time: number) => block <= l.launchBlock + RULES.sniperBlocks || time - l.launchTime < RULES.sniperMs; const quoteUsd = (l: ScoreLaunch, raw: bigint) => units(raw, l.quoteDecimals) * (l.quoteUsd as number); + /** A trade's USD value at its own hour when it was stored, else at this run's quote price. */ + const tradeUsd = (l: ScoreLaunch, t: { quoteRaw: bigint; usd?: number | null }) => (typeof t.usd === "number" && Number.isFinite(t.usd) ? t.usd : quoteUsd(l, t.quoteRaw)); const tokenUsd = (l: ScoreLaunch, raw: bigint) => units(raw, 18) * (l.tokenUsd as number); const inSeason = (t: number) => t >= input.seasonStart && t < end; + // linked wallets: $5 or more of the token from the launcher or a fee recipient BEFORE the wallet's first buy. A + // transfer after it (or dust) changes nothing, so a launcher cannot take a buyer's points away by sending them tokens + const firstAt = new Map(); + for (const f of input.firstBuys) firstAt.set(`${f.key}|${f.wallet}`, f); + const linked = new Map>(); + for (const [key, list] of input.linked) { + const l = launches.get(key); + if (!l) continue; + const before = new Map(); + for (const t of list) { + const f = firstAt.get(`${key}|${t.wallet}`); + if (f && (t.block > f.block || (t.block === f.block && t.logIndex > f.logIndex))) continue; + before.set(t.wallet, (before.get(t.wallet) ?? 0n) + t.raw); + } + const set = new Set(); + for (const [w, raw] of before) if (tokenUsd(l, raw) >= RULES.linkMinUsd) set.add(w); + linked.set(key, set); + } + const insider = (l: ScoreLaunch, w: string) => w === l.launcher || l.recipients.includes(w) || input.system.has(w) || input.flagged.has(w) || (linked.get(l.key)?.has(w) ?? false); // lots per (token, wallet): each incoming transfer is a lot; each outgoing one uses up the newest lots first (LIFO). // A lot only ever shrinks, so what is left of it at the end is the least it ever held. Linear in the moves. const lotsByTx = new Map(); // `${key}|${wallet}|${tx}` → what is left of the lots that tx brought in @@ -192,7 +227,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult { const realBuyers = new Map(); for (const f of input.firstBuys) { const l = launches.get(f.key); - if (!l || insider(l, f.wallet) || sniper(l, f.block, f.time) || quoteUsd(l, f.quoteRaw) < RULES.minTradeUsd) continue; + if (!l || insider(l, f.wallet) || sniper(l, f.block, f.time) || tradeUsd(l, f) < RULES.minTradeUsd) continue; if (!received(f.key, f.wallet, f.tx, f.tokenRaw)) continue; // the tokens went to someone else: not this wallet's buy const list = realBuyers.get(f.key) ?? []; list.push(f); @@ -207,7 +242,13 @@ export function scoreSeason(input: ScoreInput): ScoreResult { const held = list.filter((f) => tokenUsd(l, leftOf(key, f.wallet, f.tx)) >= (input.eligible.has(f.wallet) ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd)); realHolders.set(key, held); } - const holderCount = (key: string) => realHolders.get(key)?.length ?? 0; + const eligibleHolders = new Map(); + for (const [key, list] of realHolders) eligibleHolders.set(key, list.filter((f) => input.eligible.has(f.wallet)).length); + /** Counted holders of a token: every eligible one, and unverified ones up to the allowance (countedHolders). */ + const holderCount = (key: string) => { + const e = eligibleHolders.get(key) ?? 0; + return countedHolders(e, (realHolders.get(key)?.length ?? 0) - e); + }; // the season's outside trades (≥ $5, outside the sniper window), and what each creator sold const outside: ScoreSwap[] = []; @@ -216,15 +257,13 @@ export function scoreSeason(input: ScoreInput): ScoreResult { const l = launches.get(s.key); if (!l || !inSeason(s.time)) continue; if (s.trader === l.launcher && !s.isBuy) creatorSold.add(s.key); - if (insider(l, s.trader) || sniper(l, s.block, s.time) || quoteUsd(l, s.quoteRaw) < RULES.minTradeUsd) continue; + if (insider(l, s.trader) || sniper(l, s.block, s.time) || tradeUsd(l, s) < RULES.minTradeUsd) continue; outside.push(s); } // net buying: a buy earns fee points only if at least half of it has been held every moment since, so a round trip // (the fees coming back to a farmer's own token) earns nothing and washing ties up real capital for the season const heldBuy = (s: ScoreSwap) => halfLeft(s.key, s.trader, s.tx, s.tokenRaw); - const eligibleHolders = new Map(); - for (const [key, list] of realHolders) eligibleHolders.set(key, list.filter((f) => input.eligible.has(f.wallet)).length); // ── creator points ── type TokenCreator = { l: ScoreLaunch; holders: { wallet: string; pts: number; eligible: boolean }[]; fees: { trader: string; day: number; usd: number }[]; dumped: boolean }; @@ -250,7 +289,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult { // fees count only on buys by points-eligible traders who are net buyers: wash trades need real accounts and capital if (!s.isBuy || !input.eligible.has(s.trader) || !heldBuy(s)) continue; const l = launches.get(s.key)!; - const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000; + 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 @@ -283,8 +322,9 @@ export function scoreSeason(input: ScoreInput): ScoreResult { const live = tokens.filter((t) => !t.dumped); s.creatorWhy.dumped = tokens.length - live.length; // each holder once, on the token where they are worth the most among `pool` - const assign = (pool: readonly TokenCreator[]) => { - const best = new Map(); + type Assigned = Map; + const assign = (pool: readonly TokenCreator[]): Assigned => { + const best: Assigned = new Map(); for (const t of pool) { for (const h of t.holders) { const cur = best.get(h.wallet); @@ -293,6 +333,12 @@ export function scoreSeason(input: ScoreInput): ScoreResult { } return best; }; + /** This creator's room for unverified holders: max(10, 2 × the eligible holders in `best`). */ + const roomOf = (best: Assigned) => { + let n = 0; + for (const h of best.values()) if (h.eligible) n++; + return unverifiedAllowance(n); + }; /** Fee points of one token, at most 50 per trader per day, counting what `used` already holds for this creator. */ const feePoints = (t: TokenCreator, used: Map) => { let pts = 0; @@ -312,8 +358,18 @@ export function scoreSeason(input: ScoreInput): ScoreResult { // token) and its fees with the per-trader-per-day cap applied after what the tokens already picked used of it (an // uncapped estimate let one big fee payer push out a token with real holders; three tokens sharing one trader's // cap are worth one cap, not three) + // (each token's unverified holders count up to the creator's room in this estimate: a crowd of fresh wallets on + // one token is worth no more than the room, so it cannot push out a token with eligible holders; capping the + // whole creator here instead would cut holders by wallet order and could keep the wrong tokens) const firstPass = new Map(); - for (const [, h] of assign(live)) firstPass.set(h.t, (firstPass.get(h.t) ?? 0) + h.pts); + const liveBest = assign(live); + const liveRoom = roomOf(liveBest); + const unverifiedPts = new Map(); + for (const [, h] of liveBest) { + if (h.eligible) firstPass.set(h.t, (firstPass.get(h.t) ?? 0) + h.pts); + else unverifiedPts.set(h.t, [...(unverifiedPts.get(h.t) ?? []), h.pts]); + } + for (const [t, pts] of unverifiedPts) firstPass.set(t, (firstPass.get(t) ?? 0) + pts.sort((a, b) => b - a).slice(0, liveRoom).reduce((a, b) => a + b, 0)); const byDay = new Map(); for (const t of live) { const d = utcDay(t.l.launchTime); @@ -338,9 +394,13 @@ export function scoreSeason(input: ScoreInput): ScoreResult { } } kept.sort((a, b) => (added.get(b) ?? 0) - (added.get(a) ?? 0) || a.l.key.localeCompare(b.l.key)); - // the count: each holder on their best kept token (a holder whose favourite was cut still counts on another), and - // fees with the cap shared across all of this creator's kept tokens + // the count: each holder on their best kept token (a holder whose favourite was cut still counts on another), the + // unverified ones past this creator's room not paid at all (the most valuable kept, then by wallet), and fees with + // the cap shared across all of this creator's kept tokens const holderBest = assign(kept); + const room = roomOf(holderBest); + const unverified = [...holderBest].filter(([, h]) => !h.eligible).sort(([wa, a], [wb, b]) => b.pts - a.pts || wa.localeCompare(wb)); + for (const [wallet] of unverified.slice(room)) holderBest.delete(wallet); const value = new Map(); for (const [, h] of holderBest) value.set(h.t, (value.get(h.t) ?? 0) + h.pts); const feeUsed = new Map(); @@ -373,9 +433,13 @@ export function scoreSeason(input: ScoreInput): ScoreResult { } // holds: a token the wallet FIRST bought in the season (its first buy ever was a real buy, in the season), half of // that buy held a day later; a later buy of a token held from before the season, or after a first buy that was not - // a real one (a sniper, under $5), earns nothing + // a real one (a sniper, under $5), earns nothing; nor does a token with under 10 counted holders (a farmer's own + // token nobody else holds) const firstInSeason: FirstBuy[] = []; - for (const list of realBuyers.values()) for (const f of list) if (inSeason(f.time)) firstInSeason.push(f); + for (const [key, list] of realBuyers) { + if (holderCount(key) < RULES.holdMinHolders) continue; + for (const f of list) if (inSeason(f.time)) firstInSeason.push(f); + } const holdsPerDay = new Map(); for (const f of firstInSeason.sort((a, b) => a.time - b.time || a.block - b.block || a.logIndex - b.logIndex)) { if (end - f.time < DAY || !halfLeft(f.key, f.wallet, f.tx, f.tokenRaw)) continue; @@ -392,7 +456,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult { if (!s.isBuy || !heldBuy(s)) continue; if (holderCount(s.key) < RULES.scoutFeeMinHolders || (eligibleHolders.get(s.key) ?? 0) < RULES.scoutFeeMinEligibleHolders) continue; const l = launches.get(s.key)!; - const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000; + const fee = (tradeUsd(l, s) * l.lpFee) / 1_000_000; if (fee <= 0) continue; const k = `${s.trader}|${utcDay(s.time)}`; const used = scoutFeeUsed.get(k) ?? 0; @@ -420,6 +484,28 @@ export function scoreSeason(input: ScoreInput): ScoreResult { return { wallets, realHolders: new Map([...realHolders].map(([k, v]) => [k, v.length])) }; } +/** How many unverified holders count beside `eligible` eligible ones: max(10, 2 × eligible). */ +export function unverifiedAllowance(eligible: number): number { + return Math.max(RULES.unverifiedFree, RULES.unverifiedPerVerified * eligible); +} + +/** 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): number { + return eligible + Math.min(unverified, unverifiedAllowance(eligible)); +} + +/** + * The token price a run holds balances to, from its last trade price at the cut-off and 12 h and 24 h before it + * (newest first; null where it had not traded yet): the median of three, the older of two (a pump in the last hours + * moves neither), the one there is, or null. A single block's pump or dump at the end of a season then moves nothing. + */ +export function steadyPrice(samples: readonly (number | null)[]): number | null { + const known = samples.filter((p): p is number => p !== null && Number.isFinite(p) && p > 0); + if (known.length === 0) return null; + if (known.length >= 3) return [...known].sort((a, b) => a - b)[Math.floor(known.length / 2)]; + return known[known.length - 1]; // newest first: the last is the oldest +} + /** Points-eligible profile: X-verified, public, not kept off points, on an account old and followed enough. */ export function isEligible(p: { x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at?: string | null }, now: number): boolean { if (p.hidden || p.points_flag || p.deleted_at || p.x_status !== "verified") return false; diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts index 8cca8861..95537229 100644 --- a/app/src/lib/points/server.ts +++ b/app/src/lib/points/server.ts @@ -9,9 +9,11 @@ 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 { capturable } from "@/lib/launchpad/usdLedger"; import { lastBlockBetween } from "./blocks"; import { namesFor, type NameEntry } from "@/lib/profiles/server"; -import { isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type CreatorBreakdown, type FirstBuy, type PublicReason, type ScoreLaunch, type ScoreSwap, type ScoutBreakdown } from "./score"; +import { isEligible, notEligibleReason, rankBy, scoreSeason, steadyPrice, whyLine, type CreatorBreakdown, type FirstBuy, type LinkedTransfer, type PublicReason, type ScoreLaunch, type ScoreSwap, type ScoutBreakdown } from "./score"; /** * Season points, the server half: load one season's chain data, score it (score.ts), replace the season's rows. @@ -20,7 +22,11 @@ import { isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type Creat * demand from /admin. Everything is recomputed from scratch, so a rule change or an admin flag applies to the whole * season on the next run. * - * Prices: without ETH, GITLAWB or TWIG a run writes nothing (the last board stays). Another quote that normally has a + * Prices: trades count at their own hour's value (bb_launch_swaps.usd; this run's quote price where none was stored). + * Balances are held to the token's steady price (score.steadyPrice: the median of its last trade price at the + * cut-off, 12 h and 24 h before), in quote terms times the quote's USD price: for the final standings, the quote's + * price in the season's last hour (bb_quote_usd_hourly), so a final run an hour late values nothing at a later price. + * Without ETH, GITLAWB or TWIG a run writes nothing (the last board stays). Another quote that normally has a * price (a community pair priced from an openlaunch pool, a stock) but has none right now only takes its own tokens out of an hourly run, which the next run * puts back; a run that would fix the FINAL standings writes nothing instead (for up to a day), so a price blip can * never drop tokens from them for good. Tokens quoted in an unlisted pair never count (they have no price to hold @@ -145,6 +151,40 @@ async function unattributedIn(read: Db, from: string, to: string): Promise> { + const out = new Map(); + if (cids.length === 0) return out; + const rows = await read<{ chain_id: number; token: string; i: string; sqrt: string }[]>` + SELECT u.cid AS chain_id, u.tok AS token, p.i::text AS i, s.sqrt_price_x96::text AS sqrt + FROM unnest(${cids as number[]}::int[], ${toks as string[]}::text[]) AS u(cid, tok) + CROSS JOIN unnest(${times as string[]}::timestamptz[]) WITH ORDINALITY AS p(t, i) + CROSS JOIN LATERAL ( + SELECT sqrt_price_x96 FROM bb_launch_swaps s + WHERE s.token = u.tok AND s.chain_id = u.cid AND s.block_time < p.t + ORDER BY s.block_time DESC, s.block_number DESC, s.log_index DESC LIMIT 1) s`; + for (const r of rows) { + const k = `${r.chain_id}:${r.token}`; + const list = out.get(k) ?? times.map(() => null); + list[Number(r.i) - 1] = r.sqrt; + out.set(k, list); + } + return out; +} + +/** The USD price of every captured quote in the UTC hour holding `ms`: `${chain_id}:${quote}` → usd. */ +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)}`; + return new Map(rows.map((r) => [`${r.chain_id}:${r.quote.toLowerCase()}`, Number(r.usd)])); +} + /** `read` runs the season's reads one after another (a reserved connection); `write` (the pool) runs the replace. */ export async function computeSeason(read: Db, write: Db, season: Season): Promise { const t0 = Date.now(); @@ -189,8 +229,8 @@ 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 }[]>` - SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, tx_hash, block_time AS t + 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 }[]>` + SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, tx_hash, block_time AS t, usd FROM bb_launch_swaps WHERE block_time >= ${season.starts_at} AND block_time < ${until} AND trader IS NOT NULL`; const keyOf = (cid: number, token: string) => `${chainKeyOf(cid) ?? cid}:${token}`; const scope = new Map(); @@ -208,17 +248,24 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis const launchRows = await read` SELECT l.chain_id, l.token, l.launcher, l.recipients, l.block_number, l.block_time, l.lp_fee, l.quote, l.sqrt_price_x96 FROM bb_launches l JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON l.chain_id = u.cid AND l.token = u.tok`; + // 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(); const launches: ScoreLaunch[] = []; for (const l of launchRows) { const chain = chainKeyOf(l.chain_id) as ChainKey; const q = price(chain, l.quote); - if (q.usd === null && MAIN_QUOTES.has(q.key)) return { skipped: `no price for ${q.key}` }; - if (q.usd === 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 (q.usd === null) { + // 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; + 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) { dropped++; continue; } - const perToken = l.sqrt_price_x96 ? quotePerToken(BigInt(l.sqrt_price_x96), q.decimals) : 0; + const samples = (tradePrices.get(`${l.chain_id}:${l.token}`) ?? []).map((x) => (x ? quotePerToken(BigInt(x), q.decimals) : null)); + const perToken = steadyPrice(samples) ?? (l.sqrt_price_x96 ? quotePerToken(BigInt(l.sqrt_price_x96), q.decimals) : 0); launches.push({ key: keyOf(l.chain_id, l.token), launcher: l.launcher, @@ -227,13 +274,13 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis launchTime: new Date(l.block_time).getTime(), lpFee: Number(l.lp_fee), quoteDecimals: q.decimals, - quoteUsd: q.usd, - tokenUsd: q.usd === null || !perToken ? null : perToken * q.usd, + quoteUsd: usdQ, + tokenUsd: !perToken ? null : perToken * usdQ, }); } - const firstRows = await read<{ chain_id: number; token: string; trader: string; block_number: bigint; log_index: number; tx_hash: string; t: string; amount0: string; amount1: string }[]>` - SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.log_index, s.tx_hash, s.block_time AS t, s.amount0, s.amount1 + const firstRows = await read<{ chain_id: number; token: string; trader: string; block_number: bigint; log_index: number; tx_hash: string; t: string; amount0: string; amount1: string; usd: number | null }[]>` + SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.log_index, s.tx_hash, s.block_time AS t, s.amount0, s.amount1, s.usd FROM bb_launch_swaps s JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok WHERE s.is_buy AND s.trader IS NOT NULL AND s.block_time < ${until} ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`; // buys after the end are not the season's // every balance change of every buyer (and launcher) of the tokens in scope: holding is judged on the lowest balance @@ -255,8 +302,10 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok WHERE s.is_buy AND s.trader = l.launcher AND s.block_time < ${until}`; - const linkedRows = await read<{ chain_id: number; token: string; to_addr: string }[]>` - SELECT DISTINCT t.chain_id, t.token, t.to_addr + // every transfer from the launcher or a fee recipient, with its position: only those before a wallet's first buy + // (and worth $5 or more together) make it an insider (score.ts), so dust sent after a buy takes nothing away + const linkedRows = await read<{ chain_id: number; token: string; to_addr: string; block_number: bigint; log_index: number; value: string }[]>` + SELECT t.chain_id, t.token, t.to_addr, t.block_number, t.log_index, t.value::text AS value FROM bb_token_transfers t JOIN bb_launches l ON l.chain_id = t.chain_id AND l.token = t.token JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON t.chain_id = u.cid AND t.token = u.tok JOIN unnest(${boundCids}::int[], ${boundBlocks}::bigint[]) AS e(cid, eb) ON e.cid = t.chain_id AND t.block_number <= e.eb @@ -267,19 +316,20 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis const abs = (v: string) => (v.startsWith("-") ? BigInt(v.slice(1)) : BigInt(v)); const int = (v: string) => abs(v.split(".")[0]); - const swaps: ScoreSwap[] = swapRows.map((s) => ({ key: keyOf(s.chain_id, s.token), trader: s.trader, isBuy: s.is_buy, quoteRaw: abs(s.amount0), tokenRaw: abs(s.amount1), block: Number(s.block_number), logIndex: Number(s.log_index), time: new Date(s.t).getTime(), tx: s.tx_hash })); - const firstBuys: FirstBuy[] = firstRows.map((f) => ({ key: keyOf(f.chain_id, f.token), wallet: f.trader, block: Number(f.block_number), logIndex: Number(f.log_index), time: new Date(f.t).getTime(), quoteRaw: abs(f.amount0), tokenRaw: abs(f.amount1), tx: f.tx_hash })); + const usdOf = (v: number | null) => (v === null || v === undefined ? null : Number(v)); + const swaps: ScoreSwap[] = swapRows.map((s) => ({ key: keyOf(s.chain_id, s.token), trader: s.trader, isBuy: s.is_buy, quoteRaw: abs(s.amount0), tokenRaw: abs(s.amount1), block: Number(s.block_number), logIndex: Number(s.log_index), time: new Date(s.t).getTime(), tx: s.tx_hash, usd: usdOf(s.usd) })); + const firstBuys: FirstBuy[] = firstRows.map((f) => ({ key: keyOf(f.chain_id, f.token), wallet: f.trader, block: Number(f.block_number), logIndex: Number(f.log_index), time: new Date(f.t).getTime(), quoteRaw: abs(f.amount0), tokenRaw: abs(f.amount1), tx: f.tx_hash, usd: usdOf(f.usd) })); const launcherBuys = new Map(); for (const b of launcherBuyRows) { const k = keyOf(b.chain_id, b.token); launcherBuys.set(k, [...(launcherBuys.get(k) ?? []), { tx: b.tx_hash, tokenRaw: abs(b.amount1) }]); } - const linked = new Map>(); + const linked = new Map(); for (const r of linkedRows) { const k = keyOf(r.chain_id, r.token); - const set = linked.get(k) ?? new Set(); - set.add(r.to_addr); - linked.set(k, set); + const list = linked.get(k) ?? []; + list.push({ wallet: r.to_addr, block: Number(r.block_number), logIndex: Number(r.log_index), raw: int(r.value) }); + linked.set(k, list); } const eligible = new Set(profileRows.filter((p) => isEligible(p, now)).map((p) => p.wallet)); const flagged = new Set(profileRows.filter((p) => p.points_flag).map((p) => p.wallet));