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

230 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-poll-as-oracle-test.mjs — COMPOSITION 1/3: "poll-as-oracle".
*
* Wires the existing PollCovenant (on-chain voting) into the existing Family-D
* BinaryShareVaultBCH (split/merge prediction market) on the SAME `eventId`. A
* designated signer (the "poll oracle") reads the public on-chain tally after close
* and signs `{eventId, outcome}` via checkDataSig; the Family-D market settles using
* that signature exactly as it would for any other oracle attestation. Trust = the
* named signer (constrained to honestly read a public tally).
*
* Flow:
* 1. genesis share category; set up Family-D vault (eventId E, oraclePk = poll oracle)
* 2. B splits 20000 sats -> 20000 YES + 20000 NO (B's position on the prediction market)
* 3. issue 3 voter tokens (PollCovenant) for A, B, C-self — same question
* 4. all 3 voters cast YES; on-chain tally: ballot-A = 3, ballot-B = 0
* 5. the poll oracle signs {E, outcome=0x00} since YES won; attests the Family-D vault
* 6. B redeems YES(20000) -> 20000 sats; NO is worthless
*
* No new covenants — reuses BinaryShareVaultBCH + PollCovenant.
* Roles: A,B = voters; C = poll issuer + oracle (one keyholder for the demo). Resumable.
*/
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, encodeCashAddress } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-poll-as-oracle-test.json');
const POLL_ART = join(ROOT, 'contracts/dist/PollCovenant.json');
const VAULT_ART = join(ROOT, 'contracts/dist/BinaryShareVaultBCH.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 SHARE_AMOUNT = 20000n; // B's position size in sats
const SEED = 1000n;
const DUST = 1000n;
const MF = 5000n;
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 pkhToCashaddr(pkh){const r=encodeCashAddress({payload:pkh,prefix:'bchtest',type:'p2pkh'});return typeof r==='string'?r:r.address;}
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;}
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 oracleSign(privHex,evBin,outByte){const m=new Uint8Array([...evBin,outByte]);const sig=secp256k1.signMessageHashDER(hexToBin(privHex),sha256.hash(m));if(typeof sig==='string')throw new Error('sign '+sig);return{message:m,sig};}
async function main(){
const pollArt=JSON.parse(readFileSync(POLL_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();
// Poll-oracle keypair (same key signs both poll tally + Family-D attest; named).
if(!st.oracle){let p;do{p=randomBytes(32);}while(typeof secp256k1.derivePublicKeyCompressed(p)==='string');st.oracle={priv:binToHex(p),pub:binToHex(secp256k1.derivePublicKeyCompressed(p))};save(st);}
const oraclePub=hexToBin(st.oracle.pub);
if(!st.eventId){st.eventId=binToHex(randomBytes(16));save(st);}
const ev=hexToBin(st.eventId);
console.log(`Event ${st.eventId}\nPoll oracle ${st.oracle.pub}`);
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 / attestDeadline ${closeHeight}`);
// ---- Family-D vault setup ----
step('Genesis Family-D share NFT category + relocate minting NFT into the vault');
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};}
if(!st.cat){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(` genesis ${txidOf(r)} — category ${cat}`);st.cat=cat;save(st);await sleep(10000);} else console.log(` category ${st.cat}`);
const cat=st.cat;
const vault=new Contract(vaultArt,[txidLE(cat),oraclePub,ev,BigInt(closeHeight)],{provider});
console.log(` vault ${vault.tokenAddress}`);
async function vaultUtxo(){return (await provider.getUtxos(vault.tokenAddress)).find(x=>x.token?.category===cat&&x.token?.nft?.capability==='minting');}
async function findShare(w,commit){return (await provider.getUtxos(w.cashaddr)).find(x=>x.token?.category===cat&&x.token?.nft?.capability==='none'&&x.token?.nft?.commitment===commit);}
if(!st.relocate){
const g=(await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===cat&&x.token?.nft?.capability==='minting');
if(!g)throw new Error('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:cat,nft:{capability:'minting',commitment:'0000'}}});
tb.addOutput({to:C.cashaddr,amount:BigInt(g.satoshis)+t-SEED-MF});
st.relocate=txidOf(await tb.send());save(st);console.log(` vault seeded — ${st.relocate}`);await sleep(10000);
} else console.log(` (relocated ${st.relocate})`);
// ---- B splits collateral into YES + NO ----
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:cat,nft:{capability:'minting',commitment:'0000'}}});
tb.addOutput({to:B.tokenaddr,amount:DUST,token:{amount:0n,category:cat,nft:{capability:'none',commitment:yesCommit(SHARE_AMOUNT)}}});
tb.addOutput({to:B.tokenaddr,amount:DUST,token:{amount:0n,category:cat,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})`);
// ---- Poll setup: 3 voter covenants (A, B, C-self), all vote YES ----
step('POLL — issue 3 voter tokens, all 3 cast YES (choice=0)');
const voters=[['A',A,sig.a,cPub.a],['B',B,sig.b,cPub.b],['CSELF',C,sig.c,cPub.c]];
const cov={};
for(const [k,w] of voters){
cov[k]=new Contract(pollArt,[w.publicKeyHash,ballotAPkh,ballotBPkh,BigInt(closeHeight),C.publicKeyHash],{provider});
}
async function covUtxo(k){return (await provider.getUtxos(cov[k].tokenAddress)).find(x=>!x.token&&BigInt(x.satoshis)===VOTE_VALUE);}
if(!st.pollIssue){
const {p,t}=await pickC(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.pollIssue=txidOf(await tb.send());save(st);console.log(` 3 voter tokens issued — ${st.pollIssue}`);await sleep(10000);
} else console.log(` (issued ${st.pollIssue})`);
async function castYes(k,w,s,compPub){
if(st[`vote${k}`])return;
const u=await covUtxo(k);if(!u)throw new Error(`vote ${k}: missing`);
const {p,t}=await pickW(w,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(u,cov[k].unlock.vote(compPub,s,0n));
for(const x of p)tb.addInput(x,s.unlockP2PKH());
tb.addOutput({to:ballotAAddr,amount:VOTE_VALUE});
tb.addOutput({to:w.cashaddr,amount:t-MF});
st[`vote${k}`]=txidOf(await tb.send());save(st);console.log(` ${k} voted YES — ${st[`vote${k}`]}`);await sleep(10000);
}
for(const [k,w,s,cp] of voters) await castYes(k,w,s,cp);
// ---- Read tally publicly ----
step('TALLY — read on-chain 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;
console.log(` YES votes (ballot A): ${aCount}`);
console.log(` NO votes (ballot B): ${bCount}`);
const winner=aCount>=bCount?0:1;
console.log(` Winner: ${winner===0?'YES':'NO'} (outcome byte 0x0${winner})`);
// ---- Poll oracle signs the tally outcome; attest the Family-D vault ----
step('ATTEST — poll oracle signs {eventId, outcome} via checkDataSig; vault settles');
if(!st.attest){
const v=await vaultUtxo();if(!v)throw new Error('attest: vault missing');
const {message,sig:oSig}=oracleSign(st.oracle.priv,ev,winner);
const {p,t}=await pickC(MF+1000n);
const tb=new TransactionBuilder({provider});
tb.setLocktime(Number(closeHeight));
tb.addInput(v,vault.unlock.attest(oSig,message));
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
tb.addOutput({to:vault.tokenAddress,amount:BigInt(v.satoshis),token:{amount:0n,category:cat,nft:{capability:'minting',commitment:'01'+'0'+winner.toString(16)}}});
tb.addOutput({to:C.cashaddr,amount:t-MF});
st.attest=txidOf(await tb.send());st.winner=winner;save(st);console.log(` attested outcome ${winner} — ${st.attest}`);await sleep(10000);
} else console.log(` (attested outcome ${st.winner}: ${st.attest})`);
// ---- B redeems YES (winning share) -> 20000 sats ----
step(`REDEEM — B redeems YES(${SHARE_AMOUNT}) -> ${SHARE_AMOUNT} sats; NO is worthless`);
if(!st.redeem){
const v=await vaultUtxo();if(!v)throw new Error('redeem: vault missing');
const share=await findShare(B,yesCommit(SHARE_AMOUNT));if(!share)throw new Error('redeem: YES share missing at B');
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:cat,nft:{capability:'minting',commitment:'01'+'0'+st.winner.toString(16)}}});
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 — composition end-to-end');
const checks=[
[`tally read on-chain (YES=${aCount}, NO=${bCount})`, aCount===3 && bCount===0],
[`poll oracle signed outcome ${winner} matching tally`, st.winner===0],
['Family-D vault attested via the poll oracle signature', !!st.attest],
[`B redeemed YES(${SHARE_AMOUNT}) for ${SHARE_AMOUNT} sats`, !!st.redeem],
['voter covenants consumed (A, B, CSELF)', !(await covUtxo('A'))&&!(await covUtxo('B'))&&!(await covUtxo('CSELF'))],
];
let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;}
step('SUMMARY — Option 1: poll-as-oracle composition');
console.log(` Family-D vault ${vault.tokenAddress}`);
console.log(` ballot A (YES) ${ballotAAddr}`);
console.log(` ballot B (NO) ${ballotBAddr}`);
console.log(` split/attest/redeem: ${st.split} / ${st.attest} / ${st.redeem}`);
console.log(' ');
console.log(' HOW IT WORKS: a poll runs on PollCovenant with public ballot addresses;');
console.log(' any observer can count UTXOs at each ballot to read the tally; the named');
console.log(' poll-oracle signs {eventId, outcome} via checkDataSig once the tally is');
console.log(' visible; the Family-D BinaryShareVaultBCH market — bound to that same');
console.log(' oraclePk + eventId at genesis — attests on the signature and settles.');
console.log(' Trust: the signer (who is constrained to honestly read a public tally).');
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);});