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

173 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-poll-test.mjs — PollCovenant: a minimal ON-CHAIN VOTING primitive.
* Per-voter covenant UTXO bound to voterPkh; vote() consumes the UTXO and routes
* its full value into one of two ballot-box P2PKH addresses; expire() permissionlessly
* reclaims unvoted tokens to the issuer after closeHeight (CLTV). One token = one
* vote; double-vote structurally impossible (covenant UTXO consumed).
*
* 1. C (issuer) issues 3 voter tokens (per-voter covenants) for A, B, and C-self
* 2. A casts YES (choice=0) -> 2000 sats land at ballot-A P2PKH
* 3. B casts NO (choice=1) -> 2000 sats land at ballot-B P2PKH
* 4. NEG: A signs C-self token -> covenant must REJECT (hash160 mismatch)
* 5. A (a THIRD PARTY) triggers expire() on C-self after close -> sats to C
*
* Ballot pkhs are randomized per run so prior runs do not pollute the tally.
* closeHeight = current tip (so expire() works immediately).
*/
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, encodeCashAddress } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-poll-test.json');
const ARTIFACT = join(ROOT, 'contracts/dist/PollCovenant.json');
const KEYS = {
a: join(ROOT, '.chipnet-keys.json'),
b: join(ROOT, '.chipnet-keys-b.json'),
c: join(ROOT, '.chipnet-keys-c.json'),
};
const VOTE_VALUE = 2000n;
const MF = 2000n;
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 pkhToCashaddr(pkh){const r=encodeCashAddress({payload:pkh,prefix:'bchtest',type:'p2pkh'});return typeof r==='string'?r:r.address;}
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;}
const isReject=(e)=>/PollCovenant\.cash|Require statement failed|mandatory-script|failed to verify|Verify/i.test((e?.message??e).toString());
async function main(){
const art=JSON.parse(readFileSync(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 provider=new ElectrumNetworkProvider('chipnet');
const sig={a:new SignatureTemplate(A.privateKey),b:new SignatureTemplate(B.privateKey),c:new SignatureTemplate(C.privateKey)};
// Compressed pubkeys — mainnet-js .publicKey may be uncompressed; the address pkh uses compressed.
const cPub={a:secp256k1.derivePublicKeyCompressed(A.privateKey),b:secp256k1.derivePublicKeyCompressed(B.privateKey),c:secp256k1.derivePublicKeyCompressed(C.privateKey)};
let st=load();
// Per-poll randomized ballot pkhs (so a prior run's tally does not pollute this one).
if(!st.ballotA){st.ballotA=binToHex(randomBytes(20));st.ballotB=binToHex(randomBytes(20));save(st);}
const ballotAPkh=hexToBin(st.ballotA), ballotBPkh=hexToBin(st.ballotB);
const ballotAAddr=pkhToCashaddr(ballotAPkh), ballotBAddr=pkhToCashaddr(ballotBPkh);
console.log(`Ballot A (YES) ${ballotAAddr}`);
console.log(`Ballot B (NO) ${ballotBAddr}`);
if(!st.closeHeight){st.closeHeight=await provider.getBlockHeight();save(st);}
const closeHeight=st.closeHeight;
console.log(`closeHeight ${closeHeight}`);
// Per-voter covenants (one per voter pkh).
const voters=[
['A', A, sig.a, cPub.a],
['B', B, sig.b, cPub.b],
['CSELF', C, sig.c, cPub.c], // C-self token for the expire demo
];
const cov={};
for(const [k,w] of voters){
cov[k]=new Contract(art,[w.publicKeyHash,ballotAPkh,ballotBPkh,BigInt(closeHeight),C.publicKeyHash],{provider});
console.log(` voter ${k} -> ${cov[k].tokenAddress}`);
}
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};}
async function covUtxo(k){return (await provider.getUtxos(cov[k].tokenAddress)).find(x=>!x.token&&BigInt(x.satoshis)===VOTE_VALUE);}
// ---- Issue 3 voter tokens in one tx ----
step('ISSUE — C funds 2000 sats per voter covenant (A, B, C-self)');
if(!st.issue){
const {p,t}=await pickW(C,3n*VOTE_VALUE+MF+1000n);
const tb=new TransactionBuilder({provider});
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
for(const [k] of voters){tb.addOutput({to:cov[k].tokenAddress,amount:VOTE_VALUE});}
tb.addOutput({to:C.cashaddr,amount:t-3n*VOTE_VALUE-MF});
st.issue=txidOf(await tb.send());save(st);console.log(` issued — ${st.issue}`);await sleep(10000);
} else console.log(` (issued ${st.issue})`);
// ---- VOTE helper ----
async function castVote(key,w,s,compPub,choice){
const u=await covUtxo(key);if(!u)throw new Error(`vote ${key}: covenant UTXO missing`);
const {p,t}=await pickW(w,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(u,cov[key].unlock.vote(compPub,s,BigInt(choice)));
for(const x of p)tb.addInput(x,s.unlockP2PKH());
const ballotPkh=choice===0?ballotAPkh:ballotBPkh;
tb.addOutput({to:pkhToCashaddr(ballotPkh),amount:VOTE_VALUE});
tb.addOutput({to:w.cashaddr,amount:t-MF});
return txidOf(await tb.send());
}
step('VOTE — A casts YES (choice=0)');
if(!st.voteA){st.voteA=await castVote('A',A,sig.a,cPub.a,0);save(st);console.log(` ${st.voteA}`);await sleep(10000);} else console.log(` (A voted ${st.voteA})`);
step('VOTE — B casts NO (choice=1)');
if(!st.voteB){st.voteB=await castVote('B',B,sig.b,cPub.b,1);save(st);console.log(` ${st.voteB}`);await sleep(10000);} else console.log(` (B voted ${st.voteB})`);
// ---- NEG: wrong signer on the C-self token ----
step('NEG — A signs C-self token -> covenant must REJECT (hash160 mismatch)');
if(!st.negTested){
let r=false;
try{
const u=await covUtxo('CSELF');if(!u)throw new Error('CSELF: UTXO missing');
const {p,t}=await pickW(A,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(u,cov.CSELF.unlock.vote(cPub.a,sig.a,0n)); // A's pk on C's covenant
for(const x of p)tb.addInput(x,sig.a.unlockP2PKH());
tb.addOutput({to:pkhToCashaddr(ballotAPkh),amount:VOTE_VALUE});
tb.addOutput({to:A.cashaddr,amount:t-MF});
await tb.send();
} catch(e){ if(isReject(e)){r=true;console.log(` rejected on-chain: ${(e?.message??e).toString().slice(0,120)}`);} else throw e; }
if(!r) throw new Error('SECURITY: wrong-signer vote was accepted');
st.negTested=true;save(st);
} else console.log(' (neg verified)');
// ---- EXPIRE: A (third party) reclaims C-self's unvoted token to C (issuer) ----
step('EXPIRE — A (third party) triggers expire() on C-self -> 2000 sats to issuer C');
if(!st.expired){
const u=await covUtxo('CSELF');if(!u)throw new Error('expire: CSELF missing');
const {p,t}=await pickW(A,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.setLocktime(closeHeight);
tb.addInput(u,cov.CSELF.unlock.expire());
for(const x of p)tb.addInput(x,sig.a.unlockP2PKH());
tb.addOutput({to:C.cashaddr,amount:VOTE_VALUE});
tb.addOutput({to:A.cashaddr,amount:t-MF});
st.expired=txidOf(await tb.send());save(st);console.log(` expired by A -> C — ${st.expired}`);await sleep(10000);
} else console.log(` (expired ${st.expired})`);
// ---- VERIFY — tally ----
step('VERIFY — tally ballot UTXOs');
const ballotAUtxos=await provider.getUtxos(ballotAAddr);
const ballotBUtxos=await provider.getUtxos(ballotBAddr);
const aCount=ballotAUtxos.filter(x=>BigInt(x.satoshis)===VOTE_VALUE).length;
const bCount=ballotBUtxos.filter(x=>BigInt(x.satoshis)===VOTE_VALUE).length;
const checks=[
['A cast YES (covenant consumed)', !(await covUtxo('A'))],
['B cast NO (covenant consumed)', !(await covUtxo('B'))],
['NEG: wrong-signer vote rejected on-chain', !!st.negTested],
[`tally: ballot A (YES) has 1 vote (got ${aCount})`, aCount===1],
[`tally: ballot B (NO) has 1 vote (got ${bCount})`, bCount===1],
['C-self token reclaimed via permissionless expire (triggered by A)', !!st.expired && !(await covUtxo('CSELF'))],
];
let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;}
step('SUMMARY — PollCovenant (binary one-person-one-vote)');
console.log(` ballot A ${ballotAAddr}\n ballot B ${ballotBAddr}`);
console.log(` issue: ${st.issue}\n voteA: ${st.voteA}\n voteB: ${st.voteB}\n expire (A->C): ${st.expired}`);
console.log(' Tally is on-chain and publicly verifiable; each voter token is one-shot;');
console.log(' wrong-signer cast rejected by checkSig; expire is permissionless after close.');
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);});