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>
241 lines
17 KiB
JavaScript
241 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* chipnet-tally-as-oracle-test.mjs — COMPOSITION 2/3: "tally-as-oracle".
|
|
*
|
|
* Trustless tally → trustless settle. A stateful TallyState covenant holds counters
|
|
* (countA, countB) in its NFT commitment; each vote() increments the chosen counter;
|
|
* after closeHeight close() permissionlessly fixes the outcome (the side with more
|
|
* votes wins). A market variant — BinaryShareVaultPoll — has NO oracle; its attest()
|
|
* CO-SPENDS the CLOSED tally as a read-only input and reads the outcome directly.
|
|
*
|
|
* NO named signer anywhere. The covenant chain enforces: votes increment counters;
|
|
* close() picks the winner from the counters; attest() reads that winner. Trustless.
|
|
*
|
|
* 1. genesis tally NFT category; relocate minting NFT into the TallyState
|
|
* 2. genesis share category; build BinaryShareVaultPoll pinned to the tally's
|
|
* lockingBytecode; relocate share minting NFT into the vault
|
|
* 3. B splits 20000 sats -> YES(20000) + NO(20000)
|
|
* 4. A, B, C each cast YES (counters: countA=3, countB=0)
|
|
* 5. anyone calls close() on TallyState -> outcome=0x00 (YES)
|
|
* 6. anyone calls attest() on the vault, co-spending the CLOSED tally -> outcome
|
|
* transferred to the vault
|
|
* 7. B redeems YES -> 20000 sats
|
|
*
|
|
* SPIKE NOTE: voting is OPEN (any sig). Per-voter sybil resistance is left to the
|
|
* PollCovenant pattern (Option 1); the production form *composes* both.
|
|
*/
|
|
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, sha256, cashAddressToLockingBytecode } from '@bitauth/libauth';
|
|
|
|
const ROOT = join(import.meta.dirname, '..');
|
|
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-tally-as-oracle-test.json');
|
|
const TALLY_ART = join(ROOT, 'contracts/dist/TallyState.json');
|
|
const VAULT_ART = join(ROOT, 'contracts/dist/BinaryShareVaultPoll.json');
|
|
const KEYS = {
|
|
a: join(ROOT, '.chipnet-keys.json'),
|
|
b: join(ROOT, '.chipnet-keys-b.json'),
|
|
c: join(ROOT, '.chipnet-keys-c.json'),
|
|
};
|
|
const TALLY_DUST = 1000n;
|
|
const SHARE_AMOUNT = 20000n;
|
|
const SEED = 1000n;
|
|
const DUST = 1000n;
|
|
const MF = 3500n;
|
|
|
|
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 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 yesCommit=(amt)=>'00'+u64le(amt);
|
|
const noCommit =(amt)=>'01'+u64le(amt);
|
|
function p2pkh(pkh){const o=new Uint8Array(25);o[0]=0x76;o[1]=0xa9;o[2]=0x14;o.set(pkh,3);o[23]=0x88;o[24]=0xac;return o;}
|
|
function tallyCommit(phase,outcome,a,b){return phase+outcome+u64le(a)+u64le(b);}
|
|
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 addrToLB(addr){const r=cashAddressToLockingBytecode(addr);if(typeof r==='string')throw new Error('addr decode '+r);return r.bytecode;}
|
|
|
|
async function main(){
|
|
const tallyArt=JSON.parse(readFileSync(TALLY_ART,'utf8'));
|
|
const vaultArt=JSON.parse(readFileSync(VAULT_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 provider=new ElectrumNetworkProvider('chipnet');
|
|
const sig={a:new SignatureTemplate(A.privateKey),b:new SignatureTemplate(B.privateKey),c:new SignatureTemplate(C.privateKey)};
|
|
const cPub={a:secp256k1.derivePublicKeyCompressed(A.privateKey),b:secp256k1.derivePublicKeyCompressed(B.privateKey),c:secp256k1.derivePublicKeyCompressed(C.privateKey)};
|
|
let st=load();
|
|
|
|
if(!st.pollId){st.pollId=binToHex(randomBytes(16));save(st);}
|
|
if(!st.closeHeight){st.closeHeight=await provider.getBlockHeight();save(st);}
|
|
const closeHeight=st.closeHeight;
|
|
console.log(`pollId ${st.pollId}\ncloseHeight ${closeHeight}`);
|
|
|
|
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 tally category, build TallyState, relocate the minting NFT in ----
|
|
step('Genesis tally NFT category + relocate state into TallyState covenant');
|
|
const TALLY_OPEN=tallyCommit('00','00',0,0);
|
|
if(!st.tallyCat){const u=await eligible(C);const cat=u.txid;const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:0n,value:TALLY_DUST,nft:{capability:'minting',commitment:TALLY_OPEN}},[],{utxoIds:[u]});console.log(` tally genesis ${txidOf(r)} — ${cat}`);st.tallyCat=cat;save(st);await sleep(10000);} else console.log(` tallyCat ${st.tallyCat}`);
|
|
const tallyCat=st.tallyCat;
|
|
const tally=new Contract(tallyArt,[BigInt(closeHeight),hexToBin(st.pollId)],{provider});
|
|
const tallyLB=addrToLB(tally.tokenAddress);
|
|
console.log(` TallyState ${tally.tokenAddress}`);
|
|
|
|
if(!st.tallyReloc){
|
|
const g=(await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===tallyCat&&x.token?.nft?.capability==='minting');
|
|
if(!g)throw new Error('tally genesis NFT missing');
|
|
const {p,t}=await pickC(MF+TALLY_DUST);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(g,sig.c.unlockP2PKH());
|
|
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
|
|
tb.addOutput({to:tally.tokenAddress,amount:TALLY_DUST,token:{amount:0n,category:tallyCat,nft:{capability:'minting',commitment:TALLY_OPEN}}});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(g.satoshis)+t-TALLY_DUST-MF});
|
|
st.tallyReloc=txidOf(await tb.send());save(st);console.log(` tally seeded — ${st.tallyReloc}`);await sleep(10000);
|
|
} else console.log(` (tally relocated ${st.tallyReloc})`);
|
|
async function tallyUtxo(){return (await provider.getUtxos(tally.tokenAddress)).find(x=>x.token?.category===tallyCat&&x.token?.nft?.capability==='minting');}
|
|
|
|
// ---- Genesis share category, build vault, relocate ----
|
|
step('Genesis share NFT category + relocate into BinaryShareVaultPoll (tally-bound)');
|
|
if(!st.shareCat){const u=await eligible(C);const cat=u.txid;const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:0n,value:SEED,nft:{capability:'minting',commitment:'0000'}},[],{utxoIds:[u]});console.log(` share genesis ${txidOf(r)} — ${cat}`);st.shareCat=cat;save(st);await sleep(10000);} else console.log(` shareCat ${st.shareCat}`);
|
|
const shareCat=st.shareCat;
|
|
const vault=new Contract(vaultArt,[txidLE(shareCat),tallyLB,txidLE(tallyCat),BigInt(closeHeight)],{provider});
|
|
console.log(` vault ${vault.tokenAddress}`);
|
|
async function vaultUtxo(){return (await provider.getUtxos(vault.tokenAddress)).find(x=>x.token?.category===shareCat&&x.token?.nft?.capability==='minting');}
|
|
async function findShare(w,commit){return (await provider.getUtxos(w.cashaddr)).find(x=>x.token?.category===shareCat&&x.token?.nft?.capability==='none'&&x.token?.nft?.commitment===commit);}
|
|
|
|
if(!st.vaultReloc){
|
|
const g=(await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===shareCat&&x.token?.nft?.capability==='minting');
|
|
if(!g)throw new Error('share genesis NFT missing');
|
|
const {p,t}=await pickC(MF+SEED);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(g,sig.c.unlockP2PKH());
|
|
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
|
|
tb.addOutput({to:vault.tokenAddress,amount:SEED,token:{amount:0n,category:shareCat,nft:{capability:'minting',commitment:'0000'}}});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(g.satoshis)+t-SEED-MF});
|
|
st.vaultReloc=txidOf(await tb.send());save(st);console.log(` vault seeded — ${st.vaultReloc}`);await sleep(10000);
|
|
} else console.log(` (vault relocated ${st.vaultReloc})`);
|
|
|
|
// ---- B splits collateral ----
|
|
step(`SPLIT — B locks ${SHARE_AMOUNT} sats -> YES(${SHARE_AMOUNT}) + NO(${SHARE_AMOUNT})`);
|
|
if(!st.split){
|
|
const v=await vaultUtxo();if(!v)throw new Error('split: vault missing');
|
|
const {p,t}=await pickW(B,SHARE_AMOUNT+2n*DUST+MF+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(v,vault.unlock.split(SHARE_AMOUNT));
|
|
for(const x of p)tb.addInput(x,sig.b.unlockP2PKH());
|
|
tb.addOutput({to:vault.tokenAddress,amount:BigInt(v.satoshis)+SHARE_AMOUNT,token:{amount:0n,category:shareCat,nft:{capability:'minting',commitment:'0000'}}});
|
|
tb.addOutput({to:B.tokenaddr,amount:DUST,token:{amount:0n,category:shareCat,nft:{capability:'none',commitment:yesCommit(SHARE_AMOUNT)}}});
|
|
tb.addOutput({to:B.tokenaddr,amount:DUST,token:{amount:0n,category:shareCat,nft:{capability:'none',commitment:noCommit(SHARE_AMOUNT)}}});
|
|
tb.addOutput({to:B.cashaddr,amount:t-SHARE_AMOUNT-2n*DUST-MF});
|
|
st.split=txidOf(await tb.send());save(st);console.log(` split — ${st.split}`);await sleep(10000);
|
|
} else console.log(` (split ${st.split})`);
|
|
|
|
// ---- Each voter casts YES; the tally counter increments on-chain ----
|
|
async function castYes(key,w,s,compPub){
|
|
if(st[`vote${key}`])return;
|
|
const u=await tallyUtxo();if(!u)throw new Error(`vote ${key}: tally missing`);
|
|
const commit=u.token.nft.commitment;
|
|
const a=BigInt('0x'+commit.slice(4,4+16).match(/../g).reverse().join(''));
|
|
const b=BigInt('0x'+commit.slice(20,20+16).match(/../g).reverse().join(''));
|
|
const newCommit=tallyCommit('00','00',a+1n,b);
|
|
const {p,t}=await pickW(w,MF+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(u,tally.unlock.vote(0n,compPub,s));
|
|
for(const x of p)tb.addInput(x,s.unlockP2PKH());
|
|
tb.addOutput({to:tally.tokenAddress,amount:BigInt(u.satoshis),token:{amount:0n,category:tallyCat,nft:{capability:'minting',commitment:newCommit}}});
|
|
tb.addOutput({to:w.cashaddr,amount:t-MF});
|
|
st[`vote${key}`]=txidOf(await tb.send());save(st);console.log(` ${key} voted YES (countA -> ${a+1n}) — ${st[`vote${key}`]}`);await sleep(10000);
|
|
}
|
|
step('VOTE — A, B, C each cast YES (TallyState counters increment on-chain)');
|
|
await castYes('A',A,sig.a,cPub.a);
|
|
await castYes('B',B,sig.b,cPub.b);
|
|
await castYes('C',C,sig.c,cPub.c);
|
|
|
|
// ---- CLOSE: anyone (A) closes the tally after closeHeight ----
|
|
step('CLOSE — A (third party) closes the tally; outcome derived from counters');
|
|
if(!st.close){
|
|
const u=await tallyUtxo();if(!u)throw new Error('close: tally missing');
|
|
const commit=u.token.nft.commitment;
|
|
const a=BigInt('0x'+commit.slice(4,4+16).match(/../g).reverse().join(''));
|
|
const b=BigInt('0x'+commit.slice(20,20+16).match(/../g).reverse().join(''));
|
|
const winner=b>a?'01':'00';
|
|
const newCommit='01'+winner+u64le(a)+u64le(b);
|
|
const {p,t}=await pickW(A,MF+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.setLocktime(Number(closeHeight));
|
|
tb.addInput(u,tally.unlock.close());
|
|
for(const x of p)tb.addInput(x,sig.a.unlockP2PKH());
|
|
tb.addOutput({to:tally.tokenAddress,amount:BigInt(u.satoshis),token:{amount:0n,category:tallyCat,nft:{capability:'minting',commitment:newCommit}}});
|
|
tb.addOutput({to:A.cashaddr,amount:t-MF});
|
|
st.close=txidOf(await tb.send());st.outcome=winner;st.countA=a.toString();st.countB=b.toString();save(st);console.log(` closed: countA=${a}, countB=${b}, outcome=0x${winner} — ${st.close}`);await sleep(10000);
|
|
} else console.log(` (closed ${st.close})`);
|
|
|
|
// ---- ATTEST: vault attest co-spends the CLOSED tally (read-only) ----
|
|
step('ATTEST — vault co-spends CLOSED tally; outcome flows from tally to vault');
|
|
if(!st.attest){
|
|
const v=await vaultUtxo();const u=await tallyUtxo();if(!v||!u)throw new Error('attest: vault/tally missing');
|
|
const tallyCommitPreserved=u.token.nft.commitment;
|
|
const {p,t}=await pickC(MF+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.setLocktime(Number(closeHeight));
|
|
tb.addInput(v,vault.unlock.attest()); // [0] vault (covenant)
|
|
tb.addInput(u,tally.unlock.readOnly()); // [1] tally read-only
|
|
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
|
|
tb.addOutput({to:vault.tokenAddress,amount:BigInt(v.satoshis),token:{amount:0n,category:shareCat,nft:{capability:'minting',commitment:'01'+st.outcome}}});
|
|
tb.addOutput({to:tally.tokenAddress,amount:BigInt(u.satoshis),token:{amount:0n,category:tallyCat,nft:{capability:'minting',commitment:tallyCommitPreserved}}});
|
|
tb.addOutput({to:C.cashaddr,amount:t-MF});
|
|
st.attest=txidOf(await tb.send());save(st);console.log(` attested outcome 0x${st.outcome} via co-spend — ${st.attest}`);await sleep(10000);
|
|
} else console.log(` (attested ${st.attest})`);
|
|
|
|
// ---- B redeems YES (winning share) ----
|
|
step(`REDEEM — B redeems YES(${SHARE_AMOUNT}) -> ${SHARE_AMOUNT} sats`);
|
|
if(!st.redeem){
|
|
const v=await vaultUtxo();const share=await findShare(B,yesCommit(SHARE_AMOUNT));if(!v||!share)throw new Error('redeem: vault/share missing');
|
|
const {p,t}=await pickC(MF+1000n);
|
|
const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true});
|
|
tb.addInput(v,vault.unlock.redeem(p2pkh(B.publicKeyHash)));
|
|
tb.addInput(share,sig.b.unlockP2PKH());
|
|
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
|
|
tb.addOutput({to:vault.tokenAddress,amount:BigInt(v.satoshis)-SHARE_AMOUNT,token:{amount:0n,category:shareCat,nft:{capability:'minting',commitment:'01'+st.outcome}}});
|
|
tb.addOutput({to:B.cashaddr,amount:SHARE_AMOUNT});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(share.satoshis)+t-MF});
|
|
st.redeem=txidOf(await tb.send());save(st);console.log(` B received ${SHARE_AMOUNT} sats — ${st.redeem}`);await sleep(10000);
|
|
} else console.log(` (redeem ${st.redeem})`);
|
|
|
|
step('VERIFY — trustless end-to-end (no oracle anywhere)');
|
|
const checks=[
|
|
[`vote counter incremented to countA=${st.countA}, countB=${st.countB}`, st.countA==='3' && st.countB==='0'],
|
|
[`close() derived outcome=0x${st.outcome} from counters`, st.outcome==='00'],
|
|
['vault attested by co-spending CLOSED tally (no signer)', !!st.attest],
|
|
[`B redeemed YES(${SHARE_AMOUNT}) for ${SHARE_AMOUNT} sats`, !!st.redeem],
|
|
['tally state preserved (still readable by future markets)', !!(await tallyUtxo())],
|
|
];
|
|
let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;}
|
|
|
|
step('SUMMARY — Option 2: tally-as-oracle (trustless)');
|
|
console.log(` TallyState ${tally.tokenAddress}`);
|
|
console.log(` vault ${vault.tokenAddress}`);
|
|
console.log(` votes/close/attest/redeem: ${st.voteA},${st.voteB},${st.voteC} / ${st.close} / ${st.attest} / ${st.redeem}`);
|
|
console.log(' ');
|
|
console.log(' HOW IT WORKS: votes increment counters in the TallyState NFT commitment;');
|
|
console.log(' close() permissionlessly fixes the outcome from those counters (whichever');
|
|
console.log(' side has more); the vault attest() CO-SPENDS the CLOSED tally as a read-only');
|
|
console.log(' input and reads the outcome from its commitment. No oracle, no signer — the');
|
|
console.log(' covenant chain is the entire trust path; tally → settlement is on-chain only.');
|
|
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);});
|