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>
250 lines
14 KiB
JavaScript
250 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* chipnet-che-escrow-perside-test.mjs — GamePoolEscrowFlat with a per-side fee model:
|
||
*
|
||
* pot >= $1 → feeAmount = floor(1% of pot) (proportional regime)
|
||
* pot < $1 → feeAmount = 2 × $0.01 in FT ($0.01/side flat regime)
|
||
*
|
||
* Currency: CheckersCoin (CHE) — 1 CHE = 0.001 BCH = 100,000 sats, decimals 2,
|
||
* atomic FT = 1 "cent" = 1,000 sats. Players A & C, arbiter B.
|
||
*
|
||
* Two scenarios:
|
||
* A) big pot 10.00 CHE (~$3.55 @ $355) → pot >= $1 → 1% = 10 cents (0.10 CHE)
|
||
* B) small pot 1.00 CHE (~$0.36 @ $355) → pot < $1 → $0.01/side = 6 cents (0.06 CHE)
|
||
* (proportional would give only 1 cent = 0.01 CHE; per-side floor gives 6× more)
|
||
*
|
||
* Guard: server refuses to create a match if feeAmount >= poolAmount
|
||
* (pot too tiny for the double flat fee — effective minimum match size).
|
||
*
|
||
* Contract (GamePoolEscrowFlat.cash) is UNCHANGED — the per-side logic lives
|
||
* entirely in the off-chain computeFlatFee() and is re-derived by the client
|
||
* before co-signing.
|
||
*/
|
||
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-che-perside-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; // per-atomic (cent) backing rate, sats/cent
|
||
const DECIMALS = 2n; // CHE has 2 decimals → 100 cents per CHE
|
||
const WHOLE_UNIT_RATE = RATE * (10n ** DECIMALS); // 100,000 sats per whole CHE
|
||
const GENESIS_FEE = 0n;
|
||
|
||
// Stakes per player, in atomic FTs (cents)
|
||
const SCENARIOS = [
|
||
{ key: 'big', stake: 500n, label: 'big pot 10.00 CHE (~$3.55) → pot >= $1 → 1% regime' },
|
||
{ key: 'small', stake: 50n, label: 'small pot 1.00 CHE (~$0.36) → pot < $1 → $0.01/side regime' },
|
||
];
|
||
|
||
// --- CHE display helpers (decimals = 2) ---
|
||
function che(cents) {
|
||
const neg = cents < 0n; const v = neg ? -cents : cents;
|
||
const whole = v / 100n, frac = v % 100n;
|
||
return `${neg ? '-' : ''}${whole}.${frac.toString().padStart(2, '0')} CHE`;
|
||
}
|
||
|
||
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 (per-side variant):
|
||
* pot >= $1 → feeAmount = floor(1% of pot in atomic FT)
|
||
* pot < $1 → feeAmount = 2 × ceil($0.01 in atomic FT) ← each side pays $0.01
|
||
*
|
||
* Guard: throws if feeAmount >= poolAmount (pot too small to sustain the fee).
|
||
*/
|
||
function computeFlatFee(poolAmount, rateSats, bchUsd){
|
||
const potUsd = Number(poolAmount) * Number(rateSats) / 1e8 * bchUsd;
|
||
const centSats = Math.ceil(0.01 / bchUsd * 1e8); // $0.01 in sats
|
||
const centFt = BigInt(Math.ceil(centSats / Number(rateSats))); // $0.01 in atomic FT (ceil)
|
||
|
||
let fee, regime;
|
||
if(potUsd >= 1.0){
|
||
fee = poolAmount * 100n / 10000n; // floor(1%)
|
||
regime = '1%';
|
||
} else {
|
||
fee = centFt * 2n; // $0.01 per side → $0.02 total
|
||
regime = '$0.01/side';
|
||
}
|
||
|
||
// Guard: fee must be < poolAmount (winner must receive something positive)
|
||
if(fee >= poolAmount){
|
||
throw new Error(
|
||
`pot too small: feeAmount=${fee} cents >= poolAmount=${poolAmount} cents ` +
|
||
`(pot ${che(poolAmount)} ≈ $${potUsd.toFixed(4)} is below minimum match size)`
|
||
);
|
||
}
|
||
|
||
return { fee, regime, potUsd, centFt, onePctFt: poolAmount * 100n / 10000n };
|
||
}
|
||
|
||
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);
|
||
const sigB = new SignatureTemplate(B.privateKey);
|
||
const sigC = new SignatureTemplate(C.privateKey);
|
||
const flbC = p2pkh(C.publicKeyHash);
|
||
let state = load(); state.s = state.s || {};
|
||
|
||
step(`BCH price + CHE MBA (1 CHE = ${WHOLE_UNIT_RATE} sats, decimals ${DECIMALS}) — per-side fee model`);
|
||
const bchUsd = await getBchUsd(); state.bchUsd = bchUsd; save(state);
|
||
|
||
// --- Deploy CHE MBA ---
|
||
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));
|
||
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(` CHE genesis ${r?.txId??r?.txid} — ${cat}`);
|
||
state.mba = {categoryId:cat, contractTokenAddress:c.tokenAddress}; save(state); await sleep(10000);
|
||
} else {
|
||
console.log(` CHE (existing): ${state.mba.categoryId}`);
|
||
}
|
||
const mba = new Contract(mbaArt, [txidLE(state.mba.categoryId), RATE, GENESIS_FEE, flbC], {provider});
|
||
|
||
// --- Mint stakes ---
|
||
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);
|
||
}
|
||
|
||
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} cents (${che(totalStake)})`); state.mints.a=true; save(state); }
|
||
if(!state.mints.c){ await mint(C, sigC, totalStake, `C claims ${totalStake} cents (${che(totalStake)})`); state.mints.c=true; save(state); }
|
||
|
||
// --- Run scenarios ---
|
||
const summary = [];
|
||
for(const sc of SCENARIOS){
|
||
const STAKE=sc.stake, POOL=STAKE*2n, POOL_SATS=POOL*RATE, STAKE_SATS=STAKE*RATE;
|
||
const { fee, regime, potUsd, centFt, onePctFt } = 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=${che(POOL)} (${POOL} cents, ${POOL_SATS} sats, ~$${potUsd.toFixed(3)})`);
|
||
console.log(` 1% of pot = ${onePctFt} cents (${che(onePctFt)})`);
|
||
console.log(` $0.01/side = ${centFt} cents/side → total ${centFt*2n} cents (${che(centFt*2n)})`);
|
||
console.log(` regime: ${regime} → feeAmount = ${fee} cents (${che(fee)})`);
|
||
|
||
// Client re-derives and asserts before co-signing
|
||
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} cents (${che(check)}) ✓ (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 ${che(POOL)} (feeAmount=${fee} cents baked)`);
|
||
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 UTXO 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 → A wins ${che(winnerAmount)}, B earns ${che(fee)} — ${txid}`);
|
||
S.resolved={txid,fee:fee.toString(),winner:winnerAmount.toString(),regime}; save(state); await sleep(10000);
|
||
}
|
||
|
||
summary.push({key:sc.key,pool:POOL,onePctFt,centFt,fee,winnerAmount,regime,potUsd});
|
||
}
|
||
|
||
// --- Summary ---
|
||
step(`SUMMARY — CHE (1 CHE=${WHOLE_UNIT_RATE} sats, 2 dec) · GamePoolEscrowFlat per-side fee @ BCH $${bchUsd}`);
|
||
console.log(' Fee model: pot >= $1 → 1% of pot | pot < $1 → $0.01/side (= $0.02 total)');
|
||
console.log('');
|
||
console.log(' | scenario | pot | pot $ | 1% | $0.01/side | feeAmount | regime | winner |');
|
||
console.log(' |----------|-------------|----------|-----------|-------------|-------------|--------------|-------------|');
|
||
for(const s of summary){
|
||
const sideFee = `${che(s.centFt)}/side`;
|
||
console.log(` | ${s.key.padEnd(8)} | ${che(s.pool).padStart(11)} | ${('$'+s.potUsd.toFixed(3)).padStart(8)} | ${che(s.onePctFt).padStart(9)} | ${sideFee.padStart(11)} | ${che(s.fee).padStart(11)} | ${s.regime.padEnd(12)} | ${che(s.winnerAmount).padStart(11)} |`);
|
||
}
|
||
console.log('');
|
||
console.log(' Big pot: 1% > $0.01/side → proportional regime applies (0.10 CHE fee).');
|
||
console.log(' Small pot: 1% (0.01 CHE) < $0.01/side (0.06 CHE) → per-side flat kicks in.');
|
||
console.log(' Each player contributed $0.01 to the arbiter — symmetric, predictable.');
|
||
console.log(' Contract (GamePoolEscrowFlat.cash): UNCHANGED — per-side logic is purely off-chain.');
|
||
console.log(' Client re-derived feeAmount and asserted match before co-signing both scenarios.');
|
||
divider();
|
||
}
|
||
|
||
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});
|