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>
166 lines
11 KiB
JavaScript
166 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* chipnet-expiring-mba-test.mjs — ExpiringMBA: a time-boxed self-backed instrument.
|
|
* An MBA/DEA held under a maturity covenant that reverts to base BCH at expiry.
|
|
*
|
|
* 1. genesis a DEA FT (C = issuer holds supply)
|
|
* 2. Position FAR (expiry = tip+5000): C mints 10 units (backing 10000 sats) to B
|
|
* - NEG: expire() before maturity -> covenant REJECTS on-chain (CLTV)
|
|
* - owner B redeem() (sig, anytime) -> 10000 sats to B, FT burned
|
|
* 3. Position MATURE (expiry = tip): C mints 10 units to B
|
|
* - third party C triggers expire() (locktime = expiry) -> 10000 sats to B (owner),
|
|
* FT burned. Permissionless "auto-swap back to base units" to the fixed owner.
|
|
*
|
|
* rate 1000 (backing = amount*rate sats). Roles: C = issuer/funder/triggerer; B = owner.
|
|
* Resumable state JSON.
|
|
*/
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { TestNetWallet } from 'mainnet-js';
|
|
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
|
import { secp256k1 } from '@bitauth/libauth';
|
|
|
|
const ROOT = join(import.meta.dirname, '..');
|
|
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-expiring-mba-test.json');
|
|
const ARTIFACT = join(ROOT, 'contracts/dist/ExpiringMBA.json');
|
|
const KEYS = { b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json') };
|
|
const SUPPLY = 100000n;
|
|
const RATE = 1000n, AMT = 10n; // backing = 10 * 1000 = 10000 sats
|
|
const BACKING = AMT * RATE;
|
|
const DUST = 1000n;
|
|
const MF = 3000n;
|
|
|
|
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')):{};}
|
|
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;}
|
|
const isReject=(e)=>/ExpiringMBA\.cash|Require statement failed|mandatory-script|failed to verify|Verify|Locktime/i.test((e?.message??e).toString());
|
|
|
|
async function main(){
|
|
const art=JSON.parse(readFileSync(ARTIFACT,'utf8'));
|
|
for(const [k,f] of Object.entries(KEYS)) if(!existsSync(f)) throw new Error(`missing key ${k}`);
|
|
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={b:new SignatureTemplate(B.privateKey),c:new SignatureTemplate(C.privateKey)};
|
|
let st=load();
|
|
|
|
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 pick(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};}
|
|
async function ftUtxo(w,cat,min){return (await provider.getUtxos(w.cashaddr)).find(x=>x.token?.category===cat&&!x.token?.nft&&BigInt(x.token?.amount)>=min);}
|
|
async function bchBal(w){return (await provider.getUtxos(w.cashaddr)).filter(x=>!x.token).reduce((s,x)=>s+BigInt(x.satoshis),0n);}
|
|
|
|
// ---- Genesis DEA FT ----
|
|
step('Genesis DEA FT (C = issuer holds supply)');
|
|
if(!st.cat){const u=await eligible(C);const cat=u.txid;const r=await C.tokenGenesis({cashaddr:C.tokenaddr,amount:SUPPLY,value:1000n},[],{utxoIds:[u]});console.log(` genesis ${txidOf(r)} — ${cat}`);st.cat=cat;save(st);await sleep(10000);} else console.log(` category ${st.cat}`);
|
|
const cat=st.cat;
|
|
|
|
if(!st.exp){const tip=await provider.getBlockHeight();st.exp={far:tip+5000,mature:tip};save(st);console.log(` tip ${tip}`);}
|
|
const farC =new Contract(art,[txidLE(cat),RATE,B.publicKeyHash,BigInt(st.exp.far)],{provider});
|
|
const matC =new Contract(art,[txidLE(cat),RATE,B.publicKeyHash,BigInt(st.exp.mature)],{provider});
|
|
console.log(` FAR position ${farC.tokenAddress} (expiry ${st.exp.far})`);
|
|
console.log(` MATURE position ${matC.tokenAddress} (expiry ${st.exp.mature})`);
|
|
async function posUtxo(c){return (await provider.getUtxos(c.tokenAddress)).find(x=>x.token?.category===cat&&!x.token?.nft);}
|
|
|
|
// ---- Mint helper: issuer C locks AMT FT + BACKING sats into a position covenant ----
|
|
async function mint(c,label){
|
|
const fu=await ftUtxo(C,cat,AMT);if(!fu)throw new Error(`${label}: FT supply missing`);
|
|
const {p,t}=await pick(C,BACKING+DUST+MF+1000n);
|
|
const tb=new TransactionBuilder({provider});
|
|
tb.addInput(fu,sig.c.unlockP2PKH());
|
|
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
|
|
tb.addOutput({to:c.tokenAddress,amount:BACKING,token:{amount:AMT,category:cat}}); // [0] position (FT + backing sats)
|
|
tb.addOutput({to:C.tokenaddr,amount:DUST,token:{amount:BigInt(fu.token.amount)-AMT,category:cat}}); // [1] FT change
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(fu.satoshis)+t-BACKING-DUST-MF}); // [2] BCH change
|
|
return txidOf(await tb.send());
|
|
}
|
|
|
|
step('MINT — FAR-expiry position (10 units, backing 10000 sats) to B');
|
|
if(!st.mintFar){st.mintFar=await mint(farC,'mintFar');save(st);console.log(` ${st.mintFar}`);await sleep(10000);} else console.log(` (mintFar ${st.mintFar})`);
|
|
|
|
// ---- NEG: expire() the FAR position BEFORE maturity -> covenant must REJECT ----
|
|
step('NEG — expire() FAR position before maturity -> covenant must REJECT (CLTV)');
|
|
if(!st.negTested){
|
|
const tip=await provider.getBlockHeight();
|
|
let r=false;
|
|
try{
|
|
const pos=await posUtxo(farC);if(!pos)throw new Error('neg: FAR position missing');
|
|
const {p,t}=await pick(C,MF+1000n);
|
|
const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true});
|
|
tb.setLocktime(tip); // locktime now < far expiry
|
|
tb.addInput(pos,farC.unlock.expire());
|
|
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH());
|
|
tb.addOutput({to:B.cashaddr,amount:BACKING});
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(pos.satoshis)+t-BACKING-MF});
|
|
await tb.send();
|
|
}catch(e){ if(isReject(e)){r=true;console.log(` rejected (expected): ${(e?.message??e).toString().slice(0,110)}`);} else throw e; }
|
|
if(!r)throw new Error('SECURITY: expire before maturity accepted');
|
|
st.negTested=true;save(st);
|
|
} else console.log(' (neg verified)');
|
|
|
|
// ---- Owner B redeems the FAR position early (sig, anytime) ----
|
|
step('REDEEM — owner B redeems FAR position early -> 10000 sats to B, FT burned');
|
|
if(!st.redeem){
|
|
const pos=await posUtxo(farC);if(!pos)throw new Error('redeem: FAR position missing');
|
|
const bPub=secp256k1.derivePublicKeyCompressed(B.privateKey);
|
|
const {p,t}=await pick(B,MF+1000n);
|
|
const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true});
|
|
tb.addInput(pos,farC.unlock.redeem(bPub,sig.b)); // [0] position
|
|
for(const x of p)tb.addInput(x,sig.b.unlockP2PKH());
|
|
tb.addOutput({to:B.cashaddr,amount:BACKING}); // [0] backing to owner B
|
|
tb.addOutput({to:B.cashaddr,amount:BigInt(pos.satoshis)+t-BACKING-MF}); // [1] B change
|
|
st.redeem=txidOf(await tb.send());save(st);console.log(` redeemed — ${st.redeem}`);await sleep(10000);
|
|
} else console.log(` (redeem ${st.redeem})`);
|
|
|
|
// ---- MATURE position + permissionless expiry by third party C ----
|
|
step('MINT — MATURE position (expiry = tip) to B');
|
|
if(!st.mintMat){st.mintMat=await mint(matC,'mintMat');save(st);console.log(` ${st.mintMat}`);await sleep(10000);} else console.log(` (mintMat ${st.mintMat})`);
|
|
|
|
step('EXPIRE — third party C triggers expire() on the matured position -> 10000 sats to B');
|
|
if(!st.expire){
|
|
const pos=await posUtxo(matC);if(!pos)throw new Error('expire: MATURE position missing');
|
|
const {p,t}=await pick(C,MF+1000n);
|
|
const tb=new TransactionBuilder({provider,allowImplicitFungibleTokenBurn:true});
|
|
tb.setLocktime(Number(st.exp.mature));
|
|
tb.addInput(pos,matC.unlock.expire()); // [0] position — NO owner sig (permissionless)
|
|
for(const x of p)tb.addInput(x,sig.c.unlockP2PKH()); // C (third party) pays the fee
|
|
tb.addOutput({to:B.cashaddr,amount:BACKING}); // [0] backing to OWNER B (fixed)
|
|
tb.addOutput({to:C.cashaddr,amount:BigInt(pos.satoshis)+t-BACKING-MF}); // [1] C change
|
|
st.expire=txidOf(await tb.send());save(st);console.log(` expired by third party -> B — ${st.expire}`);await sleep(10000);
|
|
} else console.log(` (expire ${st.expire})`);
|
|
|
|
step('VERIFY');
|
|
const checks=[
|
|
['FAR + MATURE positions minted (covenant-held, backed)', !!st.mintFar&&!!st.mintMat],
|
|
['expire() before maturity rejected on-chain (CLTV gate)', !!st.negTested],
|
|
['owner redeemed early (sig, anytime)', !!st.redeem],
|
|
['matured position expired by a THIRD PARTY -> backing to owner B (permissionless)', !!st.expire],
|
|
['both positions consumed (FT burned, backing reverted to base BCH)', !(await posUtxo(farC))&&!(await posUtxo(matC))],
|
|
];
|
|
let ok=true;for(const [l,p] of checks){console.log(` [${p?'PASS':'FAIL'}] ${l}`);ok=ok&&p;}
|
|
|
|
step('SUMMARY — ExpiringMBA (time-boxed self-backed instrument)');
|
|
console.log(` category ${cat}`);
|
|
console.log(` FAR ${farC.tokenAddress}\n MATURE ${matC.tokenAddress}`);
|
|
console.log(` mintFar/redeem: ${st.mintFar} / ${st.redeem}`);
|
|
console.log(` mintMat/expire: ${st.mintMat} / ${st.expire}`);
|
|
console.log(' Backing is intrinsic; before expiry only the owner can act (redeem); after');
|
|
console.log(' expiry ANYONE can trigger the revert to base BCH, always to the fixed owner.');
|
|
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);});
|