deviant/scripts/chipnet-phase-b-test.mjs

314 lines
20 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* chipnet-phase-b-test.mjs — Phase B of the voting-systems §6 bicameral-sortition
* design (§6.15 roadmap). Adds RegistryState (adaptive threshold) + bicameral
* ApplicationCovenantB (two chambers: Citizen rank 0x01 + Delegated rank 0x02).
*
* Scope (Phase B): adaptive threshold + bicameral counters + per-chamber rank
* gate. Thresholds are baked into the application at creation time (founder
* snapshots current RegistryState). No sortition yet (Phase C), no fees
* (Phase D), no commit-reveal (Phase E), no Elder/founder roles (Phase F).
*
* Flow:
* 1. genesis citizen + app-state + registry NFT categories
* 2. genesis RegistryState UTXO at memberCount=0, threshold=1 (16-byte commit)
* 3. founder calls admit() FOUR times -> memberCount=4 (still threshold=1
* since we're below 100). Proves the mutator + formula work.
* 4. founder mints rank-0x01 CitizenToken to A (skipped if Phase A already
* minted one — A's existing CitizenToken is reused for the regular vote).
* 5. founder mints a fresh rank-0x02 Delegated token to B's CitizenToken
* address. (B may also still hold its Phase A rank-0x01; harmless.)
* 6. founder seeds D's ApplicationCovenantB (commit = 16 zero bytes;
* regularThreshold=1, delegatedThreshold=1).
* 7. A votes YES in the regular chamber (rYes 0->1, meets threshold).
* 8. B votes YES in the delegated chamber (dYes 0->1, meets threshold).
* 9. close() — both thresholds reached, application UTXO consumed.
* 10. founder calls admit() once more (memberCount 4->5) and mints
* rank-0x01 to D. Admission complete.
*
* Roles: C = founder/issuer; A = Citizen voter; B = Delegated voter; 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, 'ChipnetTests', '.chipnet-phase-b-test.json');
const CIT_ART = join(ROOT, 'contracts/dist/CitizenToken.json');
const APP_ART = join(ROOT, 'contracts/dist/ApplicationCovenantB.json');
const REG_ART = join(ROOT, 'contracts/dist/RegistryState.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;
const APP_DUST = 1000n;
const REG_DUST = 1000n;
const SEED_DUST = 1000n;
const MF = 2500n;
const RANK_CITIZEN = '01';
const RANK_DELEGATED = '02';
const APP_OPEN = '00000000' + '00000000' + '00000000' + '00000000'; // rYes(4)+rNo(4)+dYes(4)+dNo(4)
const REG_INIT = '0000000000000000' + '0100000000000000'; // mc=0, threshold=1 (LE)
const REGULAR_THRESHOLD = 1n; // baked from RegistryState (N=4 -> threshold=1)
const DELEGATED_THRESHOLD = 1n;
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 u32le(n){let v=BigInt(n);const o=new Uint8Array(4);for(let i=0;i<4;i++){o[i]=Number(v&0xffn);v>>=8n;}return binToHex(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);}
const appCommit=(rY,rN,dY,dN)=>u32le(rY)+u32le(rN)+u32le(dY)+u32le(dN);
const regCommit=(mc,th)=>u64le(mc)+u64le(th);
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;}
function leU64ToInt(hexLE){return BigInt('0x'+hexLE.match(/../g).reverse().join(''));}
async function main(){
const citArt=JSON.parse(readFileSync(CIT_ART,'utf8'));
const appArt=JSON.parse(readFileSync(APP_ART,'utf8'));
const regArt=JSON.parse(readFileSync(REG_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<need)throw new Error(`C low BCH ${t}<${need}`);return {p,t};}
async function pickW(w,need){const u=(await provider.getUtxos(w.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));const {p,t}=pickBch(u,need);if(t<need)throw new Error(`low BCH ${t}<${need}`);return {p,t};}
// ---- Genesis: citizen NFT category ----
step('Genesis citizen NFT category (minting NFT to founder C)');
if(!st.citCat){const u=await eligible(C);const cat=u.txid;const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:0n,value:SEED_DUST,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});console.log(` citizen genesis ${txidOf(r)} — ${cat}`);st.citCat=cat;save(st);await sleep(10000);} else console.log(` citCat ${st.citCat}`);
const citCat=st.citCat;
// ---- Genesis: application-state NFT category ----
step('Genesis application-state NFT category (minting NFT to founder C)');
if(!st.appCat){const u=await eligible(C);const cat=u.txid;const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:0n,value:SEED_DUST,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});console.log(` app-state genesis ${txidOf(r)} — ${cat}`);st.appCat=cat;save(st);await sleep(10000);} else console.log(` appCat ${st.appCat}`);
const appCat=st.appCat;
// ---- Genesis: registry NFT category ----
step('Genesis registry NFT category (minting NFT to founder C)');
if(!st.regCat){const u=await eligible(C);const cat=u.txid;const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:0n,value:SEED_DUST,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});console.log(` registry genesis ${txidOf(r)} — ${cat}`);st.regCat=cat;save(st);await sleep(10000);} else console.log(` regCat ${st.regCat}`);
const regCat=st.regCat;
// ---- Per-holder CitizenToken contracts ----
const citA=new Contract(citArt,[A.publicKeyHash],{provider});
const citB=new Contract(citArt,[B.publicKeyHash],{provider});
const citD=new Contract(citArt,[D.publicKeyHash],{provider});
console.log(` CitizenToken(A) ${citA.tokenAddress}`);
console.log(` CitizenToken(B) ${citB.tokenAddress}`);
console.log(` CitizenToken(D) ${citD.tokenAddress}`);
// ---- RegistryState covenant (founder-controlled mutator) ----
const reg=new Contract(regArt,[C.publicKeyHash],{provider});
console.log(` RegistryState ${reg.tokenAddress}`);
// ---- D's bicameral ApplicationCovenantB ----
const app=new Contract(appArt,[D.publicKeyHash,txidLE(appCat),txidLE(citCat),REGULAR_THRESHOLD,DELEGATED_THRESHOLD],{provider});
console.log(` ApplicationCovenantB(D) ${app.tokenAddress}`);
async function mintingUtxo(category){return (await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===category&&x.token?.nft?.capability==='minting');}
async function tokenAt(addr,category,commitment){return (await provider.getUtxos(addr)).find(x=>x.token?.category===category&&x.token?.nft?.capability==='mutable'&&x.token?.nft?.commitment===commitment);}
async function regUtxo(){return (await provider.getUtxos(reg.tokenAddress)).find(x=>x.token?.category===regCat&&x.token?.nft?.capability==='mutable');}
async function appUtxo(){return (await provider.getUtxos(app.tokenAddress)).find(x=>x.token?.category===appCat&&x.token?.nft?.capability==='mutable');}
// ---- Seed RegistryState UTXO at C's RegistryState address (mc=0, th=1) ----
step('Seed RegistryState (memberCount=0, threshold=1)');
if(!st.regSeed){
const m=await mintingUtxo(regCat);if(!m)throw new Error('regSeed: registry minting NFT missing at C');
const {p,t}=await pickC(REG_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:regCat,nft:{capability:'minting',commitment:''}}});
tb.addOutput({to:reg.tokenAddress,amount:REG_DUST,token:{amount:0n,category:regCat,nft:{capability:'mutable',commitment:REG_INIT}}});
tb.addOutput({to:C.cashaddr,amount:BigInt(m.satoshis)+t-SEED_DUST-REG_DUST-MF});
st.regSeed=txidOf(await tb.send());save(st);console.log(` registry seeded — ${st.regSeed}`);await sleep(10000);
} else console.log(` (registry seeded ${st.regSeed})`);
// ---- admit() helper: increments memberCount, recomputes threshold ----
async function admit(label){
if(st[`admit${label}`])return;
const r=await regUtxo();if(!r)throw new Error(`admit ${label}: registry UTXO missing`);
const mc=leU64ToInt(r.token.nft.commitment.slice(0,16));
const th=leU64ToInt(r.token.nft.commitment.slice(16,32));
const newMc=mc+1n;
let conf=(newMc+99n)/100n;if(conf>15n)conf=15n;
const newTh=conf;
const newCommit=regCommit(newMc,newTh);
const {p,t}=await pickC(MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(r,reg.unlock.admit(cPub.c,sig.c));
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
tb.addOutput({to:reg.tokenAddress,amount:REG_DUST,token:{amount:0n,category:regCat,nft:{capability:'mutable',commitment:newCommit}}});
tb.addOutput({to:C.cashaddr,amount:t-MF});
st[`admit${label}`]=txidOf(await tb.send());save(st);console.log(` admit ${label}: mc=${mc}->${newMc}, th=${th}->${newTh} — ${st[`admit${label}`]}`);await sleep(10000);
}
step('Bump RegistryState 4 times: memberCount 0 -> 4 (threshold stays 1)');
await admit('1');
await admit('2');
await admit('3');
await admit('4');
const after=await regUtxo();
if(after){const mc=leU64ToInt(after.token.nft.commitment.slice(0,16));const th=leU64ToInt(after.token.nft.commitment.slice(16,32));console.log(` registry now: mc=${mc}, threshold=${th}`);}
// ---- Mint helper ----
async function mintCitizen(targetContract,label,rank){
const key=`mint${label}r${rank}`;
if(st[key])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());
tb.addOutput({to:C.tokenaddr,amount:SEED_DUST,token:{amount:0n,category:citCat,nft:{capability:'minting',commitment:''}}});
tb.addOutput({to:targetContract.tokenAddress,amount:CIT_DUST,token:{amount:0n,category:citCat,nft:{capability:'mutable',commitment:rank}}});
tb.addOutput({to:C.cashaddr,amount:BigInt(m.satoshis)+t-SEED_DUST-CIT_DUST-MF});
st[key]=txidOf(await tb.send());save(st);console.log(` minted rank ${rank} CitizenToken to ${label} — ${st[key]}`);await sleep(10000);
}
step('Mint rank-0x01 CitizenToken to A (Citizen) — idempotent');
// If A already has a rank-0x01 token from Phase A, this still works; the new one is a separate UTXO.
// Check first to avoid spurious mints.
const aExisting=await tokenAt(citA.tokenAddress,citCat,RANK_CITIZEN);
if(!aExisting){await mintCitizen(citA,'A',RANK_CITIZEN);} else console.log(` (A already holds rank-0x01)`);
step('Mint rank-0x02 Delegated token to B');
await mintCitizen(citB,'B',RANK_DELEGATED);
// ---- Seed D's bicameral application (commit = 16 zero bytes) ----
step(`Mint ApplicationState for D (regularThreshold=${REGULAR_THRESHOLD}, delegatedThreshold=${DELEGATED_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 — ${st.appMint}`);await sleep(10000);
} else console.log(` (app state seeded ${st.appMint})`);
// ---- castVote helper: chamber 0 = regular (0x01), chamber 1 = delegated (0x02) ----
async function castVote(voterLabel,wallet,signer,compPub,citizenContract,chamber,choice,rankHex){
if(st[`vote${voterLabel}`])return;
const a=await appUtxo();if(!a)throw new Error(`vote ${voterLabel}: app UTXO missing`);
const c=await tokenAt(citizenContract.tokenAddress,citCat,rankHex);
if(!c)throw new Error(`vote ${voterLabel}: CitizenToken (rank ${rankHex}) missing at ${citizenContract.tokenAddress}`);
const commit=a.token.nft.commitment;
const rY=BigInt('0x'+commit.slice(0,8).match(/../g).reverse().join(''));
const rN=BigInt('0x'+commit.slice(8,16).match(/../g).reverse().join(''));
const dY=BigInt('0x'+commit.slice(16,24).match(/../g).reverse().join(''));
const dN=BigInt('0x'+commit.slice(24,32).match(/../g).reverse().join(''));
let nrY=rY,nrN=rN,ndY=dY,ndN=dN;
if(chamber===0&&choice===0)nrY=rY+1n;
else if(chamber===0&&choice===1)nrN=rN+1n;
else if(chamber===1&&choice===0)ndY=dY+1n;
else ndN=dN+1n;
const newCommit=appCommit(nrY,nrN,ndY,ndN);
const {p,t}=await pickW(wallet,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(a,app.unlock.vote(BigInt(chamber),BigInt(choice))); // [0] app
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:rankHex}}});
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'} in ${chamber===0?'regular':'delegated'} chamber (rY=${nrY},rN=${nrN},dY=${ndY},dN=${ndN}) — ${st[`vote${voterLabel}`]}`);
await sleep(10000);
}
step('VOTE — A casts YES in regular chamber (Citizen, rank 0x01)');
await castVote('A',A,sig.a,cPub.a,citA,0,0,RANK_CITIZEN);
step('VOTE — B casts YES in delegated chamber (Delegated, rank 0x02)');
await castVote('B',B,sig.b,cPub.b,citB,1,0,RANK_DELEGATED);
// ---- CLOSE the application (both thresholds reached) ----
step('CLOSE — A consumes the application UTXO (both thresholds met)');
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})`);
// ---- Admission: bump RegistryState (4->5) and mint rank-0x01 to D ----
step('ADMIT — RegistryState mc 4 -> 5, then mint rank-0x01 to D');
await admit('5');
await mintCitizen(citD,'D',RANK_CITIZEN);
// ---- VERIFY ----
step('VERIFY');
const dToken=await tokenAt(citD.tokenAddress,citCat,RANK_CITIZEN);
const aToken=await tokenAt(citA.tokenAddress,citCat,RANK_CITIZEN);
const bDel=await tokenAt(citB.tokenAddress,citCat,RANK_DELEGATED);
const regAfter=await regUtxo();
const regMc=regAfter?leU64ToInt(regAfter.token.nft.commitment.slice(0,16)):null;
const regTh=regAfter?leU64ToInt(regAfter.token.nft.commitment.slice(16,32)):null;
const appAfter=await appUtxo();
const checks=[
['citizen + app-state + registry categories genesised', !!citCat && !!appCat && !!regCat],
['RegistryState seeded at mc=0, th=1', !!st.regSeed],
['admit() x4 advanced mc -> 4 (threshold stays 1)', !!st.admit1 && !!st.admit2 && !!st.admit3 && !!st.admit4],
['rank-0x01 token present at A (Citizen)', !!aToken],
['rank-0x02 token present at B (Delegated)', !!bDel],
['application state seeded for D (bicameral 16-byte commit)', !!st.appMint],
['A voted YES in regular chamber (rYes 0 -> 1 = threshold)', !!st.voteA],
['B voted YES in delegated chamber (dYes 0 -> 1 = threshold)', !!st.voteB],
['application closed (both thresholds met, UTXO consumed)', !!st.close && !appAfter],
['admission admit() advanced mc -> 5 with threshold=1', !!st.admit5 && regMc===5n && regTh===1n],
['D admitted — rank-0x01 CitizenToken minted', !!dToken],
];
let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;}
step('SUMMARY — Phase B: RegistryState (adaptive threshold) + bicameral ApplicationCovenantB');
console.log(` citCat ${citCat}\n appCat ${appCat}\n regCat ${regCat}`);
console.log(` RegistryState ${reg.tokenAddress} mc=${regMc} th=${regTh}`);
console.log(` CitizenToken(A) ${citA.tokenAddress}`);
console.log(` CitizenToken(B) ${citB.tokenAddress}`);
console.log(` CitizenToken(D) ${citD.tokenAddress}`);
console.log(` ApplicationB(D) ${app.tokenAddress}`);
console.log(' ');
console.log(' RegistryState holds memberCount + derived threshold in a 16-byte commitment;');
console.log(' admit() (founder-only) bumps mc by 1 and recomputes threshold = min(15, ceil(mc/100)).');
console.log(' ApplicationCovenantB is bicameral: per-chamber counters (4-byte rYes/rNo/dYes/dNo)');
console.log(' and per-chamber rank gating (voter input[1].commitment must be 0x01 for regular,');
console.log(' 0x02 for delegated). Thresholds are baked in at application creation (snapshot of');
console.log(' RegistryState at that moment). close() requires BOTH chambers to meet threshold.');
console.log(' Phase B scope only — sortition (C), fees (D), commit-reveal (E), Elder/founder (F)');
console.log(' remain ahead in the §6.15 roadmap.');
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);});