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>
173 lines
9.7 KiB
JavaScript
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);});
|