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>
178 lines
12 KiB
JavaScript
178 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* chipnet-escrow-flat-test.mjs — GamePoolEscrowFlat with the brief's fee model:
|
|
* feeAmount = max( $0.01 , 1% of pot ), computed off-chain in WHOLE FTs at
|
|
* match creation via the price feed, baked into the contract, client-verified
|
|
* before co-signing, enforced on-chain.
|
|
*
|
|
* Two scenarios exercise BOTH regimes of the max() (MBA rate 1000, decimals 0,
|
|
* players A & C, arbiter B):
|
|
* A) large pot 1000 FT → 1% = 10 FT > $0.01 (≈3 FT) → fee = 10 FT (1% wins)
|
|
* B) tiny pot 50 FT → 1% = 0 FT (proportional would pay NOTHING)
|
|
* → $0.01 (≈3 FT) wins → fee = 3 FT (floor protects arbiter)
|
|
*
|
|
* The contract only enforces the baked `feeAmount`; the max() is the server's
|
|
* off-chain computation, which the client re-derives and asserts before funding.
|
|
*/
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
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-flat-test.json');
|
|
const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
|
|
const FLAT_ARTIFACT = join(ROOT, 'contracts/dist/GamePoolEscrowFlat.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, GENESIS_FEE = 0n;
|
|
// pot, stake (each), label
|
|
const SCENARIOS = [
|
|
{ key: 'big', stake: 500n, label: 'large pot (1% dominates)' },
|
|
{ key: 'tiny', stake: 25n, label: 'tiny pot ($0.01 floor dominates; proportional → 0)' },
|
|
];
|
|
|
|
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 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')):{s:{}};}
|
|
function save(s){writeFileSync(STATE,JSON.stringify(s,(k,v)=>typeof v==='bigint'?v.toString():v,2));}
|
|
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 getBchUsd(){
|
|
if(process.env.BCH_USD)return Number(process.env.BCH_USD);
|
|
try{const ac=new AbortController();const t=setTimeout(()=>ac.abort(),8000);
|
|
const r=await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd',{signal:ac.signal});
|
|
clearTimeout(t);const j=await r.json();const p=j?.['bitcoin-cash']?.usd;
|
|
if(p>0){console.log(` BCH price (CoinGecko): $${p}`);return Number(p);}
|
|
}catch(e){console.log(` (price fetch failed: ${e.message?.split('\n')[0]})`);}
|
|
console.log(' BCH price fallback: $500');return 500;
|
|
}
|
|
|
|
/** Server-side fee model: feeAmount = max( $0.01 in FT , floor(1% of pot in FT) ). */
|
|
function computeFlatFee(poolAmount, rateSats, bchUsd){
|
|
const onePctFt = poolAmount * 100n / 10000n; // floor(1% of pot), in FT
|
|
const centSats = Math.ceil(0.01 / bchUsd * 1e8); // $0.01 in sats
|
|
const centFt = BigInt(Math.ceil(centSats / Number(rateSats)));// $0.01 in FT (ceil)
|
|
const fee = onePctFt > centFt ? onePctFt : centFt;
|
|
return { fee, onePctFt, centFt, centSats };
|
|
}
|
|
|
|
async function main(){
|
|
const mbaArt=JSON.parse(readFileSync(MBA_ARTIFACT,'utf8'));
|
|
const flatArt=JSON.parse(readFileSync(FLAT_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();state.s=state.s||{};
|
|
|
|
step('BCH price + shared MBA (rate 1000)');
|
|
const bchUsd=await getBchUsd();state.bchUsd=bchUsd;save(state);
|
|
if(!state.mba){
|
|
const u=(await (async()=>{let x=await C.getUtxos();let e=x.filter(y=>y.vout===0&&!y.token);if(e.length)return e[0];const bad=x.filter(y=>!(y.vout===0&&!y.token));const r=await C.send([{cashaddr:C.cashaddr,value:2000n,unit:'sat'}],bad.length?{utxoIds:bad}:undefined);await sleep(12000);x=await C.getUtxos();return x.filter(y=>y.vout===0&&!y.token)[0];})());
|
|
const cat=u.txid;const c=new Contract(mbaArt,[txidLE(cat),RATE,GENESIS_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} — ${cat}`);
|
|
state.mba={categoryId:cat,contractTokenAddress:c.tokenAddress};save(state);await sleep(10000);
|
|
} else console.log(` MBA: ${state.mba.categoryId}`);
|
|
const mba=new Contract(mbaArt,[txidLE(state.mba.categoryId),RATE,GENESIS_FEE,flbC],{provider});
|
|
|
|
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}`);
|
|
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:BigInt(res.satoshis)+t-1000n-gross-mf});
|
|
await tb.send();console.log(` ${label}`);await sleep(10000);
|
|
}
|
|
// total stake needed across both scenarios per player
|
|
const totalStake=SCENARIOS.reduce((a,s)=>a+s.stake,0n);
|
|
state.mints=state.mints||{};
|
|
if(!state.mints.a){await mint(A,sigA,totalStake,`A claims ${totalStake}`);state.mints.a=true;save(state);}
|
|
if(!state.mints.c){await mint(C,sigC,totalStake,`C claims ${totalStake}`);state.mints.c=true;save(state);}
|
|
|
|
const summary=[];
|
|
for(const sc of SCENARIOS){
|
|
const STAKE=sc.stake, POOL=STAKE*2n, POOL_SATS=POOL*RATE, STAKE_SATS=STAKE*RATE;
|
|
const { fee, onePctFt, centFt } = computeFlatFee(POOL, RATE, bchUsd);
|
|
const winnerAmount=POOL-fee;
|
|
state.s[sc.key]=state.s[sc.key]||{};const S=state.s[sc.key];
|
|
step(`SCENARIO ${sc.key} — ${sc.label}`);
|
|
console.log(` pot=${POOL} FT (${POOL_SATS} sats); 1%=${onePctFt} FT; $0.01=${centFt} FT → feeAmount=max=${fee} FT`);
|
|
|
|
// CLIENT VERIFY (what the player wallet does before co-signing): recompute & assert.
|
|
const check=computeFlatFee(POOL,RATE,bchUsd).fee;
|
|
if(check!==fee)throw new Error('client fee re-derivation mismatch');
|
|
console.log(` client re-derived feeAmount=${check} FT — matches baked value ✓ (would co-sign)`);
|
|
|
|
const contract=new Contract(flatArt,[
|
|
txidLE(state.mba.categoryId), POOL, POOL_SATS, STAKE, STAKE_SATS,
|
|
A.publicKeyHash, C.publicKeyHash, B.publicKeyCompressed, hexToBin('00'.repeat(32)), 9999999n, fee,
|
|
],{provider});
|
|
|
|
if(!S.funded){
|
|
const aFt=(await provider.getUtxos(A.cashaddr)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount>=STAKE);
|
|
const cFt=(await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount>=STAKE);
|
|
if(!aFt||!cFt)throw new Error(`missing stake (A=${!!aFt} C=${!!cFt})`);
|
|
const aRem=aFt.token.amount-STAKE, cRem=cFt.token.amount-STAKE, 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);
|
|
const cChange=BigInt(aFt.satoshis)+BigInt(cFt.satoshis)+t-POOL_SATS-aRem*RATE-cRem*RATE-mf;
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(aFt,sigA.unlockP2PKH());tb.addInput(cFt,sigC.unlockP2PKH());
|
|
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
|
|
tb.addOutput({to:contract.tokenAddress,amount:POOL_SATS,token:{amount:POOL,category:state.mba.categoryId}});
|
|
if(aRem>0n)tb.addOutput({to:A.tokenaddr,amount:aRem*RATE,token:{amount:aRem,category:state.mba.categoryId}});
|
|
if(cRem>0n)tb.addOutput({to:C.tokenaddr,amount:cRem*RATE,token:{amount:cRem,category:state.mba.categoryId}});
|
|
tb.addOutput({to:C.cashaddr,amount:cChange});
|
|
await tb.send();console.log(` funded pot ${POOL} FT`);S.funded=true;save(state);await sleep(10000);
|
|
}
|
|
|
|
if(!S.resolved){
|
|
const pool=(await provider.getUtxos(contract.tokenAddress)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount===POOL);
|
|
if(!pool)throw new Error('pool not found');
|
|
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+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(pool,contract.unlock.resolveArbiter(sigB,p2pkh(A.publicKeyHash)));
|
|
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
|
|
tb.addOutput({to:A.tokenaddr,amount:winnerAmount*RATE,token:{amount:winnerAmount,category:state.mba.categoryId}});
|
|
if(fee>0n)tb.addOutput({to:B.tokenaddr,amount:fee*RATE,token:{amount:fee,category:state.mba.categoryId}});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(pool.satoshis)+t-winnerAmount*RATE-(fee>0n?fee*RATE:0n)-mf});
|
|
const sent=await tb.send();const txid=typeof sent==='string'?sent:sent?.txid??sent?.txId;
|
|
console.log(` resolved (B→A): winner ${winnerAmount} FT, arbiter ${fee} FT — ${txid}`);
|
|
S.resolved={txid,fee:fee.toString(),winner:winnerAmount.toString()};save(state);await sleep(10000);
|
|
}
|
|
summary.push({key:sc.key,pool:POOL,onePctFt,centFt,fee,winnerAmount,regime:onePctFt>centFt?'1%':'$0.01 floor'});
|
|
}
|
|
|
|
step(`SUMMARY — GamePoolEscrowFlat, fee = max($0.01, 1%) @ BCH $${bchUsd}`);
|
|
console.log(' | scenario | pot (FT) | 1% (FT) | $0.01 (FT) | feeAmount | regime | winner |');
|
|
console.log(' |----------|----------|---------|------------|-----------|-------------|--------|');
|
|
for(const s of summary){
|
|
console.log(` | ${s.key.padEnd(8)} | ${String(s.pool).padStart(8)} | ${String(s.onePctFt).padStart(7)} | ${String(s.centFt).padStart(10)} | ${String(s.fee).padStart(9)} | ${s.regime.padEnd(11)} | ${s.winnerAmount} |`);
|
|
}
|
|
console.log('');
|
|
console.log(' Large pot: 1% (10 FT) > $0.01 floor → fee follows 1%.');
|
|
console.log(' Tiny pot: 1% floors to 0 FT (proportional would pay the arbiter NOTHING) →');
|
|
console.log(' the $0.01 floor kicks in, so the arbiter still earns a real fee.');
|
|
console.log(' The contract enforced the baked feeAmount; the client re-derived & matched it');
|
|
console.log(' before co-signing. Peg preserved on both winner and fee outputs.');
|
|
divider();
|
|
}
|
|
|
|
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});
|