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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
|
// Unilateral exit — put a vtxo on-chain WITHOUT the server's cooperation,
// then claim it through the CSV exit leaf. This is the trustless backstop
// behind "you can always exit on-chain".
//
// The vtxo's genesis chain reconstructs to fully-signed transactions
// (signatures ride in the encoded vtxo). They are zero-fee v3 txs with P2A
// anchors, so each hop needs a CPFP child spending its anchor plus one of the
// wallet's on-chain coins — and TRUC (v3) topology allows only one
// unconfirmed parent, so a deep chain exits one confirmed hop at a time.
// [parent, child] pairs are submitted via esplora's POST /txs/package
// (bitcoind submitpackage). After the vtxo tx confirms and exitDelta blocks
// pass, the claim tx spends it via the delayed_sign leaf with only our key.
import { hex } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import { ripemd160 } from '@noble/hashes/ripemd160';
import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
import { concatBytes } from './proto.js';
import { pubkeyPolicyTaproot, txid } from './send.js';
import { vtxoTransactions, harkLeafTaproot, harkUnlockWitnessShape, tapLeafHash, taprootScriptSighash } from './refresh.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;
};
const varint = (n) => {
if (n < 0xfd) return Uint8Array.of(n);
if (n <= 0xffff) return concatBytes(Uint8Array.of(0xfd), u16le(n));
return concatBytes(Uint8Array.of(0xfe), u32le(n));
};
const sha256d = (b) => sha256(sha256(b));
// Segwit serialization: tx is the {version, locktime, inputs, outputs} shape
// the ark modules use; witnesses is one item-array per input ([] = empty).
export function serializeTxW(tx, witnesses) {
const base = [
u32le(tx.version),
varint(tx.inputs.length),
...tx.inputs.flatMap((i) => [i.prevout, Uint8Array.of(0x00), u32le(i.sequence)]),
varint(tx.outputs.length),
...tx.outputs.flatMap((o) => [u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey]),
];
const wit = witnesses.flatMap((items) => [
varint(items.length),
...items.flatMap((w) => [varint(w.length), w]),
]);
const full = concatBytes(u32le(tx.version), Uint8Array.of(0x00, 0x01),
...base.slice(1), ...wit, u32le(tx.locktime));
const stripped = concatBytes(...base, u32le(tx.locktime));
const weight = stripped.length * 3 + full.length;
return { raw: full, vsize: Math.ceil(weight / 4) };
}
// The vtxo's chain as broadcastable transactions, oldest first.
// Throws if any transition lacks its signature (an unsigned chain can't exit).
export const signedExitTxs = (vtxo, serverPub) => exitChain(vtxo, serverPub, false);
// Just the vsize of each hop's transaction, with the same "can this chain
// exit at all" checks — but no elliptic-curve work: the output keys are
// stand-ins of the right length. For fee estimates only; nothing here is
// broadcastable.
export const exitTxVsizes = (vtxo, serverPub) => exitChain(vtxo, serverPub, true).map((t) => t.vsize);
function exitChain(vtxo, serverPub, sizeOnly) {
const items = vtxoTransactions(vtxo, serverPub, sizeOnly);
return items.map((it, i) => {
const t = vtxo.genesis[i].transition;
let witness;
if (t.type === 'cosigned' || t.type === 'arkoor') {
if (!t.signature) throw new Error('vtxo chain is missing a signature: cannot exit');
witness = [hex.decode(t.signature)];
} else if (t.type === 'hashLockedCosigned') {
if (!t.signature || !t.unlock?.preimage) {
throw new Error('vtxo chain is missing its unlock preimage: cannot exit');
}
const preimage = hex.decode(t.unlock.preimage);
if (sizeOnly) {
const shape = harkUnlockWitnessShape(sha256(preimage));
witness = [hex.decode(t.signature), preimage, shape.unlockScript, shape.controlBlock];
} else {
const tp = harkLeafTaproot(hex.decode(t.userPubkey), serverPub, vtxo.expiryHeight, sha256(preimage));
const cb = concatBytes(Uint8Array.of(0xc0 | tp.outputParity), tp.internalXOnly, tp.expiryLeaf);
witness = [hex.decode(t.signature), preimage, tp.unlockScript, cb];
}
} else {
throw new Error('unsupported genesis transition: ' + t.type);
}
const { raw, vsize } = serializeTxW(it.tx, [witness]);
if (sizeOnly) return { vsize };
const idInternal = txid(it.tx);
return {
txid: hex.encode(idInternal.slice().reverse()),
txidInternal: idInternal,
hex: hex.encode(raw),
vsize,
anchorVout: it.tx.outputs.length - 1,
anchorValue: it.tx.outputs[it.tx.outputs.length - 1].valueSat,
};
});
}
// CPFP child: spends [parent P2A anchor (keyless, empty witness), one wallet
// p2wpkh coin] into a single change output. v3 like its parent (TRUC).
// coin: { txid (display hex), vout, value, pubkey (33B), privkey }.
export function buildBumpChild({ parentTxidInternal, anchorVout, anchorValue, coin, changeScript, feeSat }) {
const changeSat = coin.value + anchorValue - feeSat;
if (changeSat < 294) throw new Error('fee coin too small for the exit bump');
const coinPrevout = concatBytes(hex.decode(coin.txid).reverse(), u32le(coin.vout));
const tx = {
version: 3, locktime: 0,
inputs: [
{ prevout: concatBytes(parentTxidInternal, u32le(anchorVout)), sequence: 0xfffffffd },
{ prevout: coinPrevout, sequence: 0xfffffffd },
],
outputs: [{ valueSat: changeSat, scriptPubKey: changeScript }],
};
// BIP143 sighash (ALL) for the p2wpkh input
const hashPrevouts = sha256d(concatBytes(...tx.inputs.map((i) => i.prevout)));
const hashSequence = sha256d(concatBytes(...tx.inputs.map((i) => u32le(i.sequence))));
const hashOutputs = sha256d(concatBytes(...tx.outputs.map((o) =>
concatBytes(u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey))));
const pkh = ripemd160(sha256(coin.pubkey));
const scriptCode = concatBytes(hex.decode('1976a914'), pkh, hex.decode('88ac'));
const preimage = concatBytes(
u32le(tx.version), hashPrevouts, hashSequence,
coinPrevout, scriptCode, u64le(coin.value), u32le(tx.inputs[1].sequence),
hashOutputs, u32le(tx.locktime), u32le(1),
);
const sig = secp256k1.sign(sha256d(preimage), coin.privkey, { lowS: true });
const der = concatBytes(sig.toDERRawBytes ? sig.toDERRawBytes() : sig.toBytes('der'), Uint8Array.of(0x01));
const { raw, vsize } = serializeTxW(tx, [[], [der, coin.pubkey]]);
return { hex: hex.encode(raw), txid: hex.encode(txid(tx).slice().reverse()), vsize, changeSat };
}
// The claim: spend the confirmed vtxo output through the delayed_sign leaf
// (CSV = exitDelta) with only the user key, paying destScript.
export function buildExitClaim({ vtxo, keys, serverPub, destScript, feeRate }) {
const tap = pubkeyPolicyTaproot(keys.pubkey, serverPub, vtxo.exitDelta);
const cb = concatBytes(Uint8Array.of(0xc0 | tap.outputParity), tap.internalXOnly);
const prevouts = [{ valueSat: vtxo.amountSat, scriptPubKey: tap.scriptPubKey }];
const build = (feeSat) => {
const tx = {
version: 2, locktime: 0,
inputs: [{ prevout: vtxo.point.raw, sequence: vtxo.exitDelta }],
outputs: [{ valueSat: vtxo.amountSat - feeSat, scriptPubKey: destScript }],
};
const sighash = taprootScriptSighash(tx, prevouts, tapLeafHash(tap.leafScript));
const sig = schnorr.sign(sighash, keys.privkey);
return { tx, witness: [[sig, tap.leafScript, cb]] };
};
// two-pass: measure with a real-size witness, then set the fee
const probe = build(0);
const vsize = serializeTxW(probe.tx, probe.witness).vsize;
const feeSat = Math.max(200, Math.ceil(vsize * Math.max(1, feeRate)));
if (vtxo.amountSat - feeSat < 294) throw new Error('vtxo too small to claim at this fee rate');
const fin = build(feeSat);
const { raw } = serializeTxW(fin.tx, fin.witness);
return { hex: hex.encode(raw), txid: hex.encode(txid(fin.tx).slice().reverse()), feeSat, amountSat: vtxo.amountSat - feeSat, vsize };
}
// Submit a [parent, child] (or single-tx) package via esplora -> bitcoind
// submitpackage. Treats already-known txs as success so retries are safe.
// Esploras without the endpoint (regtest electrs) fall back to sequential
// /tx posts — which relays only if the node accepts zero-fee v3 parents
// (regtest with -minrelaytxfee=0); otherwise the node's error surfaces.
export async function submitPackage(esploraBase, hexes) {
const r = await fetch(`${esploraBase}/txs/package`, { method: 'POST', body: JSON.stringify(hexes) });
const body = await r.text();
if (r.ok) {
// submitpackage answers 200 even when it REJECTS the txs — the verdict is
// inside package_msg / per-tx errors. Treat anything but success (or
// already-known) as a real failure or the exit waits forever in silence.
let parsed = null;
try { parsed = JSON.parse(body); } catch {}
if (parsed && parsed.package_msg && parsed.package_msg !== 'success') {
const errs = Object.values(parsed['tx-results'] || {}).map((t) => t.error).filter(Boolean).join('; ');
if (!/already/i.test(errs)) {
throw new Error(`package rejected: ${(errs || parsed.package_msg).slice(0, 160)}`);
}
}
return body;
}
if (/already|duplicate/i.test(body)) return body;
if (r.status === 404 || /endpoint does not exist/i.test(body)) {
for (const hx of hexes) {
const br = await fetch(`${esploraBase}/tx`, { method: 'POST', body: hx });
const bb = await br.text();
if (!br.ok && !/already|duplicate/i.test(bb)) {
throw new Error(`broadcast failed: ${bb.slice(0, 160)}`);
}
}
return 'sequential';
}
throw new Error(`package submit failed: ${body.slice(0, 160)}`);
}
|