deviant/scripts/lib/t6-wallet.mjs
walerikus b5211f0fbd feat(t6-mvp): two-party self-custodial wallet protocol (Phase 4a)
T6Wallet (scripts/lib/t6-wallet.mjs) — one party's self-custodial wallet holding only its
own key. Two wallets pay each other over a transport: payer signs the next state, peer signs
it remotely, board is updated (payUni). No shared keys, no tx co-signing; the transport carries
only a proposal + a 64-byte state signature. Validated on chipnet (t6-wallet-p2p-demo.mjs):
open + 3 payments both directions, monotonic 1->3, final Alice 40000 / Bob 60000.

The MVP functional core is now complete (engine + self-custodial board + BTC 2-of-2 PSBT +
this protocol). Remaining: Phase 4b front-end UI (relay + client-side keys). Snapshot 0187.

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

57 lines
3.4 KiB
JavaScript

/**
* t6-wallet.mjs — a self-custodial T6 wallet (one party). The protocol layer the UI sits on.
*
* Each T6Wallet instance represents ONE party and holds ONLY its own key. Two wallets transact by
* exchanging small messages over a `transport`: a payment PROPOSAL and a 64-byte state SIGNATURE for
* the board (and, for settlement, a PSBT — Phase 3). Neither wallet ever holds the other's key. The
* board (BchChannelBoardUni) is the shared canonical state; each party independently signs the state
* MESSAGE (checkDataSig), so there is no transaction co-signing.
*
* The `transport` here is an interface — `request(msg) → Promise<reply>` and an inbound handler — so
* the same wallet works over an in-process bus (tests) or a WS relay (the UI). The demo passes a
* peer wallet directly, which is exactly what the relay would carry.
*/
import { T6Board, signState } from './t6-engine.mjs';
export class T6Wallet {
/**
* @param role 'alice' | 'bob' — which board slot this party occupies.
* @param privateKey this party's private key (its OWN — never shared).
* @param channelId the 32-byte channel id (== the board category / genesis txid).
* @param board a T6Board bound to the BchChannelBoardUni artifact.
* @param feeSource { cashaddr, sign } — this party's fee source when IT broadcasts an update.
*/
constructor({ role, privateKey, channelId, board, feeSource }) {
if (role !== 'alice' && role !== 'bob') throw new Error("role must be 'alice' or 'bob'");
this.role = role; this.priv = privateKey; this.channelId = channelId; this.board = board; this.feeSource = feeSource;
this.approve = () => true; // policy hook: accept/reject an incoming proposed state (default: accept)
}
/** Current on-chain channel state (read from the board — no counterparty needed). */
async state() { return this.board.readState(this.channelId); }
/** Sign a proposed new state with MY key (what a peer requests over the transport). Returns a 64-byte datasig. */
signProposal({ newState, balA, balB }) {
if (!this.approve({ newState, balA, balB })) throw new Error('proposal rejected by policy');
return signState(this.priv, this.channelId, newState, balA, balB);
}
/**
* PAY the peer `amount` (my balance → peer). I compute the next state, sign it, ask the peer to
* sign it (via `requestPeerSig`, which the transport fulfils in the peer's wallet), then assemble +
* broadcast the board update. `requestPeerSig({newState,balA,balB}) → Promise<Uint8Array datasig>`.
*/
async pay(amount, requestPeerSig) {
const cur = await this.state();
if (!cur) throw new Error('channel not open');
const newState = cur.state + 1;
let balA = cur.balA, balB = cur.balB;
if (this.role === 'alice') { if (amount > balA) throw new Error('insufficient balance'); balA -= amount; balB += amount; }
else { if (amount > balB) throw new Error('insufficient balance'); balB -= amount; balA += amount; }
const mySig = signState(this.priv, this.channelId, newState, balA, balB); // I sign with my key
const peerSig = await requestPeerSig({ newState, balA, balB }); // transport → peer signs with its key
const aliceSig = this.role === 'alice' ? mySig : peerSig;
const bobSig = this.role === 'alice' ? peerSig : mySig;
return this.board.payUni({ category: this.channelId, newState, balA, balB, aliceSig, bobSig, feeSource: this.feeSource });
}
}