#!/usr/bin/env node /** * chipnet-phase-a-test.mjs — Phase A of the voting-systems §6 bicameral-sortition * design (§6.15 roadmap). Proves the minimal end-to-end mechanic on chipnet: * per-rank CitizenToken + a single-chamber ApplicationCovenant + admission flow. * * 1. genesis citizen category + application-state category (founder C) * 2. founder mints CitizenTokens (rank=0x01) for A and B * 3. founder mints an application-state NFT at D's ApplicationCovenant address * (commit = 16 zero bytes; threshold = 2) * 4. A votes yes — co-spend application + A's CitizenToken; counter 0 -> 1 * 5. B votes yes — counter 1 -> 2 (reaches threshold) * 6. anyone calls close() on D's application — UTXO consumed * 7. founder mints CitizenToken for D — admission complete * * Phase A scope: ONE chamber, fixed threshold, no fees, no sortition, no * double-vote enforcement. Later phases layer the rest. * * Roles: C = founder/issuer; A, B = existing Citizens (vote); D = applicant. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { TestNetWallet } from 'mainnet-js'; import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript'; import { secp256k1 } from '@bitauth/libauth'; const ROOT = join(import.meta.dirname, '..'); const STATE = join(ROOT, '.chipnet-phase-a-test.json'); const CIT_ART = join(ROOT, 'contracts/dist/CitizenToken.json'); const APP_ART = join(ROOT, 'contracts/dist/ApplicationCovenant.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 CIT_DUST = 1000n; // CitizenToken UTXO sats const APP_DUST = 1000n; // ApplicationState UTXO sats const SEED_DUST = 1000n; // minting-NFT dust const MF = 2500n; const THRESHOLD = 2n; const RANK_CITIZEN = '01'; const APP_OPEN = '0000000000000000' + '0000000000000000'; // 16 zero bytes 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);} const appCommit=(yes,no)=>u64le(yes)+u64le(no); 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(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;} async function main(){ const citArt=JSON.parse(readFileSync(CIT_ART,'utf8')); const appArt=JSON.parse(readFileSync(APP_ART,'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 cPub={a:secp256k1.derivePublicKeyCompressed(A.privateKey),b:secp256k1.derivePublicKeyCompressed(B.privateKey),c:secp256k1.derivePublicKeyCompressed(C.privateKey),d:secp256k1.derivePublicKeyCompressed(D.privateKey)}; let st=load(); 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 pickC(need){const u=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));const {p,t}=pickBch(u,need);if(t!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));const {p,t}=pickBch(u,need);if(tx.token?.category===category&&x.token?.nft?.capability==='minting');} async function citUtxo(c){return (await provider.getUtxos(c.tokenAddress)).find(x=>x.token?.category===citCat&&x.token?.nft?.capability==='mutable'&&x.token?.nft?.commitment===RANK_CITIZEN);} async function appUtxo(){return (await provider.getUtxos(app.tokenAddress)).find(x=>x.token?.category===appCat&&x.token?.nft?.capability==='mutable');} // ---- Mint CitizenTokens to A and B ---- async function mintCitizen(target,label){ if(st[`mint${label}`])return; const m=await mintingUtxo(citCat);if(!m)throw new Error(`mint ${label}: citizen minting NFT missing at C`); const {p,t}=await pickC(CIT_DUST+MF+1000n); const tb=new TransactionBuilder({provider}); tb.addInput(m,sig.c.unlockP2PKH()); for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); // [0] minting NFT back to C (preserve minting authority) tb.addOutput({to:C.tokenaddr,amount:SEED_DUST,token:{amount:0n,category:citCat,nft:{capability:'minting',commitment:''}}}); // [1] new mutable CitizenToken (rank 0x01) at target's CitizenToken address tb.addOutput({to:target.tokenAddress,amount:CIT_DUST,token:{amount:0n,category:citCat,nft:{capability:'mutable',commitment:RANK_CITIZEN}}}); // [2] C change tb.addOutput({to:C.cashaddr,amount:BigInt(m.satoshis)+t-SEED_DUST-CIT_DUST-MF}); st[`mint${label}`]=txidOf(await tb.send());save(st);console.log(` minted CitizenToken to ${label} — ${st[`mint${label}`]}`);await sleep(10000); } step('Mint CitizenTokens (rank 0x01) to A and B'); await mintCitizen(citA,'A'); await mintCitizen(citB,'B'); // ---- Mint ApplicationState NFT for D (commit = 16 zero bytes) ---- step(`Mint ApplicationState for D (threshold=${THRESHOLD})`); if(!st.appMint){ const m=await mintingUtxo(appCat);if(!m)throw new Error('appMint: app-state minting NFT missing at C'); const {p,t}=await pickC(APP_DUST+MF+1000n); const tb=new TransactionBuilder({provider}); tb.addInput(m,sig.c.unlockP2PKH()); for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); tb.addOutput({to:C.tokenaddr,amount:SEED_DUST,token:{amount:0n,category:appCat,nft:{capability:'minting',commitment:''}}}); tb.addOutput({to:app.tokenAddress,amount:APP_DUST,token:{amount:0n,category:appCat,nft:{capability:'mutable',commitment:APP_OPEN}}}); tb.addOutput({to:C.cashaddr,amount:BigInt(m.satoshis)+t-SEED_DUST-APP_DUST-MF}); st.appMint=txidOf(await tb.send());save(st);console.log(` application state seeded at D's app addr — ${st.appMint}`);await sleep(10000); } else console.log(` (app state seeded ${st.appMint})`); // ---- VOTE helper: voter co-spends app[0] + their CitizenToken[1] ---- async function castVote(voterLabel,wallet,signer,compPub,citizenContract,choice){ if(st[`vote${voterLabel}`])return; const a=await appUtxo();if(!a)throw new Error(`vote ${voterLabel}: app UTXO missing`); const c=await citUtxo(citizenContract);if(!c)throw new Error(`vote ${voterLabel}: CitizenToken missing`); // Read current counts const commit=a.token.nft.commitment; const yes=BigInt('0x'+commit.slice(0,16).match(/../g).reverse().join('')); const no=BigInt('0x'+commit.slice(16,32).match(/../g).reverse().join('')); const newYes=choice===0?yes+1n:yes; const newNo=choice===1?no+1n:no; const newCommit=appCommit(newYes,newNo); const {p,t}=await pickW(wallet,MF+1000n); const tb=new TransactionBuilder({provider}); tb.addInput(a,app.unlock.vote(BigInt(choice))); // [0] application tb.addInput(c,citizenContract.unlock.vote(compPub,signer));// [1] voter's CitizenToken for(const x of p)tb.addInput(x,signer.unlockP2PKH()); tb.addOutput({to:app.tokenAddress,amount:APP_DUST,token:{amount:0n,category:appCat,nft:{capability:'mutable',commitment:newCommit}}}); tb.addOutput({to:citizenContract.tokenAddress,amount:CIT_DUST,token:{amount:0n,category:citCat,nft:{capability:'mutable',commitment:RANK_CITIZEN}}}); tb.addOutput({to:wallet.cashaddr,amount:t-MF}); st[`vote${voterLabel}`]=txidOf(await tb.send());save(st);console.log(` ${voterLabel} voted ${choice===0?'YES':'NO'} (counter -> yes=${newYes}, no=${newNo}) — ${st[`vote${voterLabel}`]}`);await sleep(10000); } step('VOTE — A casts YES'); await castVote('A',A,sig.a,cPub.a,citA,0); step('VOTE — B casts YES (reaches threshold)'); await castVote('B',B,sig.b,cPub.b,citB,0); // ---- CLOSE the application (anyone can after threshold) ---- step('CLOSE — A (third party) consumes the application UTXO'); if(!st.close){ const a=await appUtxo();if(!a)throw new Error('close: app UTXO missing'); const {p,t}=await pickW(A,MF+1000n); const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true}); tb.addInput(a,app.unlock.close()); for(const x of p)tb.addInput(x,sig.a.unlockP2PKH()); tb.addOutput({to:A.cashaddr,amount:BigInt(a.satoshis)+t-MF}); st.close=txidOf(await tb.send());save(st);console.log(` closed — ${st.close}`);await sleep(10000); } else console.log(` (closed ${st.close})`); // ---- Founder mints CitizenToken for D (admission complete) ---- step('MINT — founder mints CitizenToken for D (admission complete)'); await mintCitizen(citD,'D'); step('VERIFY'); const dToken=await citUtxo(citD); const aToken=await citUtxo(citA); const bToken=await citUtxo(citB); const appAfter=await appUtxo(); const checks=[ ['citizen + app-state categories genesised', !!citCat && !!appCat], ['CitizenTokens minted for A and B (rank 0x01)', !!aToken && !!bToken], ['application state seeded for D', !!st.appMint], ['A voted YES (counter 0 -> 1)', !!st.voteA], ['B voted YES (counter 1 -> 2 = threshold)', !!st.voteB], ['application closed (UTXO consumed)', !!st.close && !appAfter], ['D admitted — CitizenToken minted (rank 0x01)', !!dToken], ]; let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;} step('SUMMARY — Phase A: per-rank CitizenToken + single-chamber ApplicationCovenant'); console.log(` citCat ${citCat}\n appCat ${appCat}`); console.log(` CitizenToken(A) ${citA.tokenAddress}`); console.log(` CitizenToken(B) ${citB.tokenAddress}`); console.log(` CitizenToken(D) ${citD.tokenAddress}`); console.log(` App(D) ${app.tokenAddress}`); console.log(` mintA/mintB/appMint: ${st.mintA} / ${st.mintB} / ${st.appMint}`); console.log(` voteA/voteB/close/mintD: ${st.voteA} / ${st.voteB} / ${st.close} / ${st.mintD}`); console.log(' '); console.log(' Citizen tokens are per-voter mutable NFTs (rank in commitment); voters'); console.log(' authorise their participation by co-spending their token (read-only) with'); console.log(' the ApplicationCovenant\'s vote() — both contracts run in the same tx.'); console.log(' Threshold reached -> close() consumes the application; founder mints the'); console.log(' new CitizenToken to complete admission. Phase A scope only — sortition,'); console.log(' fees, second chamber, and Elder/Founder tiers are later phases.'); 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);});