#!/usr/bin/env node /** * chipnet-pegstate-test.mjs — single-oracle USDm price-adjustment (PegState) proof. * The simplest config (maturity-ladder launch tier): one oracle (E), ~10-min cadence, * deadband. Proves the epoch-peg recalibration core: * 1. genesis PegState commitment = rate(8 LE) || lastHeight(4 LE) (rate 2000, band 5%) * 2. recalibrate to 2050 (+2.5%, WITHIN band) → REJECTED (no churn on small moves) * 3. recalibrate to 2200 (+10%, BEYOND band) → ACCEPTED, rate → 2200 (CLTV epoch gate honoured) * Funder/genesis = B. Oracle = E (checkDataSig). Writes .chipnet-pegstate-test.json * Run: node --import tsx scripts/chipnet-pegstate-test.mjs --yes */ 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, sha256 as laSha256 } from '@bitauth/libauth'; const ROOT = join(import.meta.dirname, '..'); const STATE = join(ROOT, 'ChipnetTests', '.chipnet-pegstate-test.json'); const ARTIFACT = join(ROOT, 'contracts/dist/PegState.json'); const EPOCH_BLOCKS = 1n, BAND_BPS = 500n, START_RATE = 2000n, POSITION_VALUE = 1000n; const 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; }; const binToHex = (b) => Buffer.from(b).toString('hex'); const txidToConsensusBytes = (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; }; const 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)); const u32le = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(n), true); return b; }; const u64le = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }; const leToInt = (bin) => { let n = 0n; for (let i = bin.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bin[i]); return n; }; const cat = (...a) => { const t = a.reduce((n, x) => n + x.length, 0); const o = new Uint8Array(t); let p = 0; for (const x of a) { o.set(x, p); p += x.length; } return o; }; const omsg = (price) => { const m = new Uint8Array(16); m.set(u32le(1), 0); m.set(u32le(1), 4); m.set(u32le(1), 8); m.set(u32le(price), 12); return m; }; const osign = (priv, m) => { const s = secp256k1.signMessageHashSchnorr(priv, laSha256.hash(m)); if (typeof s === 'string') throw new Error(s); return s; }; const commit = (rate, h) => binToHex(cat(u64le(rate), u32le(h))); 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 r = await w.send([{ cashaddr: w.cashaddr, value: 5000n, unit: 'sat' }]); console.log(' splitter', r?.txId ?? r?.txid); await sleep(12_000); u = await w.getUtxos(); e = u.filter((x) => x.vout === 0 && !x.token); if (!e.length) throw new Error('no eligible'); return e[0]; } const pickCash = (cash, need) => { const p = []; let t = 0n; for (const u of cash) { p.push(u); t += BigInt(u.satoshis); if (t >= need) break; } if (t < need) throw new Error(`insufficient ${t}<${need}`); return { p, t }; }; async function main() { if (!process.argv.includes('--yes')) { console.log('Re-run with --yes.'); return; } const artifact = JSON.parse(readFileSync(ARTIFACT, 'utf8')); const provider = new ElectrumNetworkProvider('chipnet'); const B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-b.json'), 'utf8')).wif); const E = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-e.json'), 'utf8')).wif); const sigB = new SignatureTemplate(B.privateKey); const ctor = [E.publicKey, EPOCH_BLOCKS, BAND_BPS]; const contract = new Contract(artifact, ctor, { provider }); const st = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {}; const save = () => writeFileSync(STATE, JSON.stringify(st, null, 2)); const tip = await provider.getBlockHeight(); console.log(`PegState ${contract.tokenAddress}\noracle=E · epochBlocks ${EPOCH_BLOCKS} · band ${BAND_BPS}bps · tip ${tip}`); const pegUtxo = async () => { const ru = await provider.getUtxos(contract.tokenAddress); return ru.find((u) => u.token?.nft && u.token?.category === st.categoryId); }; // 1. genesis (lastHeight in the recent past so the epoch gate is satisfiable) if (!st.categoryId) { const utxo = await eligible(B); const lastHeight = tip - 5; const r = await B.tokenGenesis({ cashaddr: contract.tokenAddress, amount: 0n, value: POSITION_VALUE, nft: { capability: 'mutable', commitment: commit(START_RATE, lastHeight) } }, [], { utxoIds: [utxo] }); console.log(`genesis ${r?.txId ?? r?.txid} · rate ${START_RATE} · lastHeight ${lastHeight} · category ${utxo.txid}`); st.categoryId = utxo.txid; st.lastHeight = lastHeight; save(); await sleep(10_000); } async function recalibrate(newRate, { expectFail }) { const pos = await pegUtxo(); if (!pos) throw new Error('PegState not found'); const curRate = leToInt(hexToBin(pos.token.nft.commitment).slice(0, 8)); const curHeight = leToInt(hexToBin(pos.token.nft.commitment).slice(8, 12)); const locktime = await provider.getBlockHeight(); // >= lastHeight + epochBlocks const msg = omsg(newRate), sig = osign(E.privateKey, msg); const cash = (await provider.getUtxos(B.cashaddr)).filter((u) => !u.token).sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis))); const minerFee = 2000n; const { p, t } = pickCash(cash, minerFee + 1000n); const change = BigInt(pos.satoshis) + t - POSITION_VALUE - minerFee; const tb = new TransactionBuilder({ provider }); tb.setLocktime(locktime); tb.addInput(pos, contract.unlock.recalibrate(sig, msg), { sequence: 0xfffffffe }); for (const u of p) tb.addInput(u, sigB.unlockP2PKH(), { sequence: 0xfffffffe }); tb.addOutput({ to: contract.tokenAddress, amount: POSITION_VALUE, token: { amount: 0n, category: st.categoryId, nft: { capability: 'mutable', commitment: commit(newRate, locktime) } } }); if (change >= 546n) tb.addOutput({ to: B.cashaddr, amount: change }); try { const sent = await tb.send(); const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId; if (expectFail) { console.log(` ✗ UNEXPECTED: recalibrate ${curRate}→${newRate} accepted (${txid})`); return { txid, ok: false }; } console.log(` ✓ recalibrate ${curRate}→${newRate} ACCEPTED (locktime ${locktime}) · ${txid}`); return { txid, newRate: newRate.toString() }; } catch (e) { if (expectFail) { console.log(` ✓ recalibrate ${curRate}→${newRate} correctly REJECTED (within ±${Number(BAND_BPS)/100}% band): ${String(e?.message ?? e).split('\n')[0].slice(0, 70)}`); return { rejected: true }; } throw e; } } // 2. within-band → reject if (!st.withinBand) { console.log(`\nrecalibrate 2000→2050 (+2.5%, within 5% band) — expect REJECT`); st.withinBand = await recalibrate(2050n, { expectFail: true }); save(); await sleep(3_000); } // 3. beyond-band → accept if (!st.beyondBand) { console.log(`\nrecalibrate 2000→2200 (+10%, beyond 5% band) — expect ACCEPT`); st.beyondBand = await recalibrate(2200n, { expectFail: false }); save(); await sleep(9_000); } // read back const pos = await pegUtxo(); const onRate = pos ? leToInt(hexToBin(pos.token.nft.commitment).slice(0, 8)) : null; st.finalRate = onRate?.toString() ?? null; save(); console.log(`\n=== RESULT — PegState single-oracle price adjustment ===`); console.log(` within-band 2050: ${st.withinBand.rejected ? 'REJECTED ✓' : 'NOT rejected ✗'}`); console.log(` beyond-band 2200: ${st.beyondBand.txid ?? 'n/a'}`); console.log(` on-chain rate now: ${onRate} (expected 2200) ${onRate === 2200n ? '✓' : '✗'}`); } main().then(() => process.exit(0)).catch((e) => { console.error('FAILED:', e?.message ?? e); if (e?.stack) console.error(e.stack); process.exit(1); });