deviant/scripts/chipnet-tote2-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

254 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-tote2-test.mjs — Tier-2 open-entry, covenant-ENFORCED pari-mutuel tote
* in plain BCH (ADR-0024 §3.1). Validates the state-NFT + receipt state machine on
* live chipnet: open dynamic roster, covenant-computed pro-rata, double-claim guard.
*
* Money is native BCH (sats); the tote has ONE NFT category (state + receipts), so
* the pool is a SINGLE UTXO (state minting-NFT riding on the pooled sats).
*
* Scenario (sats): A=25000,B=15000 back side 0; C=20001,D=10000 back side 1.
* Pool stakes = 70001 (+ a ~1000-sat structural NFT seed). Oracle attests outcome
* 0 → side 0 splits pro-rata: A = floor(25000*70001/40000) = 43750; B last takes
* the remainder (fair 26250 + rounding residual + seed) = poolRemainder. Then:
* - A re-claims with the spent receipt -> MUST FAIL (double-claim guard).
* - loser C claims (side 1 != outcome 0) -> MUST FAIL.
* - B claims last -> drains pool, burns the state NFT.
*
* Roles: founder/operator/fees = C. Bettors A,B,C,D. Resumable state JSON.
*/
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-tote2-test.json');
const TOTE_ARTIFACT = join(ROOT, 'contracts/dist/ParimutuelToteOpen.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 SEED = 1000n; // structural NFT-UTXO dust (absorbed by the last claimant)
const DUST = 1000n; // receipt NFT dust
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;}
function u64le(n){let v=BigInt(n);const o=new Uint8Array(8);for(let i=0;i<8;i++){o[i]=Number(v&0xffn);v>>=8n;}return binToHex(o);}
function stateCommit(phase,outcome,A,B,rws){return phase+outcome+u64le(A)+u64le(B)+u64le(rws);} // 26 bytes
function receiptCommit(claimed,side,stake){return claimed+side+u64le(stake);} // 10 bytes
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(s){return typeof s==='string'?s:s?.txid??s?.txId;}
function oracleSign(privHex,evBin,outByte){const m=new Uint8Array([...evBin,outByte]);const sig=secp256k1.signMessageHashDER(hexToBin(privHex),sha256.hash(m));if(typeof sig==='string')throw new Error('sign '+sig);return{message:m,sig};}
async function main(){
const art=JSON.parse(readFileSync(TOTE_ARTIFACT,'utf8'));
for(const [k,f] of Object.entries(KEYS)) if(!existsSync(f)) throw new Error(`missing key ${k}: ${f}`);
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 sig={a:new SignatureTemplate(A.privateKey),b:new SignatureTemplate(B.privateKey),c:new SignatureTemplate(C.privateKey),d:new SignatureTemplate(D.privateKey)};
const W={a:A,b:B,c:C,d:D};
let st=load();
if(!st.oracle){let p;do{p=randomBytes(32);}while(typeof secp256k1.derivePublicKeyCompressed(p)==='string');st.oracle={priv:binToHex(p),pub:binToHex(secp256k1.derivePublicKeyCompressed(p))};save(st);}
const oraclePub=hexToBin(st.oracle.pub);
console.log(`Oracle: ${st.oracle.pub}`);
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];
}
async function cBch(need){const u=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));return pickBch(u,need);}
async function bchOf(w){const u=(await provider.getUtxos(w.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));return u;}
async function balance(w){return (await provider.getUtxos(w.cashaddr)).filter(x=>!x.token).reduce((s,u)=>s+BigInt(u.satoshis),0n);}
// ---- Genesis the tote NFT category (minting NFT, phase-0 zero commitment) ----
step('Genesis tote NFT category (state minting NFT)');
const PHASE0=stateCommit('00','00',0,0,0);
if(!st.cat){
const u=await eligible(C);const cat=u.txid;
const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:0n,value:SEED,nft:{capability:'minting',commitment:PHASE0}},[],{utxoIds:[u]});
console.log(` genesis ${txidOf(r)} — category ${cat}`);
st.cat=cat;save(st);await sleep(10000);
} else console.log(` category ${st.cat}`);
const cat=st.cat;
// deadlines persisted so the covenant address is stable across re-runs
if(!st.deadlines){const tip=await provider.getBlockHeight();st.deadlines={bet:tip,sweep:tip+500000};st.eventId=binToHex(randomBytes(16));save(st);}
const ev=hexToBin(st.eventId);
const tote=new Contract(art,[txidLE(cat),oraclePub,ev,BigInt(st.deadlines.bet),BigInt(st.deadlines.sweep),C.publicKeyHash,0n,C.publicKeyHash],{provider}); // rakeBps=0 (no rake)
console.log(` contract ${tote.tokenAddress} (betDeadline ${st.deadlines.bet})`);
async function poolUtxo(){return (await provider.getUtxos(tote.tokenAddress)).find(x=>x.token?.category===cat&&x.token?.nft?.capability==='minting');}
// ---- Relocate the minting NFT from C into the covenant (pool start) ----
step('Relocate minting NFT into the covenant');
if(!st.relocate){
const g=(await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===cat&&x.token?.nft?.capability==='minting');
if(!g)throw new Error('genesis NFT not found at C');
const {p,t}=await cBch(MF+SEED);
const tb=new TransactionBuilder({provider});
tb.addInput(g,sig.c.unlockP2PKH());
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
tb.addOutput({to:tote.tokenAddress,amount:SEED,token:{amount:0n,category:cat,nft:{capability:'minting',commitment:PHASE0}}});
tb.addOutput({to:C.cashaddr,amount:BigInt(g.satoshis)+t-SEED-MF});
st.relocate=txidOf(await tb.send());save(st);
console.log(` pool seeded at ${tote.tokenAddress} — ${st.relocate}`);await sleep(10000);
} else console.log(` (relocated: ${st.relocate})`);
// ---- BET: fold stakes, mint receipts. Track running A/B totals. ----
// bets: [key, wallet, sig, side('00'|'01'), stakeSats]
// A is a MINORITY side-0 winner (15000 of 40000) so that after A claims, the
// pool still holds >= A's payout — making the on-chain double-claim test valid.
const bets=[['a',A,sig.a,'00',15000n],['b',B,sig.b,'00',25000n],['c',C,sig.c,'01',20001n],['d',D,sig.d,'01',10000n]];
step('BET — A,B (side0) and C,D (side1) fold stakes; receipts minted');
st.bets=st.bets||{};st.totals=st.totals||{A:'0',B:'0'};
for(const [k,w,s,sideHex,stake] of bets){
if(st.bets[k]){console.log(` ${k} already bet`);continue;}
const pool=await poolUtxo();if(!pool)throw new Error('pool UTXO missing');
let tA=BigInt(st.totals.A),tB=BigInt(st.totals.B);
if(sideHex==='00')tA+=stake;else tB+=stake;
const newCommit=stateCommit('00','00',tA,tB,0);
const newSats=BigInt(pool.satoshis)+stake;
const rcpt=receiptCommit('00',sideHex,stake);
const bch=await bchOf(w);const {p,t}=pickBch(bch,stake+DUST+MF+1000n);
if(t<stake+DUST+MF+1000n)throw new Error(`${k}: insufficient BCH ${t}`);
const tb=new TransactionBuilder({provider});
tb.addInput(pool,tote.unlock.bet(stake,hexToBin(sideHex)));
for(const x of p)tb.addInput(x,s.unlockP2PKH());
tb.addOutput({to:tote.tokenAddress,amount:newSats,token:{amount:0n,category:cat,nft:{capability:'minting',commitment:newCommit}}}); // [0] pool
tb.addOutput({to:w.tokenaddr,amount:DUST,token:{amount:0n,category:cat,nft:{capability:'mutable',commitment:rcpt}}}); // [1] receipt
tb.addOutput({to:w.cashaddr,amount:t-stake-DUST-MF}); // [2] change
console.log(` ${k} bets ${stake} on side ${sideHex} — ${txidOf(await tb.send())}`);
st.bets[k]=true;st.totals={A:tA.toString(),B:tB.toString()};save(st);await sleep(10000);
}
const totalA=BigInt(st.totals.A),totalB=BigInt(st.totals.B); // 40000 / 30001
const totalPool=totalA+totalB, totalWin=totalA; // outcome 0 -> side0 wins
// ---- ATTEST outcome 0 ----
step(`ATTEST — oracle says outcome 0; freeze totalPool=${totalPool}, totalWin=${totalWin}`);
if(!st.attest){
const pool=await poolUtxo();if(!pool)throw new Error('pool missing');
const {message,sig:oSig}=oracleSign(st.oracle.priv,ev,0x00);
const newCommit=stateCommit('01','00',totalPool,totalWin,totalWin);
const {p,t}=await cBch(MF+1000n);
const tb=new TransactionBuilder({provider});
tb.setLocktime(Number(st.deadlines.bet));
tb.addInput(pool,tote.unlock.attest(oSig,message));
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
tb.addOutput({to:tote.tokenAddress,amount:BigInt(pool.satoshis),token:{amount:0n,category:cat,nft:{capability:'minting',commitment:newCommit}}});
tb.addOutput({to:C.cashaddr,amount:t-MF});
st.attest=txidOf(await tb.send());save(st);
console.log(` attested — ${st.attest}`);await sleep(10000);
} else console.log(` (attested: ${st.attest})`);
function u64leToBig(hex){let v=0n;for(let i=0;i<8;i++){v|=BigInt(parseInt(hex.slice(i*2,i*2+2),16))<<BigInt(8*i);}return v;}
async function findReceipt(w,sideHex,stake,claimedHex){
const rc=receiptCommit(claimedHex,sideHex,stake);
return (await provider.getUtxos(w.cashaddr)).find(x=>x.token?.category===cat&&x.token?.nft?.capability==='mutable'&&x.token?.nft?.commitment===rc);
}
// A covenant rejection (the VM ran and a require() failed), vs a harness/builder error.
const isCovenantReject=(e)=>/ParimutuelToteOpen\.cash|Require statement failed|mandatory-script|failed to verify|Verify/i.test((e?.message??e).toString());
// not-last claim spending the receipt with the given `claimedHex` flag.
async function notLastClaim(w,s,sideHex,stake,claimedHex){
const pool=await poolUtxo();if(!pool)throw new Error('claim: pool missing');
const rcpt=await findReceipt(w,sideHex,stake,claimedHex);if(!rcpt)throw new Error('claim: receipt missing');
const rwsNow=u64leToBig(pool.token.nft.commitment.slice(36,52));
const payout=stake*totalPool/totalWin; // floor
const newCommit=stateCommit('01','00',totalPool,totalWin,rwsNow-stake);
const {p,t}=await cBch(MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(pool,tote.unlock.claim(p2pkh(w.publicKeyHash))); // [0]
tb.addInput(rcpt,s.unlockP2PKH()); // [1] receipt
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); // [2+] C fee
tb.addOutput({to:tote.tokenAddress,amount:BigInt(pool.satoshis)-payout,token:{amount:0n,category:cat,nft:{capability:'minting',commitment:newCommit}}});
tb.addOutput({to:w.cashaddr,amount:payout});
tb.addOutput({to:w.tokenaddr,amount:DUST,token:{amount:0n,category:cat,nft:{capability:'mutable',commitment:receiptCommit('01',sideHex,stake)}}});
tb.addOutput({to:C.cashaddr,amount:BigInt(rcpt.satoshis)+t-payout-DUST-MF});
return {txid:txidOf(await tb.send()),payout};
}
// CLAIM (last): drains whole pool to claimer, burns state NFT.
async function lastClaim(w,s,sideHex,stake){
const pool=await poolUtxo();if(!pool)throw new Error('last: pool missing');
const rcpt=await findReceipt(w,sideHex,stake,'00');if(!rcpt)throw new Error('last: receipt missing');
const {p,t}=await cBch(MF+1000n);
const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true});
tb.addInput(pool,tote.unlock.claim(p2pkh(w.publicKeyHash)));
tb.addInput(rcpt,s.unlockP2PKH());
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
tb.addOutput({to:w.cashaddr,amount:BigInt(pool.satoshis)}); // all pool sats
tb.addOutput({to:w.tokenaddr,amount:DUST,token:{amount:0n,category:cat,nft:{capability:'mutable',commitment:receiptCommit('01',sideHex,stake)}}});
tb.addOutput({to:C.cashaddr,amount:BigInt(rcpt.satoshis)+t-DUST-MF});
return {txid:txidOf(await tb.send()),drained:BigInt(pool.satoshis)};
}
// NEG (on-chain): loser C (side 1) claims while the pool is FULL so the builder
// amounts are valid -> the covenant's require(rcSide==outcome) rejects on-chain.
step('NEG — loser C (side1) claims -> covenant must REJECT on-chain');
if(!st.loserTested){
let r=false;
try{ await notLastClaim(C,sig.c,'01',20001n,'00'); }
catch(e){ if(isCovenantReject(e)){r=true;console.log(` rejected on-chain (expected): ${(e?.message??e).toString().slice(0,110)}`);} else throw e; }
if(!r)throw new Error('SECURITY: loser claim was ACCEPTED on-chain');
st.loserTested=true;save(st);
} else console.log(' (loser verified)');
step('CLAIM A (minority winner) — floor pro-rata = 26250');
if(!st.claimA){const r=await notLastClaim(A,sig.a,'00',15000n,'00');st.claimA=r.txid;st.payA=r.payout.toString();save(st);console.log(` A claimed ${r.payout} — ${r.txid}`);await sleep(10000);}
else console.log(` (A claimed: ${st.claimA})`);
// NEG (on-chain): A re-claims presenting the now-SPENT receipt (claimed=01).
// Pool still holds >= A's payout, so the builder accepts -> the covenant's
// require(rcClaimed==0) rejects on-chain.
step('NEG — A re-claims spent receipt -> covenant must REJECT on-chain');
if(!st.dblTested){
let r=false;
try{ await notLastClaim(A,sig.a,'00',15000n,'01'); }
catch(e){ if(isCovenantReject(e)){r=true;console.log(` rejected on-chain (expected): ${(e?.message??e).toString().slice(0,110)}`);} else throw e; }
if(!r)throw new Error('SECURITY: double-claim was ACCEPTED on-chain');
st.dblTested=true;save(st);
} else console.log(' (double-claim verified)');
step('CLAIM B (last) — drains pool + burns state NFT');
if(!st.claimB){const r=await lastClaim(B,sig.b,'00',25000n);st.claimB=r.txid;st.drainedB=r.drained.toString();save(st);console.log(` B claimed (last) drained ${r.drained} — ${r.txid}`);await sleep(10000);}
else console.log(` (B claimed: ${st.claimB})`);
step('VERIFY');
const checks=[
[`A received pro-rata ${st.payA} (= floor 15000*70001/40000)`, st.payA==='26250'],
['loser claim rejected on-chain (require rcSide==outcome)', !!st.loserTested],
['double-claim rejected on-chain (require rcClaimed==0)', !!st.dblTested],
[`B last-claim drained ${st.drainedB} (fair + residual + seed)`, !!st.claimB],
['pool UTXO fully consumed (state NFT burned)', !(await poolUtxo())],
];
let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;}
step('SUMMARY — Tier-2 open tote (covenant-enforced pro-rata, BCH)');
console.log(` category ${cat}`);
console.log(` oracle ${st.oracle.pub}`);
console.log(` relocate/attest: ${st.relocate} / ${st.attest}`);
console.log(` claimA/claimB: ${st.claimA} / ${st.claimB}`);
console.log(' Covenant computed pro-rata from its own state-NFT ledger; receipts');
console.log(' blocked double-claim and excluded the losing side. Open entry, trustless split.');
divider();
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);});