deviant/scripts/tote-platform-server.mjs
walerikus 35076606c7 chore: checkpoint in-flight work across shell, contracts, and docs
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>
2026-07-07 02:02:05 +02:00

211 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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); });