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>
76 lines
5.1 KiB
JavaScript
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; });
|