deviant/scripts/chipnet-che-escrow-perside-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

250 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);});