#!/usr/bin/env node /** * chipnet-tote2-mba-test.mjs — Tier-2 open tote DENOMINATED IN AN MBA (ADR-0024 * §3.1, ADR-0025). The MBA-staking variant of chipnet-tote2-test.mjs: same * state-NFT + receipt state machine, but the pool is TWO co-spent covenant UTXOs * — a state-NFT UTXO (tote category) + an MBA-FT vault UTXO (peg-backed money). * * MBA = canonical DeviantOpenMintFT, rate 1000 (finely denominated → the 1-unit * vault seed + floor residual are penny-scale, per ADR-0025). * * Stakes (MBA units): A=15, B=25 (side 0); C=20, D=11 (side 1); +1 seed. Oracle * attests outcome 0 → side 0 splits pro-rata: A(minority)=floor(15*71/40)=26; * B last drains the vault remainder (46). Negatives: loser C (side1) and A's * double-claim (spent receipt) must both be REJECTED ON-CHAIN by the covenant. * * Roles: founder/operator/fees = C. 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-mba-test.json'); const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json'); const TOTE_ARTIFACT = join(ROOT, 'contracts/dist/ParimutuelToteOpenMBA.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 SEED = 1n; // 1-unit vault seed (penny-scale; absorbed by last claimant) const DUST = 1000n; // receipt / state NFT dust const MF = 6000n; function hexToBin(h){const o=new Uint8Array(h.length/2);for(let i=0;ix.toString(16).padStart(2,'0')).join('');} function txidLE(h){const d=hexToBin(h);const o=new Uint8Array(d.length);for(let i=0;i>=8n;}return binToHex(o);} function u64leToBig(hex){let v=0n;for(let i=0;i<8;i++){v|=BigInt(parseInt(hex.slice(i*2,i*2+2),16))<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(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};} 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 mbaArt=JSON.parse(readFileSync(MBA_ARTIFACT,'utf8')); 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}`); 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 flbC=p2pkh(C.publicKeyHash); 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);} // ---- MBA genesis (founder C) ---- step('Deploy MBA (DeviantOpenMintFT, rate 1000 — finely denominated)'); if(!st.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}`);st.mba=cat;save(st);await sleep(10000); } else console.log(` MBA ${st.mba}`); const mbaCat=st.mba; const mba=new Contract(mbaArt,[txidLE(mbaCat),RATE,FEE,flbC],{provider}); 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===mbaCat&&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(tx.token?.category===mbaCat&&!x.token?.nft&&BigInt(x.token?.amount)===amt);} async function mbaBal(who){return (await provider.getUtxos(who.cashaddr)).filter(x=>x.token?.category===mbaCat&&!x.token?.nft).reduce((s,x)=>s+BigInt(x.token.amount),0n);} // ---- Genesis tote NFT category ---- step('Genesis tote NFT category (state minting NFT)'); const PHASE0=stateCommit('00','00',0,0,0); if(!st.tote){const u=await eligible(C);const cat=u.txid;const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:0n,value:DUST,nft:{capability:'minting',commitment:PHASE0}},[],{utxoIds:[u]});console.log(` tote genesis ${txidOf(r)} — ${cat}`);st.tote=cat;save(st);await sleep(10000);} else console.log(` tote ${st.tote}`); const toteCat=st.tote; 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(toteCat),txidLE(mbaCat),RATE,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 stateUtxo(){return (await provider.getUtxos(tote.tokenAddress)).find(x=>x.token?.category===toteCat&&x.token?.nft?.capability==='minting');} async function vaultUtxo(){return (await provider.getUtxos(tote.tokenAddress)).find(x=>x.token?.category===mbaCat&&!x.token?.nft);} // ---- Setup: relocate state NFT + seed the MBA vault into the covenant ---- step('Setup — state NFT + 1-unit MBA vault into the covenant'); if(!st.setup){ const g=(await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===toteCat&&x.token?.nft?.capability==='minting'); const seedU=await findMba(C,SEED); if(!g||!seedU)throw new Error(`setup: missing state NFT=${!!g} seed=${!!seedU}`); const {p,t}=await cBch(MF+DUST+1000n); const tb=new TransactionBuilder({provider}); tb.addInput(g,sig.c.unlockP2PKH()); tb.addInput(seedU,sig.c.unlockP2PKH()); for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); tb.addOutput({to:tote.tokenAddress,amount:DUST,token:{amount:0n,category:toteCat,nft:{capability:'minting',commitment:PHASE0}}}); // [0] state tb.addOutput({to:tote.tokenAddress,amount:SEED*RATE,token:{amount:SEED,category:mbaCat}}); // [1] vault seed tb.addOutput({to:C.cashaddr,amount:BigInt(g.satoshis)+BigInt(seedU.satoshis)+t-DUST-SEED*RATE-MF}); st.setup=txidOf(await tb.send());save(st);console.log(` setup — ${st.setup}`);await sleep(10000); } else console.log(` (setup ${st.setup})`); // ---- BET: spend state[0]+vault[1]+bettor MBA stake; mint receipt ---- const bets=[['a',A,sig.a,'00',15n],['b',B,sig.b,'00',25n],['c',C,sig.c,'01',20n],['d',D,sig.d,'01',11n]]; step('BET — A,B side0; C,D side1 (covenant spends state + vault each tx)'); st.bets=st.bets||{};st.tot=st.tot||{A:'0',B:'0'}; for(const [k,w,s,sideHex,stake] of bets){ if(st.bets[k]){console.log(` ${k} bet`);continue;} const sUtxo=await stateUtxo(), vUtxo=await vaultUtxo(); if(!sUtxo||!vUtxo)throw new Error(`bet ${k}: state=${!!sUtxo} vault=${!!vUtxo}`); const stakeU=await findMba(w,stake);if(!stakeU)throw new Error(`bet ${k}: stake UTXO missing`); let tA=BigInt(st.tot.A),tB=BigInt(st.tot.B); if(sideHex==='00')tA+=stake;else tB+=stake; const newCommit=stateCommit('00','00',tA,tB,0); const newPool=BigInt(vUtxo.token.amount)+stake; const {p,t}=await cBch(MF+DUST+1000n); const tb=new TransactionBuilder({provider}); tb.addInput(sUtxo,tote.unlock.bet(stake,hexToBin(sideHex))); // [0] state (covenant) tb.addInput(vUtxo,tote.unlock.bet(stake,hexToBin(sideHex))); // [1] vault (covenant) tb.addInput(stakeU,s.unlockP2PKH()); // [2] bettor MBA stake for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); // [3+] C fee/dust tb.addOutput({to:tote.tokenAddress,amount:DUST,token:{amount:0n,category:toteCat,nft:{capability:'minting',commitment:newCommit}}}); // [0] state tb.addOutput({to:tote.tokenAddress,amount:newPool*RATE,token:{amount:newPool,category:mbaCat}}); // [1] vault tb.addOutput({to:w.tokenaddr,amount:DUST,token:{amount:0n,category:toteCat,nft:{capability:'mutable',commitment:receiptCommit('00',sideHex,stake)}}}); // [2] receipt tb.addOutput({to:C.cashaddr,amount:BigInt(sUtxo.satoshis)+BigInt(stakeU.satoshis)+t-DUST-DUST-newPool*RATE+BigInt(vUtxo.satoshis)-MF}); // [3] change console.log(` ${k} bets ${stake} side ${sideHex} — ${txidOf(await tb.send())}`); st.bets[k]=true;st.tot={A:tA.toString(),B:tB.toString()};save(st);await sleep(10000); } const totalPool=BigInt(st.tot.A)+BigInt(st.tot.B), totalWin=BigInt(st.tot.A); // ---- ATTEST outcome 0 ---- step(`ATTEST outcome 0 — totalPool=${totalPool}, totalWin=${totalWin}`); if(!st.attest){ const sUtxo=await stateUtxo(), vUtxo=await vaultUtxo(); 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(sUtxo,tote.unlock.attest(oSig,message)); tb.addInput(vUtxo,tote.unlock.attest(oSig,message)); for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); tb.addOutput({to:tote.tokenAddress,amount:BigInt(sUtxo.satoshis),token:{amount:0n,category:toteCat,nft:{capability:'minting',commitment:newCommit}}}); tb.addOutput({to:tote.tokenAddress,amount:BigInt(vUtxo.satoshis),token:{amount:vUtxo.token.amount,category:mbaCat}}); 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(` (attest ${st.attest})`); async function findReceipt(w,sideHex,stake,claimedHex){const rc=receiptCommit(claimedHex,sideHex,stake);return (await provider.getUtxos(w.cashaddr)).find(x=>x.token?.category===toteCat&&x.token?.nft?.capability==='mutable'&&x.token?.nft?.commitment===rc);} const isReject=(e)=>/ParimutuelToteOpenMBA\.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 claimed flag async function notLast(w,s,sideHex,stake,claimedHex){ const sUtxo=await stateUtxo(), vUtxo=await vaultUtxo(); const rcpt=await findReceipt(w,sideHex,stake,claimedHex);if(!rcpt)throw new Error('claim: receipt missing'); const rwsNow=u64leToBig(sUtxo.token.nft.commitment.slice(36,52)); const payout=stake*totalPool/totalWin; const newCommit=stateCommit('01','00',totalPool,totalWin,rwsNow-stake); const poolIn=BigInt(vUtxo.token.amount); const {p,t}=await cBch(MF+DUST+1000n); const tb=new TransactionBuilder({provider}); tb.addInput(sUtxo,tote.unlock.claim(p2pkh(w.publicKeyHash))); // [0] state tb.addInput(vUtxo,tote.unlock.claim(p2pkh(w.publicKeyHash))); // [1] vault tb.addInput(rcpt,s.unlockP2PKH()); // [2] receipt for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); tb.addOutput({to:tote.tokenAddress,amount:BigInt(sUtxo.satoshis),token:{amount:0n,category:toteCat,nft:{capability:'minting',commitment:newCommit}}}); tb.addOutput({to:tote.tokenAddress,amount:(poolIn-payout)*RATE,token:{amount:poolIn-payout,category:mbaCat}}); tb.addOutput({to:w.tokenaddr,amount:payout*RATE,token:{amount:payout,category:mbaCat}}); tb.addOutput({to:w.tokenaddr,amount:DUST,token:{amount:0n,category:toteCat,nft:{capability:'mutable',commitment:receiptCommit('01',sideHex,stake)}}}); tb.addOutput({to:C.cashaddr,amount:BigInt(vUtxo.satoshis)+BigInt(rcpt.satoshis)+t-(poolIn-payout)*RATE-payout*RATE-DUST-MF}); return {txid:txidOf(await tb.send()),payout}; } async function lastClaim(w,s,sideHex,stake){ const sUtxo=await stateUtxo(), vUtxo=await vaultUtxo(); const rcpt=await findReceipt(w,sideHex,stake,'00');if(!rcpt)throw new Error('last: receipt missing'); const poolIn=BigInt(vUtxo.token.amount); const {p,t}=await cBch(MF+DUST+1000n); const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true}); tb.addInput(sUtxo,tote.unlock.claim(p2pkh(w.publicKeyHash))); tb.addInput(vUtxo,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.tokenaddr,amount:poolIn*RATE,token:{amount:poolIn,category:mbaCat}}); // all vault MBA to claimer tb.addOutput({to:w.tokenaddr,amount:DUST,token:{amount:0n,category:toteCat,nft:{capability:'mutable',commitment:receiptCommit('01',sideHex,stake)}}}); tb.addOutput({to:C.cashaddr,amount:BigInt(sUtxo.satoshis)+BigInt(vUtxo.satoshis)+BigInt(rcpt.satoshis)+t-poolIn*RATE-DUST-MF}); return {txid:txidOf(await tb.send()),drained:poolIn}; } step('NEG — loser C (side1) claims -> covenant must REJECT on-chain'); if(!st.loserTested){let r=false;try{await notLast(C,sig.c,'01',20n,'00');}catch(e){if(isReject(e)){r=true;console.log(` rejected on-chain: ${(e?.message??e).toString().slice(0,110)}`);}else throw e;}if(!r)throw new Error('SECURITY: loser accepted');st.loserTested=true;save(st);} else console.log(' (loser ok)'); step('CLAIM A (minority) — floor 15*71/40 = 26'); if(!st.claimA){const r=await notLast(A,sig.a,'00',15n,'00');st.claimA=r.txid;st.payA=r.payout.toString();save(st);console.log(` A=${r.payout} — ${r.txid}`);await sleep(10000);} else console.log(` (A ${st.claimA})`); step('NEG — A re-claims spent receipt -> covenant must REJECT on-chain'); if(!st.dblTested){let r=false;try{await notLast(A,sig.a,'00',15n,'01');}catch(e){if(isReject(e)){r=true;console.log(` rejected on-chain: ${(e?.message??e).toString().slice(0,110)}`);}else throw e;}if(!r)throw new Error('SECURITY: double-claim accepted');st.dblTested=true;save(st);} else console.log(' (double ok)'); step('CLAIM B (last) — drains vault + burns state NFT'); if(!st.claimB){const r=await lastClaim(B,sig.b,'00',25n);st.claimB=r.txid;st.drainedB=r.drained.toString();save(st);console.log(` B drained ${r.drained} — ${r.txid}`);await sleep(10000);} else console.log(` (B ${st.claimB})`); step('VERIFY'); const aBal=await mbaBal(A), bBal=await mbaBal(B); const checks=[ [`A holds pro-rata ${st.payA} MBA`, st.payA==='26'], ['loser rejected on-chain', !!st.loserTested], ['double-claim rejected on-chain', !!st.dblTested], [`B drained ${st.drainedB} MBA (incl seed+residual)`, !!st.claimB], ['state NFT + vault consumed', !(await stateUtxo())&&!(await vaultUtxo())], ]; let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;} console.log(` MBA balances: A=${aBal} B=${bBal}`); step('SUMMARY — Tier-2 MBA-denominated open tote (2-UTXO)'); console.log(` tote ${toteCat}\n mba ${mbaCat}\n oracle ${st.oracle.pub}`); console.log(` setup/attest: ${st.setup} / ${st.attest}`); console.log(` claimA/claimB: ${st.claimA} / ${st.claimB}`); console.log(' Covenant computed MBA pro-rata from its state-NFT ledger across a co-spent'); console.log(' state+vault pair; receipts blocked double-claim + losing side on-chain.'); 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);});