// PUMP CUP payout scoring: the exact code used to split the War Chest at ceasefire. // Published for transparency. /** * Pure payout scoring (no I/O). Rules approved by the user: * - Window: T0 = tournament start, T1 = END - 2h, END = tournament end. * - Score = time-integral of balance (token-hours) from T0 to T1. Tokens * acquired after T1 earn nothing. * - Every partial sell (any balance decrease: swap, transfer out, burn) * between T0 and END multiplies the score by `sellMultiplier` (0.85). * - Hitting a zero balance wipes everything accumulated so far (score and * sell count). A later buy starts fresh from that moment. * - Eligible only with value at T1 >= minUsd AND balance > 0 at END, and not * an excluded wallet (curve, pools / any off-curve PDA owner, deployer, * War Chest, team wallets). * - Share = score / total score; lamports floor-divided, remainder to #1. * * Interpretation note: the multiplier applies to the wallet's whole score * since its last reset (score = token-hours x 0.85^sells), which is what the * breakdown reports as "penalty multiplier". */ export type BalancePoint = { /** unix seconds */ t: number; /** balance after this tx, in whole tokens (decimals applied) */ balance: number; signature?: string; }; export type ScoreParams = { t0: number; t1: number; end: number; sellMultiplier: number; }; export type WalletScore = { tokenHours: number; sells: number; multiplier: number; score: number; resets: { t: number; signature?: string }[]; sellEvents: { t: number; from: number; to: number; signature?: string }[]; balanceAtT0: number; balanceAtT1: number; balanceAtEnd: number; }; /** `points` must be in chronological order and may start before T0 (history from creation). */ export function scoreWallet(points: BalancePoint[], p: ScoreParams): WalletScore { let bal = 0; let i = 0; while (i < points.length && points[i].t < p.t0) bal = points[i++].balance; const balanceAtT0 = bal; let acc = 0; // token-seconds since last reset let sells = 0; let lastT = p.t0; const resets: WalletScore["resets"] = []; const sellEvents: WalletScore["sellEvents"] = []; let balanceAtT1 = bal; const accrueTo = (t: number) => { const a = Math.min(lastT, p.t1); const b = Math.min(t, p.t1); if (b > a) acc += bal * (b - a); lastT = Math.max(lastT, t); }; for (; i < points.length && points[i].t <= p.end; i++) { const pt = points[i]; accrueTo(pt.t); const next = pt.balance; if (next < bal) { if (next <= 0) { acc = 0; sells = 0; resets.push({ t: pt.t, signature: pt.signature }); } else { sells++; } sellEvents.push({ t: pt.t, from: bal, to: next, signature: pt.signature }); } bal = Math.max(0, next); if (pt.t <= p.t1) balanceAtT1 = bal; } accrueTo(p.t1); const balanceAtEnd = bal; const tokenHours = acc / 3600; const multiplier = Math.pow(p.sellMultiplier, sells); return { tokenHours, sells, multiplier, score: tokenHours * multiplier, resets, sellEvents, balanceAtT0, balanceAtT1, balanceAtEnd, }; } export type ExclusionCtx = { /** owner -> label (deployer, war chest, team, bonding curve, pool) */ excluded: Map; /** true if the address is a normal wallet (on the ed25519 curve) */ isOnCurve: (owner: string) => boolean; }; export function exclusionReason(owner: string, ctx: ExclusionCtx): string | null { const label = ctx.excluded.get(owner); if (label) return label; if (!ctx.isOnCurve(owner)) return "off-curve owner (PDA: pool, vault or program)"; return null; } export type PayoutRow = WalletScore & { owner: string; valueUsdAtT1: number; eligible: boolean; reason: string | null; share: number; lamports: bigint; }; export function computePayout(opts: { timelines: Map; params: ScoreParams & { minUsd: number; topN?: number | null }; priceUsdAtT1: number; exclusion: ExclusionCtx; chestLamports: bigint; }): { rows: PayoutRow[]; totalScore: number } { const { params } = opts; const rows: PayoutRow[] = []; for (const [owner, points] of opts.timelines) { const s = scoreWallet(points, params); const valueUsdAtT1 = s.balanceAtT1 * opts.priceUsdAtT1; let reason = exclusionReason(owner, opts.exclusion); if (!reason && s.balanceAtEnd <= 0) reason = "no balance at END"; if (!reason && valueUsdAtT1 < params.minUsd) reason = `held < $${params.minUsd} at T1`; if (!reason && s.score <= 0) reason = "zero score"; rows.push({ owner, ...s, valueUsdAtT1, eligible: !reason, reason, share: 0, lamports: BigInt(0) }); } rows.sort((a, b) => b.score - a.score || a.owner.localeCompare(b.owner)); let eligible = rows.filter((r) => r.eligible); if (params.topN && eligible.length > params.topN) { for (const r of eligible.slice(params.topN)) { r.eligible = false; r.reason = `outside top ${params.topN}`; } eligible = eligible.slice(0, params.topN); } const totalScore = eligible.reduce((s, r) => s + r.score, 0); const weights = eligible.map((r) => toWeight(r.score)); const lamports = allocateLamports(opts.chestLamports, weights); eligible.forEach((r, i) => { r.share = totalScore > 0 ? r.score / totalScore : 0; r.lamports = lamports[i]; }); return { rows, totalScore }; } /** Score -> integer weight (micro token-hours) so the split is exact integer math. */ export function toWeight(score: number): bigint { return BigInt(Math.floor(score * 1_000_000)); } /** Floor pro-rata split of `total` by `weights`; the rounding remainder goes to the largest weight (#1). */ export function allocateLamports(total: bigint, weights: bigint[]): bigint[] { const zero = BigInt(0); const sum = weights.reduce((a, b) => a + b, zero); if (sum === zero || total <= zero) return weights.map(() => zero); const out = weights.map((w) => (total * w) / sum); const rem = total - out.reduce((a, b) => a + b, zero); let top = 0; for (let i = 1; i < weights.length; i++) if (weights[i] > weights[top]) top = i; out[top] += rem; return out; } /** Equal split helper (kept for site.vault.split = "equal"). */ export function allocateEqual(total: bigint, n: number): bigint[] { if (n <= 0) return []; const each = total / BigInt(n); const out = Array.from({ length: n }, () => each); out[0] += total - each * BigInt(n); return out; } // ── Balance timelines from per-token-account balance changes ─────────────── export type AccountChange = { account: string; owner: string; post: bigint }; export type TxChanges = { signature: string; slot: number; t: number; changes: AccountChange[] }; /** * Replays txs (chronological) as per-token-account balance snapshots and * returns per-owner balance timelines (one point per tx that changed the * owner's total). Owner balance = sum of all its token accounts for the mint, * so moving tokens between your own accounts is not a sell. */ export function buildTimelines(txs: TxChanges[], decimals: number): Map { const accBal = new Map(); const accOwner = new Map(); const ownerBal = new Map(); const out = new Map(); const scale = Math.pow(10, decimals); for (const tx of txs) { const touched = new Set(); const before = new Map(); for (const c of tx.changes) { const prevOwner = accOwner.get(c.account); const prev = accBal.get(c.account) ?? BigInt(0); for (const o of [prevOwner, c.owner]) { if (o && !before.has(o)) before.set(o, ownerBal.get(o) ?? BigInt(0)); } if (prevOwner) ownerBal.set(prevOwner, (ownerBal.get(prevOwner) ?? BigInt(0)) - prev); ownerBal.set(c.owner, (ownerBal.get(c.owner) ?? BigInt(0)) + c.post); accBal.set(c.account, c.post); accOwner.set(c.account, c.owner); if (prevOwner) touched.add(prevOwner); touched.add(c.owner); } for (const o of touched) { const now = ownerBal.get(o) ?? BigInt(0); if (now === before.get(o)) continue; const list = out.get(o) ?? []; list.push({ t: tx.t, balance: Number(now) / scale, signature: tx.signature }); out.set(o, list); } } return out; } /** Final per-account balances after replaying `txs` (for reconciliation against chain). */ export function finalAccountBalances(txs: TxChanges[]): Map { const m = new Map(); for (const tx of txs) for (const c of tx.changes) m.set(c.account, c.post); return m; } export type Trade = { t: number; signature: string; solLamports: bigint; tokenRaw: bigint }; /** SOL per whole token from the last curve/pool trade at or before `t`. */ export function priceSolAt(trades: Trade[], t: number, decimals: number): { priceSol: number; trade: Trade } | null { let best: Trade | null = null; for (const tr of trades) if (tr.t <= t && tr.tokenRaw !== BigInt(0) && tr.solLamports !== BigInt(0)) if (!best || tr.t >= best.t) best = tr; if (!best) return null; const sol = Math.abs(Number(best.solLamports)) / 1e9; const tokens = Math.abs(Number(best.tokenRaw)) / Math.pow(10, decimals); return { priceSol: sol / tokens, trade: best }; }