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>
145 lines
9.7 KiB
JavaScript
145 lines
9.7 KiB
JavaScript
/**
|
|
* t6-engine.mjs — the T6 channel engine (BCH board lifecycle), reusable + self-custody-ready.
|
|
*
|
|
* Encapsulates the validated `BchChannelBoard` / `BchChannelBoardHTLC` lifecycle as one class:
|
|
* open() — genesis the board NFT at state 0 (the channel's opening split).
|
|
* pay() — a both-signed, strictly-monotonic balance update (a payment, on-chain on BCH).
|
|
* settleHtlc() — a both-signed update that ALSO reveals an HTLC preimage (routing / swaps).
|
|
* readState() — read the current on-chain state (no counterparty, no live connection).
|
|
*
|
|
* Self-custody-ready: signers are INJECTED. `signA` / `signB` are `cashscript` SignatureTemplate-
|
|
* like objects; in a single-process demo both are local, but the API is shaped so each can later
|
|
* be a remote signer (the counterparty signs in its own process and returns its signature) — the
|
|
* Phase-2 transport just carries those signature contributions. The board is the canonical state,
|
|
* so the engine never holds private channel state that can be lost (restore from seed + readState).
|
|
*
|
|
* The BTC 2-of-2 side (funding / settlement) is Phase 3 — this is the BCH "board" half (the part
|
|
* that replaces the LN node). One secp256k1 key works on both chains.
|
|
*/
|
|
import { Contract, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
|
import { secp256k1, sha256 as laSha256 } from '@bitauth/libauth';
|
|
|
|
const hexToBin = (h) => { const o = new Uint8Array(h.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return o; };
|
|
|
|
export const DUST = 1000n, MIN_FEE = 2000n;
|
|
|
|
// ---- commitment encode/decode: stateNumber(4 LE) || balanceA(8 LE) || balanceB(8 LE) ----
|
|
export function u32le(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(n), true); return b; }
|
|
export 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; }
|
|
export const encodeCommit = (state, balA, balB) => cat(u32le(state), u64le(balA), u64le(balB));
|
|
|
|
const toBin = (x) => (typeof x === 'string' ? hexToBin(x) : x);
|
|
// The message both parties independently sign (checkDataSig) to authorize a unilateral-board update:
|
|
// channelId(32) || stateNumber(4 LE) || balanceA(8 LE) || balanceB(8 LE).
|
|
// channelId is a fixed 32-byte value both sides control (the contract's constructor arg — no VM
|
|
// tokenCategory-order ambiguity), so the off-chain message matches the covenant's exactly.
|
|
export function stateMessage(channelId, state, balA, balB) {
|
|
return cat(toBin(channelId), u32le(state), u64le(balA), u64le(balB));
|
|
}
|
|
/** A party signs the state message with ONLY its own key → a 64-byte datasig. No tx, no sighash. */
|
|
export function signState(privateKey, channelId, state, balA, balB) {
|
|
const s = secp256k1.signMessageHashSchnorr(privateKey, laSha256.hash(stateMessage(channelId, state, balA, balB)));
|
|
if (typeof s === 'string') throw new Error(s);
|
|
return s;
|
|
}
|
|
export function decodeCommit(hex) { const c = Buffer.from(hex, 'hex'); return { state: c.readUInt32LE(0), balA: c.readBigUInt64LE(4), balB: c.readBigUInt64LE(12) }; }
|
|
const hex = (u) => Buffer.from(u).toString('hex');
|
|
function pickBch(utxos, need) { const p = []; let t = 0n; for (const x of utxos) { p.push(x); t += BigInt(x.satoshis); if (t >= need) break; } return { picked: p, total: t }; }
|
|
|
|
/**
|
|
* A T6 channel's BCH board. `alicePk`/`bobPk` are the two parties' compressed pubkeys (public).
|
|
* `feeSource` abstracts the party that pays the tiny BCH fee per update: { cashaddr, sign } where
|
|
* `sign` is that party's P2PKH SignatureTemplate (its OWN key only).
|
|
*/
|
|
export class T6Board {
|
|
constructor({ artifact, provider, alicePk, bobPk, channelId }) {
|
|
this.provider = provider;
|
|
const args = channelId !== undefined ? [alicePk, bobPk, toBin(channelId)] : [alicePk, bobPk];
|
|
this.contract = new Contract(artifact, args, { provider });
|
|
}
|
|
|
|
get tokenAddress() { return this.contract.tokenAddress; }
|
|
|
|
async _bchUtxos(cashaddr, need) {
|
|
const u = (await this.provider.getUtxos(cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
|
|
const { picked, total } = pickBch(u, need);
|
|
if (total < need) throw new Error(`low BCH at ${cashaddr.slice(0, 16)}…: ${total}<${need}`);
|
|
return { picked, total };
|
|
}
|
|
|
|
/** Read the current on-chain state for a channel `category`, or null if not found. */
|
|
async readState(category) {
|
|
const u = (await this.provider.getUtxos(this.tokenAddress)).find((x) => x.token?.category === category && x.token?.nft);
|
|
if (!u) return null;
|
|
return { ...decodeCommit(u.token.nft.commitment), utxo: u };
|
|
}
|
|
|
|
/**
|
|
* Open the channel: genesis the board NFT at state 0. The opener funds it (and pays the fee).
|
|
* `genesisInput` must be a vout==0 UTXO (CashToken genesis rule); `category` defaults to its txid.
|
|
*/
|
|
async open({ genesisInput, balA, balB, feeSource }) {
|
|
const category = genesisInput.txid;
|
|
const { picked, total } = await this._bchUtxos(feeSource.cashaddr, DUST + MIN_FEE);
|
|
const tb = new TransactionBuilder({ provider: this.provider });
|
|
tb.addInput(genesisInput, feeSource.sign.unlockP2PKH());
|
|
for (const x of picked) if (!(x.txid === genesisInput.txid && x.vout === genesisInput.vout)) tb.addInput(x, feeSource.sign.unlockP2PKH());
|
|
tb.addOutput({ to: this.tokenAddress, amount: DUST, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: hex(encodeCommit(0, balA, balB)) } } });
|
|
tb.addOutput({ to: feeSource.cashaddr, amount: BigInt(genesisInput.satoshis) + total - DUST - MIN_FEE });
|
|
const txid = (await tb.send())?.txid ?? (await tb.send());
|
|
return { category, state: 0, balA, balB, txid: typeof txid === 'string' ? txid : txid?.txid };
|
|
}
|
|
|
|
/** Both-signed, strictly-monotonic balance update — a payment. signA/signB are each party's own signer. */
|
|
async pay({ category, newState, balA, balB, signA, signB, feeSource }) {
|
|
const board = await this.readState(category);
|
|
if (!board) throw new Error('channel not found');
|
|
if (newState <= board.state) throw new Error(`non-monotonic: ${newState} <= ${board.state}`);
|
|
const { picked, total } = await this._bchUtxos(feeSource.cashaddr, MIN_FEE);
|
|
const tb = new TransactionBuilder({ provider: this.provider });
|
|
tb.addInput(board.utxo, this.contract.unlock.update(signA, signB, u32le(newState), u64le(balA), u64le(balB)));
|
|
for (const x of picked) tb.addInput(x, feeSource.sign.unlockP2PKH());
|
|
tb.addOutput({ to: this.tokenAddress, amount: DUST, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: hex(encodeCommit(newState, balA, balB)) } } });
|
|
tb.addOutput({ to: feeSource.cashaddr, amount: total + BigInt(board.utxo.satoshis) - DUST - MIN_FEE });
|
|
const r = await tb.send();
|
|
return { category, state: newState, balA, balB, txid: typeof r === 'string' ? r : r?.txid };
|
|
}
|
|
|
|
/**
|
|
* SELF-CUSTODIAL update on a BchChannelBoardUni board. Both parties independently `signState()`
|
|
* the new state MESSAGE with their own key (no transaction, no sighash — see `signState` below),
|
|
* exchange the two 64-byte `datasig`s over a transport, and EITHER party calls this to assemble +
|
|
* broadcast. The contract input carries the raw data-signatures (no SignatureTemplate → cashscript
|
|
* never re-signs the contract input), so there is no cross-process transaction co-signing at all.
|
|
* Only the broadcaster (`feeSource`) signs its own fee inputs. Neither party holds the other's key.
|
|
*/
|
|
async payUni({ category, newState, balA, balB, aliceSig, bobSig, feeSource }) {
|
|
const board = await this.readState(category);
|
|
if (!board) throw new Error('channel not found');
|
|
if (newState <= board.state) throw new Error(`non-monotonic: ${newState} <= ${board.state}`);
|
|
const { picked, total } = await this._bchUtxos(feeSource.cashaddr, MIN_FEE);
|
|
const tb = new TransactionBuilder({ provider: this.provider });
|
|
tb.addInput(board.utxo, this.contract.unlock.update(aliceSig, bobSig, u32le(newState), u64le(balA), u64le(balB)));
|
|
for (const x of picked) tb.addInput(x, feeSource.sign.unlockP2PKH());
|
|
tb.addOutput({ to: this.tokenAddress, amount: DUST, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: hex(encodeCommit(newState, balA, balB)) } } });
|
|
tb.addOutput({ to: feeSource.cashaddr, amount: total + BigInt(board.utxo.satoshis) - DUST - MIN_FEE });
|
|
const r = await tb.send();
|
|
return { category, state: newState, balA, balB, txid: typeof r === 'string' ? r : r?.txid };
|
|
}
|
|
|
|
/** Both-signed update that also reveals an HTLC preimage (routing / swap settlement). */
|
|
async settleHtlc({ category, newState, balA, balB, preimage, paymentHash, signA, signB, feeSource }) {
|
|
const board = await this.readState(category);
|
|
if (!board) throw new Error('channel not found');
|
|
if (newState <= board.state) throw new Error(`non-monotonic: ${newState} <= ${board.state}`);
|
|
const { picked, total } = await this._bchUtxos(feeSource.cashaddr, MIN_FEE);
|
|
const tb = new TransactionBuilder({ provider: this.provider });
|
|
tb.addInput(board.utxo, this.contract.unlock.settleHtlc(signA, signB, preimage, Buffer.from(paymentHash), u32le(newState), u64le(balA), u64le(balB)));
|
|
for (const x of picked) tb.addInput(x, feeSource.sign.unlockP2PKH());
|
|
tb.addOutput({ to: this.tokenAddress, amount: DUST, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: hex(encodeCommit(newState, balA, balB)) } } });
|
|
tb.addOutput({ to: feeSource.cashaddr, amount: total + BigInt(board.utxo.satoshis) - DUST - MIN_FEE });
|
|
const r = await tb.send();
|
|
return { category, state: newState, balA, balB, txid: typeof r === 'string' ? r : r?.txid };
|
|
}
|
|
}
|