feat(football-tote): pari-mutuel tote demo on chipnet + web platform + wiki

Real WC2026-fixture bet placed and settled on chipnet; deployable web platform in design/football-tote plus the chipnet test and tote server; the oracle-result-publish path for third-party totes. Snapshot 0176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
walerikus 2026-06-26 22:16:29 +02:00
parent bc363a85df
commit 0398d7481b
5 changed files with 853 additions and 0 deletions

View file

@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Football Tote · World Cup 2026 · Bitcoin Cash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--ink:#1a1a1a;--ink-soft:#2a2a2a;--paper:#f4ede1;--paper-warm:#ebe1cf;--paper-soft:#f0e7d6;--acid:#d6ff3d;--rust:#a8442e;--gold:#a87a35;--green:#355a3d;--blue:#2c4a6b;--muted:#6a5d4a;}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui,sans-serif;line-height:1.55;-webkit-font-smoothing:antialiased;}
.mono{font-family:'JetBrains Mono',monospace;}
.wrap{max-width:940px;margin:0 auto;padding:40px 24px 80px;}
header.master{border-bottom:3px solid var(--ink);padding-bottom:24px;margin-bottom:28px;position:relative;}
.kicker{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.28em;text-transform:uppercase;color:var(--muted);font-weight:600;}
h1{font-size:clamp(34px,6vw,56px);font-weight:800;line-height:1;letter-spacing:-.03em;margin-top:12px;}
h1 .acc{color:var(--rust);}
.sub{font-size:17px;color:var(--ink-soft);margin-top:14px;max-width:680px;}
.badge{position:absolute;top:0;right:0;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:var(--green);border:1.5px solid var(--green);padding:4px 10px;border-radius:4px;font-weight:600;}
.layout{display:grid;grid-template-columns:300px 1fr;gap:24px;}
@media(max-width:760px){.layout{grid-template-columns:1fr;}}
.panel{background:var(--paper-warm);border-radius:10px;padding:18px 20px;}
.lbl{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);font-weight:600;margin-bottom:12px;}
.match{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1.5px solid transparent;border-radius:8px;cursor:pointer;margin-bottom:7px;background:var(--paper-soft);transition:border-color .12s;}
.match:hover{border-color:var(--gold);}
.match.act{border-color:var(--rust);background:var(--paper);}
.match .teams{font-size:14px;font-weight:600;}
.match .meta{font-size:11px;color:var(--muted);margin-top:2px;}
.match .vol{margin-left:auto;font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--ink-soft);text-align:right;}
.card{background:var(--paper);border:1.5px solid var(--paper-warm);border-radius:12px;padding:24px;}
.mhead{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:8px;}
.mhead .t{font-size:24px;font-weight:800;letter-spacing:-.02em;}
.mhead .t .vs{color:var(--gold);font-weight:500;font-size:18px;margin:0 6px;}
.pill{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;padding:3px 8px;border-radius:4px;font-weight:600;}
.pill.real{color:var(--green);background:rgba(53,90,61,.12);}
.when{font-size:13px;color:var(--muted);margin-top:6px;}
.outcomes{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:22px 0 18px;}
@media(max-width:520px){.outcomes{grid-template-columns:1fr;}}
.oc{border:2px solid var(--paper-warm);border-radius:10px;padding:14px;cursor:pointer;background:var(--paper-soft);transition:border-color .12s,transform .08s;}
.oc:hover{border-color:var(--gold);}
.oc.sel{border-color:var(--rust);background:var(--paper);}
.oc .name{font-size:14px;font-weight:600;}
.oc .odds{font-family:'JetBrains Mono',monospace;font-size:28px;font-weight:600;margin-top:6px;}
.oc .pool{font-size:11px;color:var(--muted);margin-top:3px;}
.oc .you{font-size:11px;color:var(--rust);font-weight:600;margin-top:2px;}
.betbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
input[type=number]{font-family:'JetBrains Mono',monospace;font-size:15px;width:120px;padding:9px 11px;border:1.5px solid var(--paper-warm);border-radius:8px;background:var(--paper);color:var(--ink);}
input[type=number]:focus{outline:none;border-color:var(--rust);}
.btn{font-family:Inter,sans-serif;font-size:14px;font-weight:600;padding:10px 18px;border-radius:8px;border:1.5px solid var(--ink);background:var(--ink);color:var(--paper);cursor:pointer;transition:transform .08s;}
.btn:hover{transform:translateY(-1px);}
.btn:active{transform:scale(.98);}
.btn.ghost{background:transparent;color:var(--ink);}
.btn.sm{font-size:13px;padding:7px 13px;}
.note{font-size:12px;color:var(--muted);margin-top:10px;}
.settle{border-top:1.5px solid var(--paper-warm);margin-top:22px;padding-top:18px;}
.settle-btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
.result{margin-top:16px;background:var(--paper-soft);border-left:4px solid var(--green);border-radius:0 8px 8px 0;padding:14px 16px;}
.result .win{color:var(--green);font-weight:700;}
.result .lose{color:var(--rust);font-weight:600;}
.how{margin-top:36px;background:var(--ink);color:var(--paper);border-radius:12px;padding:26px 28px;}
.how h3{font-size:13px;font-family:'JetBrains Mono',monospace;letter-spacing:.16em;text-transform:uppercase;color:var(--acid);font-weight:600;margin-bottom:16px;}
.how table{width:100%;border-collapse:collapse;font-size:13.5px;}
.how td{padding:9px 0;border-bottom:1px solid rgba(244,237,225,.13);vertical-align:top;}
.how td:first-child{width:42%;font-weight:600;padding-right:18px;}
.how td:last-child{color:#d8cfbf;}
.how tr:last-child td{border-bottom:none;}
footer{margin-top:40px;border-top:1.5px solid var(--paper-warm);padding-top:20px;font-size:12.5px;color:var(--muted);line-height:1.7;}
footer strong{color:var(--ink);}
.live{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);margin-right:6px;vertical-align:middle;}
</style>
</head>
<body>
<div class="wrap">
<header class="master">
<div class="badge"><span class="live"></span>chipnet</div>
<div class="kicker">Deviant · pari-mutuel on Bitcoin Cash</div>
<h1>Football <span class="acc">Tote.</span></h1>
<div class="sub">Create and join pari-mutuel bets on FIFA World Cup 2026 matches. Pools and payouts are enforced by an on-chain covenant; results are settled by an oracle-signed attestation published to Bitcoin Cash — no bookmaker, no house edge, no server holding the pot.</div>
</header>
<div class="layout">
<div>
<div class="panel">
<div class="lbl">World Cup 2026 · group stage</div>
<div id="matches"></div>
</div>
</div>
<div>
<div class="card" id="panel"></div>
</div>
</div>
<div class="how">
<h3>What each action is, on-chain</h3>
<table>
<tr><td>Place bet</td><td>bet() — your stake folds into the single OpenTote pool UTXO; the covenant enforces the peg.</td></tr>
<tr><td>Odds shift as pools fill</td><td>Pure pari-mutuel: decimal odds = total pool ÷ stake on that outcome. No bookmaker sets a price.</td></tr>
<tr><td>Oracle settles the result</td><td>The result oracle signs (gameId, outcome) and publishes it on-chain; settle() verifies it with checkDataSig.</td></tr>
<tr><td>Payout</td><td>The winning side splits the whole pool pro-rata to stake. The covenant releases it; no one can withhold it.</td></tr>
</table>
</div>
<footer>
<p><span class="live"></span><strong>Validated on chipnet.</strong> The pool mechanism, oracle-gated settlement, and pro-rata payout are proven end-to-end on Bitcoin Cash testnet (<span class="mono">chipnet-opentote-test.mjs</span>, <span class="mono">chipnet-game-oracle-test.mjs</span>, <span class="mono">chipnet-football-tote-test.mjs</span>). This page runs the pool math in your browser for instant interaction; the production build replaces it with live <span class="mono">OpenTote</span> transactions and a results oracle fed by a football-data feed.</p>
<p style="margin-top:10px;">Fixtures: <strong>Mexico vs South Africa</strong> is the real confirmed opener (Jun 11, Estadio Azteca). The other three use real World Cup 2026 nations as illustrative group-stage matchups. Stakes shown in <span class="mono">tBCH</span> (testnet). For three-way 1/X/2 markets the production build uses the K-outcome tote variant.</p>
</footer>
</div>
<script>
const STAKE='tBCH';
const matches=[
{id:'mex',g:'Group A',when:'Jun 11 · Estadio Azteca, Mexico City',home:'Mexico',away:'South Africa',pools:[1200,430,370],real:true},
{id:'usa',g:'Group stage',when:'Jun 12 · SoFi Stadium, Inglewood',home:'USA',away:'Wales',pools:[980,520,500]},
{id:'arg',g:'Group stage',when:'Jun 13',home:'Argentina',away:'Croatia',pools:[1850,560,590]},
{id:'bra',g:'Group stage',when:'Jun 14',home:'Brazil',away:'Morocco',pools:[1500,470,530]}
];
let activeId='mex',sel=0,userBets={},LIVE=null,liveStatus='',connectedWif=null,connectedInfo=null;
const FLAGS={'Mexico':'mx','South Africa':'za','USA':'us','Wales':'gb-wls','Argentina':'ar','Croatia':'hr','Brazil':'br','Morocco':'ma'};
function flag(team){const c=FLAGS[team];return c?'<img src="https://flagcdn.com/h40/'+c+'.png" alt="" style="height:0.78em;width:auto;vertical-align:-1px;margin-right:6px;border-radius:2px;border:0.5px solid rgba(0,0,0,.14)">':'';}
const fmt=n=>Math.round(n).toLocaleString();
const total=m=>m.pools[0]+m.pools[1]+m.pools[2];
const dec=(m,i)=>(total(m)/m.pools[i]).toFixed(2);
const outcomes=m=>[m.home+' win','Draw',m.away+' win'];
function renderMatches(){
document.getElementById('matches').innerHTML=matches.map(m=>
'<div class="match '+(m.id===activeId?'act':'')+'" data-id="'+m.id+'"><div><div class="teams">'+flag(m.home)+m.home+' v '+flag(m.away)+m.away+'</div><div class="meta">'+m.g+'</div></div><div class="vol">'+fmt(total(m))+'<br><span style="font-size:10px;color:var(--muted)">'+STAKE+'</span></div></div>'
).join('');
document.querySelectorAll('.match').forEach(el=>el.onclick=()=>{activeId=el.dataset.id;sel=0;renderMatches();renderPanel();});
}
function renderPanel(){
if(activeId==='mex'&&LIVE){return renderLivePanel();}
const m=matches.find(x=>x.id===activeId),oc=outcomes(m),mb=userBets[m.id]||{},t=total(m);
const myTotal=Object.values(mb).reduce((a,b)=>a+b,0);
const cards=oc.map((name,i)=>{const mine=mb[i]||0;
return '<div class="oc '+(i===sel?'sel':'')+'" data-i="'+i+'"><div class="name">'+name+'</div><div class="odds">'+dec(m,i)+'<span style="font-size:15px">×</span></div><div class="pool">pool '+fmt(m.pools[i])+' '+STAKE+'</div>'+(mine?'<div class="you">you staked '+fmt(mine)+'</div>':'')+'</div>';
}).join('');
document.getElementById('panel').innerHTML=
'<div class="mhead"><div class="t">'+flag(m.home)+m.home+'<span class="vs">v</span>'+flag(m.away)+m.away+'</div>'+(m.real?'<span class="pill real">confirmed fixture</span>':'')+'</div>'+
'<div class="when">'+m.g+' · '+m.when+'</div>'+
'<div class="outcomes">'+cards+'</div>'+
'<div class="betbar"><input id="stake" type="number" min="1" step="1" value="50"><span class="mono" style="font-size:13px;color:var(--muted)">'+STAKE+'</span><button class="btn" id="bet">Place bet on '+oc[sel].replace(' win','')+'</button></div>'+
'<div class="note">Pool '+fmt(t)+' '+STAKE+' · your total stake here '+fmt(myTotal)+' '+STAKE+' · if your pick wins you take your pro-rata share of the whole pool.</div>'+
'<div class="settle"><div class="lbl" style="margin-bottom:4px">Oracle settles the result</div><div class="note" style="margin-top:0">When the match ends, the result oracle signs the outcome and the covenant pays the winning side. Pick the result to simulate it:</div><div class="settle-btns">'+oc.map((name,i)=>'<button class="btn ghost sm settle-b" data-w="'+i+'">'+name+'</button>').join('')+'</div><div id="result"></div></div>';
document.querySelectorAll('.oc').forEach(el=>el.onclick=()=>{sel=+el.dataset.i;renderPanel();});
document.getElementById('bet').onclick=()=>{
const s=Math.max(1,Math.round(+document.getElementById('stake').value||0));
m.pools[sel]+=s;userBets[m.id]=userBets[m.id]||{};userBets[m.id][sel]=(userBets[m.id][sel]||0)+s;
renderMatches();renderPanel();
};
document.querySelectorAll('.settle-b').forEach(b=>b.onclick=()=>settle(m,+b.dataset.w));
}
function settle(m,w){
const t=total(m),oc=outcomes(m),mb=userBets[m.id]||{},mineW=mb[w]||0;
const payout=mineW>0?mineW/m.pools[w]*t:0;
document.getElementById('result').innerHTML=
'<div class="result"><div style="font-weight:700;margin-bottom:4px">Result: '+oc[w]+'</div>'+
'<div style="font-size:13px;color:var(--muted)">Oracle signed (gameId, outcome '+w+') and published it on-chain. The '+fmt(t)+' '+STAKE+' pool splits among '+oc[w]+' backers, pro-rata to stake.</div>'+
'<div style="margin-top:9px;font-size:14px">'+(mineW>0?'You backed the winner with '+fmt(mineW)+' '+STAKE+' → <span class="win">payout '+fmt(payout)+' '+STAKE+'</span> ('+(payout/mineW).toFixed(2)+'×)':'<span class="lose">You did not back '+oc[w]+'</span> — no payout on this market.')+'</div></div>';
}
function sx(tx){return tx?('<span class="mono">'+tx.slice(0,14)+'…</span>'):'—';}
function renderLivePanel(){
sel=Math.min(sel,1);
const connectbar=connectedInfo
?'<div style="background:var(--paper-soft);border-radius:8px;padding:11px 14px;margin:14px 0;border-left:4px solid var(--green)"><div style="font-size:13px"><strong>Connected</strong> · <span class="mono">…'+connectedInfo.address.slice(-12)+'</span></div><div style="font-size:12px;color:var(--muted);margin-top:5px">'+fmt(connectedInfo.bch)+' sats · '+fmt(connectedInfo.stake)+' stake-tokens · bets fold from your key <button class="btn ghost sm" id="disc" style="margin-left:6px">disconnect</button></div></div>'
:'<div style="background:var(--paper-soft);border-radius:8px;padding:11px 14px;margin:14px 0;border-left:4px solid var(--gold)"><div style="font-size:12px;color:var(--muted);margin-bottom:8px">Bet from your own chipnet key — <strong>testnet only</strong>, never paste a mainnet WIF:</div><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><input id="wif" class="mono" placeholder="paste chipnet WIF…" style="flex:1;min-width:170px;font-size:12px;padding:8px 10px;border:1.5px solid var(--paper-warm);border-radius:8px;background:var(--paper);color:var(--ink)"><button class="btn sm" id="conn">Connect</button><button class="btn ghost sm" id="gen">Generate test key</button></div></div>';
const oc=['Mexico win','South Africa / draw'],sd=[LIVE.sides.yes,LIVE.sides.no];
const t=LIVE.poolTotal||(sd[0]+sd[1]),dd=i=>sd[i]>0?(t/sd[i]).toFixed(2):'—';
const cards=oc.map((name,i)=>'<div class="oc '+(sel===i?'sel':'')+'" data-i="'+i+'"><div class="name">'+name+'</div><div class="odds">'+dd(i)+'<span style="font-size:15px">×</span></div><div class="pool">pool '+fmt(sd[i])+' '+STAKE+'</div></div>').join('');
const betui=LIVE.settled
?'<div class="result"><div style="font-weight:700">Settled — winner: '+(LIVE.settled.winner==='yes'?'Mexico':'South Africa / draw')+'</div><div style="font-size:12px;color:var(--muted);margin-top:6px">publish '+sx(LIVE.settled.publishTx)+' · settle '+sx(LIVE.settled.settleTx)+'</div></div>'
:'<div class="betbar"><input id="stake" type="number" min="1" step="1" value="5"><span class="mono" style="font-size:13px;color:var(--muted)">'+STAKE+'</span><button class="btn" id="bet">Place real bet on '+(sel===0?'Mexico':'South Africa/draw')+'</button></div><div class="note">Total pool '+fmt(t)+' '+STAKE+' on-chain · clicking broadcasts a real OpenTote bet() transaction to chipnet (~15s).</div><div class="settle"><div class="lbl" style="margin-bottom:4px">Operator settles (oracle result)</div><div class="settle-btns"><button class="btn ghost sm" id="sy">Mexico won</button><button class="btn ghost sm" id="sn">South Africa / draw</button></div></div>';
document.getElementById('panel').innerHTML=
'<div class="mhead"><div class="t">'+flag('Mexico')+'Mexico<span class="vs">v</span>'+flag('South Africa')+'South Africa</div><span class="pill" style="color:var(--green);background:rgba(53,90,61,.12)"><span class="live"></span>live on chipnet</span></div>'+
'<div class="when">Group A · Jun 11 · Estadio Azteca · binary market: "Mexico to win?"</div>'+
connectbar+
'<div class="outcomes" style="grid-template-columns:1fr 1fr">'+cards+'</div>'+betui+
(liveStatus?'<div style="margin-top:14px;font-size:12px;color:var(--ink-soft);background:var(--paper-soft);border-radius:8px;padding:10px 12px">'+liveStatus+'</div>':'')+
'<div class="note" style="margin-top:10px">gameId <span class="mono">'+LIVE.gameId.slice(0,16)+'…</span> · tote <span class="mono">'+LIVE.toteAddress.slice(0,20)+'…</span></div>';
document.querySelectorAll('.oc').forEach(el=>el.onclick=()=>{sel=+el.dataset.i;renderPanel();});
const cn=document.getElementById('conn');if(cn)cn.onclick=()=>connectWif((document.getElementById('wif').value||'').trim());
const gk=document.getElementById('gen');if(gk)gk.onclick=()=>genKey();
const dc=document.getElementById('disc');if(dc)dc.onclick=()=>{connectedWif=null;connectedInfo=null;liveStatus='Disconnected — bets use the demo wallet again.';renderPanel();};
const b=document.getElementById('bet');if(b)b.onclick=()=>liveBet(sel===0?'yes':'no');
const sy=document.getElementById('sy');if(sy)sy.onclick=()=>liveSettle('yes');
const sn=document.getElementById('sn');if(sn)sn.onclick=()=>liveSettle('no');
}
async function liveBet(side){
const s=Math.max(1,Math.round(+(document.getElementById('stake')||{}).value||5));
liveStatus='Broadcasting a '+s+' '+STAKE+' bet on '+(side==='yes'?'Mexico':'South Africa/draw')+(connectedWif?' from your key':' (demo wallet)')+'…';renderPanel();
try{const r=await fetch('/api/bet',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({side,amount:s,wif:connectedWif})});const d=await r.json();
if(d.error){liveStatus='Error: '+d.error;}else{LIVE.poolTotal=d.poolTotal;LIVE.sides=d.sides;liveStatus='Bet confirmed — tx '+sx(d.txid);if(connectedWif)refreshConnected();}}
catch(e){liveStatus='Cannot reach server: '+e.message;}
renderPanel();
}
async function refreshConnected(){try{const r=await fetch('/api/connect',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wif:connectedWif})});const d=await r.json();if(!d.error){connectedInfo=d;renderPanel();}}catch(e){}}
async function connectWif(wif){
if(!wif){liveStatus='Paste a chipnet WIF first (or generate a test key).';return renderPanel();}
liveStatus='Connecting…';renderPanel();
try{const r=await fetch('/api/connect',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wif})});const d=await r.json();
if(d.error){liveStatus='Connect failed: '+d.error;}else{connectedWif=wif;connectedInfo=d;liveStatus='Connected as …'+d.address.slice(-10)+' — bets now fold from your key.';}}
catch(e){liveStatus='Cannot reach server: '+e.message;}
renderPanel();
}
async function genKey(){
liveStatus='Generating a funded chipnet test key… (~12s)';renderPanel();
try{const r=await fetch('/api/genkey',{method:'POST'});const d=await r.json();
if(d.error){liveStatus='Error: '+d.error;}else{await connectWif(d.wif);liveStatus='New funded test key — save this WIF: '+d.wif;renderPanel();}}
catch(e){liveStatus='Cannot reach server: '+e.message;renderPanel();}
}
async function liveSettle(winner){
liveStatus='Oracle signs the result + publishes it on-chain, tote pays the winning side…';renderPanel();
try{const r=await fetch('/api/settle',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({winner})});const d=await r.json();
if(d.error){liveStatus='Error: '+d.error;}else{LIVE.settled=d.settled;liveStatus='Settled on chipnet.';}}
catch(e){liveStatus='Cannot reach server: '+e.message;}
renderPanel();
}
async function loadLive(){try{const r=await fetch('/api/state');if(r.ok)LIVE=await r.json();}catch(e){LIVE=null;}}
loadLive().then(()=>{renderMatches();renderPanel();});
</script>
</body>
</html>

View file

@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* chipnet-football-tote-test.mjs — a LIVE Football Tote on chipnet for a real
* fixture. Ties together the two validated primitives:
* - OpenTote (oracle-settled pari-mutuel pool, contracts/OpenTote.cash)
* - on-chain result publishing (OP_RETURN, as in chipnet-game-oracle-test.mjs)
*
* Fixture: FIFA World Cup 2026 opener, Mexico vs South Africa (Jun 11, Estadio
* Azteca). Binary market "Mexico to win?": A backs YES (side 0), B backs NO
* (side 1). The result oracle signs (gameId, outcome) AND publishes it on-chain;
* the tote settles against that exact attestation; the winning side takes the pool.
*
* Roles: founder+operator+oracle-payer = C; bettors = A (YES), B (NO).
* Fresh each run. Requires .chipnet-keys{,-b,-c}.json funded.
*
* Run: node --import tsx scripts/chipnet-football-tote-test.mjs
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { secp256k1, sha256, decodeTransaction } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const MBA_ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/DeviantOpenMintFT.json'), 'utf8'));
const TOTE_ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/OpenTote.json'), 'utf8'));
const KEYS = { a: '.chipnet-keys.json', b: '.chipnet-keys-b.json', c: '.chipnet-keys-c.json' };
const RESERVOIR = 1_000_000_000_000_000_000n;
const RATE = 1000n, FEE = 0n, MF = 6000n;
const TAG = 'DEVGAME-RESULT';
const GAME = 'FIFA World Cup 2026 — Mexico vs South Africa (Jun 11, Estadio Azteca)';
const MARKET = 'Mexico to win? YES=side0 NO=side1';
const hx = (h) => { const o = new Uint8Array(h.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return o; };
const bh = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
const txidLE = (h) => { const d = hx(h); const o = new Uint8Array(d.length); for (let i = 0; i < d.length; i++) o[i] = d[d.length - 1 - i]; return o; };
function p2pkh(pkh) { const o = new Uint8Array(25); o[0] = 0x76; o[1] = 0xa9; o[2] = 0x14; o.set(pkh, 3); o[23] = 0x88; o[24] = 0xac; return o; }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const line = (c = '=') => console.log(c.repeat(80));
const step = (t) => { console.log(''); line(); console.log(t); line(); };
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
function pickBch(utxos, need) { const p = []; let t = 0n; for (const u of utxos) { p.push(u); t += BigInt(u.satoshis); if (t >= need) break; } return { p, t }; }
function oracleSign(privHex, eventIdBin, outcomeByte) {
const message = new Uint8Array([...eventIdBin, outcomeByte]);
const sig = secp256k1.signMessageHashDER(hx(privHex), sha256.hash(message));
if (typeof sig === 'string') throw new Error('oracle sign failed: ' + sig);
return { message, sig };
}
function parseOpReturn(lb) { if (!lb || lb[0] !== 0x6a) return null; const c = []; let i = 1; while (i < lb.length) { let n = lb[i++]; if (n === 0x4c) n = lb[i++]; else if (n > 0x4b) break; c.push(lb.slice(i, i + n)); i += n; } return c; }
async function main() {
line();
console.log('CHIPNET FOOTBALL TOTE — live, oracle-settled, on-chain result');
line();
console.log(`Fixture: ${GAME}`);
console.log(`Market: ${MARKET}`);
const A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, KEYS.a), 'utf8')).wif);
const B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, KEYS.b), 'utf8')).wif);
const C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, KEYS.c), 'utf8')).wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sig = { a: new SignatureTemplate(A.privateKey), b: new SignatureTemplate(B.privateKey), c: new SignatureTemplate(C.privateKey) };
const flbC = p2pkh(C.publicKeyHash);
const operatorPk = C.publicKeyCompressed;
let opriv; do { opriv = randomBytes(32); } while (typeof secp256k1.derivePublicKeyCompressed(opriv) === 'string');
const oraclePub = secp256k1.derivePublicKeyCompressed(opriv);
const oraclePubHex = bh(oraclePub);
console.log(`Oracle: ${oraclePubHex} (named result oracle)`);
// ---- MBA genesis (founder C) ----
step('Deploy stake token (DeviantOpenMintFT, rate 1000)');
async function eligible(w) {
let u = await w.getUtxos(); let e = u.filter((x) => x.vout === 0 && !x.token);
if (e.length) return e[0];
const bad = u.filter((x) => !(x.vout === 0 && !x.token));
await w.send([{ cashaddr: w.cashaddr, value: 4000n, unit: 'sat' }], bad.length ? { utxoIds: bad } : undefined);
await sleep(12000);
u = await w.getUtxos(); e = u.filter((x) => x.vout === 0 && !x.token);
if (!e.length) throw new Error('no vout-0 genesis UTXO'); return e[0];
}
const elig = await eligible(C); const cat = elig.txid;
const mba = new Contract(MBA_ARTIFACT, [txidLE(cat), RATE, FEE, flbC], { provider });
await C.tokenGenesis({ cashaddr: mba.tokenAddress, amount: RESERVOIR, value: 1000n, nft: { capability: 'minting', commitment: '' } }, [], { utxoIds: [elig] });
console.log(` stake token category ${cat.slice(0, 16)}…`);
await sleep(10000);
async function mint(who, s, amount, label) {
const ru = await provider.getUtxos(mba.tokenAddress);
const res = ru.find((x) => x.token?.nft?.capability === 'minting' && x.token?.category === cat && x.token?.amount > 0n);
const gross = amount * RATE;
const bch = (await provider.getUtxos(who.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis));
const { p, t } = pickBch(bch, gross + MF + 1000n);
const change = BigInt(res.satoshis) + t - 1000n - gross - MF;
const tb = new TransactionBuilder({ provider });
tb.addInput(res, mba.unlock.claim(amount, p2pkh(who.publicKeyHash)));
for (const x of p) tb.addInput(x, s.unlockP2PKH());
tb.addOutput({ to: mba.tokenAddress, amount: 1000n, token: { amount: res.token.amount - amount, category: cat, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: who.tokenaddr, amount: gross, token: { amount, category: cat } });
tb.addOutput({ to: who.cashaddr, amount: change });
console.log(` ${label}: ${amount} stake-tokens — ${txidOf(await tb.send())}`); await sleep(10000);
}
step('Bettors acquire stake tokens: A=30, B=20');
await mint(A, sig.a, 30n, 'A (backs Mexico/YES)');
await mint(B, sig.b, 20n, 'B (backs NO)');
const findExact = async (who, amount) => (await provider.getUtxos(who.cashaddr)).find((x) => x.token?.category === cat && !x.token?.nft && BigInt(x.token?.amount) === amount);
const cFee = async (need) => pickBch((await provider.getUtxos(C.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis)), need);
// ---- deploy the tote for this fixture ----
step('Open the tote for the fixture (gameId = match id)');
const gameId = randomBytes(16); const ev = gameId; const gameIdHex = bh(gameId);
const tip = await provider.getBlockHeight();
const betDeadline = BigInt(tip), refundDeadline = BigInt(tip + 500000);
const tote = new Contract(TOTE_ARTIFACT, [txidLE(cat), RATE, oraclePub, ev, operatorPk, betDeadline, refundDeadline], { provider });
console.log(` gameId ${gameIdHex} · tote ${tote.tokenAddress}`);
// A opens the pool with its 30 (side 0 = Mexico/YES)
{
const a30 = await findExact(A, 30n); if (!a30) throw new Error('A 30 stake missing');
const { p, t } = pickBch((await provider.getUtxos(A.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis)), MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(a30, sig.a.unlockP2PKH());
for (const x of p) tb.addInput(x, sig.a.unlockP2PKH());
tb.addOutput({ to: tote.tokenAddress, amount: 30000n, token: { amount: 30n, category: cat } });
tb.addOutput({ to: A.cashaddr, amount: BigInt(a30.satoshis) + t - 30000n - MF });
console.log(` A opened pool (30 on Mexico/YES) — ${txidOf(await tb.send())}`); await sleep(10000);
}
// B folds its 20 (side 1 = NO) → pool = 50
{
const pool = (await provider.getUtxos(tote.tokenAddress)).find((x) => x.token?.category === cat && !x.token?.nft && BigInt(x.token?.amount) === 30n);
const b20 = await findExact(B, 20n); if (!b20) throw new Error('B 20 stake missing');
const { p, t } = pickBch((await provider.getUtxos(B.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis)), MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(pool, tote.unlock.bet(20n));
tb.addInput(b20, sig.b.unlockP2PKH());
for (const x of p) tb.addInput(x, sig.b.unlockP2PKH());
tb.addOutput({ to: tote.tokenAddress, amount: 50000n, token: { amount: 50n, category: cat } });
tb.addOutput({ to: B.cashaddr, amount: BigInt(pool.satoshis) + BigInt(b20.satoshis) + t - 50000n - MF });
console.log(` B bet (20 on NO) → pool 50 — ${txidOf(await tb.send())}`); await sleep(10000);
}
// ---- the match ends: Mexico win (outcome 0). Oracle signs + PUBLISHES on-chain. ----
step('Full time — oracle signs the result AND publishes it on-chain (OP_RETURN)');
const OUTCOME = 0x00; // Mexico win → side 0 (YES) wins
const { message, sig: oSig } = oracleSign(bh(opriv), ev, OUTCOME);
let publishTx;
{
const { p, t } = await cFee(MF + 1000n);
const tb = new TransactionBuilder({ provider });
for (const x of p) tb.addInput(x, sig.c.unlockP2PKH());
tb.addOpReturnOutput([TAG, '0x' + gameIdHex, '0x00', '0x' + oraclePubHex, '0x' + bh(oSig)]);
tb.addOutput({ to: C.cashaddr, amount: t - MF });
publishTx = txidOf(await tb.send());
console.log(` result "Mexico win" published on-chain — ${publishTx}`); await sleep(8000);
}
// ---- settle against the published attestation: side 0 (A) takes the 50 pool ----
step('Settle the tote against the oracle attestation → Mexico backers win the pool');
{
const pool = (await provider.getUtxos(tote.tokenAddress)).find((x) => x.token?.category === cat && !x.token?.nft && BigInt(x.token?.amount) === 50n);
if (!pool) throw new Error('pool@50 not found');
const { p, t } = await cFee(MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.setLocktime(Number(betDeadline));
tb.addInput(pool, tote.unlock.settle(oSig, message, sig.c));
for (const x of p) tb.addInput(x, sig.c.unlockP2PKH());
tb.addOutput({ to: A.tokenaddr, amount: 50000n, token: { amount: 50n, category: cat } }); // A (only side-0 backer) takes all 50
tb.addOutput({ to: C.cashaddr, amount: BigInt(pool.satoshis) + t - 50000n - MF });
console.log(` settled → A wins 50 stake-tokens — ${txidOf(await tb.send())}`); await sleep(10000);
}
// ---- verify ----
step('VERIFY');
const aWon = (await provider.getUtxos(A.cashaddr)).some((x) => x.token?.category === cat && !x.token?.nft && BigInt(x.token?.amount) === 50n);
const raw = await provider.getRawTransaction(publishTx);
const dec = decodeTransaction(hx(raw));
const opret = typeof dec === 'string' ? null : dec.outputs.map((o) => parseOpReturn(o.lockingBytecode)).find(Boolean);
const resultVerifies = opret && secp256k1.verifySignatureDERLowS(opret[4], opret[3], sha256.hash(new Uint8Array([...opret[1], opret[2][0]])));
const checks = [
['Pari-mutuel: Mexico backer (A) won the 50-token pool', aWon],
['Result is published on-chain and the signature verifies from chain data', resultVerifies === true],
];
let ok = true; for (const [l, p] of checks) { console.log(` [${p ? 'PASS' : 'FAIL'}] ${l}`); ok = ok && p; }
step('SUMMARY — a real fixture, bet and settled live on chipnet');
console.log(` Fixture: ${GAME}`);
console.log(` gameId: ${gameIdHex}`);
console.log(` Result published on-chain: ${publishTx}`);
console.log(' A backed Mexico/YES (30), B backed NO (20); pool 50. Oracle signed');
console.log(' "Mexico win", published it on-chain, and the tote paid the Mexico');
console.log(' side. Anyone can fetch the result tx and verify it — third-party Totes');
console.log(' can settle on the same attestation without trusting any server.');
line();
if (!ok) { console.error('VERIFY FAILED'); process.exit(1); }
process.exit(0);
}
main().catch((e) => { console.error('FAILED:', e?.message ?? e); if (e?.stack) console.error(e.stack); process.exit(1); });

View file

@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* football-tote-server.mjs — a thin backend bridge that makes the Football Tote
* platform's "Place bet" / "Settle" buttons broadcast REAL chipnet transactions
* into a live OpenTote pool. The browser stays the UI; this server holds the
* validated chain logic (the same role the Checkers relay+arbiter plays).
*
* On startup it ensures ONE live pool for the WC2026 opener (Mexico vs South
* Africa) — genesis a stake token, deploy OpenTote, open the pool with a small
* house seed — and persists it to .football-tote-state.json (reused across runs).
*
* API (JSON):
* GET / → serves design/football-tote/index.html
* GET /api/state → { gameId, toteAddress, poolTotal, sides:{yes,no}, bets:[], settled, oraclePub }
* POST /api/bet {side,amount} → mint a stake + fold it via bet() → { txid, poolTotal, sides }
* POST /api/settle {winner} → oracle signs+publishes result on-chain + settle() → { publishTx, settleTx }
*
* Sides are recorded OFF-CHAIN here (OpenTote's accountable-operator model: the
* pool is one FT total on-chain; who backed which side is the operator's ledger).
* Bets execute from demo wallet A (testnet) — clearly a chipnet demo, no real funds.
*
* Run: node --import tsx scripts/football-tote-server.mjs (then open http://localhost:4100)
*/
import { createServer } from 'node:http';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { secp256k1, sha256 } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, '.football-tote-state.json');
const PAGE = join(ROOT, 'design/football-tote/index.html');
const MBA_ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/DeviantOpenMintFT.json'), 'utf8'));
const TOTE_ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/OpenTote.json'), 'utf8'));
const PORT = 4100, TAG = 'DEVGAME-RESULT';
const RESERVOIR = 1_000_000_000_000_000_000n, RATE = 1000n, FEE = 0n, MF = 6000n;
const SEED = 10n; // house seed: 5 yes / 5 no, so odds start even
const hx = (h) => { const o = new Uint8Array(h.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return o; };
const bh = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
const txidLE = (h) => { const d = hx(h); const o = new Uint8Array(d.length); for (let i = 0; i < d.length; i++) o[i] = d[d.length - 1 - i]; return o; };
const p2pkh = (k) => { const o = new Uint8Array(25); o[0] = 0x76; o[1] = 0xa9; o[2] = 0x14; o.set(k, 3); o[23] = 0x88; o[24] = 0xac; return o; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
const pickBch = (u, need) => { const p = []; let t = 0n; for (const x of u) { p.push(x); t += BigInt(x.satoshis); if (t >= need) break; } return { p, t }; };
const load = () => (existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : null);
const save = (s) => writeFileSync(STATE, JSON.stringify(s, null, 2));
let A, B, C, provider, sigA, sigC, mba, tote, st;
function oracleSign(privHex, ev, outcome) {
const message = new Uint8Array([...ev, outcome]);
const sig = secp256k1.signMessageHashDER(hx(privHex), sha256.hash(message));
if (typeof sig === 'string') throw new Error('sign failed: ' + sig);
return { message, sig };
}
async function plainBch(w, need) {
return pickBch((await provider.getUtxos(w.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis)), need);
}
async function poolUtxo() {
return (await provider.getUtxos(tote.tokenAddress)).find((x) => x.token?.category === st.cat && !x.token?.nft);
}
async function poolTotal() { const p = await poolUtxo(); return p ? Number(p.token.amount) : 0; }
// mint `amount` stake tokens to wallet `who`, then return its exact UTXO
async function mintStake(who, s, amount) {
const res = (await provider.getUtxos(mba.tokenAddress)).find((x) => x.token?.nft?.capability === 'minting' && x.token?.category === st.cat && x.token?.amount > 0n);
const gross = amount * RATE;
const { p, t } = await plainBch(who, gross + MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(res, mba.unlock.claim(amount, p2pkh(who.publicKeyHash)));
for (const x of p) tb.addInput(x, s.unlockP2PKH());
tb.addOutput({ to: mba.tokenAddress, amount: 1000n, token: { amount: res.token.amount - amount, category: st.cat, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: who.tokenaddr, amount: gross, token: { amount, category: st.cat } });
tb.addOutput({ to: who.cashaddr, amount: BigInt(res.satoshis) + t - 1000n - gross - MF });
await tb.send(); await sleep(9000);
return (await provider.getUtxos(who.cashaddr)).find((x) => x.token?.category === st.cat && !x.token?.nft && BigInt(x.token?.amount) === amount);
}
async function ensurePool() {
st = load();
if (st && st.cat) {
const ev = hx(st.gameId);
tote = new Contract(TOTE_ARTIFACT, [txidLE(st.cat), RATE, hx(st.oraclePub), ev, C.publicKeyCompressed, BigInt(st.betDeadline), BigInt(st.refundDeadline)], { provider });
mba = new Contract(MBA_ARTIFACT, [txidLE(st.cat), RATE, FEE, p2pkh(C.publicKeyHash)], { provider });
console.log(`[pool] reusing live pool — gameId ${st.gameId}, tote ${tote.tokenAddress}, total ${await poolTotal()}`);
return;
}
console.log('[pool] deploying a fresh live pool (genesis + tote + open)…');
let u = await C.getUtxos(); let elig = u.filter((x) => x.vout === 0 && !x.token)[0];
if (!elig) { await C.send([{ cashaddr: C.cashaddr, value: 5000n, unit: 'sat' }]); await sleep(12000); elig = (await C.getUtxos()).filter((x) => x.vout === 0 && !x.token)[0]; }
const cat = elig.txid;
mba = new Contract(MBA_ARTIFACT, [txidLE(cat), RATE, FEE, p2pkh(C.publicKeyHash)], { provider });
await C.tokenGenesis({ cashaddr: mba.tokenAddress, amount: RESERVOIR, value: 1000n, nft: { capability: 'minting', commitment: '' } }, [], { utxoIds: [elig] });
await sleep(10000);
let opriv; do { opriv = randomBytes(32); } while (typeof secp256k1.derivePublicKeyCompressed(opriv) === 'string');
const oraclePub = secp256k1.derivePublicKeyCompressed(opriv);
const gameId = randomBytes(16);
const tip = await provider.getBlockHeight();
st = { cat, gameId: bh(gameId), oraclePub: bh(oraclePub), oraclePriv: bh(opriv), betDeadline: tip, refundDeadline: tip + 500000, bets: [], seed: { yes: 5, no: 5 }, settled: null };
tote = new Contract(TOTE_ARTIFACT, [txidLE(cat), RATE, oraclePub, gameId, C.publicKeyCompressed, BigInt(tip), BigInt(tip + 500000)], { provider });
// open the pool with a SEED stake (house) so odds start even
const seedUtxo = await mintStake(C, sigC, SEED);
const { p, t } = await plainBch(C, MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(seedUtxo, sigC.unlockP2PKH());
for (const x of p) tb.addInput(x, sigC.unlockP2PKH());
tb.addOutput({ to: tote.tokenAddress, amount: SEED * RATE, token: { amount: SEED, category: cat } });
tb.addOutput({ to: C.cashaddr, amount: BigInt(seedUtxo.satoshis) + t - SEED * RATE - MF });
await tb.send(); await sleep(10000);
save(st);
console.log(`[pool] live — gameId ${st.gameId}, tote ${tote.tokenAddress}, seed ${SEED}`);
}
async function placeBet(side, amount, w, s) {
w = w || A; s = s || sigA;
const amt = BigInt(Math.max(1, Math.round(amount)));
let stake;
try { stake = await mintStake(w, s, amt); }
catch (e) { throw new Error('could not stake from your key — need ~' + (Number(amt) * 1000 + 9000) + ' tBCH-sats for backing + fees (' + (e?.message ?? e) + ')'); }
if (!stake) throw new Error('stake mint produced no token UTXO');
const pool = await poolUtxo();
const to = BigInt(pool.token.amount) + amt;
const { p, t } = await plainBch(w, MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(pool, tote.unlock.bet(amt));
tb.addInput(stake, s.unlockP2PKH());
for (const x of p) tb.addInput(x, s.unlockP2PKH());
tb.addOutput({ to: tote.tokenAddress, amount: to * RATE, token: { amount: to, category: st.cat } });
tb.addOutput({ to: w.cashaddr, amount: BigInt(pool.satoshis) + BigInt(stake.satoshis) + t - to * RATE - MF });
const txid = txidOf(await tb.send());
st.bets.push({ side, amount: Number(amt), txid, by: w.cashaddr.slice(-6) });
save(st); await sleep(8000);
return txid;
}
const wifCache = {};
async function walletFromWif(wif) {
if (!wif) return { w: A, s: sigA };
if (!wifCache[wif]) { const w = await TestNetWallet.fromWIF(wif); wifCache[wif] = { w, s: new SignatureTemplate(w.privateKey) }; }
return wifCache[wif];
}
async function walletInfo(w) {
const u = await provider.getUtxos(w.cashaddr);
let bch = 0, stake = 0;
for (const x of u) { if (x.token?.category === st.cat && !x.token?.nft) stake += Number(x.token.amount); else if (!x.token) bch += Number(x.satoshis); }
return { address: w.cashaddr, bch, stake };
}
async function genKey() {
const w = await TestNetWallet.newRandom();
await C.send([{ cashaddr: w.cashaddr, value: 90000n, unit: 'sat' }]); await sleep(11000);
return { wif: w.privateKeyWif, address: w.cashaddr };
}
async function settle(winner) {
const w = winner === 'yes' ? 0x00 : 0x01;
const ev = hx(st.gameId);
const { message, sig } = oracleSign(st.oraclePriv, ev, w);
// publish result on-chain
let pp = await plainBch(C, MF + 1000n);
let tb = new TransactionBuilder({ provider });
for (const x of pp.p) tb.addInput(x, sigC.unlockP2PKH());
tb.addOpReturnOutput([TAG, '0x' + st.gameId, '0x' + (w === 0 ? '00' : '01'), '0x' + st.oraclePub, '0x' + bh(sig)]);
tb.addOutput({ to: C.cashaddr, amount: pp.t - MF });
const publishTx = txidOf(await tb.send()); await sleep(8000);
// settle: pay the winning side's total to A (demo collector); operator computes split off-chain
const pool = await poolUtxo();
pp = await plainBch(C, MF + 1000n);
tb = new TransactionBuilder({ provider });
tb.setLocktime(Number(st.betDeadline));
tb.addInput(pool, tote.unlock.settle(sig, message, sigC));
for (const x of pp.p) tb.addInput(x, sigC.unlockP2PKH());
tb.addOutput({ to: A.tokenaddr, amount: BigInt(pool.token.amount) * RATE, token: { amount: BigInt(pool.token.amount), category: st.cat } });
tb.addOutput({ to: C.cashaddr, amount: BigInt(pool.satoshis) + pp.t - BigInt(pool.token.amount) * RATE - MF });
const settleTx = txidOf(await tb.send()); await sleep(10000);
st.settled = { winner, publishTx, settleTx }; save(st);
return { publishTx, settleTx };
}
function sides() {
const s = { yes: st.seed.yes, no: st.seed.no };
for (const b of st.bets) s[b.side] += b.amount;
return s;
}
async function stateJson() {
return { gameId: st.gameId, toteAddress: tote.tokenAddress, oraclePub: st.oraclePub, poolTotal: await poolTotal(), sides: sides(), bets: st.bets.slice(-12), settled: st.settled };
}
function body(req) { return new Promise((res) => { let d = ''; req.on('data', (c) => (d += c)); req.on('end', () => res(d ? JSON.parse(d) : {})); }); }
function json(r, code, obj) { r.writeHead(code, { 'content-type': 'application/json' }); r.end(JSON.stringify(obj)); }
async function main() {
A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys.json'), 'utf8')).wif);
B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-b.json'), 'utf8')).wif);
C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-c.json'), 'utf8')).wif);
provider = new ElectrumNetworkProvider('chipnet');
sigA = new SignatureTemplate(A.privateKey); sigC = new SignatureTemplate(C.privateKey);
await ensurePool();
const server = createServer(async (req, res) => {
try {
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(readFileSync(PAGE)); }
if (req.method === 'GET' && req.url === '/api/state') return json(res, 200, await stateJson());
if (req.method === 'POST' && req.url === '/api/genkey') return json(res, 200, await genKey());
if (req.method === 'POST' && req.url === '/api/connect') {
const { wif } = await body(req);
try { const { w } = await walletFromWif(wif); return json(res, 200, await walletInfo(w)); }
catch (e) { return json(res, 400, { error: 'invalid WIF: ' + (e?.message ?? e).toString().slice(0, 80) }); }
}
if (req.method === 'POST' && req.url === '/api/bet') {
const { side, amount, wif } = await body(req);
if (side !== 'yes' && side !== 'no') return json(res, 400, { error: 'side must be yes|no' });
if (st.settled) return json(res, 400, { error: 'market already settled' });
const { w, s } = await walletFromWif(wif);
const txid = await placeBet(side, amount || 5, w, s);
return json(res, 200, { txid, by: w.cashaddr, ...(await stateJson()) });
}
if (req.method === 'POST' && req.url === '/api/settle') {
const { winner } = await body(req);
if (winner !== 'yes' && winner !== 'no') return json(res, 400, { error: 'winner must be yes|no' });
if (st.settled) return json(res, 400, { error: 'already settled' });
const r = await settle(winner);
return json(res, 200, { ...r, ...(await stateJson()) });
}
json(res, 404, { error: 'not found' });
} catch (e) { console.error('ERR', e?.message ?? e); json(res, 500, { error: (e?.message ?? String(e)).slice(0, 200) }); }
});
server.listen(PORT, () => console.log(`[server] Football Tote live on http://localhost:${PORT} (market: Mexico to win? · gameId ${st.gameId})`));
}
main().catch((e) => { console.error('FATAL', e?.message ?? e); process.exit(1); });

View file

@ -0,0 +1,96 @@
# Snapshot 0176: football tote — real WC2026 fixture bet and settled live on chipnet; deployable platform
_Captured 2026-06-25T19:59:22.333Z_
## Git state
- Rev: `c0a0614`
- Working tree:
```
M Parameters/FIATm/README.md
M Parameters/FIATm/fiatm-comparisons.html
M Parameters/FIATm/fiatm-exchange_demo.html
M Parameters/FIATm/fiatm_complete-design_FOR-design-record.md
M Parameters/FIATm/fiatm_synthesis_FOR-reading-first.md
M Parameters/MBA_Shares/STATUS-CHART.md
M package-lock.json
M package.json
M scripts/dashboard-serve.mjs
M shells/app/src/App.tsx
M shells/app/src/components/SwapFlow.tsx
M shells/app/src/components/TabBar.tsx
M shells/app/src/connect/responder.ts
M shells/app/src/connect/useConnect.ts
M shells/app/src/core/wallet.ts
M shells/app/src/hooks/useAllDevBalances.ts
M shells/app/src/screens/AppsScreen.tsx
M shells/app/src/screens/MainScreen.tsx
M shells/app/src/shell.css
M shells/app/vite.config.ts
M snapshots/0167-shell-hero-balance-artemision-inplace-bch-mint.md
M wiki/index.md
?? .usdm-historical-chart.html
?? Parameters/CoinSpectrum/
?? Parameters/DEX_P2P/ASM_DEX_categories.md
?? Parameters/FIATm/AncientGreekCoins.txt
?? Parameters/FIATm/fiatm-sovereign-asset.md
?? Parameters/dApp/Agora/
?? contracts/BchChannelBoardHTLC.cash
?? design/explorer/
?? design/football-tote/
?? scripts/chipnet-football-tote-test.mjs
?? scripts/chipnet-ln-to-t6-routing.mjs
?? scripts/chipnet-product1-rewards-coin.mjs
?? scripts/dashboard.html
?? scripts/derive-chipnet-addresses.mjs
?? scripts/sim-usdm-historical-coingecko.mjs
?? shells/app/asm.html
?? shells/app/src/asm-core/
?? shells/app/src/asm-standalone/
?? snapshots/0168-shell-main-page-redesign-subtabs-walletconnect.md
?? snapshots/0169-real-data-historical-sim-empirical-7b-va.md
```
## File counts
| Area | Files |
|---|---|
| src/ | 78 |
| contracts/ | 129 |
| test/ | 57 |
## Notes
**Shipped: a visible Football Tote demo + a live chipnet proof on a real fixture.**
Answers "can we have a visible demonstration… a tote platform where users create
bets on a football championship running right now?" — built on the already-validated
`OpenTote` + result-oracle primitives.
**Artifacts:**
- `design/football-tote/index.html` — deployable, self-contained pari-mutuel
Football Tote (CosmoGenesis aesthetic): browse WC2026 matches, place stakes,
live odds, oracle settlement. In-browser pool math; production swaps in live txs.
- `scripts/chipnet-football-tote-test.mjs` — live chipnet test composing
`OpenTote` + on-chain result publishing.
- `wiki/football-tote-demo.md` — record (linked from index).
**Live chipnet validation (2026-06-09)** — FIFA WC2026 opener Mexico vs South
Africa, binary "Mexico to win?": A staked 30 on YES, B 20 on NO (pool 50); oracle
signed "Mexico win", **published it on-chain**, tote settled → Mexico side won.
Both checks PASS.
- gameId `ff7d7bc122c9293e9bc374c19eba1373`
- result published (OP_RETURN) `6fc6cf1d…a4ede63a`
- settlement `e0215976…699b4ae9`
**Context (this session):** earlier turns validated the pieces — `OpenTote`
oracle settlement (`chipnet-opentote-test.mjs`), on-chain result publish
(`chipnet-game-oracle-test.mjs`), and a checkers move-state-machine covenant
(`CheckersGame.cash` + `chipnet-checkers-game-test.mjs`, "1 move = 1 tx"). The
"what do we need for a third-party Tote" analysis concluded: publish a named
oracle key + sign `(gameId, outcome)` + publish on-chain — the move covenant is
NOT on the betting path (it's for serverless play).
**Deferred:** wiring the web platform's bet/settle to live `OpenTote` txs via the
`DeviantWallet` shell; three-way 1/X/2 via `ParimutuelToteOpenK4`; a real
football-data-fed results oracle.

View file

@ -0,0 +1,99 @@
# Football Tote — a pari-mutuel betting demo on a real fixture
_Created 2026-06-09. A visible demonstration of oracle-settled pari-mutuel betting
on Bitcoin Cash, applied to a live event: the FIFA World Cup 2026. Built on the
already-validated [event-prediction-and-tote.md](event-prediction-and-tote.md)
primitives (`OpenTote` + a result oracle). Two artifacts: a deployable web
platform and a live chipnet test._
## What it is
A **Football Tote**: users create/join pari-mutuel pools on World Cup 2026 match
outcomes. There is no bookmaker and no house edge — the pool *is* the market, the
odds are `total pool ÷ stake on that outcome`, and the winning side splits the
whole pool pro-rata. Settlement is bound to an **oracle-signed result published
on-chain**; a third party can build a Tote on the same attestation without
trusting any server (the conclusion from the session's "what do we need" analysis:
publish a named oracle key + sign `(gameId, outcome)` + publish it on-chain).
## Artifacts
- **Deployable platform** — `design/football-tote/index.html`. Self-contained
(CosmoGenesis aesthetic), interactive: browse matches, place stakes, watch
pari-mutuel odds move, settle via oracle result. Runs the pool math in-browser
for instant interaction; the production build swaps in live `OpenTote` txs + a
results oracle fed by a football-data feed.
- **Live chipnet test** — `scripts/chipnet-football-tote-test.mjs`. Runs the whole
flow on Bitcoin Cash testnet against the real opener.
## Live chipnet validation (2026-06-09)
Fixture: **FIFA World Cup 2026 opener — Mexico vs South Africa** (Jun 11, Estadio
Azteca). Binary market "Mexico to win?": A staked 30 on YES (side 0), B staked 20
on NO (side 1) → pool 50. The result oracle signed "Mexico win" (outcome 0), **published it on-chain**, and the tote settled against that attestation.
```
gameId: ff7d7bc122c9293e9bc374c19eba1373
tote: bchtest:r0huvg3m968ga5gakmpp2py8vanr8ngljvt8m8kd4avghze7wd2a7gwh2dqs5
result published (OP_RETURN): 6fc6cf1d19c793ee85e241589c96567d0e118f47ce7f01bd60460542a4ede63a
settlement: e02159761d14259b6867e363174c6840e77efda8ab078911f5704922699b4ae9
```
Both checks PASS:
- **Pari-mutuel payout** — the Mexico/YES backer (A) won the 50-token pool.
- **On-chain result** — the signed result is on-chain and the signature verifies
from chain data alone (anyone can fetch the tx and confirm it).
## Live server — platform wired to chipnet (2026-06-09)
`scripts/football-tote-server.mjs` is a thin backend bridge that makes the
platform's buttons broadcast REAL chipnet transactions (the role the Checkers
relay+arbiter plays). On startup it deploys one persistent live pool and serves
the page; the browser calls it:
- `GET /api/state` → live pool total (on-chain) + per-side ledger (off-chain).
- `POST /api/bet {side,amount,wif?}` → mints a stake + folds it via `bet()` → returns the txid. With an optional `wif`, the stake + bet are signed by **that** key (the bettor's own wallet), not the demo wallet.
- `POST /api/connect {wif}` → import a chipnet WIF; returns the address + tBCH / stake-token balance.
- `POST /api/genkey` → generate a fresh chipnet key and auto-fund it (so a visitor can bet in one click).
- `POST /api/settle {winner}` → oracle signs + publishes the result on-chain, then `settle()` pays the winning side.
Run: `node --import tsx scripts/football-tote-server.mjs` → open `http://localhost:4100`.
The page falls back to the in-browser sim when opened standalone (no server).
**Proven live (2026-06-09):** the server deployed a pool (seed 10 = 5 yes / 5 no),
then a real `POST /api/bet {yes, 8}` broadcast `bet()` tx `d10ef81f…c53203`,
growing the on-chain pool **10 → 18** (yes 13 / no 5) and shifting the odds.
**Connect with your own key (2026-06-09).** The live panel has a wallet bar: paste
a chipnet WIF (or click "generate test key" → a fresh auto-funded key) and every
bet folds from **your** address. Verified live: `POST /api/genkey` produced a
funded key, `POST /api/connect` returned its balance, and `POST /api/bet {no,6,wif}`
broadcast `bet()` tx `672b3a75…` whose inputs came from the new key's address
(pool 18 → 24). **Chipnet only** — the WIF reaches the local backend, which signs
with it; never use a mainnet key. The fully trustless variant (key never leaves the
browser; the page signs `bet()` locally) belongs in the **Deviant shell**
(`shells/app`) — it already bundles cashscript and holds keys client-side. That,
not esm.sh on the standalone page, is its proper home.
## How it maps to the validated primitives
| Platform action | On-chain |
|---|---|
| Place bet | `OpenTote.bet()` — stake folds into the pool UTXO (peg enforced) |
| Odds shift | pari-mutuel: `total ÷ outcome-stake` |
| Oracle settles | result oracle signs `(gameId, outcome)`, published on-chain; `settle()` verifies via `checkDataSig` |
| Payout | winning side splits the pool pro-rata; covenant releases it |
The mechanism was validated separately in `chipnet-opentote-test.mjs` (oracle-
settled tote, forged-sig rejection, pro-rata payout) and `chipnet-game-oracle-test.mjs`
(on-chain result publishing). This demo composes them on a real fixture.
## Boundaries / next steps
- Live wiring is done (`football-tote-server.mjs`). The remaining production step is
per-USER wallets in the browser — each bettor signing their own `bet()` via the
`DeviantWallet` shell, rather than the demo wallet the server signs with.
- `OpenTote` is **binary**; three-way 1/X/2 markets use the K-outcome tote variant
(`ParimutuelToteOpenK4`, already built).
- A real results oracle is a named key fed by a football-data API, signing the
final result and publishing it on-chain per match.