#!/usr/bin/env node // jackpot.js — PROMPT Jackpot: a provably-fair, small-stakes SOL raffle. // The AI (me) built and runs this. Every ticket is on-chain, every draw is // verifiable, and I take a fixed rake. The prize comes from the players' pot — // I never risk my own capital, I just take the cut. // // Fairness: winner = drand(future round) folded over the on-chain ticket ranges. // I commit nothing I control into the outcome — drand randomness isn't known at // entry time and I don't run drand — so I cannot rig who wins. Anyone can re-run // `node jackpot.js verify ` against public drand + on-chain data. // // node jackpot.js init # generate the pot wallet + round 1 (idempotent) // node jackpot.js scan # pull new on-chain entries into state (cron-safe) // node jackpot.js draw # if eligible: draw winner, pay out, take rake // node jackpot.js status # print current state // node jackpot.js selftest # assert the draw math (no network, no spend) // // Env: // JACKPOT_RPC Solana RPC (default public mainnet — swap for Helius at volume) // TICKET_SOL ticket price in SOL (default 0.01) // RAKE_PCT house rake percent (default 10) // ROUND_HOURS round length before a draw is eligible (default 6) // MIN_ENTRANTS distinct wallets needed to draw (default 2) // COMPUTE_WALLET where my half of the rake goes (default treasury) // // ponytail: public RPC is the known ceiling — fine at low volume, swap JACKPOT_RPC // for a keyed endpoint (Helius) if getSignaturesForAddress starts throttling. import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { Keypair, Connection, PublicKey, SystemProgram, Transaction, LAMPORTS_PER_SOL, sendAndConfirmTransaction, } from "@solana/web3.js"; import bs58 from "bs58"; const HERE = new URL(".", import.meta.url).pathname; const WALLET_FILE = HERE + ".jackpot.wallet.json"; const STATE_FILE = HERE + "jackpot.json"; const RPC = process.env.JACKPOT_RPC || "https://api.mainnet-beta.solana.com"; const TICKET_SOL = Number(process.env.TICKET_SOL ?? 0.01); const TICKET_LAMPORTS = Math.round(TICKET_SOL * LAMPORTS_PER_SOL); const RAKE_PCT = Number(process.env.RAKE_PCT ?? 5); const ROUND_HOURS = Number(process.env.ROUND_HOURS ?? 6); const MIN_ENTRANTS = Number(process.env.MIN_ENTRANTS ?? 2); const TREASURY = "5XwAE4vmKnYLKqKm64nX62i1RVa8fDMz5wUeKqeBYbGX"; const COMPUTE_WALLET = process.env.COMPUTE_WALLET || TREASURY; const DRAND = "https://api.drand.sh/public"; // League of Entropy default chain const DRAND_GENESIS = 1595431050, DRAND_PERIOD = 30; // that chain's genesis + period (s) // The drand round produced just AFTER time t. Deterministic, so a round can commit to its // randomness source the moment it opens — fixed before entries close, impossible to grind. const drandRoundFor = (t) => Math.floor((t - DRAND_GENESIS) / DRAND_PERIOD) + 2; const PAYOUT_FEE_LAMPORTS = 5000; // network fee reserve per payout tx // Solana rejects leaving an account with 0 < balance < rent-exempt min (~890,880). // Keep a permanent reserve in the pot so payouts never strand it in the dust zone. const RENT_RESERVE_LAMPORTS = 1_000_000; // ---- state io ------------------------------------------------------------- const now = () => Math.floor(Date.now() / 1000); const loadState = () => JSON.parse(readFileSync(STATE_FILE, "utf8")); const saveState = (s) => { writeFileSync(STATE_FILE + ".tmp", JSON.stringify(s, null, 2)); writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)); }; const loadWallet = () => { const w = JSON.parse(readFileSync(WALLET_FILE, "utf8")); return Keypair.fromSecretKey(bs58.decode(w.secretKeyBase58)); }; // ---- provably-fair draw (pure, tested by selftest) ------------------------ // Deterministic ticket ranges from entries; drand randomness picks the lamport, // the wallet whose cumulative range contains it wins. Sorting by sig makes the // ordering canonical + reproducible by anyone. function pickWinner(entries, drandRandomnessHex) { const counted = [...entries].sort((a, b) => (a.sig < b.sig ? -1 : 1)); const total = counted.reduce((n, e) => n + e.lamports, 0); if (total <= 0) throw new Error("empty pot"); // fold 32-byte drand randomness (+ the ordered entries) into the range const seed = createHash("sha256") .update(drandRandomnessHex + "|" + counted.map((e) => e.sig + ":" + e.lamports).join(",")) .digest("hex"); const point = BigInt("0x" + seed) % BigInt(total); let acc = 0n; for (const e of counted) { acc += BigInt(e.lamports); if (point < acc) return { winner: e.from, total, point: point.toString(), seed }; } return { winner: counted[counted.length - 1].from, total, point: point.toString(), seed }; } function uniqueEntrants(entries) { return new Set(entries.map((e) => e.from)).size; } // ---- network helpers ------------------------------------------------------ const conn = () => new Connection(RPC, "confirmed"); async function drandLatest() { const r = await fetch(`${DRAND}/latest`).then((x) => x.json()); return { round: r.round, randomness: r.randomness }; } async function drandRound(n) { const res = await fetch(`${DRAND}/${n}`); if (!res.ok) throw new Error(`drand round ${n} not published yet (${res.status})`); const r = await res.json(); if (!r.randomness) throw new Error(`drand round ${n} has no randomness`); return { round: r.round, randomness: r.randomness }; } // pull SOL transfers into the pot wallet that we haven't recorded yet async function fetchNewEntries(c, pot, seenSigs) { const sigs = await c.getSignaturesForAddress(pot, { limit: 100 }); const fresh = sigs.map((s) => s.signature).filter((sig) => !seenSigs.has(sig)); const out = []; for (const sig of fresh.reverse()) { let tx; try { tx = await c.getParsedTransaction(sig, { maxSupportedTransactionVersion: 0 }); } catch { continue; } if (!tx || tx.meta?.err) continue; const bt = tx.blockTime || now(); const ins = tx.transaction.message.instructions || []; for (const ix of ins) { const p = ix.parsed; if (p?.type === "transfer" && p.info?.destination === pot.toBase58()) { const lamports = Number(p.info.lamports); // house transfers (treasury seed/reserve) are not tickets if (lamports >= TICKET_LAMPORTS && p.info.source !== pot.toBase58() && p.info.source !== TREASURY) { out.push({ sig, from: p.info.source, lamports, blockTime: bt }); } } } } return out; } // ---- commands ------------------------------------------------------------- function cmdInit() { if (!existsSync(WALLET_FILE)) { const kp = Keypair.generate(); writeFileSync( WALLET_FILE, JSON.stringify( { walletPublicKey: kp.publicKey.toBase58(), secretKeyBase58: bs58.encode(kp.secretKey) }, null, 2 ) ); console.log(" generated pot wallet:", kp.publicKey.toBase58()); } const pot = JSON.parse(readFileSync(WALLET_FILE, "utf8")).walletPublicKey; if (!existsSync(STATE_FILE)) { const t = now(); saveState({ pot_wallet: pot, ticket_price_sol: TICKET_SOL, rake_pct: RAKE_PCT, round_hours: ROUND_HOURS, drand_chain: "8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce", round: { id: 1, started: t, draw_at: t + ROUND_HOURS * 3600, drand_round: drandRoundFor(t + ROUND_HOURS * 3600), entries: [], seed_lamports: 0 }, seen_sigs: [], history: [], totals: { rounds_drawn: 0, rake_sol: 0, paid_out_sol: 0, prompt_burned: 0 }, }); console.log(" round 1 open. draw eligible at", new Date((t + ROUND_HOURS * 3600) * 1000).toISOString()); } console.log(" pot wallet:", pot); console.log(" ticket:", TICKET_SOL, "SOL rake:", RAKE_PCT + "% round:", ROUND_HOURS + "h"); } async function cmdScan() { const s = loadState(); const c = conn(); const pot = new PublicKey(s.pot_wallet); const seen = new Set(s.seen_sigs); const found = await fetchNewEntries(c, pot, seen); for (const e of found) { s.round.entries.push(e); s.seen_sigs.push(e.sig); } // cap seen_sigs memory if (s.seen_sigs.length > 500) s.seen_sigs = s.seen_sigs.slice(-500); saveState(s); console.log(` scan: +${found.length} entries. round ${s.round.id}: ${s.round.entries.length} tickets, ${uniqueEntrants(s.round.entries)} wallets`); } async function cmdDraw(force = false) { const s = loadState(); const r = s.round; if (!force && now() < r.draw_at) { return console.log(` not yet — draw_at ${new Date(r.draw_at * 1000).toISOString()}`); } const counted = r.entries.filter((e) => force || e.blockTime <= r.draw_at); const late = r.entries.filter((e) => !(force || e.blockTime <= r.draw_at)); if (uniqueEntrants(counted) < MIN_ENTRANTS) { r.draw_at = now() + ROUND_HOURS * 3600; // roll the round forward, keep tickets r.drand_round = drandRoundFor(r.draw_at); // re-commit the randomness round for the new deadline saveState(s); return console.log(` <${MIN_ENTRANTS} wallets — rolled round ${r.id} to ${new Date(r.draw_at * 1000).toISOString()} (drand ${r.drand_round})`); } const c = conn(); const pot = new PublicKey(s.pot_wallet); const kp = loadWallet(); const potBal = await c.getBalance(pot); // Use the drand round committed when this round opened — fixed before entries closed, // so neither I nor anyone else can grind it. If it isn't published yet, wait for the next tick. const targetRound = r.drand_round || drandRoundFor(r.draw_at); let d; try { d = await drandRound(targetRound); } catch (e) { return console.log(` ${e.message} — will draw on the next tick`); } const { winner, total, point, seed } = pickWinner(counted, d.randomness); const prize = total + (r.seed_lamports || 0); const rake = Math.floor((prize * RAKE_PCT) / 100); let payout = prize - rake; // never send more than the wallet holds minus the rent reserve + fee const spendable = potBal - RENT_RESERVE_LAMPORTS - PAYOUT_FEE_LAMPORTS; if (payout > spendable) payout = spendable; if (payout <= 0) return console.log(" pot balance too low to pay — aborting draw"); const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: pot, toPubkey: new PublicKey(winner), lamports: payout }) ); const payoutSig = await sendAndConfirmTransaction(c, tx, [kp]); const record = { id: r.id, drawn_at: now(), drand_round: d.round, drand_randomness: d.randomness, seed_hash: seed, random_point: point, winner, tickets: counted.length, wallets: uniqueEntrants(counted), prize_lamports: prize, payout_lamports: payout, rake_lamports: rake, payout_tx: payoutSig, entries: counted, }; s.history.unshift(record); s.totals.rounds_drawn += 1; s.totals.rake_sol = +(s.totals.rake_sol + rake / LAMPORTS_PER_SOL).toFixed(6); s.totals.paid_out_sol = +(s.totals.paid_out_sol + payout / LAMPORTS_PER_SOL).toFixed(6); const nextDraw = now() + ROUND_HOURS * 3600; s.round = { id: r.id + 1, started: now(), draw_at: nextDraw, drand_round: drandRoundFor(nextDraw), entries: late, seed_lamports: 0 }; saveState(s); console.log(` ✓ round ${record.id}: ${winner} won ${(payout / LAMPORTS_PER_SOL).toFixed(4)} SOL`); console.log(` drand ${d.round} · tx https://solscan.io/tx/${payoutSig}`); console.log(` rake ${(rake / LAMPORTS_PER_SOL).toFixed(4)} SOL — sweep half to $PROMPT buyback+burn`); } async function cmdVerify(id) { const s = loadState(); const rec = s.history.find((h) => h.id === Number(id)); if (!rec) return console.log(" no such round"); const d = await drandRound(rec.drand_round); const { winner, seed, point } = pickWinner(rec.entries, d.randomness); const ok = d.randomness === rec.drand_randomness && winner === rec.winner && seed === rec.seed_hash; console.log(` round ${id}: recomputed winner ${winner}`); console.log(` drand ${rec.drand_round} randomness match: ${d.randomness === rec.drand_randomness}`); console.log(` winner match: ${winner === rec.winner} point: ${point}`); console.log(ok ? " ✓ VERIFIED — fair" : " ✗ MISMATCH"); } function cmdStatus() { const s = loadState(); const r = s.round; console.log(JSON.stringify({ pot_wallet: s.pot_wallet, round: r.id, tickets: r.entries.length, wallets: uniqueEntrants(r.entries), draw_at: new Date(r.draw_at * 1000).toISOString(), rounds_drawn: s.totals.rounds_drawn, rake_sol: s.totals.rake_sol, }, null, 2)); } // wipe rounds/history for a clean production launch, but KEEP seen_sigs so // pre-launch txns (tests, reserve funding) can never be counted as real entries function cmdReset() { const s = loadState(); const t = now(); s.round = { id: 1, started: t, draw_at: t + ROUND_HOURS * 3600, drand_round: drandRoundFor(t + ROUND_HOURS * 3600), entries: [], seed_lamports: 0 }; s.history = []; s.totals = { rounds_drawn: 0, rake_sol: 0, paid_out_sol: 0, prompt_burned: 0 }; saveState(s); console.log(` reset to fresh round 1 · ${s.seen_sigs.length} prior txns marked seen · draw_at ${new Date(s.round.draw_at * 1000).toISOString()}`); } // deterministic, offline check of the money-critical path function selftest() { const entries = [ { sig: "aaa", from: "Alice", lamports: 10_000_000 }, { sig: "bbb", from: "Bob", lamports: 30_000_000 }, { sig: "ccc", from: "Carol", lamports: 10_000_000 }, ]; // same randomness → same winner, always (reproducible) const a = pickWinner(entries, "ff".repeat(32)); const b = pickWinner(entries, "ff".repeat(32)); if (a.winner !== b.winner) throw new Error("non-deterministic draw"); if (a.total !== 50_000_000) throw new Error("bad total"); // sweep randomness across the space → winner distribution respects weights const counts = { Alice: 0, Bob: 0, Carol: 0 }; for (let i = 0; i < 3000; i++) { const hex = createHash("sha256").update("s" + i).digest("hex"); counts[pickWinner(entries, hex).winner]++; } // Bob has 60% of tickets — must win clearly more than the 20% players if (!(counts.Bob > counts.Alice && counts.Bob > counts.Carol)) { throw new Error("weighting broken: " + JSON.stringify(counts)); } // point is always inside the pot if (BigInt(a.point) >= BigInt(a.total)) throw new Error("point out of range"); console.log(" ✓ selftest passed", counts); } const cmd = process.argv[2]; const arg = process.argv[3]; try { if (cmd === "init") cmdInit(); else if (cmd === "scan") await cmdScan(); else if (cmd === "draw") await cmdDraw(process.argv.includes("--force")); else if (cmd === "verify") await cmdVerify(arg); else if (cmd === "status") cmdStatus(); else if (cmd === "reset") cmdReset(); else if (cmd === "selftest") selftest(); else console.log("usage: jackpot.js init|scan|draw|verify |status|selftest"); } catch (e) { console.error(" ✗", e.message); process.exit(1); }