"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>
165 lines
9.9 KiB
JavaScript
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; });
|