deviant/scripts/chipnet-tote4-test.mjs
walerikus 599202a721 chore: checkpoint working tree — shell Tier-B retirement + accumulated WIP
Snapshot of accumulated work-in-progress on top of the baseline commit.

This session — retire Tier B from the wallet shell (single-tier model, glossary
"Tiers & trust"):
- SendFlow.tsx: BCH-only; removed the non-functional Tier-B send path, the D-4/D-5
  raw-BCH co-display, and the ADR-0013 friction ladder.
- ArtemisionScreen.tsx: Create wizard collapsed to a single 5-step on-chain genesis
  flow; tier-selection step + Tier-B branch (prepareTierBImport) deleted.
- QuickActions.tsx / SettingsScreen.tsx: receive note reworded; "Send friction
  thresholds" + "Tier explainer" rows removed; registry-trust note reworded.
- Verified: tsc clean, vitest 74/74, full preview walkthrough, no console errors.

Pre-existing uncommitted work also folded in:
- USDm -> FIATm family rename/reorg (Parameters/FIATm/, design records)
- ~130 chipnet + sim-fiatm scripts (catalytic-converter, hyperinflation backtests)
- snapshots 0153-0168; wiki updates (glossary, decisions, index); ADR-0044/0045
- mockups + additional shell screens (Agora, Apps, MainScreen, AssetDetail, ...)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:01:37 +02:00

207 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-tote4-test.mjs — 4-party pari-mutuel tote on chipnet (ADR-0024).
*
* Extends the 3-bettor ParimutuelTote validation (snapshot 0045) to FOUR
* bettors with two on each side, so the winning side ALWAYS splits the pool
* pro-rata between two winners — proving 2-winner pro-rata on either outcome:
*
* TOTE4 (Family C, ParimutuelTote4) — A(30) + B(10) back side 0; C(20) + D(10)
* back side 1. Pool = 70 FT. Oracle signs outcome 0 → side 0 splits the pool
* pro-rata by stake: A = 30*70/40 = 52 FT (floor of 52.5), B = remainder = 18
* FT. C and D lose their stake. (Sum 52+18 = 70 → no FT lost to rounding.)
*
* The result-oracle signs {eventId(16) ++ outcome(1)} off-chain; the covenant
* verifies via checkDataSig and computes the payout — no party declares the
* winner. If the resolution tx confirms, the covenant's pro-rata outputs matched
* exactly: that IS the proof.
*
* Roles: founder = C; bettors = A, B, C, D. Miner fees from C. Resumable via
* state (.chipnet-tote4-test.json). Requires .chipnet-keys{,-b,-c,-d}.json,
* each funded on chipnet (run chipnet:setup{,-b,-c,-d} + faucet first).
*/
import { existsSync, readFileSync, writeFileSync } 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, 'ChipnetTests', '.chipnet-tote4-test.json');
const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const TOTE_ARTIFACT = join(ROOT, 'contracts/dist/ParimutuelTote4.json');
const KEYS = {
a: join(ROOT, '.chipnet-keys.json'),
b: join(ROOT, '.chipnet-keys-b.json'),
c: join(ROOT, '.chipnet-keys-c.json'),
d: join(ROOT, '.chipnet-keys-d.json'),
};
const RESERVOIR = 1_000_000_000_000_000_000n;
const RATE = 1000n, FEE = 0n;
const FAR = 9_999_999n; // far-future deadline → resolves via oracle, not refund
const MF = 6000n; // miner-fee headroom
function hexToBin(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;}
function binToHex(b){return Array.from(b,(x)=>x.toString(16).padStart(2,'0')).join('');}
function txidLE(h){const d=hexToBin(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));
function divider(c='='){console.log(c.repeat(80));}
function step(t){console.log('');divider();console.log(t);divider();}
function load(){return existsSync(STATE)?JSON.parse(readFileSync(STATE,'utf8')):{};}
function save(s){writeFileSync(STATE,JSON.stringify(s,(k,v)=>typeof v==='bigint'?v.toString():v,2));}
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 txidOf(sent){return typeof sent==='string'?sent:sent?.txid??sent?.txId;}
// Result-oracle: sign {eventId ++ outcome} the way OP_CHECKDATASIG expects —
// an ECDSA DER signature over sha256(message), no sighash byte.
function oracleSign(privHex, eventIdBin, outcomeByte){
const message = new Uint8Array([...eventIdBin, outcomeByte]);
const sig = secp256k1.signMessageHashDER(hexToBin(privHex), sha256.hash(message));
if (typeof sig === 'string') throw new Error(`oracle sign failed: ${sig}`);
return { message, sig };
}
async function main(){
const mbaArt=JSON.parse(readFileSync(MBA_ARTIFACT,'utf8'));
const toteArt=JSON.parse(readFileSync(TOTE_ARTIFACT,'utf8'));
for(const [k,f] of Object.entries(KEYS)) if(!existsSync(f)) throw new Error(`missing key file for wallet ${k.toUpperCase()}: ${f} (run chipnet:setup-${k==='a'?'':k} + faucet)`);
const A=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.a,'utf8')).wif);
const B=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.b,'utf8')).wif);
const C=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c,'utf8')).wif);
const D=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.d,'utf8')).wif);
const provider=new ElectrumNetworkProvider('chipnet');
const sigA=new SignatureTemplate(A.privateKey), sigB=new SignatureTemplate(B.privateKey);
const sigC=new SignatureTemplate(C.privateKey), sigD=new SignatureTemplate(D.privateKey);
const flbC=p2pkh(C.publicKeyHash);
let state=load();
// ---- Result-oracle key (generated once, reused) ----
if(!state.oracle){
let priv; do { priv=randomBytes(32); } while (typeof secp256k1.derivePublicKeyCompressed(priv)==='string');
const pub=secp256k1.derivePublicKeyCompressed(priv);
state.oracle={priv:binToHex(priv),pub:binToHex(pub)};save(state);
}
const oraclePub=hexToBin(state.oracle.pub);
console.log(`Result-oracle pubkey: ${state.oracle.pub}`);
console.log(`Bettors: A=${A.cashaddr}\n B=${B.cashaddr}\n C=${C.cashaddr}\n D=${D.cashaddr}`);
// ---- MBA genesis (founder C) ----
step('Deploy MBA (canonical DeviantOpenMintFT, rate 1000, fee 0)');
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));
const r=await w.send([{cashaddr:w.cashaddr,value:2000n,unit:'sat'}],bad.length?{utxoIds:bad}:undefined);
console.log(` splitter ${txidOf(r)}`);await sleep(12000);
u=await w.getUtxos();e=u.filter(x=>x.vout===0&&!x.token);
if(!e.length)throw new Error('splitter failed');return e[0];
}
if(!state.mba){
const u=await eligible(C);const cat=u.txid;
const c=new Contract(mbaArt,[txidLE(cat),RATE,FEE,flbC],{provider});
const r=await C.tokenGenesis({cashaddr:c.tokenAddress,amount:RESERVOIR,value:1000n,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});
console.log(` MBA genesis ${txidOf(r)} — category ${cat}`);
state.mba={categoryId:cat};save(state);await sleep(10000);
} else console.log(` MBA already: ${state.mba.categoryId}`);
const cat=state.mba.categoryId;
const mba=new Contract(mbaArt,[txidLE(cat),RATE,FEE,flbC],{provider});
// claim exactly `amount` FT into `who` as its own UTXO
async function mint(who,sig,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)-Number(a.satoshis));
const {p,t}=pickBch(bch,gross+MF+1000n);
if(t<gross+MF+1000n)throw new Error(`${label}: insufficient BCH ${t} < ${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,sig.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}: minted ${amount} FT — ${txidOf(await tb.send())}`);await sleep(10000);
}
state.mints=state.mints||{};
const mints=[['a30',A,sigA,30n],['b10',B,sigB,10n],['c20',C,sigC,20n],['d10',D,sigD,10n]];
step('Mint stakes to A(30), B(10), C(20), D(10)');
for(const [k,w,s,amt] of mints){ if(!state.mints[k]){await mint(w,s,amt,`mint ${k}`);state.mints[k]=true;save(state);} else console.log(` ${k} already minted`); }
// find a FT UTXO held by `who` with EXACTLY `amount` of our category
async function findExact(who,amount){
const u=await provider.getUtxos(who.cashaddr);
return u.find(x=>x.token?.category===cat&&!x.token?.nft&&BigInt(x.token?.amount)===amount);
}
async function cBchFee(need){
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
return pickBch(cBch,need);
}
async function ftAt(who,amount){ // verify: who holds a UTXO of exactly `amount` FT of our category
const u=await provider.getUtxos(who.cashaddr);
return u.some(x=>x.token?.category===cat&&!x.token?.nft&&BigInt(x.token?.amount)===amount);
}
// ================= TOTE4 (Family C, 4 bettors) =================
step('TOTE4 — A(30)+B(10) side0, C(20)+D(10) side1; oracle says outcome 0 → A=52, B=18 pro-rata');
state.tote=state.tote||{};
if(!state.tote.eventId){state.tote.eventId=binToHex(randomBytes(16));save(state);}
const toteEvent=hexToBin(state.tote.eventId);
const tote=new Contract(toteArt,[txidLE(cat),RATE,oraclePub,toteEvent,FAR,
A.publicKeyHash,30n,B.publicKeyHash,10n,C.publicKeyHash,20n,D.publicKeyHash,10n],{provider});
console.log(` contract: ${tote.tokenAddress}`);
if(!state.tote.fund){
const a30=await findExact(A,30n), b10=await findExact(B,10n), c20=await findExact(C,20n), d10=await findExact(D,10n);
if(!a30||!b10||!c20||!d10)throw new Error(`tote4: missing stake (A30=${!!a30} B10=${!!b10} C20=${!!c20} D10=${!!d10})`);
const {p,t}=await cBchFee(MF+2000n);
const tb=new TransactionBuilder({provider});
tb.addInput(a30,sigA.unlockP2PKH());
tb.addInput(b10,sigB.unlockP2PKH());
tb.addInput(c20,sigC.unlockP2PKH());
tb.addInput(d10,sigD.unlockP2PKH());
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
tb.addOutput({to:tote.tokenAddress,amount:70000n,token:{amount:70n,category:cat}}); // pool 70 FT
tb.addOutput({to:C.cashaddr,amount:BigInt(a30.satoshis)+BigInt(b10.satoshis)+BigInt(c20.satoshis)+BigInt(d10.satoshis)+t-70000n-MF});
state.tote.fund=txidOf(await tb.send());save(state);
console.log(` funded pool 70 FT / 70000 sat — ${state.tote.fund}`);await sleep(10000);
} else console.log(` (funded: ${state.tote.fund})`);
if(!state.tote.resolve){
const pu=(await provider.getUtxos(tote.tokenAddress)).find(x=>x.token?.category===cat&&!x.token?.nft&&BigInt(x.token?.amount)===70n);
if(!pu)throw new Error('tote4: pool UTXO not found');
const {message,sig}=oracleSign(state.oracle.priv,toteEvent,0x00);
const {p,t}=await cBchFee(MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(pu,tote.unlock.resolveOracle(sig,message));
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
tb.addOutput({to:A.tokenaddr,amount:52000n,token:{amount:52n,category:cat}}); // A pro-rata 30*70/40 = 52
tb.addOutput({to:B.tokenaddr,amount:18000n,token:{amount:18n,category:cat}}); // B remainder = 18
tb.addOutput({to:C.cashaddr,amount:BigInt(pu.satoshis)+t-52000n-18000n-MF});
state.tote.resolve=txidOf(await tb.send());save(state);
console.log(` oracle(outcome 0) → A=52, B=18 (C=0, D=0) — ${state.tote.resolve}`);await sleep(10000);
} else console.log(` (resolved: ${state.tote.resolve})`);
// ================= VERIFY =================
step('VERIFY — confirm winners hold the exact pro-rata amounts');
const checks=[
['TOTE4 A holds 52 FT (30*70/40 floor)', await ftAt(A,52n)],
['TOTE4 B holds 18 FT (remainder)', await ftAt(B,18n)],
];
let ok=true;
for(const [label,pass] of checks){console.log(` [${pass?'PASS':'FAIL'}] ${label}`);ok=ok&&pass;}
step('SUMMARY — 4-party pari-mutuel tote (ADR-0024)');
console.log(` MBA category: ${cat}`);
console.log(` Oracle pubkey: ${state.oracle.pub}`);
console.log(` TOTE4 fund/resolve: ${state.tote.fund} / ${state.tote.resolve}`);
console.log('');
console.log(' 4-bettor covenant-enforced pari-mutuel pro-rata (two winners on the');
console.log(' winning side) validated on live chipnet consensus.');
divider();
if(!ok){console.error('VERIFY FAILED');process.exit(1);}
process.exit(0); // mainnet-js holds wss sockets open; exit explicitly so the run ends
}
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});