deviant/scripts/chipnet-bonded-signer-test.mjs
walerikus 850afd651d feat(t6): validate BTC↔BCH end-to-end + bonded M-of-N + wallet UX (ADR-0046)
"Bitcoin Cash as a better Lightning" (T6) and the BTC-IOU trust floor, validated
end-to-end on BCH chipnet + BTC signet with real coins.

- LN→T6 routing: a routed payment received into a T6 channel; one preimage settles
  both chains (BCH board reveal -> BTC HTLC claim). Scripts: chipnet-ln-to-t6-routing,
  btc-signet-ln-leg, btc-claim-htlc.
- T6c unilateral exit complete: honest CSV-delayed self-close (ELSE branch) added
  alongside the penalty branch (btc-signet-honest-close).
- T4 real BTC↔BCH atomic swap: one hashlock over a signet P2WSH HTLC + chipnet
  AtomicSwapHTLC; claim + refund (btc-bch-htlc-swap). Fixes btc-htlc.mjs bigint bug.
- Model-3 bonded M-of-N redemption: BondedSignerSlash.cash (equivocation -> slash) +
  2-of-3 signet release; BTC enforces the threshold, BCH enforces the punishment
  (chipnet-bonded-signer-test, btc-signet-mofn-release).
- T6-aware wallet UX: one-tap send/receive over the validated stack, every tap a real
  on-chain BchChannelBoard update (t6-wallet-server + design/t6-wallet); fresh-provider
  + retry hardening for flaky-chipnet broadcasts.
- Docs: ADR-0046, spec §16, Observability-and-privacy companion (the auditability
  property + the three-baseline privacy framing), complete-record/model-explainer/
  tracker updates, snapshots 0177–0179.

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

165 lines
9.9 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-bonded-signer-test.mjs — Step 5 (core): validate the SLASHABLE BOND that makes a
* BTC peg's M-of-N signer set trust-MINIMIZED instead of trusted.
*
* A trust-minimized BTC peg releases the locked BTC with an M-of-N multisig (BTC side, signet).
* BTC can't punish a signer who cheats; BCH can. Each signer bonds here. EQUIVOCATION — two
* valid signatures by the same signer over the SAME redeemId with CONFLICTING payloads — is a
* cryptographic fraud proof anyone can submit to SLASH the bond. Honest signers reclaim their
* bond after an unbond window.
*
* Checks (a guard only counts if it passes/fails for the COVENANT's reason):
* S1 slash with conflicting double-sign (same id, different payload) → SUCCEEDS (bond seized)
* S2 slash with the same message twice (id+payload identical) → REJECTED (payload equal)
* S3 slash with two different redeemIds → REJECTED (ids differ)
* W1 withdraw after the unbond height → SUCCEEDS (signer reclaims)
* W2 withdraw before the unbond height → REJECTED (CLTV guard)
*
* Run: node --import tsx scripts/chipnet-bonded-signer-test.mjs --yes
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
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 ART = join(ROOT, 'contracts/dist/BondedSignerSlash.json');
const KEYS = { c: join(ROOT, '.chipnet-keys-c.json'), e: join(ROOT, '.chipnet-keys-e.json') };
const BOND = 12000n, MF = 2000n;
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, 110);
const oracleSign = (priv, msg) => { const s = secp256k1.signMessageHashSchnorr(priv, laSha256.hash(msg)); if (typeof s === 'string') throw new Error(s); return s; };
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 u64le = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; };
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 art = JSON.parse(readFileSync(ART, 'utf8'));
for (const [k, f] of Object.entries(KEYS)) if (!existsSync(f)) throw new Error(`missing key ${k} (${f})`);
const C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c, 'utf8')).wif); // challenger / funder / fees
const E = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.e, 'utf8')).wif); // the bonded signer
const provider = new ElectrumNetworkProvider('chipnet');
const sigC = new SignatureTemplate(C.privateKey);
const sigE = new SignatureTemplate(E.privateKey);
const signerPk = E.publicKeyCompressed;
const bond = (unbondHeight) => new Contract(art, [signerPk, BigInt(unbondHeight)], { provider });
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 fundBond(c) { // fund via TransactionBuilder from the LIVE utxo view (reliable)
const { p, t } = await pick(C, BOND + MF);
const tb = new TransactionBuilder({ provider });
for (const x of p) tb.addInput(x, sigC.unlockP2PKH());
tb.addOutput({ to: c.address, amount: BOND });
tb.addOutput({ to: C.cashaddr, amount: t - BOND - MF });
const id = txidOf(await tb.send()); await sleep(6000);
for (let i = 0; i < 12; i++) { const u = (await provider.getUtxos(c.address)).find((x) => !x.token && BigInt(x.satoshis) === BOND); if (u) return u; await sleep(6000); }
throw new Error('bond utxo not visible');
}
const h = await provider.getBlockHeight();
console.log(`signer(E) ${E.cashaddr}\nchallenger(C) ${C.cashaddr}\nchipnet height ${h}`);
const results = [];
// ---- S1: slash with a genuine equivocation (same redeemId, different payload) ----
step('S1 — slash on equivocation (same redeemId, CONFLICTING payloads)');
{
const c = bond(h + 1000); const u = await fundBond(c);
const redeemId = new Uint8Array(randomBytes(32));
const msgA = cat(redeemId, u64le(50000), Uint8Array.from([0x01])); // outcome A
const msgB = cat(redeemId, u64le(50000), Uint8Array.from([0x02])); // outcome B (conflict)
const sigA = oracleSign(E.privateKey, msgA), sigB = oracleSign(E.privateKey, msgB);
let ok = false;
try {
const tb = new TransactionBuilder({ provider });
tb.addInput(u, c.unlock.slash(sigA, msgA, sigB, msgB));
tb.addOutput({ to: C.cashaddr, amount: BOND - MF }); // challenger seizes the bond
const id = txidOf(await tb.send()); ok = true;
console.log(` slashed: ${id} — challenger seized ${BOND - MF} sat`);
} catch (e) { console.log(` UNEXPECTED reject: ${head1(e)}`); }
results.push(['S1 equivocation fraud proof slashes the bond', ok]); await sleep(6000);
}
// ---- S2: same message twice → not a conflict → rejected ----
step('S2 — slash attempt with the SAME message twice (no conflict) → must reject');
{
const c = bond(h + 1000); const u = await fundBond(c);
const redeemId = new Uint8Array(randomBytes(32));
const msg = cat(redeemId, u64le(50000), Uint8Array.from([0x01]));
const s = oracleSign(E.privateKey, msg);
let rejected = false;
try {
const tb = new TransactionBuilder({ provider });
tb.addInput(u, c.unlock.slash(s, msg, s, msg));
tb.addOutput({ to: C.cashaddr, amount: BOND - MF });
await tb.send(); console.log(' !! slash SUCCEEDED with identical messages — BUG');
} catch (e) { rejected = true; console.log(` rejected as expected: ${head1(e)}`); }
results.push(['S2 identical (non-conflicting) messages cannot slash', rejected]); await sleep(6000);
}
// ---- S3: different redeemIds → not the same slot → rejected ----
step('S3 — slash attempt across DIFFERENT redeemIds (not the same slot) → must reject');
{
const c = bond(h + 1000); const u = await fundBond(c);
const msgA = cat(new Uint8Array(randomBytes(32)), u64le(50000), Uint8Array.from([0x01]));
const msgB = cat(new Uint8Array(randomBytes(32)), u64le(50000), Uint8Array.from([0x02]));
const sigA = oracleSign(E.privateKey, msgA), sigB = oracleSign(E.privateKey, msgB);
let rejected = false;
try {
const tb = new TransactionBuilder({ provider });
tb.addInput(u, c.unlock.slash(sigA, msgA, sigB, msgB));
tb.addOutput({ to: C.cashaddr, amount: BOND - MF });
await tb.send(); console.log(' !! slash SUCCEEDED across different redeemIds — BUG');
} catch (e) { rejected = true; console.log(` rejected as expected: ${head1(e)}`); }
results.push(['S3 two different redeemIds cannot slash (must be the same slot)', rejected]); await sleep(6000);
}
// ---- W1: honest withdraw after the unbond height (already elapsed) ----
step('W1 — honest withdraw AFTER the unbond height → signer reclaims the bond');
{
const c = bond(h); const u = await fundBond(c); // unbondHeight = now → already mature
let ok = false;
try {
const tb = new TransactionBuilder({ provider });
tb.addInput(u, c.unlock.withdraw(sigE), { sequence: 0xfffffffe });
tb.addOutput({ to: E.cashaddr, amount: BOND - MF });
tb.setLocktime(h);
const id = txidOf(await tb.send()); ok = true;
console.log(` withdrawn: ${id} — signer reclaimed ${BOND - MF} sat`);
} catch (e) { console.log(` UNEXPECTED reject: ${head1(e)}`); }
results.push(['W1 signer reclaims the bond after the unbond window', ok]); await sleep(6000);
}
// ---- W2: withdraw before the unbond height → rejected ----
step('W2 — withdraw BEFORE the unbond height → must reject (CLTV guard)');
{
const c = bond(h + 50); const u = await fundBond(c); // unbondHeight in the future
let rejected = false;
try {
const tb = new TransactionBuilder({ provider });
tb.addInput(u, c.unlock.withdraw(sigE), { sequence: 0xfffffffe });
tb.addOutput({ to: E.cashaddr, amount: BOND - MF });
tb.setLocktime(h); // locktime now < unbondHeight → reject
await tb.send(); console.log(' !! withdraw SUCCEEDED early — BUG');
} catch (e) { rejected = true; console.log(` rejected as expected: ${head1(e)}`); }
results.push(['W2 withdraw before the unbond height is rejected', rejected]);
}
// ---- result ----
step('RESULT — bonded peg-signer (the trust-minimization core of Model-3 redemption)');
let ok = true; for (const [l, p] of results) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
divider();
console.log(ok
? 'BONDED SIGNER VALIDATED on chipnet — equivocation is slashable, honest bonds are reclaimable.\nThis is what makes a BTC peg\'s M-of-N trust-MINIMIZED: BCH enforces the punishment BTC cannot,\nso a signer who double-signs a redeem loses their bond. Pair with the signet M-of-N release\n(the BTC side) for the full bonded redemption leg of Model 3.'
: 'SOME CHECKS FAILED.');
if (!ok) process.exitCode = 1;
}
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });