1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
|
// Native-JS board: move onchain funds into the Ark.
//
// Mirrors bark's lib/src/board.rs BoardBuilder: the funding output pays a
// taproot of musig(user, server) with a server-after-expiry sweep leaf (the
// same shape as the checkpoint policy), the exit tx is cosigned via MuSig2
// through RequestBoardCosign, and the resulting Vtxo<Full> has a single
// Cosigned genesis transition anchored at the funding outpoint.
import { hex, bech32m } from '@scure/base';
import { schnorr } from '@noble/curves/secp256k1';
import * as musig2 from '@scure/btc-signer/musig2';
import { grpcCall, pbWriter, pbFields, concatBytes } from './proto.js';
import { checkpointPolicyTaproot, pubkeyPolicyTaproot, taprootSighash, P2A_SCRIPT } from './send.js';
const u16le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff);
const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff);
const u64le = (n) => {
const b = new Uint8Array(8);
new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
return b;
};
// Fee fields are absent when zero (proto3) — e.g. Second's signet server
// boards for free — so every term defaults to 0.
// pver >= 4 rounds the ppm component UP to the satoshi (server-side calculate()).
export const boardFee = (amountSat, fees = {}) =>
Math.max(fees.minFeeSat || 0, (fees.baseFeeSat || 0) + Math.ceil((amountSat * (fees.ppm || 0)) / 1_000_000));
export const p2trAddress = (programXOnly, hrp = 'bcrt') =>
bech32m.encode(hrp, [1, ...bech32m.toWords(programXOnly)], 1023);
// Build the board: funding output spec + exit tx + sighash.
// `fundingOutpointRaw` (36B) is set after the funding tx is known.
export function buildBoard({ userPub, serverPub, amountSat, feeSat, expiryHeight, exitDelta, fundingOutpointRaw }) {
const fundingTaproot = checkpointPolicyTaproot(userPub, serverPub, expiryHeight);
const exitTaproot = pubkeyPolicyTaproot(userPub, serverPub, exitDelta);
const exitTx = {
version: 3, locktime: 0,
inputs: [{ prevout: fundingOutpointRaw, sequence: 0 }],
outputs: [
{ valueSat: amountSat - feeSat, scriptPubKey: exitTaproot.scriptPubKey },
{ valueSat: feeSat, scriptPubKey: P2A_SCRIPT }, // board fee rides the anchor
],
};
const fundingPrevout = { valueSat: amountSat, scriptPubKey: fundingTaproot.scriptPubKey };
const sighash = taprootSighash(exitTx, [fundingPrevout]);
return { fundingTaproot, exitTaproot, exitTx, sighash };
}
export async function requestBoardCosign(ark, { amountSat, fundingOutpointRaw, expiryHeight, userPub, pubNonce, fundingTxBytes }) {
const w = pbWriter();
w.varintField(1, amountSat);
w.bytesField(2, fundingOutpointRaw);
w.varintField(3, expiryHeight);
w.bytesField(4, userPub);
w.bytesField(5, pubNonce);
// Mandatory on mainnet since 0.6.0 (require_board_funding_tx): the server
// validates the board outpoint against the funding tx and refuses inputs
// that are VTXOs. The txid check ignores witnesses, so the broadcast
// (signed) serialization is fine here.
if (fundingTxBytes) w.bytesField(6, fundingTxBytes);
const resp = await grpcCall(ark, 'bark_server.ArkService/RequestBoardCosign', w.finish());
const out = {};
for (const { field, value } of pbFields(resp)) {
if (field === 1) out.pubNonce = value;
if (field === 2) out.partialSig = value;
}
return out;
}
// MuSig2: combine our partial with the server's over the exit tx sighash.
export function combineBoardSignature({ board, serverCosign, userNonces, vtxoKeys, serverPub }) {
const aggNonce = musig2.nonceAggregate([userNonces.public, serverCosign.pubNonce]);
const session = new musig2.Session(
aggNonce, board.fundingTaproot.sortedKeys, board.sighash, [board.fundingTaproot.tapTweak], [true],
);
const serverIdx = board.fundingTaproot.sortedKeys.findIndex(
(k) => hex.encode(k) === hex.encode(serverPub));
const noncesInKeyOrder = serverIdx === 0
? [serverCosign.pubNonce, userNonces.public] : [userNonces.public, serverCosign.pubNonce];
if (!session.partialSigVerify(serverCosign.partialSig, noncesInKeyOrder, serverIdx)) {
throw new Error('server board partial signature is invalid');
}
const userPartial = session.sign(userNonces.secret, vtxoKeys.privkey);
const finalSig = session.partialSigAgg([userPartial, serverCosign.partialSig]);
if (!schnorr.verify(finalSig, board.sighash, board.fundingTaproot.outputXOnly)) {
throw new Error('combined board signature does not verify');
}
return finalSig;
}
// Vtxo<Full> bytes for the board: one Cosigned genesis transition.
export function encodeBoardVtxo({ userPub, serverPub, amountSat, feeSat, expiryHeight, exitDelta,
fundingOutpointRaw, exitTxidInternal, finalSig }) {
const cosignedTransition = concatBytes(
Uint8Array.of(0x01), // GenesisTransition::Cosigned
Uint8Array.of(0x02), userPub, serverPub, // LengthPrefixedVector [user, server]
finalSig,
);
return concatBytes(
u16le(2), // encoding version
u64le(amountSat - feeSat),
u32le(expiryHeight),
serverPub,
u16le(exitDelta),
fundingOutpointRaw, // anchor point = funding utxo
Uint8Array.of(0x01), // one genesis item
cosignedTransition,
Uint8Array.of(0x01, 0x00), // nb_outputs=1, output_idx=0
u64le(feeSat), // fee_amount
Uint8Array.of(0x00), userPub, // policy: Pubkey(user)
exitTxidInternal, u32le(0), // point = exit_txid:0
);
}
export async function registerBoardVtxo(ark, vtxoBytes) {
const w = pbWriter();
w.bytesField(1, vtxoBytes);
await grpcCall(ark, 'bark_server.ArkService/RegisterBoardVtxo', w.finish());
}
|