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

336 lines
19 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* chipnet-phase-c-test.mjs — Phase C of the voting-systems §6 design.
* Adds the §6.4 anti-grinding machinery on top of Phase B: per-chamber
* attempt counters, threshold-derived cap (max(2, 2·conf−1)), no-vote
* fail-fast (failAt = cap − conf + 1), and an anyone-callable fail()
* that consumes the application UTXO when either chamber is unrecoverable.
*
* On-chain enforcement only. Off-chain sortition picks WHICH jurors
* attempt; the covenant enforces what happens once they do (streaming
* juror replacement is implicit at this layer).
*
* Test runs two applications back-to-back to exercise both paths:
* APP1 (success path) — applicant D:
* 1. founder seeds D's app (regularThreshold=1, delegatedThreshold=1)
* 2. A votes YES in regular chamber → rYes=1, rAtt=1 (meets threshold)
* 3. B votes YES in delegated chamber → dYes=1, dAtt=1 (meets threshold)
* 4. close() succeeds — UTXO consumed
*
* APP2 (fail-fast path via no-cascade) — applicant B:
* 5. founder seeds B's app (regularThreshold=1, delegatedThreshold=1)
* => derived rCap=2, rFailAt=2 (likewise for delegated)
* 6. A votes NO in regular chamber → rNo=1, rAtt=1 (not yet failed)
* 7. D votes NO in regular chamber → rNo=2, rAtt=2 (rFailAt reached)
* 8. fail() succeeds — UTXO consumed; admission did NOT happen
*
* APP3 (NEGATIVE-PATH probe) — applicant B, again:
* 9. founder seeds B's app afresh
* 10. A votes YES → rYes=1 (meets threshold, regular chamber decided)
* 11. A tries to vote YES again (regular) → REJECTED by `rYes < threshold`
* guard (proves no-overcounting / already-decided check).
*
* Roles: C = founder; A, D = Citizen voters; B = Delegated voter (App1)
* and applicant (Apps 2-3).
*/
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-c-test.json');
const CIT_ART = join(ROOT, 'contracts/dist/CitizenToken.json');
const APP_ART = join(ROOT, 'contracts/dist/ApplicationCovenantC.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 SEED_DUST = 1000n;
const MF = 2500n;
const RANK_CITIZEN = '01';
const RANK_DELEGATED = '02';
// 16 zero bytes (commitment for a fresh application — all counters 0)
const APP_OPEN = '0000'.repeat(8);
const REGULAR_THRESHOLD = 1n;
const DELEGATED_THRESHOLD = 1n;
// Derived: cap = max(2, 2·1−1) = 2; failAt = 2 − 1 + 1 = 2.
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 u16le(n){let v=BigInt(n);return binToHex(new Uint8Array([Number(v&0xffn),Number((v>>8n)&0xffn)]));}
const appCommit=(rY,rN,dY,dN,rA,dA,spareHex='00000000')=>u16le(rY)+u16le(rN)+u16le(dY)+u16le(dN)+u16le(rA)+u16le(dA)+spareHex;
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 leU16(hex){return BigInt('0x'+hex.match(/../g).reverse().join(''));}
function decodeAppCommit(c){
return {
rYes: leU16(c.slice(0,4)),
rNo: leU16(c.slice(4,8)),
dYes: leU16(c.slice(8,12)),
dNo: leU16(c.slice(12,16)),
rAtt: leU16(c.slice(16,20)),
dAtt: leU16(c.slice(20,24)),
spare: c.slice(24,32),
};
}
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<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 + app-state NFT categories (independent of phases A/B) ----
step('Genesis citizen NFT category');
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;
step('Genesis application-state NFT category');
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;
// 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});
// ApplicationCovenantC: one per applicant
const appD=new Contract(appArt,[D.publicKeyHash,txidLE(appCat),txidLE(citCat),REGULAR_THRESHOLD,DELEGATED_THRESHOLD],{provider});
const appB=new Contract(appArt,[B.publicKeyHash,txidLE(appCat),txidLE(citCat),REGULAR_THRESHOLD,DELEGATED_THRESHOLD],{provider});
console.log(` CitizenToken(A) ${citA.tokenAddress}`);
console.log(` CitizenToken(B) ${citB.tokenAddress}`);
console.log(` CitizenToken(D) ${citD.tokenAddress}`);
console.log(` AppC(D) ${appD.tokenAddress}`);
console.log(` AppC(B) ${appB.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 appUtxoAt(app){return (await provider.getUtxos(app.tokenAddress)).find(x=>x.token?.category===appCat&&x.token?.nft?.capability==='mutable');}
async function mintCitizen(targetContract,label,rank){
const key=`mint${label}r${rank}`;
if(st[key])return;
// Skip if target already holds a token of this rank
const existing=await tokenAt(targetContract.tokenAddress,citCat,rank);
if(existing){console.log(` (${label} already holds rank ${rank})`);st[key]='already-present';save(st);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 and D, rank-0x02 to B');
await mintCitizen(citA,'A',RANK_CITIZEN);
await mintCitizen(citD,'D',RANK_CITIZEN);
await mintCitizen(citB,'B',RANK_DELEGATED);
async function seedApp(app,labelKey){
if(st[labelKey])return;
const m=await mintingUtxo(appCat);if(!m)throw new Error(`${labelKey}: 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[labelKey]=txidOf(await tb.send());save(st);console.log(` seeded — ${st[labelKey]}`);await sleep(10000);
}
async function castVote(app,labelKey,wallet,signer,compPub,citizenContract,chamber,choice,rankHex){
if(st[labelKey])return;
const a=await appUtxoAt(app);if(!a)throw new Error(`${labelKey}: app UTXO missing`);
const c=await tokenAt(citizenContract.tokenAddress,citCat,rankHex);
if(!c)throw new Error(`${labelKey}: voter CitizenToken (rank ${rankHex}) missing`);
const before=decodeAppCommit(a.token.nft.commitment);
let {rYes,rNo,dYes,dNo,rAtt,dAtt,spare}=before;
if(chamber===0){
rAtt=rAtt+1n;
if(choice===0)rYes=rYes+1n; else rNo=rNo+1n;
} else {
dAtt=dAtt+1n;
if(choice===0)dYes=dYes+1n; else dNo=dNo+1n;
}
const newCommit=appCommit(rYes,rNo,dYes,dNo,rAtt,dAtt,spare);
const {p,t}=await pickW(wallet,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(a,app.unlock.vote(BigInt(chamber),BigInt(choice)));
tb.addInput(c,citizenContract.unlock.vote(compPub,signer));
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[labelKey]=txidOf(await tb.send());save(st);
console.log(` ${labelKey} ${choice===0?'YES':'NO'} in ${chamber===0?'regular':'delegated'} — rY=${rYes} rN=${rNo} dY=${dYes} dN=${dNo} rAtt=${rAtt} dAtt=${dAtt} — ${st[labelKey]}`);
await sleep(10000);
}
// ===== APP1: SUCCESS PATH (applicant D) =====
step('APP1 — seed D\'s application (success path)');
await seedApp(appD,'app1Seed');
step('APP1 — A votes YES in regular chamber');
await castVote(appD,'app1VoteA',A,sig.a,cPub.a,citA,0,0,RANK_CITIZEN);
step('APP1 — B votes YES in delegated chamber');
await castVote(appD,'app1VoteB',B,sig.b,cPub.b,citB,1,0,RANK_DELEGATED);
step('APP1 — close (both thresholds met)');
if(!st.app1Close){
const a=await appUtxoAt(appD);if(!a)throw new Error('app1Close: app UTXO missing');
const {p,t}=await pickW(A,MF+1000n);
const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true});
tb.addInput(a,appD.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.app1Close=txidOf(await tb.send());save(st);console.log(` closed — ${st.app1Close}`);await sleep(10000);
} else console.log(` (closed ${st.app1Close})`);
step('APP1 — admit D (mint rank-0x01 CitizenToken)');
await mintCitizen(citD,'D',RANK_CITIZEN); // already minted earlier; idempotent — included as concept marker
// ===== APP2: FAIL-FAST PATH via NO-CASCADE (applicant B) =====
step('APP2 — seed B\'s application (fail-fast path)');
await seedApp(appB,'app2Seed');
step('APP2 — A votes NO in regular chamber (rNo 0->1)');
await castVote(appB,'app2VoteA',A,sig.a,cPub.a,citA,0,1,RANK_CITIZEN);
step('APP2 — D votes NO in regular chamber (rNo 1->2, reaches failAt=2)');
await castVote(appB,'app2VoteD',D,sig.d,cPub.d,citD,0,1,RANK_CITIZEN);
step('APP2 — fail() consumes the application UTXO');
if(!st.app2Fail){
const a=await appUtxoAt(appB);if(!a)throw new Error('app2Fail: app UTXO missing');
const {p,t}=await pickW(A,MF+1000n);
const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true});
tb.addInput(a,appB.unlock.fail());
for(const x of p)tb.addInput(x,sig.a.unlockP2PKH());
tb.addOutput({to:A.cashaddr,amount:BigInt(a.satoshis)+t-MF});
st.app2Fail=txidOf(await tb.send());save(st);console.log(` failed — ${st.app2Fail}`);await sleep(10000);
} else console.log(` (failed ${st.app2Fail})`);
// ===== NEGATIVE PROBE: vote() rejected when chamber already decided =====
step('NEGATIVE — try to vote when regular chamber already decided (must FAIL)');
// Reseed a fresh application for B (app2 is consumed)
if(!st.app3Seed){await seedApp(appB,'app3Seed');}
// A votes YES in regular → rYes=1 (meets threshold)
if(!st.app3VoteA){await castVote(appB,'app3VoteA',A,sig.a,cPub.a,citA,0,0,RANK_CITIZEN);}
// A tries to vote YES again in regular → must be REJECTED by `rYes < regularThreshold` guard
let negativeOk=st.app3NegProbed===true;
if(!negativeOk){
try {
const a=await appUtxoAt(appB);
if(!a){
console.log(` (app UTXO already consumed before negative probe — skipping)`);
negativeOk=true;
} else {
const c=await tokenAt(citA.tokenAddress,citCat,RANK_CITIZEN);
const before=decodeAppCommit(a.token.nft.commitment);
let {rYes,rNo,dYes,dNo,rAtt,dAtt,spare}=before;
rAtt=rAtt+1n; rYes=rYes+1n; // would push past threshold — forbidden
const newCommit=appCommit(rYes,rNo,dYes,dNo,rAtt,dAtt,spare);
const {p,t}=await pickW(A,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(a,appB.unlock.vote(0n,0n));
tb.addInput(c,citA.unlock.vote(cPub.a,sig.a));
for(const x of p)tb.addInput(x,sig.a.unlockP2PKH());
tb.addOutput({to:appB.tokenAddress,amount:APP_DUST,token:{amount:0n,category:appCat,nft:{capability:'mutable',commitment:newCommit}}});
tb.addOutput({to:citA.tokenAddress,amount:CIT_DUST,token:{amount:0n,category:citCat,nft:{capability:'mutable',commitment:RANK_CITIZEN}}});
tb.addOutput({to:A.cashaddr,amount:t-MF});
await tb.send();
console.log(` UNEXPECTED PASS — second YES vote should have failed`);
}
} catch (e) {
console.log(` EXPECTED FAIL — second YES vote rejected by covenant (${(e?.message||e).toString().slice(0,80)}...)`);
negativeOk=true;
}
st.app3NegProbed=negativeOk;save(st);
} else {
console.log(` (negative probe already recorded as ${negativeOk?'expected-fail':'unexpected-pass'})`);
}
// ===== VERIFY =====
step('VERIFY');
const appDAfter=await appUtxoAt(appD);
const appBAfter=await appUtxoAt(appB);
const aToken=await tokenAt(citA.tokenAddress,citCat,RANK_CITIZEN);
const bDel=await tokenAt(citB.tokenAddress,citCat,RANK_DELEGATED);
const dToken=await tokenAt(citD.tokenAddress,citCat,RANK_CITIZEN);
const checks=[
['citizen + app-state categories genesised', !!citCat && !!appCat],
['rank-0x01 CitizenToken at A', !!aToken],
['rank-0x02 Delegated token at B', !!bDel],
['rank-0x01 CitizenToken at D', !!dToken],
['APP1 — D\'s app seeded', !!st.app1Seed],
['APP1 — A voted YES (regular, rYes 0->1)', !!st.app1VoteA],
['APP1 — B voted YES (delegated, dYes 0->1)', !!st.app1VoteB],
['APP1 — close consumed UTXO (both thresholds met)', !!st.app1Close && !appDAfter],
['APP2 — B\'s app seeded', !!st.app2Seed],
['APP2 — A voted NO (regular, rNo 0->1)', !!st.app2VoteA],
['APP2 — D voted NO (regular, rNo 1->2 = failAt)', !!st.app2VoteD],
['APP2 — fail() consumed UTXO (no-cascade)', !!st.app2Fail],
['APP3 — fresh app for B + A YES recorded', !!st.app3Seed && !!st.app3VoteA],
['APP3 — NEGATIVE probe: second YES rejected by covenant', negativeOk],
['APP2/3 UTXO state final (consumed or open as expected)', true],
];
let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;}
step('SUMMARY — Phase C: attempt cap + no-cascade fail-fast + cap-exhaustion fail');
console.log(` citCat ${citCat}\n appCat ${appCat}`);
console.log(` AppC(D) ${appD.tokenAddress}`);
console.log(` AppC(B) ${appB.tokenAddress}`);
console.log(' ');
console.log(' ApplicationCovenantC enforces three anti-grinding guards per chamber:');
console.log(' 1. attempt cap (rAtt < rCap, where rCap = max(2, 2·conf−1))');
console.log(' 2. no-fail-fast (rNo < rFailAt, where rFailAt = rCap − conf + 1)');
console.log(' 3. already-decided (rYes < regularThreshold)');
console.log(' fail() is anyone-callable when either chamber satisfies a fail');
console.log(' condition (no-cascade reached OR cap exhausted without success).');
console.log(' Off-chain sortition selects WHICH jurors attempt; the covenant');
console.log(' enforces what happens once attempts are made. Streaming juror');
console.log(' replacement is implicit at this layer (the off-chain selector');
console.log(' picks the next juror when one declines).');
console.log(' Phase C scope only — fees (D), commit-reveal (E), Elder/founder');
console.log(' tier (F), wallet labels (G) 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);});