deviant/scripts/chipnet-channel-test.mjs
walerikus 4b785d8323 feat(crosschain): BTC↔BCH test program — HTLC swap, native channel, on-chain rails, BTC-IOU
Validated on chipnet — the "BCH as BTC's L2" program (three models, ship all):
- T1 AtomicSwapHTLC — HTLC hashlock+timelock primitive (claim/reveal/refund + guards).
- T2 BchPaymentChannel + BchChannelClose — native-BCH payment channel: 2-of-2/covenant
  funding, off-chain commitments, cooperative close, covenant-enforced penalty-on-stale,
  finalize-after-CSV. BCH is judge + memory (BTC can't — no ANYPREVOUT).
- T5 + round-trip — swap into BCH, transact on-chain to brand-new recipients (0-conf,
  offline-receive, no channel), swap back. Model 2 (swap) end to end.
- Model 3 BTC-IOU — reuse DeviantWrapMintIOU: mint a BTC-IOU, transact it on-chain, no
  off-chain/LN/node/hub, publicly-verifiable backing. The captured-audience on-ramp.

Snapshots 0172/0173/0174 + program tracker + cross-chain design record §11.
Covenants compile under cashc 0.12.1; all chipnet runs PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:28:34 +02:00

213 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-channel-test.mjs — T2 of "BTC on BCH rails": a native-BCH payment channel
* (the LN analog), where Bitcoin Cash itself is the payment channel — 2-of-2/covenant
* funding, OFF-CHAIN commitments, penalty-on-stale, BCH as judge + memory.
*
* Two covenants:
* BchPaymentChannel (OPEN) — funding UTXO = channelTotal sats + a mutable state NFT.
* coopClose (both sign) | startUnilateral (post a
* both-signed state → move into the close covenant).
* BchChannelClose (CLOSE) — challenge (penalise a STALE state → all to the honest
* party) | finalize (after CSV window → the posted split).
*
* A state is 12 bytes signed OFF-CHAIN by BOTH via checkDataSig:
* stateNumber(4 LE) || aliceBalance(8 LE). bobBalance = channelTotal - aliceBalance.
*
* Demonstrates:
* PATH A — open → 2 off-chain payments Alice→Bob → COOPERATIVE close (both sign).
* PATH B — open → Alice maliciously closes with STALE state 0 → Bob CHALLENGES with
* the latest state → PENALTY: Bob takes the whole channel.
* PATH C — open → honest unilateral close (latest state) → FINALIZE after the CSV
* window → the committed split. (best-effort; needs `window` blocks.)
*
* Run: node --import tsx scripts/chipnet-channel-test.mjs --yes
*/
import { existsSync, readFileSync } 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, cashAddressToLockingBytecode } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const ART_OPEN = join(ROOT, 'contracts/dist/BchPaymentChannel.json');
const ART_CLOSE = join(ROOT, 'contracts/dist/BchChannelClose.json');
const KEYS = { b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json') };
const TOTAL = 50000n; // channel capacity (sats)
const WINDOW = Number(process.env.CH_WINDOW || 2); // challenge window (blocks, CSV); override for the finalize re-run
const MF = 2000n; // miner fee per tx
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const divider = (c = '=') => console.log(c.repeat(80));
const step = (t) => { console.log(''); divider(); console.log(t); divider(); };
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
const head1 = (e) => String(e?.message ?? e).split('\n')[0].slice(0, 120);
const hex = (u) => Buffer.from(u).toString('hex');
function u32le(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(n), true); return b; }
function u64le(n) { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }
function cat(...a) { const o = new Uint8Array(a.reduce((s, x) => s + x.length, 0)); let p = 0; for (const x of a) { o.set(x, p); p += x.length; } return o; }
const stateMsg = (num, bal) => cat(u32le(num), u64le(bal));
function signState(priv, num, bal) { const s = secp256k1.signMessageHashSchnorr(priv, laSha256.hash(stateMsg(num, bal))); if (typeof s === 'string') throw new Error(s); return s; }
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 }; }
async function main() {
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes to broadcast on chipnet.'); return; }
const artOpen = JSON.parse(readFileSync(ART_OPEN, 'utf8'));
const artClose = JSON.parse(readFileSync(ART_CLOSE, 'utf8'));
for (const [k, f] of Object.entries(KEYS)) if (!existsSync(f)) throw new Error(`missing key ${k} (${f})`);
const A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c, 'utf8')).wif); // Alice
const B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.b, 'utf8')).wif); // Bob
const provider = new ElectrumNetworkProvider('chipnet');
const sigA = new SignatureTemplate(A.privateKey);
const sigB = new SignatureTemplate(B.privateKey);
const aPk = A.publicKeyCompressed, bPk = B.publicKeyCompressed;
// The close covenant is fixed (no category dependency) → so is its locking bytecode
// and the open covenant that points at it. Both addresses are known before funding.
const close = new Contract(artClose, [aPk, bPk, TOTAL, BigInt(WINDOW)], { provider });
const clr = cashAddressToLockingBytecode(close.tokenAddress);
if (typeof clr === 'string') throw new Error(clr);
const closeLock = clr.bytecode;
const open = new Contract(artOpen, [aPk, bPk, TOTAL, closeLock], { provider });
const bchBal = async (w) => (await provider.getUtxos(w.cashaddr)).filter((x) => !x.token).reduce((s, x) => s + BigInt(x.satoshis), 0n);
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 at ${w.cashaddr.slice(0, 16)}… ${t}<${need}`); return { p, t }; }
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: 3000n, 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];
}
// Fund a fresh channel: genesis a mutable NFT + TOTAL sats into the OPEN covenant.
async function fundChannel(label) {
const u0 = await eligible(A); const category = u0.txid;
const need = TOTAL + MF; const uSat = BigInt(u0.satoshis);
const more = (await provider.getUtxos(A.cashaddr)).filter((x) => !x.token && !(x.txid === u0.txid && x.vout === u0.vout)).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
const { p, t } = pickBch(more, need > uSat ? need - uSat : 0n); const total = uSat + t;
const tb = new TransactionBuilder({ provider });
tb.addInput(u0, sigA.unlockP2PKH()); for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({ to: open.tokenAddress, amount: TOTAL, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: '' } } });
tb.addOutput({ to: A.cashaddr, amount: total - TOTAL - MF });
const id = txidOf(await tb.send()); await sleep(9000);
console.log(` [${label}] funded — channel ${id} · category ${category.slice(0, 16)}…`);
return category;
}
const openUtxo = async (category) => (await provider.getUtxos(open.tokenAddress)).find((x) => x.token?.category === category && x.token?.nft && BigInt(x.satoshis) === TOTAL);
const closeUtxo = async (category) => (await provider.getUtxos(close.tokenAddress)).find((x) => x.token?.category === category && x.token?.nft);
const results = [];
console.log(`Alice ${A.cashaddr}\nBob ${B.cashaddr}\nOPEN ${open.tokenAddress}\nCLOSE ${close.tokenAddress}\nchannel ${TOTAL} sats · window ${WINDOW}`);
// =========================================================================
// PATH A — open → off-chain payments → cooperative close
// =========================================================================
step('PATH A — cooperative close after off-chain payments');
const catA = await fundChannel('A');
// Off-chain: state 0 (alice=TOTAL), state 1 (alice pays bob 8000), state 2 (pays 4000 more).
// None of these are broadcast — they live only as mutually-signed messages.
const aliceFinal = TOTAL - 12000n; // alice 38000 / bob 12000 after two payments
console.log(` off-chain updates (not broadcast): s0 alice=${TOTAL} → s1 alice=${TOTAL - 8000n} → s2 alice=${aliceFinal}`);
{
const u = await openUtxo(catA); if (!u) throw new Error('PATH A open utxo missing');
const aBefore = await bchBal(A), bBefore = await bchBal(B);
const tb = new TransactionBuilder({ provider });
tb.addInput(u, open.unlock.coopClose(sigA, sigB)); // both sign → agreed split
tb.addOutput({ to: A.cashaddr, amount: aliceFinal - MF });
tb.addOutput({ to: B.cashaddr, amount: TOTAL - aliceFinal });
const id = txidOf(await tb.send()); await sleep(9000);
const bGain = (await bchBal(B)) - bBefore;
console.log(` coop-close ${id} — Bob +${bGain} (expected ${TOTAL - aliceFinal})`);
results.push(['PATH A cooperative close pays the agreed off-chain split', bGain === TOTAL - aliceFinal]);
}
// =========================================================================
// PATH B — penalty: Alice posts a STALE state, Bob challenges, takes all
// =========================================================================
step('PATH B — penalty-on-stale (BCH is the judge)');
const catB = await fundChannel('B');
// Later off-chain state both signed: s2, alice=38000 (Alice paid Bob 12000).
const aliceLatest = TOTAL - 12000n;
// Alice CHEATS: unilaterally closes with the STALE state 0 (she had everything).
console.log(' Alice closes with STALE state 0 (claims she still holds the whole channel)…');
{
const u = await openUtxo(catB); if (!u) throw new Error('PATH B open utxo missing');
const aSig0 = signState(A.privateKey, 0, TOTAL), bSig0 = signState(B.privateKey, 0, TOTAL);
const commit0 = cat(u32le(0), u64le(TOTAL), new Uint8Array([0x01])); // num0||bal||closer=Alice
const { p, t } = await pick(A, MF);
const tb = new TransactionBuilder({ provider });
tb.addInput(u, open.unlock.startUnilateral(sigA, 1n, aSig0, bSig0, u32le(0), u64le(TOTAL)));
for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({ to: close.tokenAddress, amount: TOTAL, token: { amount: 0n, category: catB, nft: { capability: 'mutable', commitment: hex(commit0) } } });
tb.addOutput({ to: A.cashaddr, amount: t - MF });
const id = txidOf(await tb.send()); await sleep(9000);
console.log(` stale close posted ${id} (state 0, closer=Alice)`);
}
console.log(' Bob challenges with the LATEST signed state (s2)…');
{
const u = await closeUtxo(catB); if (!u) throw new Error('PATH B close utxo missing');
const aSig2 = signState(A.privateKey, 2, aliceLatest), bSig2 = signState(B.privateKey, 2, aliceLatest);
const bBefore = await bchBal(B);
const { p, t } = await pick(B, MF);
const tb = new TransactionBuilder({ provider });
tb.addInput(u, close.unlock.challenge(sigB, aSig2, bSig2, u32le(2), u64le(aliceLatest)));
for (const x of p) tb.addInput(x, sigB.unlockP2PKH());
tb.addOutput({ to: B.cashaddr, amount: TOTAL }); // PENALTY — whole channel to Bob
tb.addOutput({ to: B.cashaddr, amount: t - MF }); // Bob's fee change
const id = txidOf(await tb.send()); await sleep(9000);
const bGain = (await bchBal(B)) - bBefore;
console.log(` penalty ${id} — Bob net +${bGain} (≈ channel ${TOTAL} minus his own fee)`);
results.push(['PATH B stale-state close is penalised — honest party takes the whole channel', bGain >= TOTAL - MF]);
}
// =========================================================================
// PATH C — honest unilateral close → finalize after the CSV window
// =========================================================================
step('PATH C — honest unilateral close → finalize after CSV window (best-effort)');
try {
const catC = await fundChannel('C');
const aliceC = TOTAL - 5000n; // latest state: alice 45000 / bob 5000
{
const u = await openUtxo(catC); if (!u) throw new Error('PATH C open utxo missing');
const aS = signState(A.privateKey, 3, aliceC), bS = signState(B.privateKey, 3, aliceC);
const commit = cat(u32le(3), u64le(aliceC), new Uint8Array([0x01]));
const { p, t } = await pick(A, MF);
const tb = new TransactionBuilder({ provider });
tb.addInput(u, open.unlock.startUnilateral(sigA, 1n, aS, bS, u32le(3), u64le(aliceC)));
for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({ to: close.tokenAddress, amount: TOTAL, token: { amount: 0n, category: catC, nft: { capability: 'mutable', commitment: hex(commit) } } });
tb.addOutput({ to: A.cashaddr, amount: t - MF });
console.log(` honest close posted ${txidOf(await tb.send())} (state 3) — waiting ${WINDOW} blocks for the window…`); await sleep(9000);
}
const startH = await provider.getBlockHeight(); let h = startH, tries = 0;
while (h < startH + WINDOW && tries < 24) { await sleep(15000); h = await provider.getBlockHeight(); tries++; }
if (h < startH + WINDOW) { console.log(' window not reached in time — finalize SKIPPED'); results.push(['PATH C finalize after window', null]); }
else {
const u = await closeUtxo(catC); if (!u) throw new Error('PATH C close utxo missing');
const aBefore = await bchBal(A), bBefore = await bchBal(B);
const { p, t } = await pick(B, MF);
const tb = new TransactionBuilder({ provider });
tb.addInput(u, close.unlock.finalize(), { sequence: WINDOW });
for (const x of p) tb.addInput(x, sigB.unlockP2PKH());
tb.addOutput({ to: A.cashaddr, amount: aliceC });
tb.addOutput({ to: B.cashaddr, amount: TOTAL - aliceC });
tb.addOutput({ to: B.cashaddr, amount: t - MF });
const id = txidOf(await tb.send()); await sleep(9000);
const aGain = (await bchBal(A)) - aBefore;
console.log(` finalize ${id} — Alice +${aGain} (expected ${aliceC})`);
results.push(['PATH C finalize after window pays the committed split', aGain === aliceC]);
}
} catch (e) { console.log(` PATH C error (non-fatal): ${head1(e)}`); results.push(['PATH C finalize after window', null]); }
// =========================================================================
step('RESULT');
let ok = true;
for (const [label, pass] of results) { console.log(` ${pass === null ? 'SKIP' : pass ? 'PASS' : 'FAIL'} ${label}`); if (pass === false) ok = false; }
divider();
console.log(ok
? 'CHANNEL VALIDATED on chipnet — native-BCH payment channel: 2-of-2/covenant funding, off-chain commitments, cooperative close, and penalty-on-stale. BCH is judge + memory. The LN model, on-chain, no middlemen.'
: 'SOME CHECKS FAILED.');
if (!ok) process.exitCode = 1;
}
main().catch((e) => { console.error(e); process.exitCode = 1; });