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>
213 lines
14 KiB
JavaScript
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; });
|