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>
231 lines
14 KiB
JavaScript
231 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* chipnet-escrow-test.mjs — GamePoolEscrow on chipnet, all 3 resolution paths,
|
|
* staking an MBA token. Winner-take-all pool (pari-mutuel prize).
|
|
*
|
|
* Roles: founder + arbiter = C; players = A and B.
|
|
* MBA: fresh canonical DeviantOpenMintFT (rate 1000, fee 0). Stake = 10 FT
|
|
* each → pool 20 FT / 20,000 sats backing.
|
|
*
|
|
* Three independent pools (each a separate contract instance / address):
|
|
* POOL-ARB → resolveArbiter: C signs, winner = A → A gets 20 FT.
|
|
* POOL-HASH → resolveHashlock: reveal secret, winner = B → B gets 20 FT.
|
|
* POOL-TIME → refund (deadline passed): split 10 FT back to A, 10 FT to B.
|
|
*
|
|
* Each pool is created by a SINGLE co-signed funding tx (A + B both sign their
|
|
* 10-FT inputs → one pool UTXO) = atomic mutuality. Miner fees come from C.
|
|
*/
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { TestNetWallet } from 'mainnet-js';
|
|
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
|
|
|
const ROOT = join(import.meta.dirname, '..');
|
|
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-escrow-test.json');
|
|
const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
|
|
const ESCROW_ARTIFACT = join(ROOT, 'contracts/dist/GamePoolEscrow.json');
|
|
const KEYS = { a: join(ROOT, '.chipnet-keys.json'), b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json') };
|
|
|
|
const RESERVOIR = 1_000_000_000_000_000_000n;
|
|
const RATE = 1000n, FEE = 0n;
|
|
const STAKE = 10n; // FTs per player
|
|
const STAKE_SATS = STAKE * RATE; // 10,000
|
|
const POOL = STAKE * 2n; // 20
|
|
const POOL_SATS = STAKE_SATS * 2n; // 20,000
|
|
|
|
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 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));}
|
|
|
|
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 ${r?.txId??r?.txid}`);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];
|
|
}
|
|
function pickBch(utxos,need){const p=[];let t=0n;for(const u of utxos){p.push(u);t+=BigInt(u.satoshis);if(t>=need)break;}return{p,t};}
|
|
|
|
async function main(){
|
|
const mbaArt=JSON.parse(readFileSync(MBA_ARTIFACT,'utf8'));
|
|
const escArt=JSON.parse(readFileSync(ESCROW_ARTIFACT,'utf8'));
|
|
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 sigA=new SignatureTemplate(A.privateKey), sigB=new SignatureTemplate(B.privateKey), sigC=new SignatureTemplate(C.privateKey);
|
|
const flbC=p2pkh(C.publicKeyHash);
|
|
let state=load();
|
|
|
|
// ---- MBA genesis (founder C) ----
|
|
step('Deploy MBA (rate 1000) and mint stakes to A and B');
|
|
if(!state.mba){
|
|
const u=await eligible(C);const cat=u.txid;
|
|
const c=new Contract(mbaArt,[txidLE(cat),RATE,FEE,flbC],{provider});
|
|
const r=await C.tokenGenesis({cashaddr:c.tokenAddress,amount:RESERVOIR,value:1000n,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});
|
|
console.log(` MBA genesis ${r?.txId??r?.txid} — category ${cat}`);
|
|
state.mba={categoryId:cat,contractTokenAddress:c.tokenAddress};save(state);await sleep(10000);
|
|
} else console.log(` MBA already: ${state.mba.categoryId}`);
|
|
const mba=new Contract(mbaArt,[txidLE(state.mba.categoryId),RATE,FEE,flbC],{provider});
|
|
|
|
// claim `amount` MBA FT to `who`
|
|
async function mint(who,sig,amount,label){
|
|
const ru=await provider.getUtxos(mba.tokenAddress);
|
|
const res=ru.find(x=>x.token?.nft?.capability==='minting'&&x.token?.category===state.mba.categoryId&&x.token?.amount>0n);
|
|
const gross=amount*RATE;
|
|
const bch=(await provider.getUtxos(who.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
|
|
const mf=6000n;const {p,t}=pickBch(bch,gross+mf+1000n);
|
|
if(t<gross+mf+1000n)throw new Error(`${label}: insufficient BCH ${t} < ${gross+mf+1000n}`);
|
|
const change=BigInt(res.satoshis)+t-1000n-gross-mf;
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(res,mba.unlock.claim(amount,p2pkh(who.publicKeyHash)));
|
|
for(const x of p)tb.addInput(x,sig.unlockP2PKH());
|
|
tb.addOutput({to:mba.tokenAddress,amount:1000n,token:{amount:res.token.amount-amount,category:state.mba.categoryId,nft:{capability:'minting',commitment:''}}});
|
|
tb.addOutput({to:who.tokenaddr,amount:gross,token:{amount,category:state.mba.categoryId}});
|
|
tb.addOutput({to:who.cashaddr,amount:change});
|
|
const sent=await tb.send();console.log(` ${label}: minted ${amount} MBA — ${typeof sent==='string'?sent:sent?.txid??sent?.txId}`);
|
|
await sleep(10000);
|
|
}
|
|
state.mints=state.mints||{};
|
|
if(!state.mints.a){await mint(A,sigA,30n,'A claims 30');state.mints.a=true;save(state);}
|
|
if(!state.mints.b){await mint(B,sigB,30n,'B claims 30');state.mints.b=true;save(state);}
|
|
|
|
// build an escrow contract instance with given secretHash + deadline
|
|
function escrow(secretHashHex,deadline){
|
|
return new Contract(escArt,[
|
|
txidLE(state.mba.categoryId), POOL, POOL_SATS, STAKE, STAKE_SATS,
|
|
A.publicKeyHash, B.publicKeyHash, C.publicKeyCompressed, hexToBin(secretHashHex), BigInt(deadline),
|
|
0n, // arbiterFeeBps: this base test is fee-free
|
|
],{provider});
|
|
}
|
|
|
|
// find a stake-sized FT UTXO held by `who`
|
|
async function findStake(who){
|
|
const u=await provider.getUtxos(who.cashaddr);
|
|
return u.find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount>=STAKE);
|
|
}
|
|
|
|
// co-signed funding: A's stake + B's stake → one pool UTXO at the escrow
|
|
async function fundPool(contract,label){
|
|
const aFt=await findStake(A), bFt=await findStake(B);
|
|
if(!aFt||!bFt)throw new Error(`${label}: missing stake UTXO (A=${!!aFt} B=${!!bFt})`);
|
|
// C pays the miner fee + supplies dust top-up if stakes carry exactly backing
|
|
const mf=6000n;
|
|
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
|
|
const {p,t}=pickBch(cBch,mf+POOL_SATS+1000n); // headroom
|
|
// value math: pool output = POOL_SATS. Inputs: aFt(STAKE_SATS)+bFt(STAKE_SATS)+C bch(t).
|
|
// A and B FT change: each stake UTXO may hold MORE than STAKE (e.g. 30) → return remainder FT to owner.
|
|
const aRemain=aFt.token.amount-STAKE, bRemain=bFt.token.amount-STAKE;
|
|
const aRemainSats=aRemain*RATE, bRemainSats=bRemain*RATE;
|
|
const cChange=BigInt(aFt.satoshis)+BigInt(bFt.satoshis)+t-POOL_SATS-aRemainSats-bRemainSats-mf;
|
|
if(cChange<546n)throw new Error(`${label}: C change ${cChange} < dust`);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(aFt,sigA.unlockP2PKH());
|
|
tb.addInput(bFt,sigB.unlockP2PKH());
|
|
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
|
|
// output 0: the pool UTXO at the escrow (token-aware address)
|
|
tb.addOutput({to:contract.tokenAddress,amount:POOL_SATS,token:{amount:POOL,category:state.mba.categoryId}});
|
|
// FT change back to A and B (their stake UTXOs were bigger than one stake)
|
|
if(aRemain>0n)tb.addOutput({to:A.tokenaddr,amount:aRemainSats,token:{amount:aRemain,category:state.mba.categoryId}});
|
|
if(bRemain>0n)tb.addOutput({to:B.tokenaddr,amount:bRemainSats,token:{amount:bRemain,category:state.mba.categoryId}});
|
|
tb.addOutput({to:C.cashaddr,amount:cChange});
|
|
const sent=await tb.send();const txid=typeof sent==='string'?sent:sent?.txid??sent?.txId;
|
|
console.log(` ${label}: funded pool (${POOL} FT / ${POOL_SATS} sats) at ${contract.tokenAddress} — ${txid}`);
|
|
await sleep(10000);return txid;
|
|
}
|
|
|
|
async function poolUtxo(contract){
|
|
const u=await provider.getUtxos(contract.tokenAddress);
|
|
return u.find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount===POOL);
|
|
}
|
|
|
|
state.results=state.results||{};
|
|
|
|
// ===== POOL 1 — arbiter resolves, winner = A =====
|
|
step('POOL-ARB — co-fund, then arbiter (C) awards the pool to A');
|
|
if(!state.results.arb){
|
|
const secret=randomBytes(32); const sh=binToHex(createHash('sha256').update(secret).digest());
|
|
const c=escrow(sh,9999999); // far-future deadline; resolves via arbiter
|
|
state.arbSecretHash=sh;state.arbAddr=c.tokenAddress;save(state);
|
|
await fundPool(c,'POOL-ARB fund');
|
|
const pool=await poolUtxo(c); if(!pool)throw new Error('POOL-ARB: pool UTXO not found');
|
|
const winnerLB=p2pkh(A.publicKeyHash);
|
|
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
|
|
const mf=6000n;const {p,t}=pickBch(cBch,mf+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(pool,c.unlock.resolveArbiter(sigC,winnerLB));
|
|
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
|
|
tb.addOutput({to:A.tokenaddr,amount:POOL_SATS,token:{amount:POOL,category:state.mba.categoryId}});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(pool.satoshis)+t-POOL_SATS-mf});
|
|
const sent=await tb.send();const txid=typeof sent==='string'?sent:sent?.txid??sent?.txId;
|
|
console.log(` arbiter resolved → A wins ${POOL} FT — ${txid}`);
|
|
state.results.arb={txid,winner:'A'};save(state);await sleep(10000);
|
|
} else console.log(` (done: ${state.results.arb.txid})`);
|
|
|
|
// ===== POOL 2 — hashlock, winner reveals secret = B =====
|
|
step('POOL-HASH — co-fund, then B reveals the secret to take the pool');
|
|
if(!state.results.hash){
|
|
const secret=randomBytes(32); const sh=binToHex(createHash('sha256').update(secret).digest());
|
|
const c=escrow(sh,9999999);
|
|
state.hashSecret=binToHex(secret);state.hashAddr=c.tokenAddress;save(state);
|
|
await fundPool(c,'POOL-HASH fund');
|
|
const pool=await poolUtxo(c); if(!pool)throw new Error('POOL-HASH: pool UTXO not found');
|
|
const winnerLB=p2pkh(B.publicKeyHash);
|
|
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
|
|
const mf=6000n;const {p,t}=pickBch(cBch,mf+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(pool,c.unlock.resolveHashlock(secret,winnerLB));
|
|
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
|
|
tb.addOutput({to:B.tokenaddr,amount:POOL_SATS,token:{amount:POOL,category:state.mba.categoryId}});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(pool.satoshis)+t-POOL_SATS-mf});
|
|
const sent=await tb.send();const txid=typeof sent==='string'?sent:sent?.txid??sent?.txId;
|
|
console.log(` hashlock revealed → B wins ${POOL} FT — ${txid}`);
|
|
state.results.hash={txid,winner:'B'};save(state);await sleep(10000);
|
|
} else console.log(` (done: ${state.results.hash.txid})`);
|
|
|
|
// ===== POOL 3 — timelock refund, split back to A and B =====
|
|
step('POOL-TIME — co-fund with a past deadline, then refund-split to A and B');
|
|
if(!state.results.refund){
|
|
const tip=await provider.getBlockHeight();
|
|
const deadline=tip-1; // already passed → refund spendable now
|
|
const c=escrow('00'.repeat(32),deadline);
|
|
state.refundAddr=c.tokenAddress;save(state);
|
|
await fundPool(c,'POOL-TIME fund');
|
|
const pool=await poolUtxo(c); if(!pool)throw new Error('POOL-TIME: pool UTXO not found');
|
|
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
|
|
const mf=6000n;const {p,t}=pickBch(cBch,mf+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.setLocktime(tip); // nLockTime >= deadline for CLTV
|
|
tb.addInput(pool,c.unlock.refund());
|
|
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
|
|
tb.addOutput({to:A.tokenaddr,amount:STAKE_SATS,token:{amount:STAKE,category:state.mba.categoryId}});
|
|
tb.addOutput({to:B.tokenaddr,amount:STAKE_SATS,token:{amount:STAKE,category:state.mba.categoryId}});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(pool.satoshis)+t-STAKE_SATS-STAKE_SATS-mf});
|
|
const sent=await tb.send();const txid=typeof sent==='string'?sent:sent?.txid??sent?.txId;
|
|
console.log(` refund (deadline ${deadline}, locktime ${tip}) → A and B each get ${STAKE} FT back — ${txid}`);
|
|
state.results.refund={txid};save(state);await sleep(10000);
|
|
} else console.log(` (done: ${state.results.refund.txid})`);
|
|
|
|
step('SUMMARY — GamePoolEscrow, all 3 resolution paths');
|
|
console.log(` MBA category: ${state.mba.categoryId}`);
|
|
console.log(` POOL-ARB → arbiter signed, A took ${POOL} FT — ${state.results.arb?.txid}`);
|
|
console.log(` POOL-HASH → secret revealed, B took ${POOL} FT — ${state.results.hash?.txid}`);
|
|
console.log(` POOL-TIME → refund split ${STAKE}+${STAKE} FT to A,B — ${state.results.refund?.txid}`);
|
|
console.log('');
|
|
console.log(' All three winner-take-all resolutions worked on a single escrow covenant');
|
|
console.log(' staking an MBA token: arbiter (trusted), hashlock (trustless reveal), and');
|
|
console.log(' timelock refund (trustless safety net). Mutual equal stakes locked via a');
|
|
console.log(' single co-signed funding tx; pool released under the chosen condition.');
|
|
divider();
|
|
}
|
|
|
|
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});
|