// F-01, the off-chain half: the keeper's draw (keeper/strike-draw.mjs steps // 3-5, copied verbatim - the seeded weighted draw WITHOUT replacement and the // equal split) run over the fields the report describes. The on-chain half // (a one-unit stake is eligible; winnersFor) is test/SecondReview.t.sol. // // node analysis/repro-f01-draw.mjs import { ethers } from "../keeper/node_modules/ethers/lib.esm/index.js"; // winnersFor, as TicketStrike.sol:237-250. const MIN_WINNERS = 10; const winnersFor = (eligible, shareBps) => { if (eligible === 0) return 0; let n = Math.floor((eligible * shareBps + 9_999) / 10_000); if (n < MIN_WINNERS) n = MIN_WINNERS; return n > eligible ? eligible : n; }; // The OLD rule (before round 23): N distinct wallets, without replacement. function drawOld(eligible, seed, shareBps) { const drawTotal = eligible.reduce((s, e) => s + e.weight, 0n); const n = winnersFor(eligible.length, shareBps); const pool = eligible.slice(); let remaining = drawTotal; const winners = []; for (let i = 0; winners.length < n && pool.length > 0; i++) { const r = BigInt(ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "uint256"], [seed, i]))) % remaining; let acc = 0n, idx = 0; for (; idx < pool.length; idx++) { acc += pool[idx].weight; if (r < acc) break; } const [w] = pool.splice(idx, 1); remaining -= w.weight; winners.push(w.address); } return { n, winners }; } // The NEW rule, as strike-draw.mjs step 4 now: N tickets WITH replacement, // payout per ticket, aggregated by wallet. Returns the per-wallet fraction. function drawNew(eligible, seed, shareBps) { const drawTotal = eligible.reduce((s, e) => s + e.weight, 0n); const n = winnersFor(eligible.length, shareBps); const tickets = new Map(); for (let i = 0; i < n; i++) { const r = BigInt(ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "uint256"], [seed, i]))) % drawTotal; let acc = 0n, idx = 0; for (; idx < eligible.length; idx++) { acc += eligible[idx].weight; if (r < acc) break; } const a = eligible[idx].address; tickets.set(a, (tickets.get(a) || 0) + 1); } return { n, tickets }; } const attackerFractionNew = (tickets, n, isAttacker) => [...tickets].filter(([a]) => isAttacker(a)).reduce((s, [, k]) => s + k, 0) / n; const seedFor = (i) => ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "uint256"], [ethers.ZeroHash, i])); const attackerShare = (winners, isAttacker) => winners.filter(isAttacker).length / winners.length; const SEEDS = 200; const realField = () => { const e = []; for (let k = 0; k < 10; k++) e.push({ address: "real" + k, weight: (5_000n + BigInt(k) * 3_000n) * 10n ** 9n * 10n }); return e; }; const isDust = (a) => a.startsWith("dust"); function compare(label, eligible, shareBps) { let oldMin = 1, oldSum = 0, newMax = 0, newSum = 0; for (let i = 0; i < SEEDS; i++) { const o = attackerShare(drawOld(eligible, seedFor(i), shareBps).winners, isDust); const { n, tickets } = drawNew(eligible, seedFor(i), shareBps); const w = attackerFractionNew(tickets, n, isDust); oldMin = Math.min(oldMin, o); oldSum += o; newMax = Math.max(newMax, w); newSum += w; } console.log(`${label.padEnd(44)} share ${(shareBps / 100).toFixed(0).padStart(2)}% OLD: attacker mean ${(100 * oldSum / SEEDS).toFixed(1)}% (min ${(100 * oldMin).toFixed(1)}%) NEW: attacker mean ${(100 * newSum / SEEDS).toFixed(3)}% (max ${(100 * newMax).toFixed(3)}%)`); } // --- thin field: one genuine staker, nine one-unit dust wallets { const e = [{ address: "whale", weight: 40_000n * 10n ** 9n * 4n }]; for (let k = 0; k < 9; k++) e.push({ address: "dust" + k, weight: 1n }); for (const s of [100, 1000]) compare("1 whale + 9 dust", e, s); } // --- realistic field: ten genuine stakers, 2,000 dust wallets { const e = realField(); for (let k = 0; k < 2000; k++) e.push({ address: "dust" + k, weight: 1n }); for (const s of [100, 500, 1000]) compare("10 real + 2000 dust wallets", e, s); } // --- INVARIANCE: the same 2,000 units of stake as one wallet vs 2,000 wallets, // and a real-sized attacker (10% of the field) split 1 way vs 200 ways. { const one = realField(); one.push({ address: "dust-as-one", weight: 2000n }); const many = realField(); for (let k = 0; k < 2000; k++) many.push({ address: "dust" + k, weight: 1n }); const f = (e) => { let s = 0; for (let i = 0; i < SEEDS; i++) { const { n, tickets } = drawNew(e, seedFor(i), 100); s += attackerFractionNew(tickets, n, isDust); } return 100 * s / SEEDS; }; console.log(` invariance, NEW rule, 1% share: 2000 units as ONE wallet -> attacker mean ${f(one).toFixed(4)}%; as 2000 wallets -> ${f(many).toFixed(4)}% (weight share of field: ${(100 * 2000 / Number(realField().reduce((s, e) => s + e.weight, 0n) + 2000n)).toExponential(2)}%)`); const W = realField().reduce((s, e) => s + e.weight, 0n) / 9n; // ~10% of the field const big1 = realField(); big1.push({ address: "dust-big", weight: W }); const big200 = realField(); for (let k = 0; k < 200; k++) big200.push({ address: "dust" + k, weight: W / 200n }); console.log(`invariance, NEW rule, 1% share: a 10%-of-field stake as ONE wallet -> ${f(big1).toFixed(2)}%; split 200 ways -> ${f(big200).toFixed(2)}% (weight share: 10.00%)`); const g = (e) => { let s = 0; for (let i = 0; i < SEEDS; i++) s += attackerShare(drawOld(e, seedFor(i), 100).winners, isDust); return 100 * s / SEEDS; }; console.log(`same two fields, OLD rule: ONE wallet -> ${g(big1).toFixed(2)}%; split 200 ways -> ${g(big200).toFixed(2)}%`); }