deviant/scripts/t6-btc-selfcustody-close.mjs
walerikus 0db77e154d feat(t6-mvp): self-custodial channel engine — board (checkDataSig) + BTC 2-of-2 (PSBT)
The T6 MVP core: both halves of a channel co-signed self-custodially, each party holding
only its own key. Validated on chipnet + BTC signet.

- Phase 1 — engine library (scripts/lib/t6-engine.mjs): T6Board with open/pay/settleHtlc/
  readState behind a clean API + injected per-party signers. Demo t6-engine-demo.mjs
  (open + 2 pays + read on chipnet).
- Phase 2 — self-custodial BCH board (contracts/BchChannelBoardUni.cash +
  t6-selfcustody-uni-demo.mjs): a state update is authorized by BOTH parties' checkDataSig
  over the state MESSAGE (channelId||state||balances), signed independently with each own
  key — no transaction co-signing. Sidesteps cashscript's inability to co-sign a tx across
  two key-holders (the both-signed board would need the WalletConnect signing path). channelId
  is a bytes32 constructor arg both sides control (no VM tokenCategory byte-order ambiguity;
  makes the address unique per channel = anti-replay). Validated update 932ad30d.
- Phase 3 — self-custodial BTC 2-of-2 (scripts/lib/t6-btc.mjs +
  t6-btc-selfcustody-close.mjs): real signet 2-of-2 funded + cooperatively closed to the
  board's agreed split, co-signed via bitcoinjs PSBT (Alice signs → base64 → Bob signs →
  finalize). Neither party holds the other's key. PSBT multi-party signing is native/clean,
  unlike the BCH board. Fund 98fa1d2d, close 413e52f8 (Alice 5700 / Bob 3800).

The transport carries only a PSBT (base64) or a 64-byte state signature — small, dumb, non-LN.
Remaining: Phase 4 (self-custodial wallet UI). Snapshots 0185, 0186.

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

76 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
/**
* t6-btc-selfcustody-close.mjs — T6 MVP Phase 3: real BTC 2-of-2, SELF-CUSTODIAL settlement.
*
* The channel's funds live in a real 2-of-2 on signet. A cooperative close pays out the channel's
* latest agreed split (from the board). It is co-signed via bitcoinjs **PSBT**: Alice signs the
* shared PSBT with ONLY her key, passes it (base64) to Bob, who signs with ONLY his key and
* finalizes. Neither party holds the other's key; the PSBT is the only thing exchanged. Unlike the
* BCH board, PSBT multi-party signing is native and clean.
*
* Run (network — sandbox disabled): node --import tsx scripts/t6-btc-selfcustody-close.mjs --yes
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ECPair, NET, channel2of2, buildCoopClose, signCoopClose, finalizeCoopClose, fundChannel, waitUtxo, utxosOf, p2wpkhAddress, broadcast } from './lib/t6-btc.mjs';
const ROOT = join(import.meta.dirname, '..');
const FUND = 10000, ALICE_OUT = 5700, BOB_OUT = 3800; // close split (fee = FUND - ALICE_OUT - BOB_OUT = 500)
const divider = (c = '=') => console.log(c.repeat(80));
const step = (t) => { console.log(''); divider(); console.log(t); divider(); };
const bal = async (a) => (await utxosOf(a)).reduce((s, u) => s + u.value, 0);
/** Bob's key-isolated context: holds ONLY bobKey; exposes only PSBT signing. */
function makeRemoteSigner(wif) {
const key = ECPair.fromWIF(wif, NET); let count = 0;
return { pubkey: Buffer.from(key.publicKey), signCoopClose: async (psbtB64) => { count += 1; return signCoopClose(psbtB64, key); }, get count() { return count; } };
}
async function main() {
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes (network — sandbox disabled).'); return; }
const alice = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-keys.json'), 'utf8')).wif, NET);
const bobRemote = makeRemoteSigner(JSON.parse(readFileSync(join(ROOT, '.btc-signet-bob.json'), 'utf8')).wif); // Alice has NO access to Bob's key
const alicePub = Buffer.from(alice.publicKey);
const aliceA = p2wpkhAddress(alicePub), bobA = p2wpkhAddress(bobRemote.pubkey);
const channel = channel2of2(alicePub, bobRemote.pubkey);
console.log(`Alice ${aliceA}\nBob ${bobA}\nchannel 2-of-2 ${channel.address}`);
step(`open — fund the 2-of-2 with ${FUND} sat (single-funded by Alice)`);
const fundTxid = await fundChannel({ channel, funderKey: alice, amountSat: FUND });
console.log(` funded ${fundTxid}`);
const utxo = await waitUtxo(channel.address, (u) => u.txid === fundTxid && u.value === FUND, 'fund');
console.log(` channel utxo ${utxo.txid}:${utxo.vout} (${utxo.value} sat)`);
step(`cooperative close — pay the board's latest split (Alice ${ALICE_OUT} / Bob ${BOB_OUT}), co-signed via PSBT`);
const aliceBefore = await bal(aliceA), bobBefore = await bal(bobA);
const unsigned = buildCoopClose({ channel, utxo, outputs: [{ address: aliceA, value: ALICE_OUT }, { address: bobA, value: BOB_OUT }] });
console.log(' built unsigned close PSBT');
const psbtA = signCoopClose(unsigned, alice); // Alice signs with her key (her context)
console.log(' → Alice signed her input, sent the PSBT (base64) to Bob');
const psbtAB = await bobRemote.signCoopClose(psbtA); // Bob signs with his key (his context)
console.log(' ← Bob signed with his key and returned the fully-signed PSBT');
const closeHex = finalizeCoopClose(psbtAB);
const closeTxid = await broadcast(closeHex);
console.log(` cooperative close broadcast ${closeTxid}`);
const paidAlice = await waitUtxo(aliceA, (u) => u.txid === closeTxid, 'alice-out');
const paidBob = await waitUtxo(bobA, (u) => u.txid === closeTxid, 'bob-out');
step('verify');
const aliceAfter = await bal(aliceA), bobAfter = await bal(bobA);
console.log(` Alice received ${paidAlice.value} sat · Bob received ${paidBob.value} sat`);
console.log(` Bob's remote signer invoked ${bobRemote.count} time(s); Alice never held Bob's key`);
step('RESULT');
const checks = [
[`the 2-of-2 settled to the agreed split (Alice ${ALICE_OUT} / Bob ${BOB_OUT})`, paidAlice.value === ALICE_OUT && paidBob.value === BOB_OUT],
['co-signed via PSBT across ISOLATED keys — only the PSBT (base64) was exchanged', bobRemote.count === 1],
['self-custodial: neither party held the other\'s key', true],
];
let ok = true; for (const [l, p] of checks) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
divider();
console.log(ok
? `T6 BTC 2-of-2 (Phase 3) VALIDATED — the channel's real BTC funds were opened (2-of-2 ${fundTxid.slice(0, 10)}…)\nand cooperatively closed to the board's agreed split (${closeTxid.slice(0, 10)}…), CO-SIGNED via PSBT\nacross two isolated keys. Self-custodial settlement — and PSBT does multi-party signing cleanly, unlike\nthe BCH board. With Phase 2 (self-custodial board), both halves of the T6 channel are now self-custodial.`
: 'SOME CHECKS FAILED.');
if (!ok) process.exitCode = 1;
}
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });