From aa6ab6e5ebd94263c44529aeabab46d876a19315 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 11 Nov 2026 10:11:42 +0800 Subject: [PATCH 1/3] feat(points): rules v2, with in-app trading bonuses, a week's vesting and creator volume milestones New seasons start on rules version 2. A season that is already running keeps version 1. - In-app buys: 15 points per $1 of fees, against 10 for other buys. - In-app sells: 5 points per $1 of fees on the part of a sale that sold the wallet's own in-season buys made a week or more earlier. LIFO means a round trip never qualifies. - Daily cap per trader: min(300, min(200, plain) + in-app buys + sells). The result does not depend on trade order. - Vesting: a buy half held for a week keeps its fee points (creator and scout) after it is sold. Each lot now records its outflows by block, and the block of a moment is interpolated between the trades either side of it. - Creator volume milestones per token: outside trades at their own hour's value. Each trader counts for at most $1,000 a day across one creator's tokens ($100 without a qualifying profile). $10k / $50k / $250k / $1M, with 10 / 25 / 75 / 200 distinct eligible traders and 50 counted holders, add 100 / 400 / 1,500 / 4,000 points. Dumped tokens earn none, and the points count in the best-3-per-day pick. - The breakdown gains volumeUsd/volumePts and appFeesUsd/sellFeesUsd (optional, so older rows still read), and the why lines name them. The rules page has a "Trading bonuses" section, and the leaderboard says when a season includes them. - Outside trades are sorted in chain order, so daily caps fill the same way on every run. Checked against a throwaway Postgres 16 with the real computeSeason: a v2 season's in-app buy held 8 days and sold in-app scores 150 + 50, and the v1 season's checks are unchanged. --- app/src/app/leaderboard/page.tsx | 4 +- app/src/app/rules/page.tsx | 7 +- app/src/lib/points/score.test.ts | 99 +++++++++++- app/src/lib/points/score.ts | 264 +++++++++++++++++++++++++++---- app/src/lib/points/server.ts | 6 +- 5 files changed, 339 insertions(+), 41 deletions(-) diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx index 5341c92d..57336a9d 100644 --- a/app/src/app/leaderboard/page.tsx +++ b/app/src/app/leaderboard/page.tsx @@ -7,7 +7,7 @@ import { WhoAvatar, WhoName } from "@/components/profile/Who"; import BoardRefresh from "@/components/points/BoardRefresh"; import YourStanding from "@/components/points/YourStanding"; import { memo } from "@/lib/launchpad/memo"; -import { RULES } from "@/lib/points/score"; +import { RULES, rulesFor } from "@/lib/points/score"; import { boardRows, publicSeason, seasonEnded, seasonFinal, type Board } from "@/lib/points/server"; import { pageMetadata } from "@/lib/seo"; @@ -60,7 +60,7 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:

Leaderboard

-

Real buyers and early finds, read from the chain every hour. Points are reputation: no cash value, not a token. How points work

+

Real buyers and early finds, read from the chain every hour. Points are reputation: no cash value, not a token. How points work{rulesFor(season.rules_version).bonus ? <> · This season adds trading bonuses for trades in the app and creator volume milestones. : null}

{ended ? ( diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index f863fac0..8ddc2299 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -13,7 +13,11 @@ 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 } from "@/lib/points/score"; +import { RULES, RULES_V2 } from "@/lib/points/score"; + +/** The trading bonuses of the latest rules (seasons started on version 2 or later). */ +const BONUS = RULES_V2.bonus!; +const n = (v: number) => v.toLocaleString("en-US"); export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." }); @@ -129,6 +133,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.

+
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, 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/points/score.test.ts b/app/src/lib/points/score.test.ts index 3db61308..3bce23f3 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, 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, 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); @@ -431,3 +431,100 @@ test("rules versions: a season scores on the version it started with; an unknown assert.equal(scoreSeason(build(holders), RULES_V1).wallets.get(w(1))!.scoutWhy.holds, 1); assert.equal(scoreSeason(build(holders), { ...RULES_V1, holdMinHolders: 11 }).wallets.get(w(1))?.scoutWhy.holds ?? 0, 0); }); + +// ── version 2: in-app trading, vesting, creator volume ─────────────────────── +let tn = 0; +/** One trade of `ethIn` at `t` (block given, so blocks follow time), with its token move. */ +function trade(wallet: string, t: number, ethIn: number, block: number, o: { isBuy?: boolean; app?: boolean; launch?: ScoreLaunch } = {}) { + tn++; + const launch = o.launch ?? L; + const isBuy = o.isBuy ?? true; + const tokenRaw = tokensFor(ethIn); + const tx = `0xv2t${tn}`; + const swap: ScoreSwap = { key: launch.key, trader: wallet, isBuy, quoteRaw: eth(ethIn), tokenRaw, block, logIndex: 5, time: t, tx, app: o.app }; + const move: Move = { key: launch.key, wallet, block, logIndex: 6, tx, delta: isBuy ? tokenRaw : -tokenRaw }; + return { swap, move }; +} +/** 20 holders of L, 7 of them eligible: L passes the scout fee gate (7 + min(13, 14) = 20 counted). */ +const gate = crowd(20, 3000); +const gateEligible = wallets(gate.slice(0, 7)); +function v2(trades: ReturnType[], over: Partial = {}, rules = RULES_V2) { + return scoreSeason(build(gate, { swaps: [...gate.map((g) => g.swap), ...trades.map((x) => x.swap)], moves: [...gate.flatMap((g) => g.moves), ...trades.map((x) => x.move)], eligible: new Set(gateEligible), ...over }), rules); +} + +test("v2 in-app buys: 15 a $1 of fees instead of 10; v1 ignores the tag", () => { + const app = trade(w(1), START + 2 * DAY, 0.5, 30_000, { app: true }); // $1,000 → $10 fee + const plain = trade(w(2), START + 2 * DAY, 0.5, 30_001); + const r = v2([app, plain]); + assert.equal(r.wallets.get(w(1))!.scout, 150); + assert.equal(r.wallets.get(w(1))!.scoutWhy.appFeesUsd, 10); + assert.equal(r.wallets.get(w(2))!.scout, 100); + assert.equal(v2([app], {}, RULES_V1).wallets.get(w(1))!.scout, 100, "version 1: no bonus"); +}); + +test("v2 daily cap: plain buys stop at 200, in-app trades fill the day to 300", () => { + const big = trade(w(1), START + 2 * DAY, 3, 30_000); // $6,000 → $60 fee → 600 plain, capped at 200 + const app = trade(w(1), START + 2 * DAY + 1, 0.5, 30_001, { app: true }); // 150 + assert.equal(v2([big]).wallets.get(w(1))!.scout, 200); + assert.equal(v2([big, app]).wallets.get(w(1))!.scout, 300); + assert.equal(v2([trade(w(3), START + 2 * DAY, 3, 30_002, { app: true })]).wallets.get(w(3))!.scout, 300, "in-app alone up to 300"); + // the next day is a new day + assert.equal(v2([big, app, trade(w(1), START + 3 * DAY, 0.5, 31_000, { app: true })]).wallets.get(w(1))!.scout, 450); +}); + +test("v2 sells: an in-app sell of a buy held a week earns 5 a $1 of fees; a younger lot or a sell elsewhere earns none", () => { + const buy = trade(w(1), START + DAY + 3_600_000, 0.5, 30_000, { app: true }); // 150 + const sellLate = trade(w(1), START + 9 * DAY, 0.5, 40_000, { isBuy: false, app: true }); // $10 fee → 50 + const r = v2([buy, sellLate]).wallets.get(w(1))!; + assert.equal(r.scout, 150 + 50, "the buy keeps its points (held a week), the sell earns its own"); + assert.equal(r.scoutWhy.sellFeesUsd, 10); + const sellEarly = trade(w(1), START + 6 * DAY, 0.5, 36_000, { isBuy: false, app: true }); + assert.equal(v2([buy, sellEarly]).wallets.get(w(1))?.scout ?? 0, 0, "sold after 5 days: no sell points, and the buy was not held a week"); + const sellElsewhere = trade(w(1), START + 9 * DAY, 0.5, 40_000, { isBuy: false }); + assert.equal(v2([buy, sellElsewhere]).wallets.get(w(1))!.scout, 150, "a sell outside the app earns nothing of its own"); + // a round trip: a fresh buy the same day is what the sell uses up (LIFO), so the old lot never qualifies + const fresh = trade(w(1), START + 9 * DAY - 60_000, 0.5, 39_990); + assert.equal(v2([buy, fresh, sellLate]).wallets.get(w(1))!.scoutWhy.sellFeesUsd ?? 0, 0); + assert.equal(v2([buy, sellLate], {}, RULES_V1).wallets.get(w(1))?.scout ?? 0, 0, "version 1: sold, so nothing"); +}); + +test("v2 vesting: a buy half held for a week keeps the creator's fee points after it is sold", () => { + const buy = trade(w(1), START + DAY + 3_600_000, 0.5, 30_000); // an eligible trader, $10 fee + const sell = trade(w(1), START + 9 * DAY, 0.5, 40_000, { isBuy: false }); + const eligible = new Set([...gateEligible, w(1)]); + assert.equal(v2([buy, sell], { eligible }).wallets.get(CREATOR)!.creatorWhy.feesUsd, 10 + 7 * 0.06, "the vested buy and the 7 eligible holders' own"); + assert.equal(v2([buy, sell], { eligible }, RULES_V1).wallets.get(CREATOR)!.creatorWhy.feesUsd, Math.round(7 * 0.06 * 100) / 100, "version 1: the sold buy counts for nothing"); + const sellAt3 = trade(w(1), START + 3 * DAY, 0.5, 32_000, { isBuy: false }); + assert.equal(v2([buy, sellAt3], { eligible }).wallets.get(CREATOR)!.creatorWhy.feesUsd, Math.round(7 * 0.06 * 100) / 100, "sold after 2 days: not vested"); +}); + +test("v2 creator volume: milestones need qualified volume, distinct eligible traders and 50 counted holders", () => { + // 17 eligible + 34 unverified holders: 17 + min(34, 34) = 51 counted; their own buys are $306 of volume + const holders = crowd(51, 6000); + const eligibleHolders = wallets(holders.slice(0, 17)); + const traders = (n: number, days: number) => + Array.from({ length: n }, (_, i) => Array.from({ length: days }, (_, d) => trade(w(9000 + i), START + (2 + d) * DAY + i, 0.5, 50_000 + d * 100 + i))).flat(); // $1,000 a day each + const run = (extra: ReturnType[], traderWallets: string[], hs = holders, eh = eligibleHolders, rules = RULES_V2) => + scoreSeason(build(hs, { swaps: [...hs.map((h) => h.swap), ...extra.map((x) => x.swap)], moves: [...hs.flatMap((h) => h.moves), ...extra.map((x) => x.move)], eligible: new Set([...eh, ...traderWallets]) }), rules).wallets.get(CREATOR)!; + const t8 = traders(8, 7); // 8 more eligible traders × 7 days = $56,000; 25 distinct eligible traders + const c = run(t8, t8.map((x) => x.swap.trader)); + assert.equal(c.creatorWhy.volumePts, 100 + 400); + assert.equal(c.creatorWhy.volumeUsd, 56_000 + 51 * 6); + const t7 = traders(7, 8); // $56,000 too, but 24 distinct eligible traders: the second milestone needs 25 + assert.equal(run(t7, t7.map((x) => x.swap.trader)).creatorWhy.volumePts, 100); + // one trader's $50,000 in a day counts as $1,000; without a qualifying profile, $100 + const whale = [trade(w(9500), START + 2 * DAY, 25, 50_000)]; + assert.equal(run(whale, [w(9500)]).creatorWhy.volumeUsd, undefined, "$1,306 qualified: no milestone"); + // under 50 counted holders: no milestone at all (16 + min(35, 32) = 48) + assert.equal(run(t8, t8.map((x) => x.swap.trader), holders, eligibleHolders.slice(0, 16)).creatorWhy.volumePts, undefined); + // version 1: none + assert.equal(run(t8, t8.map((x) => x.swap.trader), holders, eligibleHolders, RULES_V1).creatorWhy.volumePts, undefined); +}); + +test("v2 why lines name in-app fees, sells and qualified volume; old rows without them still read", () => { + const sc = { early: 0, holds: 0, feesUsd: 25, appFeesUsd: 10, sellFeesUsd: 4 }; + const z = { tokens: 1, verifiedHolders: 2, holders: 0, feesUsd: 0, dumped: 0, volumeUsd: 12_345, volumePts: 100 }; + assert.equal(whyLine("scout", { creatorWhy: z, scoutWhy: sc }), "$25.00 fees paid ($10.00 in the app) · $4.00 fees on sells after a week"); + assert.equal(whyLine("creator", { creatorWhy: z, scoutWhy: sc }), "2 verified holders · $12,345 qualified volume"); + assert.equal(whyLine("scout", { creatorWhy: z, scoutWhy: { early: 1, holds: 0, feesUsd: 0 } }), "early on 1 token"); +}); diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts index 17468b66..2144e73a 100644 --- a/app/src/lib/points/score.ts +++ b/app/src/lib/points/score.ts @@ -41,6 +41,17 @@ * - 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 * + * 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; + * an in-app sell earns 5 per $1 of fees on the share of it that sold the wallet's own in-season buys made a week + * or more before (LIFO, so a round trip never qualifies). Per trader per day: min(300, min(200, plain buys) + + * in-app buys + sells). Same token gates as scout fees. The tag is public, so the bonus stays small and capped + * - vesting: a buy half held for a week keeps its fee points (creator and scout) after it is sold + * - creator volume milestones per token: outside trades at their own hour's value, each trader counted for at most + * $1,000 a day across one creator's tokens ($100 without a qualifying profile). $10k / $50k / $250k / $1M of that + * with 10 / 25 / 75 / 200 distinct eligible traders on the token add 100 / 400 / 1,500 / 4,000 (6,000 at most), + * with 50+ counted holders, on a token that is not a dump; part of the token's value in the best-3-per-day pick + * * Only points-eligible wallets rank; everyone else sees what is waiting for them. */ @@ -83,15 +94,57 @@ export const RULES_V1 = { eligibleMinFollowers: 20, } as const; -/** The rules one season scores on. */ -export type Rules = typeof RULES_V1; +/** Version 2's bonuses (score.ts header, "Version 2"). */ +export type BonusRules = { + /** points per $1 of fees on an in-app buy (10 elsewhere) */ + appFeePointsPerUsd: number; + /** a trader's daily fee points with in-app trades: plain buys still stop at scoutFeeCapPerDay, in-app ones fill the rest */ + scoutFeeCapPerDayApp: number; + /** points per $1 of fees on an in-app sell of tokens the wallet bought in the season at least `sellMinLotAgeMs` before */ + sellFeePointsPerUsd: number; + sellMinLotAgeMs: number; + /** a buy half held this long keeps its fee points (creator and scout) after it is sold */ + buyVestMs: number; + /** a creator's qualified volume: per trader per UTC day across all their tokens, at most this much */ + volumeTraderCapUsd: number; + volumeUnverifiedTraderCapUsd: number; + /** a token earns volume milestones only with this many counted holders */ + volumeMinHolders: number; + /** cumulative: each one reached (qualified volume and distinct eligible traders on the token) adds its points */ + volumeTiers: readonly { usd: number; pts: number; traders: number }[]; +}; +/** The rules one season scores on: version 1, plus the bonuses from version 2 on. */ +export type Rules = typeof RULES_V1 & { bonus?: BonusRules }; /** The rules every page describes where all versions agree (eligibility, real buyers): version 1's. */ export const RULES: Rules = RULES_V1; + +/** Version 2: version 1 plus in-app trading points, a week's vesting on buys, and creator volume milestones. */ +export const RULES_V2: Rules = { + ...RULES_V1, + bonus: { + appFeePointsPerUsd: 15, + scoutFeeCapPerDayApp: 300, + sellFeePointsPerUsd: 5, + sellMinLotAgeMs: 7 * 86_400_000, + buyVestMs: 7 * 86_400_000, + volumeTraderCapUsd: 1_000, + volumeUnverifiedTraderCapUsd: 100, + volumeMinHolders: 50, + volumeTiers: [ + { usd: 10_000, pts: 100, traders: 10 }, + { usd: 50_000, pts: 400, traders: 25 }, + { usd: 250_000, pts: 1_500, traders: 75 }, + { usd: 1_000_000, pts: 4_000, traders: 200 }, + ], + }, +}; /** The version a new season starts on. */ -export const LATEST_RULES_VERSION = 1; +export const LATEST_RULES_VERSION = 2; /** The rules of a version (an unknown one scores on version 1). */ export function rulesFor(version: number | null | undefined): Rules { switch (version) { + case 2: + return RULES_V2; default: return RULES_V1; } @@ -111,7 +164,8 @@ export type ScoreLaunch = { tokenUsd: number | null; // USD per whole token now; null → out of scoring }; /** `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 }; +/** `app`: made in the app (bb_launch_swaps.via_app; appTag.ts). */ +export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; logIndex: number; time: number; tx: string; usd?: number | null; app?: boolean }; /** 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; usd?: number | null }; /** One transfer of a token from its launcher or a fee recipient to `wallet`. */ @@ -146,8 +200,9 @@ export type ScoreInput = { seasonStartBlocks: ReadonlyMap; }; -export type CreatorBreakdown = { tokens: number; verifiedHolders: number; holders: number; feesUsd: number; dumped: number }; -export type ScoutBreakdown = { early: number; holds: number; feesUsd: number }; +/** Version 2 adds `volumeUsd` / `volumePts` (creator) and `appFeesUsd` / `sellFeesUsd` (scout); older rows lack them. */ +export type CreatorBreakdown = { tokens: number; verifiedHolders: number; holders: number; feesUsd: number; dumped: number; volumeUsd?: number; volumePts?: number }; +export type ScoutBreakdown = { early: number; holds: number; feesUsd: number; appFeesUsd?: number; sellFeesUsd?: number }; export type WalletScore = { wallet: string; creator: number; scout: number; total: number; creatorWhy: CreatorBreakdown; scoutWhy: ScoutBreakdown }; export type ScoreResult = { wallets: Map; realHolders: Map }; @@ -200,6 +255,10 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe // 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 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 lotsOfTx = new Map(); // `${key}|${wallet}|${tx}` → the lots that tx brought in + const usedBy = new Map(); // `${key}|${wallet}|${outTx}` → the lots it used up { const byPair = new Map>(); for (const m of input.moves) { @@ -211,11 +270,11 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe byPair.set(k, byAt); } for (const [k, byAt] of byPair) { - const stack: { tx: string; left: bigint }[] = []; - const all: { tx: string; left: bigint }[] = []; + const stack: Lot[] = []; + const all: Lot[] = []; for (const m of [...byAt.values()].sort((a, b) => a.block - b.block || a.logIndex - b.logIndex)) { if (m.delta > 0n) { - const lot = { tx: m.tx, left: m.delta }; + const lot: Lot = { tx: m.tx, size: m.delta, left: m.delta, outs: [] }; stack.push(lot); all.push(lot); receivedByTx.set(`${k}|${m.tx}`, (receivedByTx.get(`${k}|${m.tx}`) ?? 0n) + m.delta); @@ -226,11 +285,18 @@ 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 (top.left === 0n) stack.pop(); } } } - for (const lot of all) lotsByTx.set(`${k}|${lot.tx}`, (lotsByTx.get(`${k}|${lot.tx}`) ?? 0n) + lot.left); + 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]); + } } } /** What is left of the tokens a transaction brought into a wallet (matched by transaction, not log order). */ @@ -241,6 +307,41 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe return left > 0n && left * 2n >= boughtRaw; }; + /** What was left of the lots a transaction brought in after every outflow up to and including `block`. */ + const leftAt = (key: string, w: string, tx: string, block: number) => { + let left = 0n; + for (const lot of lotsOfTx.get(`${key}|${w}|${tx}`) ?? []) left += lot.size - lot.outs.reduce((a, o) => (o.block <= block ? a + o.take : a), 0n); + return left; + }; + // (version 2) the last block of a moment, per chain, from the trades' own (time, block) pairs (transfers carry blocks, + // not times): interpolated between the trades on either side of it, which is exact on a chain with steady block times + // (Base: 2 s) and stays between those two trades on one without; after the last trade, every block so far + const timeline = new Map(); + for (const t of [...input.swaps, ...input.firstBuys]) { + const c = t.key.split(":")[0]; + const list = timeline.get(c) ?? []; + list.push({ time: t.time, block: t.block }); + timeline.set(c, list); + } + for (const list of timeline.values()) list.sort((a, b) => a.time - b.time || a.block - b.block); + const lastBlockAt = (key: string, ms: number) => { + const list = timeline.get(key.split(":")[0]) ?? []; + let lo = 0; + let hi = list.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (list[mid].time <= ms) lo = mid + 1; + else hi = mid; + } + if (lo >= list.length) return Number.POSITIVE_INFINITY; // no trade after the moment: everything so far + const after = list[lo]; + if (lo === 0) return after.block - 1; // before the first trade known + const before = list[lo - 1]; + if (after.time === before.time || after.block <= before.block) return before.block; + const guess = before.block + Math.floor(((ms - before.time) / (after.time - before.time)) * (after.block - before.block)); + return Math.min(after.block - 1, Math.max(before.block, guess)); + }; + /** The wallet received at least half of what a buy bought, in that buy's own transaction. */ const received = (key: string, w: string, tx: string, boughtRaw: bigint) => (receivedByTx.get(`${key}|${w}|${tx}`) ?? 0n) * 2n >= boughtRaw; @@ -281,18 +382,26 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe if (insider(l, s.trader) || sniper(l, s.block, s.time) || tradeUsd(l, s) < R.minTradeUsd) continue; outside.push(s); } + // in chain order, so a daily cap is used up the same way on every run (the database returns rows in any order) + outside.sort((a, b) => a.time - b.time || a.key.localeCompare(b.key) || a.block - b.block || a.logIndex - b.logIndex); // 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); + // (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 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; + }; // ── creator points ── - type TokenCreator = { l: ScoreLaunch; holders: { wallet: string; pts: number; eligible: boolean }[]; fees: { trader: string; day: number; usd: number }[]; dumped: boolean }; + type TokenCreator = { l: ScoreLaunch; holders: { wallet: string; pts: number; eligible: boolean }[]; fees: { trader: string; day: number; usd: number }[]; dumped: boolean; volume: { pts: number; usd: number } }; const perToken = new Map(); const ensure = (l: ScoreLaunch) => { let t = perToken.get(l.key); if (!t) { - t = { l, holders: [], fees: [], dumped: false }; + t = { l, holders: [], fees: [], dumped: false, volume: { pts: 0, usd: 0 } }; perToken.set(l.key, t); } return t; @@ -313,6 +422,34 @@ 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 }); } + // (version 2) volume milestones: each token's qualified volume is its outside trades (both sides, at their own hour's + // value), each trader counted for at most $1,000 a day across all of one creator's tokens ($100 without a qualifying + // profile); a milestone needs that volume, that many distinct eligible traders on the token, and 50 counted holders + if (B) { + const used = new Map(); // `${creator}|${trader}|${day}` → qualified volume counted so far + const volume = new Map }>(); + for (const s of outside) { + const l = launches.get(s.key)!; + const eligible = input.eligible.has(s.trader); + const k = `${l.launcher}|${s.trader}|${utcDay(s.time)}`; + const add = Math.min(tradeUsd(l, s), (eligible ? B.volumeTraderCapUsd : B.volumeUnverifiedTraderCapUsd) - (used.get(k) ?? 0)); + if (add <= 0) continue; + used.set(k, (used.get(k) ?? 0) + add); + const v = volume.get(s.key) ?? { usd: 0, traders: new Set() }; + v.usd += add; + if (eligible) v.traders.add(s.trader); + volume.set(s.key, v); + } + for (const [key, v] of volume) { + if (holderCount(key) < B.volumeMinHolders) continue; + let pts = 0; + for (const tier of B.volumeTiers) { + if (v.usd < tier.usd || v.traders.size < tier.traders) break; + pts += tier.pts; + } + 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); @@ -405,7 +542,7 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe let best = 0; let bestValue = -1; left.forEach((t, i) => { - const v = (firstPass.get(t) ?? 0) + feePoints(t, new Map(used)).pts; + const v = (firstPass.get(t) ?? 0) + feePoints(t, new Map(used)).pts + t.volume.pts; if (v > bestValue) [best, bestValue] = [i, v]; }); const [t] = left.splice(best, 1); @@ -428,11 +565,15 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe const counted = new Set(); for (const t of kept) { const fees = feePoints(t, feeUsed); - const v = (value.get(t) ?? 0) + fees.pts; + const v = (value.get(t) ?? 0) + fees.pts + t.volume.pts; if (v <= 0) continue; s.creator += v; s.creatorWhy.tokens++; s.creatorWhy.feesUsd += fees.usd; + if (t.volume.pts > 0) { + s.creatorWhy.volumePts = (s.creatorWhy.volumePts ?? 0) + t.volume.pts; + s.creatorWhy.volumeUsd = (s.creatorWhy.volumeUsd ?? 0) + t.volume.usd; + } for (const [wallet, h] of holderBest) { if (h.t !== t || counted.has(wallet)) continue; counted.add(wallet); @@ -472,21 +613,70 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe w.scout += R.hold; w.scoutWhy.holds++; } - const scoutFeeUsed = new Map(); - for (const s of outside) { - if (!s.isBuy || !heldBuy(s)) continue; - if (holderCount(s.key) < R.scoutFeeMinHolders || (eligibleHolders.get(s.key) ?? 0) < R.scoutFeeMinEligibleHolders) continue; - const l = launches.get(s.key)!; - 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; - const pts = Math.min(fee * R.scoutFeePointsPerUsd, R.scoutFeeCapPerDay - used); - if (pts <= 0) continue; - scoutFeeUsed.set(k, used + pts); - const w = get(s.trader); - w.scout += pts; - w.scoutWhy.feesUsd += fee; + const feeGate = (key: string) => holderCount(key) >= R.scoutFeeMinHolders && (eligibleHolders.get(key) ?? 0) >= R.scoutFeeMinEligibleHolders; + if (!B) { + const scoutFeeUsed = new Map(); + for (const s of outside) { + if (!s.isBuy || !heldBuy(s)) continue; + if (!feeGate(s.key)) continue; + const l = launches.get(s.key)!; + 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; + const pts = Math.min(fee * R.scoutFeePointsPerUsd, R.scoutFeeCapPerDay - used); + if (pts <= 0) continue; + scoutFeeUsed.set(k, used + pts); + const w = get(s.trader); + w.scout += pts; + w.scoutWhy.feesUsd += fee; + } + } else { + // version 2: per trader per UTC day, plain buys earn 10 a $1 of fees up to 200; in-app buys 15 a $1 and in-app + // sells of tokens bought in the season a week or more before 5 a $1, filling the day up to 300 together: + // min(300, min(200, plain) + in-app buys + sells), the same whatever order the trades came in + 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 days = new Map(); + for (const s of outside) { + if (!feeGate(s.key)) continue; + const l = launches.get(s.key)!; + const fee = (tradeUsd(l, s) * l.lpFee) / 1_000_000; + if (fee <= 0) continue; + const k = `${s.trader}|${utcDay(s.time)}`; + const d = days.get(k) ?? { trader: s.trader, plain: 0, app: 0, sell: 0, feesUsd: 0, appUsd: 0, sellUsd: 0 }; + if (s.isBuy) { + if (!heldBuy(s)) continue; + if (s.app) { + d.app += fee * B.appFeePointsPerUsd; + d.appUsd += fee; + } else d.plain += fee * R.scoutFeePointsPerUsd; + d.feesUsd += fee; + } else { + if (!s.app || s.tokenRaw <= 0n) continue; + // the share of the sell that used up 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) + let aged = 0n; + for (const u of usedBy.get(`${s.key}|${s.trader}|${s.tx}`) ?? []) { + const at = buyTime.get(`${s.key}|${s.trader}|${u.lotTx}`); + if (at !== undefined && s.time - at >= B.sellMinLotAgeMs) aged += u.take; + } + const share = Math.min(1, Number((aged * 1_000_000n) / s.tokenRaw) / 1_000_000); + if (share <= 0) continue; + d.sell += fee * share * B.sellFeePointsPerUsd; + d.sellUsd += fee * share; + } + days.set(k, d); + } + for (const d of days.values()) { + const pts = Math.min(B.scoutFeeCapPerDayApp, Math.min(R.scoutFeeCapPerDay, d.plain) + d.app + d.sell); + if (pts <= 0) continue; + const w = get(d.trader); + w.scout += pts; + w.scoutWhy.feesUsd += d.feesUsd; + if (d.appUsd > 0) w.scoutWhy.appFeesUsd = (w.scoutWhy.appFeesUsd ?? 0) + d.appUsd; + if (d.sellUsd > 0) w.scoutWhy.sellFeesUsd = (w.scoutWhy.sellFeesUsd ?? 0) + d.sellUsd; + } } for (const w of [...wallets.keys()]) { @@ -498,8 +688,12 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe s.creator = Math.round(s.creator); s.scout = Math.round(s.scout); s.total = s.creator + s.scout; - s.creatorWhy.feesUsd = Math.round(s.creatorWhy.feesUsd * 100) / 100; - s.scoutWhy.feesUsd = Math.round(s.scoutWhy.feesUsd * 100) / 100; + const cents = (n: number) => Math.round(n * 100) / 100; + s.creatorWhy.feesUsd = cents(s.creatorWhy.feesUsd); + s.scoutWhy.feesUsd = cents(s.scoutWhy.feesUsd); + if (s.creatorWhy.volumeUsd !== undefined) s.creatorWhy.volumeUsd = cents(s.creatorWhy.volumeUsd); + if (s.scoutWhy.appFeesUsd !== undefined) s.scoutWhy.appFeesUsd = cents(s.scoutWhy.appFeesUsd); + if (s.scoutWhy.sellFeesUsd !== undefined) s.scoutWhy.sellFeesUsd = cents(s.scoutWhy.sellFeesUsd); if (s.total <= 0) wallets.delete(w); } return { wallets, realHolders: new Map([...realHolders].map(([k, v]) => [k, v.length])) }; @@ -575,11 +769,13 @@ export function whyLine(board: "creator" | "scout", s: Pick= 0.01) parts.push(`${usd(c.feesUsd)} fees from verified traders`); + if (c.volumePts) parts.push(`${usd(c.volumeUsd ?? 0)} qualified volume`); } else { const c = s.scoutWhy; if (c.early) parts.push(`early on ${c.early} token${c.early === 1 ? "" : "s"}`); if (c.holds) parts.push(`${c.holds} held a day+`); - if (c.feesUsd >= 0.01) parts.push(`${usd(c.feesUsd)} fees paid`); + if (c.feesUsd >= 0.01) parts.push(`${usd(c.feesUsd)} fees paid${(c.appFeesUsd ?? 0) >= 0.01 ? ` (${usd(c.appFeesUsd ?? 0)} in the app)` : ""}`); + if ((c.sellFeesUsd ?? 0) >= 0.01) parts.push(`${usd(c.sellFeesUsd ?? 0)} fees on sells after a week`); } return parts.join(" · "); } diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts index 592f5768..329cfbfb 100644 --- a/app/src/lib/points/server.ts +++ b/app/src/lib/points/server.ts @@ -230,8 +230,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; usd: number | null }[]>` - SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, tx_hash, block_time AS t, usd + const swapRows = await read<{ chain_id: number; token: string; trader: string; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; log_index: number; tx_hash: string; t: string; usd: number | null; app: boolean }[]>` + SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, tx_hash, block_time AS t, usd, via_app IS TRUE AS app FROM bb_launch_swaps WHERE block_time >= ${season.starts_at} AND block_time < ${until} AND trader IS NOT NULL`; const keyOf = (cid: number, token: string) => `${chainKeyOf(cid) ?? cid}:${token}`; const scope = new Map(); @@ -318,7 +318,7 @@ 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 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 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), app: Boolean(s.app) })); 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) { From 4a281a92ad06da312a81dfb5505cfe913f4f34f9 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 11 Nov 2026 11:03:43 +0800 Subject: [PATCH 2/3] fix(points): address CodeRabbit's review of #139 - Rules type: every version-1 field is now typed as a number, not its version-1 value. A test (or a later version) can set a different value: a test passing holdMinHolders: 11 did not typecheck (test files are outside the regular typecheck; checked separately, 1 error before, 0 after). - applyLaunchTx reads the transaction once, in parallel with the receipt, so the wallet no longer waits for up to two reads in a row. If that read fails, the unknown tag is not kept in the cache. The log poll then reads the transaction again, and applySwap fills via_app on the conflict for the same credited trader. An unknown tag no longer stays NULL for good. - Router calls: the suffix must end the input, or be followed only by another ERC-8021 suffix (a wallet appending its own builder code). The same bytes inside the swap's arguments no longer count. The two real app trades in the public feed are still tagged. --- app/src/lib/launchpad/appTag.test.ts | 14 ++++- app/src/lib/launchpad/appTag.ts | 23 +++++-- app/src/lib/launchpad/indexer.ts | 94 ++++++++++++++++------------ app/src/lib/points/score.ts | 7 ++- 4 files changed, 89 insertions(+), 49 deletions(-) diff --git a/app/src/lib/launchpad/appTag.test.ts b/app/src/lib/launchpad/appTag.test.ts index f98e35af..b4c768d3 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, swapInApp } from "./appTag.ts"; +import { HANDLE_OPS_ABI, appTagOf, endsWithSuffix, swapInApp } from "./appTag.ts"; import { BUILDER_DATA_SUFFIX } from "../chainPublic.ts"; const ROUTER = "0x6ff5693b99212da76ad316178a184ab56d299b43"; @@ -21,9 +21,17 @@ const op07 = (sender: string, callData: Hex) => ({ sender: sender as Hex, nonce: const bundle06 = (ops: ReturnType[]) => encodeFunctionData({ abi: [HANDLE_OPS_ABI[0]], args: [ops, OTHER as Hex] }); const bundle07 = (ops: ReturnType[]) => encodeFunctionData({ abi: [HANDLE_OPS_ABI[1]], args: [ops, OTHER as Hex] }); -test("a wallet's own router call is in-app with the suffix (also when its wallet appends a code after ours)", () => { +// another wallet's own ERC-8021 suffix ("bc_other", schema 0), as it would append after ours +const OTHER_CODE = "62635f6f74686572" + "08" + "00" + "80218021802180218021802180218021"; + +test("a wallet's own router call is in-app with the suffix at the end (or followed only by another builder suffix)", () => { assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail, router: ROUTER, suffix: SUFFIX }).direct, true); - assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail + "deadbeef", router: ROUTER, suffix: SUFFIX }).direct, true); + assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail + OTHER_CODE, router: ROUTER, suffix: SUFFIX }).direct, true); + // anything else after it, or the same bytes inside the swap's arguments, is not our suffix + assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail + "deadbeef", router: ROUTER, suffix: SUFFIX }).direct, false); + const embedded = encodeFunctionData({ abi: execute, args: ["0x10", [`0x${tail}`], 1n] }); + assert.equal(appTagOf({ to: ROUTER, input: embedded, router: ROUTER, suffix: SUFFIX }).direct, false); + assert.equal(endsWithSuffix("ab" + "00".repeat(300) + "80218021802180218021802180218021", "ab"), false, "a tail longer than any suffix"); assert.equal(appTagOf({ to: ROUTER, input: swapCall, router: ROUTER, suffix: SUFFIX }).direct, false); // the suffix on a call to anything but the router (a bot contract, an aggregator) is not in-app assert.equal(appTagOf({ to: OTHER, input: swapCall + tail, router: ROUTER, suffix: SUFFIX }).direct, false); diff --git a/app/src/lib/launchpad/appTag.ts b/app/src/lib/launchpad/appTag.ts index a03e447f..82637011 100644 --- a/app/src/lib/launchpad/appTag.ts +++ b/app/src/lib/launchpad/appTag.ts @@ -3,10 +3,12 @@ * * Every transaction the app sends carries its ERC-8021 builder-code suffix (chainPublic.BUILDER_DATA_SUFFIX) on the * calldata. A swap counts as in-app when: - * - the transaction was sent to the chain's Universal Router and its input carries the suffix (a wallet sending its - * own transaction: the suffix ends the input, unless the wallet appended a builder code of its own after it), or + * - 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). + * 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). * 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 @@ -33,6 +35,19 @@ export type AppTag = { direct: boolean; senders: readonly string[] }; const NONE: AppTag = { direct: false, senders: [] }; +/** ERC-8021's closing marker: the last 16 bytes of every builder-code suffix. */ +const ERC8021_MARKER = "80218021802180218021802180218021"; +/** The longest tail another wallet's suffix may add after ours (hex characters: 256 bytes). */ +const MAX_TAIL_HEX = 512; + +/** Whether `input` ends with `needle`, or with `needle` followed only by another ERC-8021 suffix. */ +export function endsWithSuffix(input: string, needle: string): boolean { + const at = input.lastIndexOf(needle); + if (at < 0) return false; + const rest = input.slice(at + needle.length); + return rest === "" || (rest.length % 2 === 0 && rest.length <= MAX_TAIL_HEX && rest.endsWith(ERC8021_MARKER)); +} + /** The suffix as bare lowercase hex (no 0x), for searching an input. */ function bare(suffix: string): string { const s = suffix.toLowerCase(); @@ -45,7 +60,7 @@ export function appTagOf(p: { to: string | null; input: string; router: string; const needle = bare(p.suffix); if (!to || needle.length === 0) return NONE; const input = p.input.toLowerCase(); - if (to === p.router.toLowerCase()) return { direct: input.includes(needle), senders: [] }; + if (to === p.router.toLowerCase()) return { direct: endsWithSuffix(input, needle), senders: [] }; 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 }); diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index 3d63c109..df3d978d 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -211,7 +211,14 @@ async function applySwap(db: Db, chain: ChainKey, log: Log & { args: Swap }, poo WHERE p.chain_id = ${cid} AND p.quote = ${quote} AND p.hour = ${hourIso(time)}::timestamptz)) ON CONFLICT DO NOTHING RETURNING tx_hash, usd`; - if (inserted.length === 0) return false; + if (inserted.length === 0) { + // already recorded (the wallet's receipt got here first, or an overlap re-read): an app tag that was unknown then + // (applyLaunchTx could not read the transaction) is filled now, for the same credited trader + if (who.app !== null && who.trader) { + await t`UPDATE bb_launch_swaps SET via_app = ${who.app} WHERE chain_id = ${cid} AND tx_hash = ${log.transactionHash!.toLowerCase()} AND log_index = ${li} AND via_app IS NULL AND trader = ${who.trader}`; + } + return false; + } const usd = (inserted[0] as { usd: number | null }).usd ?? 0; await t` UPDATE bb_launches SET @@ -888,56 +895,63 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise null); + // the receipt has no input: the transaction is read for the app tag at the same time, once, so the wallet waits no + // longer for it. If that read fails, the trade is still credited from the receipt with its tag unknown (NULL), and + // the tag is not cached: the log poll reads the transaction again and fills it in (applySwap, on the conflict) + const [receipt, sent] = await Promise.all([ + publicClient(chain).getTransactionReceipt({ hash }).catch(() => null), + publicClient(chain).getTransaction({ hash }).catch(() => null), + ]); if (!receipt) return { status: "not_found" }; if (receipt.status !== "success") return { status: "reverted" }; // the receipt in hand is what attribution needs (sender, destination, logs): give it to traderOf, so crediting a // smart wallet's swap here never depends on reading the same receipt a second time from a node that may lag const k = `${chain}:${receipt.transactionHash}`; // the same hash the receipt's logs carry, so traderOf finds it - // the receipt has no input: the transaction is read for the app tag (twice at most). If that fails, the trade is - // still credited from the receipt, with its app tag unknown (NULL, never in-app) rather than held back const to = receipt.to ? receipt.to.toLowerCase() : null; - let input: string | null = null; - for (let attempt = 0; attempt < 2 && input === null; attempt++) input = (await publicClient(chain).getTransaction({ hash: receipt.transactionHash }).catch(() => null))?.input ?? null; + const input = sent?.input ?? null; if (txFacts.size > 5000) txFacts.clear(); txFacts.set(k, { from: receipt.from.toLowerCase(), to, tag: input === null ? null : tagOf(chain, to, input) }); 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) }))); - const cfg = launchpad(chain); - const factory = cfg.factory!.toLowerCase(); - const locker = cfg.locker!.toLowerCase(); - const pm = cfg.v4.poolManager.toLowerCase(); - - let launches = 0; - let swaps = 0; - let fees = 0; - const tokens: string[] = []; - const launched = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs.filter((l) => l.address.toLowerCase() === factory) }); - for (const l of launched) { - if (await applyLaunched(db, chain, l as unknown as Log & { args: Launched })) launches++; - tokens.push(l.args.token.toLowerCase()); - } - const { poolToToken, tokenIdToToken, tokenQuote } = await poolMaps(db, chain); - // no price capture here: this path runs while the user waits, and a capture can wait on price sources. Its trades take - // the hour's price the poller already captured, or are priced by the poller's heal pass a few minutes later. - const swapLogs = parseEventLogs({ abi: POOL_MANAGER_ABI, eventName: "Swap", logs: receipt.logs.filter((l) => l.address.toLowerCase() === pm) }); - for (const l of swapLogs) { - if (await applySwap(db, chain, l as unknown as Log & { args: Swap }, poolToToken, tokenQuote)) { - swaps++; - const t = poolToToken.get(l.args.id.toLowerCase()); - if (t && !tokens.includes(t)) tokens.push(t); + try { + const cfg = launchpad(chain); + const factory = cfg.factory!.toLowerCase(); + const locker = cfg.locker!.toLowerCase(); + const pm = cfg.v4.poolManager.toLowerCase(); + + let launches = 0; + let swaps = 0; + let fees = 0; + const tokens: string[] = []; + const launched = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs.filter((l) => l.address.toLowerCase() === factory) }); + for (const l of launched) { + if (await applyLaunched(db, chain, l as unknown as Log & { args: Launched })) launches++; + tokens.push(l.args.token.toLowerCase()); } - } - const feeLogs = parseEventLogs({ abi: LAUNCH_LOCKER_ABI, logs: receipt.logs.filter((l) => l.address.toLowerCase() === locker) }); - for (const l of feeLogs) if (await applyFee(db, chain, l as unknown as FeeLog, tokenIdToToken)) fees++; - let boosts = 0; - const twig = TWIG_ADDRESSES[chain]; - if (twig) { - const burnLogs = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: receipt.logs.filter((l) => l.address.toLowerCase() === twig) }); - for (const l of burnLogs) { - if (await applyBoost(db, chain, l as unknown as Log & { args: TransferArgs }, locker)) boosts++; - else await applyPairBurn(chain, l as unknown as Log & { args: TransferArgs }, locker); + const { poolToToken, tokenIdToToken, tokenQuote } = await poolMaps(db, chain); + // no price capture here: this path runs while the user waits, and a capture can wait on price sources. Its trades take + // the hour's price the poller already captured, or are priced by the poller's heal pass a few minutes later. + const swapLogs = parseEventLogs({ abi: POOL_MANAGER_ABI, eventName: "Swap", logs: receipt.logs.filter((l) => l.address.toLowerCase() === pm) }); + for (const l of swapLogs) { + if (await applySwap(db, chain, l as unknown as Log & { args: Swap }, poolToToken, tokenQuote)) { + swaps++; + const t = poolToToken.get(l.args.id.toLowerCase()); + if (t && !tokens.includes(t)) tokens.push(t); + } } + const feeLogs = parseEventLogs({ abi: LAUNCH_LOCKER_ABI, logs: receipt.logs.filter((l) => l.address.toLowerCase() === locker) }); + for (const l of feeLogs) if (await applyFee(db, chain, l as unknown as FeeLog, tokenIdToToken)) fees++; + let boosts = 0; + const twig = TWIG_ADDRESSES[chain]; + if (twig) { + const burnLogs = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: receipt.logs.filter((l) => l.address.toLowerCase() === twig) }); + for (const l of burnLogs) { + if (await applyBoost(db, chain, l as unknown as Log & { args: TransferArgs }, locker)) boosts++; + else await applyPairBurn(chain, l as unknown as Log & { args: TransferArgs }, locker); + } + } + return { status: "applied", launches, swaps, fees, boosts, tokens }; + } finally { + if (input === null) txFacts.delete(k); // unknown: the log poll reads the transaction again } - return { status: "applied", launches, swaps, fees, boosts, tokens }; } diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts index 2144e73a..a8bb0332 100644 --- a/app/src/lib/points/score.ts +++ b/app/src/lib/points/score.ts @@ -113,8 +113,11 @@ export type BonusRules = { /** cumulative: each one reached (qualified volume and distinct eligible traders on the token) adds its points */ volumeTiers: readonly { usd: number; pts: number; traders: number }[]; }; -/** The rules one season scores on: version 1, plus the bonuses from version 2 on. */ -export type Rules = typeof RULES_V1 & { bonus?: BonusRules }; +/** + * The rules one season scores on: every version-1 field as a number (not its version-1 value, so a version or a + * test can change one), plus the bonuses from version 2 on. + */ +export type Rules = { readonly [K in keyof typeof RULES_V1]: number } & { readonly bonus?: BonusRules }; /** The rules every page describes where all versions agree (eligibility, real buyers): version 1's. */ export const RULES: Rules = RULES_V1; From 9d4089d8a74f967740100245436cdc1fc9ae5232 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 11 Nov 2026 11:18:14 +0800 Subject: [PATCH 3/3] fix(points): volume milestones count only trades with capital at risk CodeRabbit (#140): milestones counted both sides of every outside trade, so eligible wallets could wash their way to them. Qualified volume now counts: - buys only while held (heldBuy: half held throughout, or for a week); - sells only for the part that sold the trader's own in-season buys made a week or more before. This is the same rule as sell points, now one shared helper. A same-day round trip counts for nothing on either side. The test: 10 eligible wallets churning $140k over a week reach no milestone, while six $1,000 buys held 8 days and then sold count $2,000 each. The rules page says so. --- app/src/app/rules/page.tsx | 2 +- app/src/lib/points/score.test.ts | 19 +++++++++++++++ app/src/lib/points/score.ts | 41 +++++++++++++++++++------------- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index 8ddc2299..50b7b254 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -133,7 +133,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.

-
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, 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.

+
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/points/score.test.ts b/app/src/lib/points/score.test.ts index 3bce23f3..0b3a39ca 100644 --- a/app/src/lib/points/score.test.ts +++ b/app/src/lib/points/score.test.ts @@ -528,3 +528,22 @@ test("v2 why lines name in-app fees, sells and qualified volume; old rows withou assert.equal(whyLine("creator", { creatorWhy: z, scoutWhy: sc }), "2 verified holders · $12,345 qualified volume"); assert.equal(whyLine("scout", { creatorWhy: z, scoutWhy: { early: 1, holds: 0, feesUsd: 0 } }), "early on 1 token"); }); + +test("v2 creator volume: round trips count for nothing; a sell of a week-old buy counts", () => { + const holders = crowd(51, 6000); + const eligibleHolders = wallets(holders.slice(0, 17)); + // 10 eligible wallets each buy $1,000 and sell it straight back, every day for a week: $140,000 of churn + const wash = Array.from({ length: 10 }, (_, i) => Array.from({ length: 7 }, (_, d) => [ + trade(w(9100 + i), START + (2 + d) * DAY + i, 0.5, 60_000 + d * 100 + i * 2), + trade(w(9100 + i), START + (2 + d) * DAY + i + 60_000, 0.5, 60_000 + d * 100 + i * 2 + 1, { isBuy: false }), + ]).flat()).flat(); + const run = (extra: ReturnType[], who: string[]) => + scoreSeason(build(holders, { swaps: [...holders.map((h) => h.swap), ...extra.map((x) => x.swap)], moves: [...holders.flatMap((h) => h.moves), ...extra.map((x) => x.move)], eligible: new Set([...eligibleHolders, ...who]) }), RULES_V2).wallets.get(CREATOR)!; + assert.equal(run(wash, wash.map((x) => x.swap.trader)).creatorWhy.volumePts, undefined, "only the holders' own $306 is at risk"); + // a $1,000 buy held 8 days then sold: both sides count ($2,000) + const held = [trade(w(9300), START + 1 * DAY + 3_600_000, 0.5, 30_000), trade(w(9300), START + 9 * DAY, 0.5, 90_000, { isBuy: false })]; + const pairs = Array.from({ length: 5 }, (_, i) => [trade(w(9400 + i), START + 1 * DAY + 3_600_000 + i, 0.5, 30_001 + i), trade(w(9400 + i), START + 9 * DAY + i, 0.5, 90_001 + i, { isBuy: false })]).flat(); + const c = run([...held, ...pairs], [w(9300), ...pairs.map((x) => x.swap.trader)]); + assert.equal(c.creatorWhy.volumeUsd, 6 * 2_000 + 51 * 6, "6 traders × ($1,000 held buy + $1,000 sell of a week-old buy) + the holders'"); + assert.equal(c.creatorWhy.volumePts, 100); +}); diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts index a8bb0332..db573102 100644 --- a/app/src/lib/points/score.ts +++ b/app/src/lib/points/score.ts @@ -47,7 +47,8 @@ * or more before (LIFO, so a round trip never qualifies). Per trader per day: min(300, min(200, plain buys) + * in-app buys + sells). Same token gates as scout fees. The tag is public, so the bonus stays small and capped * - vesting: a buy half held for a week keeps its fee points (creator and scout) after it is sold - * - creator volume milestones per token: outside trades at their own hour's value, each trader counted for at most + * - creator volume milestones per token: outside trades with capital at risk (held buys, sells of buys a week old; + * a round trip counts for nothing) at their own hour's value, each trader counted for at most * $1,000 a day across one creator's tokens ($100 without a qualifying profile). $10k / $50k / $250k / $1M of that * with 10 / 25 / 75 / 200 distinct eligible traders on the token add 100 / 400 / 1,500 / 4,000 (6,000 at most), * with 50+ counted holders, on a token that is not a dump; part of the token's value in the best-3-per-day pick @@ -397,6 +398,19 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe const left = leftAt(s.key, s.trader, s.tx, lastBlockAt(s.key, s.time + B.buyVestMs)); return left > 0n && left * 2n >= s.tokenRaw; }; + // (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) + 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; + for (const u of usedBy.get(`${s.key}|${s.trader}|${s.tx}`) ?? []) { + 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); + }; // ── creator points ── type TokenCreator = { l: ScoreLaunch; holders: { wallet: string; pts: number; eligible: boolean }[]; fees: { trader: string; day: number; usd: number }[]; dumped: boolean; volume: { pts: number; usd: number } }; @@ -425,17 +439,21 @@ 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 }); } - // (version 2) volume milestones: each token's qualified volume is its outside trades (both sides, at their own hour's - // value), each trader counted for at most $1,000 a day across all of one creator's tokens ($100 without a qualifying - // profile); a milestone needs that volume, that many distinct eligible traders on the token, and 50 counted holders + // (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 + // counted for at most $1,000 a day across all of one creator's tokens ($100 without a qualifying profile). A + // milestone needs that volume, that many distinct eligible traders on the token, and 50 counted holders if (B) { const used = new Map(); // `${creator}|${trader}|${day}` → qualified volume counted so far const volume = new Map }>(); for (const s of outside) { const l = launches.get(s.key)!; const eligible = input.eligible.has(s.trader); + const atRisk = s.isBuy ? (heldBuy(s) ? 1 : 0) : agedSellShare(s); + if (atRisk <= 0) continue; const k = `${l.launcher}|${s.trader}|${utcDay(s.time)}`; - const add = Math.min(tradeUsd(l, s), (eligible ? B.volumeTraderCapUsd : B.volumeUnverifiedTraderCapUsd) - (used.get(k) ?? 0)); + const add = Math.min(tradeUsd(l, s) * atRisk, (eligible ? B.volumeTraderCapUsd : B.volumeUnverifiedTraderCapUsd) - (used.get(k) ?? 0)); if (add <= 0) continue; used.set(k, (used.get(k) ?? 0) + add); const v = volume.get(s.key) ?? { usd: 0, traders: new Set() }; @@ -638,8 +656,6 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe // version 2: per trader per UTC day, plain buys earn 10 a $1 of fees up to 200; in-app buys 15 a $1 and in-app // sells of tokens bought in the season a week or more before 5 a $1, filling the day up to 300 together: // min(300, min(200, plain) + in-app buys + sells), the same whatever order the trades came in - 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 days = new Map(); for (const s of outside) { if (!feeGate(s.key)) continue; @@ -656,15 +672,8 @@ export function scoreSeason(input: ScoreInput, rules: Rules = RULES_V1): ScoreRe } else d.plain += fee * R.scoutFeePointsPerUsd; d.feesUsd += fee; } else { - if (!s.app || s.tokenRaw <= 0n) continue; - // the share of the sell that used up 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) - let aged = 0n; - for (const u of usedBy.get(`${s.key}|${s.trader}|${s.tx}`) ?? []) { - const at = buyTime.get(`${s.key}|${s.trader}|${u.lotTx}`); - if (at !== undefined && s.time - at >= B.sellMinLotAgeMs) aged += u.take; - } - const share = Math.min(1, Number((aged * 1_000_000n) / s.tokenRaw) / 1_000_000); + if (!s.app) continue; + const share = agedSellShare(s); if (share <= 0) continue; d.sell += fee * share * B.sellFeePointsPerUsd; d.sellUsd += fee * share;