Snapshot of the shared working tree's outstanding changes (multiple concurrent workstreams), excluding generated runtime state files (.football-tote-state.json, .tote-platform-state.json, .usdm-historical-chart.html). Spans: wallet shell (connect/, keystore MultiKeyStore+vault, screens, currency picker, sign-message), verify artifacts, new Potidaea/DRA/tote contracts + regtest suites, cross-chain/T6 + WalletConnect Parameters, a telegram-orderbook subproject, design mockups, and docs. See shells/app/WALLET-COORDINATION.md for the per-file ownership split. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
17 KiB
JavaScript
211 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* tote-platform-server.mjs — a standalone TOTE PLATFORM. Generalises the
|
||
* football demo into an event-agnostic, multi-market pari-mutuel platform:
|
||
*
|
||
* - Each MARKET is a binary yes/no proposition ("Mexico to beat South Africa?")
|
||
* with its own oracle-settled OpenTote pool. (Multi-outcome 1/X/2 is a later
|
||
* K-outcome-tote add-on; binary is the general primitive, Polymarket-style.)
|
||
* - ONE shared stake token (MBA) backs every pool; each market is its own tote.
|
||
* - Anyone can CREATE a market, BET on one, and SETTLE it (oracle result).
|
||
* - Football is the launch category (seeded); others just need a category tag.
|
||
* - The JSON API is the integration surface; /embed/:id is a drop-in iframe.
|
||
*
|
||
* API: GET /api/markets · GET /api/markets/:id · POST /api/markets {title,category,deadlineBlocks?}
|
||
* POST /api/bet {marketId,outcome,amount,wif?} · POST /api/settle {marketId,outcome}
|
||
* POST /api/connect {wif} · POST /api/genkey
|
||
* GET / (platform) · GET /embed/:id (single-market widget)
|
||
*
|
||
* Bets sign from a demo wallet, or from a connected WIF (chipnet-only). Run:
|
||
* node --import tsx scripts/tote-platform-server.mjs → http://localhost:4200
|
||
*/
|
||
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, '.tote-platform-state.json');
|
||
const PAGE = join(ROOT, 'design/tote-platform/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 = 4200, TAG = 'DEVGAME-RESULT';
|
||
const RESERVOIR = 1_000_000_000_000_000_000n, RATE = 1000n, FEE = 0n, MF = 6000n, SEED = 10n;
|
||
|
||
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 = () => writeFileSync(STATE, JSON.stringify(st, null, 2));
|
||
const slug = () => bh(randomBytes(4));
|
||
|
||
let A, C, provider, sigA, sigC, mba, 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 };
|
||
}
|
||
const plainBch = async (w, need) => pickBch((await provider.getUtxos(w.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis)), need);
|
||
const toteFor = (m) => new Contract(TOTE_ARTIFACT, [txidLE(st.cat), RATE, hx(m.oraclePub), hx(m.gameId), C.publicKeyCompressed, BigInt(m.betDeadline), BigInt(m.refundDeadline)], { provider });
|
||
const poolUtxo = async (tote) => (await provider.getUtxos(tote.tokenAddress)).find((x) => x.token?.category === st.cat && !x.token?.nft);
|
||
const poolTotal = async (tote) => { const p = await poolUtxo(tote); return p ? Number(p.token.amount) : 0; };
|
||
|
||
async function mintStake(who, s, amount) {
|
||
let res = null;
|
||
for (let i = 0; i < 8; i++) { res = (await provider.getUtxos(mba.tokenAddress)).find((x) => x.token?.nft?.capability === 'minting' && x.token?.category === st.cat && x.token?.amount > 0n); if (res) break; await sleep(4000); }
|
||
if (!res) throw new Error('minting reservoir NFT not found');
|
||
const gross = amount * RATE;
|
||
const { p, t } = await plainBch(who, gross + MF + 1000n);
|
||
if (t < gross + MF + 1000n) throw new Error('wallet has insufficient tBCH for this stake');
|
||
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();
|
||
let stake = null;
|
||
for (let i = 0; i < 8; i++) { await sleep(4000); stake = (await provider.getUtxos(who.cashaddr)).find((x) => x.token?.category === st.cat && !x.token?.nft && BigInt(x.token?.amount) === amount); if (stake) break; }
|
||
if (!stake) throw new Error('minted stake UTXO not found after mint');
|
||
return stake;
|
||
}
|
||
|
||
async function ensureMba() {
|
||
st = load();
|
||
if (st && st.cat) { mba = new Contract(MBA_ARTIFACT, [txidLE(st.cat), RATE, FEE, p2pkh(C.publicKeyHash)], { provider }); console.log(`[mba] reusing ${st.cat.slice(0, 16)}… · ${Object.keys(st.markets).length} markets`); return; }
|
||
console.log('[mba] deploying shared stake token…');
|
||
// make a dedicated, sufficiently-funded, confirmed vout-0 genesis input
|
||
await C.send([{ cashaddr: C.cashaddr, value: 6000n, unit: 'sat' }]); await sleep(13000);
|
||
const elig = (await C.getUtxos()).filter((x) => x.vout === 0 && !x.token && Number(x.satoshis) >= 4000)[0];
|
||
if (!elig) throw new Error('could not create a genesis-eligible UTXO');
|
||
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] });
|
||
let nft = null;
|
||
for (let i = 0; i < 12; i++) { await sleep(5000); nft = (await provider.getUtxos(mba.tokenAddress)).find((x) => x.token?.nft?.capability === 'minting' && x.token?.category === cat); if (nft) break; }
|
||
if (!nft) throw new Error('genesis did not produce a minting NFT (cat ' + cat.slice(0, 12) + '…) — aborting');
|
||
st = { cat, markets: {} }; save();
|
||
console.log(`[mba] live ${cat.slice(0, 16)}… (minting NFT confirmed)`);
|
||
}
|
||
|
||
async function createMarket({ title, category, deadlineBlocks }) {
|
||
let opriv; do { opriv = randomBytes(32); } while (typeof secp256k1.derivePublicKeyCompressed(opriv) === 'string');
|
||
const tip = await provider.getBlockHeight();
|
||
const id = slug();
|
||
const m = { id, title, category: category || 'other', gameId: bh(randomBytes(16)), oraclePub: bh(secp256k1.derivePublicKeyCompressed(opriv)), oraclePriv: bh(opriv), betDeadline: tip + (Number(deadlineBlocks) || 0), refundDeadline: tip + 500000, seed: { yes: Number(SEED) / 2, no: Number(SEED) / 2 }, bets: [], settled: null };
|
||
// open the pool with a house seed so odds start even (persist only on success)
|
||
const tote = toteFor(m);
|
||
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: st.cat } });
|
||
tb.addOutput({ to: C.cashaddr, amount: BigInt(seedUtxo.satoshis) + t - SEED * RATE - MF });
|
||
await tb.send(); await sleep(9000);
|
||
st.markets[id] = m; save();
|
||
console.log(`[market] "${title}" (${m.category}) — id ${id}, tote ${tote.tokenAddress.slice(0, 18)}…`);
|
||
return m;
|
||
}
|
||
|
||
async function placeBet(marketId, outcome, amount, w, s) {
|
||
const m = st.markets[marketId]; if (!m) throw new Error('no such market'); if (m.settled) throw new Error('market settled');
|
||
w = w || A; s = s || sigA;
|
||
const amt = BigInt(Math.max(1, Math.round(amount)));
|
||
const tote = toteFor(m);
|
||
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 (' + (e?.message ?? e) + ')'); }
|
||
let pool = null; for (let i = 0; i < 8; i++) { pool = await poolUtxo(tote); if (pool) break; await sleep(4000); }
|
||
if (!pool) throw new Error('market pool UTXO not found');
|
||
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());
|
||
m.bets.push({ outcome, amount: Number(amt), txid, by: w.cashaddr.slice(-6) }); save(); await sleep(8000);
|
||
return txid;
|
||
}
|
||
|
||
async function settleMarket(marketId, outcome) {
|
||
const m = st.markets[marketId]; if (!m) throw new Error('no such market'); if (m.settled) throw new Error('already settled');
|
||
const w = outcome === 'yes' ? 0x00 : 0x01;
|
||
const ev = hx(m.gameId);
|
||
const { message, sig } = oracleSign(m.oraclePriv, ev, w);
|
||
const tote = toteFor(m);
|
||
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' + m.gameId, '0x' + (w === 0 ? '00' : '01'), '0x' + m.oraclePub, '0x' + bh(sig)]);
|
||
tb.addOutput({ to: C.cashaddr, amount: pp.t - MF });
|
||
const publishTx = txidOf(await tb.send()); await sleep(8000);
|
||
const pool = await poolUtxo(tote);
|
||
pp = await plainBch(C, MF + 1000n);
|
||
tb = new TransactionBuilder({ provider });
|
||
tb.setLocktime(Number(m.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(9000);
|
||
m.settled = { outcome, publishTx, settleTx }; save();
|
||
return m.settled;
|
||
}
|
||
|
||
function sidesOf(m) { const s = { yes: m.seed.yes, no: m.seed.no }; for (const b of m.bets) s[b.outcome] += b.amount; return s; }
|
||
async function marketState(id) {
|
||
const m = st.markets[id]; if (!m) return null;
|
||
const tote = toteFor(m);
|
||
return { id, title: m.title, category: m.category, oraclePub: m.oraclePub, gameId: m.gameId, toteAddress: tote.tokenAddress, poolTotal: await poolTotal(tote), sides: sidesOf(m), bets: m.bets.slice(-8), settled: m.settled };
|
||
}
|
||
const listMarkets = async () => Promise.all(Object.keys(st.markets).map(marketState));
|
||
|
||
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 }; }
|
||
|
||
const body = (req) => new Promise((res) => { let d = ''; req.on('data', (c) => (d += c)); req.on('end', () => res(d ? JSON.parse(d) : {})); });
|
||
const json = (r, code, obj) => { r.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }); r.end(JSON.stringify(obj)); };
|
||
function embedPage(id) { return '<!DOCTYPE html><meta charset=utf-8><body style="margin:0;font-family:Inter,system-ui,sans-serif"><div id=e style="padding:14px"></div><script>const ID="' + id + '";async function R(){const m=await (await fetch("/api/markets/"+ID)).json();const t=m.poolTotal||1,sy=m.sides.yes,sn=m.sides.no;document.getElementById("e").innerHTML=\'<div style="border:1px solid #ddd;border-radius:10px;padding:14px;max-width:340px"><div style="font-weight:600;font-size:14px;margin-bottom:8px">\'+m.title+\'</div><div style="display:flex;gap:8px"><button onclick="B(0)" style="flex:1;padding:8px;border:1px solid #ccc;border-radius:8px;background:#f6f6f6;cursor:pointer">Yes <b>\'+(t/sy).toFixed(2)+\'×</b></button><button onclick="B(1)" style="flex:1;padding:8px;border:1px solid #ccc;border-radius:8px;background:#f6f6f6;cursor:pointer">No <b>\'+(t/sn).toFixed(2)+\'×</b></button></div><div style="font-size:11px;color:#888;margin-top:8px">pool \'+t+\' tBCH · powered by Deviant Tote</div></div>\';}window.B=async o=>{await fetch("/api/bet",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({marketId:ID,outcome:o?"no":"yes",amount:5})});R();};R();</script>'; }
|
||
|
||
async function main() {
|
||
A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys.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 ensureMba();
|
||
if (Object.keys(st.markets).length === 0) {
|
||
console.log('[seed] creating launch football markets…');
|
||
await createMarket({ title: 'Mexico to beat South Africa? (WC2026 opener)', category: 'football' });
|
||
await createMarket({ title: 'Argentina to win World Cup 2026?', category: 'football' });
|
||
}
|
||
|
||
createServer(async (req, res) => {
|
||
try {
|
||
const u = (req.url || '').split('?')[0];
|
||
if (req.method === 'GET' && (u === '/' || u === '/index.html')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(readFileSync(PAGE)); }
|
||
if (req.method === 'GET' && u.startsWith('/embed/')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(embedPage(u.slice(7))); }
|
||
if (req.method === 'GET' && u === '/api/markets') return json(res, 200, await listMarkets());
|
||
if (req.method === 'GET' && u.startsWith('/api/markets/')) { const m = await marketState(u.slice(13)); return m ? json(res, 200, m) : json(res, 404, { error: 'no such market' }); }
|
||
if (req.method === 'POST' && u === '/api/markets') { const { title, category, deadlineBlocks } = await body(req); if (!title || title.length < 4) return json(res, 400, { error: 'title too short' }); const m = await createMarket({ title: title.slice(0, 90), category, deadlineBlocks }); return json(res, 200, await marketState(m.id)); }
|
||
if (req.method === 'POST' && u === '/api/genkey') return json(res, 200, await genKey());
|
||
if (req.method === 'POST' && u === '/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' }); } }
|
||
if (req.method === 'POST' && u === '/api/bet') { const { marketId, outcome, amount, wif } = await body(req); if (outcome !== 'yes' && outcome !== 'no') return json(res, 400, { error: 'outcome must be yes|no' }); const { w, s } = await walletFromWif(wif); const txid = await placeBet(marketId, outcome, amount || 5, w, s); return json(res, 200, { txid, ...(await marketState(marketId)) }); }
|
||
if (req.method === 'POST' && u === '/api/settle') { const { marketId, outcome } = await body(req); if (outcome !== 'yes' && outcome !== 'no') return json(res, 400, { error: 'outcome must be yes|no' }); const r = await settleMarket(marketId, outcome); return json(res, 200, { ...r, ...(await marketState(marketId)) }); }
|
||
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) }); }
|
||
}).listen(PORT, () => console.log(`[server] Tote platform on http://localhost:${PORT} · ${Object.keys(st.markets).length} markets`));
|
||
}
|
||
main().catch((e) => { console.error('FATAL', e?.message ?? e); process.exit(1); });
|