@getcofund/protocol 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +414 -2
  3. package/dist/btc/index.d.ts +12 -0
  4. package/dist/btc/index.js +13 -0
  5. package/dist/btc/psbt.d.ts +121 -0
  6. package/dist/btc/psbt.js +377 -0
  7. package/dist/btc/reader.d.ts +42 -0
  8. package/dist/btc/reader.js +85 -0
  9. package/dist/btc/select.d.ts +48 -0
  10. package/dist/btc/select.js +59 -0
  11. package/dist/btc/spend-rules.d.ts +24 -0
  12. package/dist/btc/spend-rules.js +43 -0
  13. package/dist/btc/spend.d.ts +136 -0
  14. package/dist/btc/spend.js +180 -0
  15. package/dist/btc/types.d.ts +65 -0
  16. package/dist/btc/types.js +62 -0
  17. package/dist/btc/vault/bip341-nums.d.ts +35 -0
  18. package/dist/btc/vault/bip341-nums.js +121 -0
  19. package/dist/btc/vault/build.d.ts +5 -0
  20. package/dist/btc/vault/build.js +136 -0
  21. package/dist/btc/vault/canonical-order.d.ts +10 -0
  22. package/dist/btc/vault/canonical-order.js +18 -0
  23. package/dist/btc/vault/from-contract-id.d.ts +9 -0
  24. package/dist/btc/vault/from-contract-id.js +74 -0
  25. package/dist/btc/vault/helpers.d.ts +73 -0
  26. package/dist/btc/vault/helpers.js +129 -0
  27. package/dist/btc/vault/index.d.ts +21 -0
  28. package/dist/btc/vault/index.js +21 -0
  29. package/dist/btc/vault/limits.d.ts +9 -0
  30. package/dist/btc/vault/limits.js +9 -0
  31. package/dist/btc/vault/participant-order.d.ts +69 -0
  32. package/dist/btc/vault/participant-order.js +135 -0
  33. package/dist/btc/vault/script.d.ts +13 -0
  34. package/dist/btc/vault/script.js +39 -0
  35. package/dist/btc/vault/spend.d.ts +63 -0
  36. package/dist/btc/vault/spend.js +93 -0
  37. package/dist/btc/vault/tx-size.d.ts +66 -0
  38. package/dist/btc/vault/tx-size.js +71 -0
  39. package/dist/btc/vault/types.d.ts +49 -0
  40. package/dist/btc/vault/types.js +1 -0
  41. package/dist/btc/vault/vault-policy.d.ts +109 -0
  42. package/dist/btc/vault/vault-policy.js +213 -0
  43. package/dist/btc/vault/vaultContractUtil.d.ts +80 -0
  44. package/dist/btc/vault/vaultContractUtil.js +87 -0
  45. package/dist/btc/vault/xpub.d.ts +28 -0
  46. package/dist/btc/vault/xpub.js +41 -0
  47. package/dist/btc/witness.d.ts +101 -0
  48. package/dist/btc/witness.js +439 -0
  49. package/dist/btc-policies/activation.d.ts +76 -0
  50. package/dist/btc-policies/activation.js +153 -0
  51. package/dist/btc-policies/index.d.ts +7 -0
  52. package/dist/btc-policies/index.js +8 -0
  53. package/dist/btc-policies/policy.d.ts +55 -0
  54. package/dist/btc-policies/policy.js +210 -0
  55. package/dist/evm/abis/index.d.ts +10 -0
  56. package/dist/evm/abis/index.js +11 -0
  57. package/dist/evm/abis/policies.d.ts +279 -0
  58. package/dist/evm/abis/policies.js +363 -0
  59. package/dist/evm/abis/sponsor-guard.d.ts +201 -0
  60. package/dist/evm/abis/sponsor-guard.js +263 -0
  61. package/dist/evm/abis/state.d.ts +989 -0
  62. package/dist/evm/abis/state.js +1290 -0
  63. package/dist/evm/abis/users.d.ts +408 -0
  64. package/dist/evm/abis/users.js +532 -0
  65. package/dist/evm/abis/vault-factory.d.ts +92 -0
  66. package/dist/evm/abis/vault-factory.js +126 -0
  67. package/dist/evm/abis/vault.d.ts +343 -0
  68. package/dist/evm/abis/vault.js +444 -0
  69. package/dist/evm/address.d.ts +2 -0
  70. package/dist/evm/address.js +4 -0
  71. package/dist/evm/calls/common.d.ts +30 -0
  72. package/dist/evm/calls/common.js +51 -0
  73. package/dist/evm/calls/index.d.ts +11 -0
  74. package/dist/evm/calls/index.js +4 -0
  75. package/dist/evm/calls/policies.d.ts +25 -0
  76. package/dist/evm/calls/policies.js +173 -0
  77. package/dist/evm/calls/users.d.ts +61 -0
  78. package/dist/evm/calls/users.js +353 -0
  79. package/dist/evm/calls/vault.d.ts +25 -0
  80. package/dist/evm/calls/vault.js +184 -0
  81. package/dist/evm/chain.d.ts +74 -0
  82. package/dist/evm/chain.js +48 -0
  83. package/dist/evm/company-name.d.ts +46 -0
  84. package/dist/evm/company-name.js +64 -0
  85. package/dist/evm/deployment.d.ts +47 -0
  86. package/dist/evm/deployment.js +110 -0
  87. package/dist/evm/eip712.d.ts +271 -0
  88. package/dist/evm/eip712.js +224 -0
  89. package/dist/evm/index.d.ts +20 -0
  90. package/dist/evm/index.js +21 -0
  91. package/dist/evm/reads.d.ts +64 -0
  92. package/dist/evm/reads.js +131 -0
  93. package/dist/evm/signatures.d.ts +57 -0
  94. package/dist/evm/signatures.js +105 -0
  95. package/dist/evm/simulate/balances.d.ts +76 -0
  96. package/dist/evm/simulate/balances.js +201 -0
  97. package/dist/evm/simulate/describe.d.ts +79 -0
  98. package/dist/evm/simulate/describe.js +188 -0
  99. package/dist/evm/simulate/index.d.ts +12 -0
  100. package/dist/evm/simulate/index.js +13 -0
  101. package/dist/evm/simulate/own-node.d.ts +62 -0
  102. package/dist/evm/simulate/own-node.js +122 -0
  103. package/dist/evm/simulate/request.d.ts +86 -0
  104. package/dist/evm/simulate/request.js +235 -0
  105. package/dist/evm/simulate/revert.d.ts +29 -0
  106. package/dist/evm/simulate/revert.js +192 -0
  107. package/dist/evm/simulate/run.d.ts +51 -0
  108. package/dist/evm/simulate/run.js +214 -0
  109. package/dist/evm/stocks/decode-stock-swap.d.ts +32 -0
  110. package/dist/evm/stocks/decode-stock-swap.js +79 -0
  111. package/dist/evm/stocks/encode-swap.d.ts +45 -0
  112. package/dist/evm/stocks/encode-swap.js +45 -0
  113. package/dist/evm/stocks/index.d.ts +9 -0
  114. package/dist/evm/stocks/index.js +10 -0
  115. package/dist/evm/stocks/quote.d.ts +19 -0
  116. package/dist/evm/stocks/quote.js +46 -0
  117. package/dist/evm/stocks/stock-pools.d.ts +61 -0
  118. package/dist/evm/stocks/stock-pools.js +179 -0
  119. package/dist/evm/submitters.d.ts +32 -0
  120. package/dist/evm/submitters.js +105 -0
  121. package/dist/evm/types.d.ts +146 -0
  122. package/dist/evm/types.js +128 -0
  123. package/dist/keys/index.d.ts +6 -0
  124. package/dist/keys/index.js +7 -0
  125. package/dist/keys/keys.d.ts +63 -0
  126. package/dist/keys/keys.js +80 -0
  127. package/dist/keys/xpub-ownership.d.ts +10 -0
  128. package/dist/keys/xpub-ownership.js +13 -0
  129. package/package.json +60 -3
@@ -0,0 +1,377 @@
1
+ import { schnorr } from "@noble/curves/secp256k1.js";
2
+ import { bytesToHex } from "@noble/hashes/utils";
3
+ import { HDKey } from "@scure/bip32";
4
+ import * as btc from "@scure/btc-signer";
5
+ import { TAP_LEAF_VERSION } from "@scure/btc-signer/payment.js";
6
+ import { compareBytes, equalBytes, tagSchnorr, } from "@scure/btc-signer/utils.js";
7
+ import { DEFAULT_MAX_FEE_RATE, networkOfAddress, scriptOf, spendVbytes, } from "./spend-rules.js";
8
+ import { buildVaultSpend, vaultSpendSighash, } from "./vault/spend.js";
9
+ import { BIP32_VERSIONS, COFUND_LEAF_INDEX, deriveChildFromXpub, } from "./vault/xpub.js";
10
+ import { encodeCBFromStruct, leafScriptPolicy, tryFinalizeAny, verifyPartialSignatureAgainstMsg, } from "./witness.js";
11
+ /** Zero aux randomness: BIP-340 permits it, and it keeps a signer's signatures deterministic. */
12
+ const ZERO_AUX = new Uint8Array(32);
13
+ /** A {@link BtcLeafSigner} over an account xprv (tprv off mainnet), `m/86'/coin'/0'`, held in memory. */
14
+ export function localLeafSigner(accountXprv) {
15
+ const account = HDKey.fromExtendedKey(accountXprv, BIP32_VERSIONS[accountXprv.startsWith("tprv") ? "testnet" : "mainnet"]);
16
+ if (!account.privateKey) {
17
+ throw new Error("localLeafSigner takes an account's private key (xprv)");
18
+ }
19
+ const xpub = account.publicExtendedKey;
20
+ return {
21
+ xpub: () => Promise.resolve(xpub),
22
+ signTaprootLeaf(sighash, derivation) {
23
+ const key = derivation.reduce((node, index) => node.deriveChild(index), account);
24
+ if (!key.privateKey) {
25
+ throw new Error(`Could not derive the key at ${derivation.join("/")}`);
26
+ }
27
+ return Promise.resolve(schnorr.sign(sighash, key.privateKey, ZERO_AUX));
28
+ },
29
+ };
30
+ }
31
+ /** Change below this many sats is left to the fee: a P2TR output under it is dust. */
32
+ export const VAULT_CHANGE_DUST = 330n;
33
+ function vaultNetwork(vault) {
34
+ const net = networkOfAddress(vault.address);
35
+ if (!net) {
36
+ throw new Error(`${vault.address} is not a Bitcoin address`);
37
+ }
38
+ return net;
39
+ }
40
+ /** BIP-341's merkle root, from a leaf's hash and its path. */
41
+ function merkleRootOf(leaf) {
42
+ return leaf.controlBlockStruct.merklePath.reduce((node, sibling) => compareBytes(node, sibling) <= 0
43
+ ? tagSchnorr("TapBranch", node, sibling)
44
+ : tagSchnorr("TapBranch", sibling, node), leaf.leafHash);
45
+ }
46
+ /** The key's own BIP-32 fingerprint: the origin a member's leaf keys are derived from. */
47
+ function accountFingerprint(accountXpub) {
48
+ return HDKey.fromExtendedKey(accountXpub, BIP32_VERSIONS[accountXpub.startsWith("tpub") ? "testnet" : "mainnet"]).fingerprint;
49
+ }
50
+ /**
51
+ * Every leaf key with its origin: the member's account xpub (its fingerprint)
52
+ * and the path below it, `branch/COFUND_LEAF_INDEX`. The definition holds
53
+ * account xpubs, not their wallets' master fingerprints, so the origin starts
54
+ * at the account.
55
+ */
56
+ function tapBip32Derivations(vault) {
57
+ const byKey = new Map();
58
+ for (const { leaf, branch } of vault.leaves) {
59
+ for (const signer of leaf.signers) {
60
+ const member = vault.definition.members.find(({ id }) => id === signer.id);
61
+ if (!member) {
62
+ continue;
63
+ }
64
+ const key = bytesToHex(signer.xonly);
65
+ const known = byKey.get(key);
66
+ if (known) {
67
+ known[1].hashes.push(leaf.leafHash);
68
+ continue;
69
+ }
70
+ byKey.set(key, [
71
+ signer.xonly,
72
+ {
73
+ hashes: [leaf.leafHash],
74
+ der: {
75
+ fingerprint: accountFingerprint(member.accountXpub),
76
+ path: [branch, COFUND_LEAF_INDEX],
77
+ },
78
+ },
79
+ ]);
80
+ }
81
+ }
82
+ return [...byKey.values()];
83
+ }
84
+ /**
85
+ * The unsigned spend as a PSBT: the payees, then the extra outputs, then
86
+ * change back to the vault when it is not dust (the inputs less the outputs
87
+ * less `fee`); the inputs in their canonical order (`buildVaultSpend`, by
88
+ * `txid:vout`). Each input carries its prevout (`witnessUtxo`), every leaf of
89
+ * the vault with its control block (`tapLeafScript`), the internal key and
90
+ * merkle root, and each leaf key's origin (`tapBip32Derivation`).
91
+ */
92
+ export function buildSpendPsbt(request) {
93
+ const { vault, inputs, payees, extraOutputs = [] } = request;
94
+ if (payees.length === 0) {
95
+ throw new Error("A spend pays at least one payee");
96
+ }
97
+ if (inputs.length === 0) {
98
+ throw new Error("A spend takes at least one of the vault's coins");
99
+ }
100
+ const fee = BigInt(request.fee);
101
+ if (fee < 0n) {
102
+ throw new Error(`The fee of ${fee} sats is negative`);
103
+ }
104
+ const net = vaultNetwork(vault);
105
+ const outputs = [...payees, ...extraOutputs].map(({ address, amount }) => {
106
+ if (BigInt(amount) <= 0n) {
107
+ throw new Error(`The output to ${address} pays ${amount} sats`);
108
+ }
109
+ return { script: scriptOf(net, address), amount: BigInt(amount) };
110
+ });
111
+ const seen = new Set();
112
+ const spendInputs = inputs.map(({ txid, vout, value }) => {
113
+ const id = `${txid}:${vout}`;
114
+ if (seen.has(id)) {
115
+ throw new Error(`The spend lists ${id} twice`);
116
+ }
117
+ seen.add(id);
118
+ return { id, txid, vout, amount: BigInt(value) };
119
+ });
120
+ const total = spendInputs.reduce((sum, { amount }) => sum + amount, 0n);
121
+ const paid = outputs.reduce((sum, { amount }) => sum + amount, 0n);
122
+ const change = total - paid - fee;
123
+ if (change < 0n) {
124
+ throw new Error(`Insufficient funds: have ${total}, need ${paid + fee}`);
125
+ }
126
+ if (change >= VAULT_CHANGE_DUST) {
127
+ outputs.push({ script: vault.script, amount: change });
128
+ }
129
+ const { tx } = buildVaultSpend({
130
+ vaultScript: vault.script,
131
+ inputs: spendInputs,
132
+ outputs,
133
+ });
134
+ const [{ leaf: anyLeaf }] = vault.leaves;
135
+ const taproot = {
136
+ tapInternalKey: anyLeaf.controlBlockStruct.internalKey,
137
+ tapMerkleRoot: merkleRootOf(anyLeaf),
138
+ tapLeafScript: vault.leaves.map(({ leaf }) => [
139
+ btc.TaprootControlBlock.decode(encodeCBFromStruct(leaf.controlBlockStruct)),
140
+ new Uint8Array([...leaf.script, TAP_LEAF_VERSION]),
141
+ ]),
142
+ tapBip32Derivation: tapBip32Derivations(vault),
143
+ };
144
+ for (let index = 0; index < tx.inputsLength; index++) {
145
+ tx.updateInput(index, taproot);
146
+ }
147
+ return tx.toPSBT();
148
+ }
149
+ function parsePsbt(psbt) {
150
+ return btc.Transaction.fromPSBT(psbt);
151
+ }
152
+ /** Each input's outpoint and amount; throws unless every input is the vault's, once. */
153
+ function vaultInputs(tx, vault) {
154
+ if (tx.inputsLength === 0) {
155
+ throw new Error("The spend has no inputs");
156
+ }
157
+ const seen = new Set();
158
+ return Array.from({ length: tx.inputsLength }, (_, index) => {
159
+ const input = tx.getInput(index);
160
+ if (!input.txid || input.index === undefined) {
161
+ throw new Error(`Input ${index} has no outpoint`);
162
+ }
163
+ const txid = bytesToHex(input.txid);
164
+ const outpoint = `${txid}:${input.index}`;
165
+ if (!(input.witnessUtxo && equalBytes(input.witnessUtxo.script, vault.script))) {
166
+ throw new Error(`Input ${index} (${outpoint}) does not spend the vault ${vault.address}`);
167
+ }
168
+ if (seen.has(outpoint)) {
169
+ throw new Error(`The spend lists ${outpoint} twice`);
170
+ }
171
+ seen.add(outpoint);
172
+ return { txid, vout: input.index, value: input.witnessUtxo.amount };
173
+ });
174
+ }
175
+ /**
176
+ * Check a PSBT against the verified vault and what the consumer expects;
177
+ * throws on the first thing that does not hold:
178
+ *
179
+ * - every input spends the vault's script (`witnessUtxo`), and none twice: a
180
+ * prevout the PSBT misstates only makes every signature over it invalid,
181
+ * since BIP-341 commits to each prevout's script and amount;
182
+ * - the outputs are the payees, then the extra outputs, each its address and
183
+ * amount, then at most one more: change back to the vault;
184
+ * - the network fee (the inputs less the outputs) is at most `maxFeeRate`
185
+ * sat/vB of the spend's vsize as it is priced.
186
+ */
187
+ export function checkPsbt(psbt, vault, expectations) {
188
+ const tx = parsePsbt(psbt);
189
+ const net = vaultNetwork(vault);
190
+ const inputs = vaultInputs(tx, vault);
191
+ const expected = [
192
+ ...expectations.payees,
193
+ ...(expectations.extraOutputs ?? []),
194
+ ];
195
+ const outputs = Array.from({ length: tx.outputsLength }, (_, index) => {
196
+ const { script, amount } = tx.getOutput(index);
197
+ if (!script || amount === undefined) {
198
+ throw new Error(`Output ${index} has no script or amount`);
199
+ }
200
+ return { script, amount };
201
+ });
202
+ for (const [index, { address, amount }] of expected.entries()) {
203
+ const output = outputs[index];
204
+ if (!(output &&
205
+ equalBytes(output.script, scriptOf(net, address)) &&
206
+ output.amount === BigInt(amount))) {
207
+ throw new Error(`Output ${index} is not ${amount} sats to ${address}`);
208
+ }
209
+ }
210
+ const rest = outputs.slice(expected.length);
211
+ if (rest.length > 1) {
212
+ throw new Error(`The spend has ${rest.length} outputs beyond the expected ones; at most one, the change, may follow them`);
213
+ }
214
+ const [change] = rest;
215
+ if (change && !equalBytes(change.script, vault.script)) {
216
+ throw new Error(`Output ${expected.length} pays ${change.amount} sats to a script that is not the vault's`);
217
+ }
218
+ const fee = inputs.reduce((sum, { value }) => sum + value, 0n) -
219
+ outputs.reduce((sum, { amount }) => sum + amount, 0n);
220
+ if (fee < 0n) {
221
+ throw new Error("The outputs pay more than the inputs hold");
222
+ }
223
+ const maxFeeRate = expectations.maxFeeRate ?? DEFAULT_MAX_FEE_RATE;
224
+ const vsize = spendVbytes(vault, inputs.length, outputs);
225
+ if (fee > BigInt(Math.floor(maxFeeRate * vsize))) {
226
+ throw new Error(`The network fee of ${fee} sats is over ${maxFeeRate} sat/vB of ${vsize} vB`);
227
+ }
228
+ const paid = (from, to) => expected.slice(from, to).map(({ address, amount }) => ({
229
+ address,
230
+ amount: BigInt(amount),
231
+ }));
232
+ return {
233
+ vault: vault.address,
234
+ inputs,
235
+ payees: paid(0, expectations.payees.length),
236
+ extraOutputs: paid(expectations.payees.length, expected.length),
237
+ change: change?.amount ?? 0n,
238
+ fee,
239
+ vsize,
240
+ };
241
+ }
242
+ /**
243
+ * The signer's BIP-340 signatures added to the PSBT (`tapScriptSig`), one per
244
+ * input for each leaf that holds the signer's key, over the BIP-341 sighash
245
+ * (SIGHASH_DEFAULT) of the spend with the verified leaf's script. First the
246
+ * PSBT is checked (`checkPsbt`); each signature is verified against the
247
+ * signer's key in the leaf before it is added.
248
+ */
249
+ export async function signPsbt(psbt, vault, signer, expectations) {
250
+ checkPsbt(psbt, vault, expectations);
251
+ const xpub = await signer.xpub();
252
+ if (!vault.definition.members.some(({ accountXpub }) => accountXpub === xpub)) {
253
+ throw new Error(`The signer's xpub is not a member of vault ${vault.address}`);
254
+ }
255
+ const mine = vault.leaves.flatMap(({ leaf, branch }) => {
256
+ const { xonly } = deriveChildFromXpub(xpub, branch, COFUND_LEAF_INDEX);
257
+ return leaf.signers.some((member) => equalBytes(member.xonly, xonly))
258
+ ? [{ leaf, branch, xonly }]
259
+ : [];
260
+ });
261
+ if (mine.length === 0) {
262
+ throw new Error(`Vault ${vault.address} holds no leaf with the signer's key`);
263
+ }
264
+ const tx = parsePsbt(psbt);
265
+ for (const { leaf, branch, xonly } of mine) {
266
+ for (let index = 0; index < tx.inputsLength; index++) {
267
+ const sighash = vaultSpendSighash(tx, index, leaf.script);
268
+ const signature = await signer.signTaprootLeaf(sighash, [
269
+ branch,
270
+ COFUND_LEAF_INDEX,
271
+ ]);
272
+ if (!verifyPartialSignatureAgainstMsg(sighash, xonly, signature)) {
273
+ throw new Error(`The signer's signature over input ${index} does not verify against its key in leaf ${bytesToHex(leaf.leafHash)}`);
274
+ }
275
+ tx.updateInput(index, {
276
+ tapScriptSig: [
277
+ [{ pubKey: xonly, leafHash: leaf.leafHash }, signature],
278
+ ],
279
+ }, true);
280
+ }
281
+ }
282
+ return tx.toPSBT();
283
+ }
284
+ /** One PSBT with the signatures of all of them; they must be the same spend of the same prevouts. */
285
+ export function combinePsbts(psbts) {
286
+ const [first, ...rest] = psbts;
287
+ if (!first) {
288
+ throw new Error("No PSBTs to combine");
289
+ }
290
+ const tx = parsePsbt(first);
291
+ for (const psbt of rest) {
292
+ const other = parsePsbt(psbt);
293
+ const shared = Math.min(tx.inputsLength, other.inputsLength);
294
+ for (let index = 0; index < shared; index++) {
295
+ const mine = tx.getInput(index).witnessUtxo;
296
+ const theirs = other.getInput(index).witnessUtxo;
297
+ if (!(mine &&
298
+ theirs &&
299
+ mine.amount === theirs.amount &&
300
+ equalBytes(mine.script, theirs.script))) {
301
+ throw new Error(`The PSBTs disagree on input ${index}'s prevout`);
302
+ }
303
+ }
304
+ // Refuses a different unsigned transaction.
305
+ tx.combine(other);
306
+ }
307
+ return tx.toPSBT();
308
+ }
309
+ /** Why no leaf finalizes: each leaf's rule, and the fewest signers it has on any input. */
310
+ function unmetLeaves(vault, partials, inputs) {
311
+ return vault.leaves
312
+ .map(({ leaf }) => {
313
+ const policy = leafScriptPolicy(leaf);
314
+ const perLeaf = partials.get(bytesToHex(leaf.leafHash));
315
+ const fewest = Math.min(...Array.from({ length: inputs }, (_, index) => perLeaf?.get(index)?.size ?? 0));
316
+ const rule = policy?.kind === "admin-required"
317
+ ? `its admin and ${policy.bodyThreshold} more`
318
+ : `${policy?.threshold} signatures`;
319
+ return `leaf ${bytesToHex(leaf.leafHash)} needs ${rule}, has ${fewest} signer(s) on every input`;
320
+ })
321
+ .join("; ");
322
+ }
323
+ /** The PSBT's `tapScriptSig`s that verify over the spend, by leaf, input and the member whose key made them. */
324
+ function verifiedPartials(tx, candidates) {
325
+ const verified = Array.from({ length: tx.inputsLength }, (_, index) => (tx.getInput(index).tapScriptSig ?? []).flatMap(([{ pubKey, leafHash }, signature]) => {
326
+ const leaf = candidates.find((candidate) => equalBytes(candidate.leafHash, leafHash));
327
+ const msg = leaf && vaultSpendSighash(tx, index, leaf.script);
328
+ return msg && verifyPartialSignatureAgainstMsg(msg, pubKey, signature)
329
+ ? [{ leaf, index, pubKey, signature }]
330
+ : [];
331
+ })).flat();
332
+ const partials = new Map();
333
+ for (const { leaf, index, pubKey, signature } of verified) {
334
+ const key = bytesToHex(leaf.leafHash);
335
+ const perLeaf = partials.get(key) ?? new Map();
336
+ const perInput = perLeaf.get(index) ?? new Map();
337
+ for (const member of leaf.signers) {
338
+ if (equalBytes(member.xonly, pubKey)) {
339
+ perInput.set(member.id, signature);
340
+ }
341
+ }
342
+ perLeaf.set(index, perInput);
343
+ partials.set(key, perLeaf);
344
+ }
345
+ return partials;
346
+ }
347
+ /**
348
+ * Assemble each input's witness once one leaf has its threshold on every
349
+ * input, by the template's rules (`tryFinalizeAny`): k of the leaf's keys, and
350
+ * on an ADMINS_REQUIRED leaf its admin plus k − 1 of the rest. Only signatures
351
+ * that verify over the spend count. Returns the finalized PSBT, its signing
352
+ * fields cleared; throws when no leaf is met.
353
+ */
354
+ export function finalizePsbt(psbt, vault) {
355
+ const tx = parsePsbt(psbt);
356
+ vaultInputs(tx, vault);
357
+ const candidates = vault.leaves.map(({ leaf }) => leaf);
358
+ const partials = verifiedPartials(tx, candidates);
359
+ const { finalized } = tryFinalizeAny({ tx, candidates, partials }, vault.definition.vaultType);
360
+ if (!finalized) {
361
+ throw new Error(`No leaf of vault ${vault.address} has its threshold on every input: ${unmetLeaves(vault, partials, tx.inputsLength)}`);
362
+ }
363
+ for (let index = 0; index < tx.inputsLength; index++) {
364
+ tx.updateInput(index, {
365
+ tapScriptSig: undefined,
366
+ tapLeafScript: undefined,
367
+ tapBip32Derivation: undefined,
368
+ tapInternalKey: undefined,
369
+ tapMerkleRoot: undefined,
370
+ }, true);
371
+ }
372
+ return tx.toPSBT();
373
+ }
374
+ /** A finalized PSBT's transaction, as raw hex to broadcast. */
375
+ export function extractTx(psbt) {
376
+ return bytesToHex(parsePsbt(psbt).extract());
377
+ }
@@ -0,0 +1,42 @@
1
+ /** An unspent output; `value` in sats. */
2
+ export type Utxo = {
3
+ txid: string;
4
+ vout: number;
5
+ value: bigint;
6
+ confirmed: boolean;
7
+ };
8
+ /** A transaction as the chain holds it: whether it is in a block, and what it pays. */
9
+ export type BtcTx = {
10
+ txid: string;
11
+ confirmed: boolean;
12
+ blockHeight: number | null;
13
+ /** In order; `address` is null for a script with none. */
14
+ outputs: {
15
+ address: string | null;
16
+ value: bigint;
17
+ }[];
18
+ };
19
+ /** Where Bitcoin is read from and spends are sent: Esplora, a node, anything. */
20
+ export type BtcReader = {
21
+ utxos(address: string): Promise<Utxo[]>;
22
+ /** Null when the transaction is unknown. */
23
+ tx(txid: string): Promise<BtcTx | null>;
24
+ /** sat/vB. */
25
+ feeRate(): Promise<number>;
26
+ /** Sends a raw transaction (hex); returns its txid. */
27
+ broadcast(hex: string): Promise<string>;
28
+ };
29
+ /**
30
+ * A {@link BtcReader} over an Esplora API (Blockstream's, mempool.space's, or
31
+ * your own electrs), with `fetch` only. `baseUrl` is the API root, e.g.
32
+ * `https://mempool.space/api`. The fee rate is Esplora's estimate for
33
+ * confirming within `feeTarget` blocks (default 1).
34
+ */
35
+ export declare function esploraReader(baseUrl: string, { feeTarget }?: {
36
+ feeTarget?: number;
37
+ }): BtcReader;
38
+ /**
39
+ * Send a raw transaction (hex) through `reader`. Returns its txid, which must
40
+ * be the transaction's own.
41
+ */
42
+ export declare function broadcast(hex: string, reader: BtcReader): Promise<string>;
@@ -0,0 +1,85 @@
1
+ import { hexToBytes } from "@noble/hashes/utils";
2
+ import * as btc from "@scure/btc-signer";
3
+ const TRAILING_SLASHES = /\/+$/;
4
+ /**
5
+ * A {@link BtcReader} over an Esplora API (Blockstream's, mempool.space's, or
6
+ * your own electrs), with `fetch` only. `baseUrl` is the API root, e.g.
7
+ * `https://mempool.space/api`. The fee rate is Esplora's estimate for
8
+ * confirming within `feeTarget` blocks (default 1).
9
+ */
10
+ export function esploraReader(baseUrl, { feeTarget = 1 } = {}) {
11
+ const root = baseUrl.replace(TRAILING_SLASHES, "");
12
+ const get = async (path) => {
13
+ const res = await fetch(`${root}${path}`);
14
+ if (!(res.ok || res.status === 404)) {
15
+ throw new Error(`Esplora GET ${path}: ${res.status} ${await res.text()}`);
16
+ }
17
+ return res;
18
+ };
19
+ return {
20
+ async utxos(address) {
21
+ const res = await get(`/address/${encodeURIComponent(address)}/utxo`);
22
+ if (res.status === 404) {
23
+ throw new Error(`Esplora does not know the address ${address}`);
24
+ }
25
+ const rows = (await res.json());
26
+ return rows.map(({ txid, vout, value, status }) => ({
27
+ txid,
28
+ vout,
29
+ value: BigInt(value),
30
+ confirmed: status.confirmed,
31
+ }));
32
+ },
33
+ async tx(txid) {
34
+ const res = await get(`/tx/${encodeURIComponent(txid)}`);
35
+ if (res.status === 404) {
36
+ return null;
37
+ }
38
+ const body = (await res.json());
39
+ return {
40
+ txid: body.txid,
41
+ confirmed: body.status.confirmed,
42
+ blockHeight: body.status.block_height ?? null,
43
+ outputs: body.vout.map((output) => ({
44
+ address: output.scriptpubkey_address ?? null,
45
+ value: BigInt(output.value),
46
+ })),
47
+ };
48
+ },
49
+ async feeRate() {
50
+ const res = await get("/fee-estimates");
51
+ const estimates = (await res.json());
52
+ const rate = estimates[String(feeTarget)];
53
+ if (!(typeof rate === "number" && rate > 0)) {
54
+ throw new Error(`Esplora has no fee estimate for ${feeTarget} block(s)`);
55
+ }
56
+ return rate;
57
+ },
58
+ async broadcast(hex) {
59
+ const res = await fetch(`${root}/tx`, {
60
+ method: "POST",
61
+ headers: { "content-type": "text/plain" },
62
+ body: hex,
63
+ });
64
+ const text = (await res.text()).trim();
65
+ if (!res.ok) {
66
+ throw new Error(`Esplora refused the transaction: ${text}`);
67
+ }
68
+ return text;
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * Send a raw transaction (hex) through `reader`. Returns its txid, which must
74
+ * be the transaction's own.
75
+ */
76
+ export async function broadcast(hex, reader) {
77
+ const { id } = btc.Transaction.fromRaw(hexToBytes(hex), {
78
+ allowUnknownOutputs: true,
79
+ });
80
+ const txid = await reader.broadcast(hex);
81
+ if (txid !== id) {
82
+ throw new Error(`The reader broadcast ${txid}, not the transaction ${id}`);
83
+ }
84
+ return txid;
85
+ }
@@ -0,0 +1,48 @@
1
+ import type { VerifiedBtcVault } from "./spend.ts";
2
+ /**
3
+ * A margin on the raw vsize before the fee rate applies. At 1 sat/vB the fee
4
+ * equals the vsize, so a vsize a hair under the node's own count would be
5
+ * refused as below min-relay; overpaying a few sats beats a failed broadcast.
6
+ */
7
+ export declare const FEE_SAFETY_MULTIPLIER = 1.125;
8
+ /**
9
+ * vsize of one input of `vault`'s spend: its largest leaf's script-path
10
+ * witness, since which leaf a spend takes is not known before it is signed.
11
+ */
12
+ export declare function vaultSpendInputVbytes(vault: VerifiedBtcVault): number;
13
+ export type CoinSelectionRequest<T> = {
14
+ /** The vault's spendable coins; `value` in sats. */
15
+ utxos: readonly T[];
16
+ /** Sats paid to the payees, in all. */
17
+ amount: number | bigint;
18
+ /** sat/vB. */
19
+ feeRate: number;
20
+ /** vsize of one vault input (`vaultSpendInputVbytes`). */
21
+ inputVbytes: number;
22
+ /** Outputs paying `amount` (default 1). */
23
+ payeeCount?: number;
24
+ /** Sats of each output beyond the payees and change, such as a fee to an address. */
25
+ extraOutputs?: readonly (number | bigint)[];
26
+ /** Spend every coin, sweeping the vault into one change output, instead of only what the spend needs. */
27
+ consolidate?: boolean;
28
+ };
29
+ export type CoinSelection<T> = {
30
+ /** Largest first. */
31
+ selected: T[];
32
+ /** Sats the selected coins hold. */
33
+ total: bigint;
34
+ /** The network fee: the spend's vsize, with change, at `feeRate`, times {@link FEE_SAFETY_MULTIPLIER}, rounded up. */
35
+ fee: bigint;
36
+ };
37
+ /**
38
+ * Coins for a spend paying `amount` (and any extra outputs) at `feeRate`,
39
+ * priced with the vault's real input size. Largest first, it takes coins until
40
+ * they cover the outputs and the fee for that many inputs; with `consolidate`,
41
+ * it takes every coin. It locks nothing: two spends selected from one set of
42
+ * coins can pick the same ones.
43
+ *
44
+ * Throws when there are no coins, or they cannot cover the outputs and the fee.
45
+ */
46
+ export declare function selectCoins<T extends {
47
+ value: number | string | bigint;
48
+ }>(request: CoinSelectionRequest<T>): CoinSelection<T>;
@@ -0,0 +1,59 @@
1
+ import { leafInputVbytes, vaultTxVbytes } from "./vault/tx-size.js";
2
+ /**
3
+ * A margin on the raw vsize before the fee rate applies. At 1 sat/vB the fee
4
+ * equals the vsize, so a vsize a hair under the node's own count would be
5
+ * refused as below min-relay; overpaying a few sats beats a failed broadcast.
6
+ */
7
+ export const FEE_SAFETY_MULTIPLIER = 1.125;
8
+ // Every output is sized as P2TR (a 34-byte script), the largest a vault spend
9
+ // emits: change is always P2TR, so the fee errs high when a payee's is smaller.
10
+ const P2TR_SCRIPT_LEN = 34;
11
+ /**
12
+ * vsize of one input of `vault`'s spend: its largest leaf's script-path
13
+ * witness, since which leaf a spend takes is not known before it is signed.
14
+ */
15
+ export function vaultSpendInputVbytes(vault) {
16
+ return Math.max(...vault.leaves.map(({ leaf }) => leafInputVbytes(leaf)));
17
+ }
18
+ /**
19
+ * Coins for a spend paying `amount` (and any extra outputs) at `feeRate`,
20
+ * priced with the vault's real input size. Largest first, it takes coins until
21
+ * they cover the outputs and the fee for that many inputs; with `consolidate`,
22
+ * it takes every coin. It locks nothing: two spends selected from one set of
23
+ * coins can pick the same ones.
24
+ *
25
+ * Throws when there are no coins, or they cannot cover the outputs and the fee.
26
+ */
27
+ export function selectCoins(request) {
28
+ const { utxos, feeRate, inputVbytes, payeeCount = 1, extraOutputs = [], consolidate = false, } = request;
29
+ if (!utxos || utxos.length === 0) {
30
+ throw new Error("No available UTXOs found for the specified vault");
31
+ }
32
+ if (!(Number.isFinite(feeRate) && feeRate > 0)) {
33
+ throw new Error(`The fee rate must be above 0 sat/vB, not ${feeRate}`);
34
+ }
35
+ // Payees, the extra outputs, and change.
36
+ const outputScriptLens = Array.from({ length: payeeCount + extraOutputs.length + 1 }, () => P2TR_SCRIPT_LEN);
37
+ const feeFor = (nIn) => BigInt(Math.ceil(feeRate *
38
+ vaultTxVbytes({ inputVbytes, nIn, outputScriptLens }) *
39
+ FEE_SAFETY_MULTIPLIER));
40
+ const needed = BigInt(request.amount) +
41
+ extraOutputs.reduce((sum, value) => sum + BigInt(value), 0n);
42
+ const sorted = utxos
43
+ .slice()
44
+ .sort((a, b) => Number(b.value) - Number(a.value));
45
+ const selected = [];
46
+ let total = 0n;
47
+ for (const utxo of sorted) {
48
+ if (!consolidate && total >= needed + feeFor(selected.length)) {
49
+ break;
50
+ }
51
+ selected.push(utxo);
52
+ total += BigInt(utxo.value);
53
+ }
54
+ const fee = feeFor(selected.length);
55
+ if (total < needed + fee) {
56
+ throw new Error("balance too low for requested amount + fee");
57
+ }
58
+ return { selected, total, fee };
59
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What every spend check shares (`checkBtcSpend`, `checkPsbt`): the vault's
3
+ * network, an address as a script on it, and the spend's size as the fee is
4
+ * priced. Internal: not part of `./btc`.
5
+ */
6
+ import * as btc from "@scure/btc-signer";
7
+ import type { VerifiedBtcVault } from "./spend.ts";
8
+ /** The network fee's default ceiling, in sat/vB. */
9
+ export declare const DEFAULT_MAX_FEE_RATE = 1000;
10
+ /**
11
+ * The network of a bech32 address, by its prefix; undefined when it is none
12
+ * of Bitcoin's. Read when called, not when the module loads: an app test that
13
+ * mocks part of `@scure/btc-signer` still loads this entry.
14
+ */
15
+ export declare function networkOfAddress(address: string): typeof btc.NETWORK | undefined;
16
+ export declare function scriptOf(net: typeof btc.NETWORK, address: string): Uint8Array;
17
+ /**
18
+ * The spend's vsize as the app prices its fee: every input witnessed by the
19
+ * vault's largest leaf, since which leaf the spend takes is not known before
20
+ * it is signed.
21
+ */
22
+ export declare function spendVbytes(vault: VerifiedBtcVault, inputs: number, outputs: {
23
+ script: Uint8Array;
24
+ }[]): number;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What every spend check shares (`checkBtcSpend`, `checkPsbt`): the vault's
3
+ * network, an address as a script on it, and the spend's size as the fee is
4
+ * priced. Internal: not part of `./btc`.
5
+ */
6
+ import * as btc from "@scure/btc-signer";
7
+ import { vaultSpendInputVbytes } from "./select.js";
8
+ import { vaultTxVbytes } from "./vault/tx-size.js";
9
+ /** The network fee's default ceiling, in sat/vB. */
10
+ export const DEFAULT_MAX_FEE_RATE = 1000;
11
+ /**
12
+ * The network of a bech32 address, by its prefix; undefined when it is none
13
+ * of Bitcoin's. Read when called, not when the module loads: an app test that
14
+ * mocks part of `@scure/btc-signer` still loads this entry.
15
+ */
16
+ export function networkOfAddress(address) {
17
+ const networks = {
18
+ bc: btc.NETWORK,
19
+ tb: btc.TEST_NETWORK,
20
+ bcrt: { ...btc.TEST_NETWORK, bech32: "bcrt" },
21
+ };
22
+ return networks[address.slice(0, address.lastIndexOf("1"))];
23
+ }
24
+ export function scriptOf(net, address) {
25
+ try {
26
+ return btc.OutScript.encode(btc.Address(net).decode(address));
27
+ }
28
+ catch {
29
+ throw new Error(`${address} is not an address on this network`);
30
+ }
31
+ }
32
+ /**
33
+ * The spend's vsize as the app prices its fee: every input witnessed by the
34
+ * vault's largest leaf, since which leaf the spend takes is not known before
35
+ * it is signed.
36
+ */
37
+ export function spendVbytes(vault, inputs, outputs) {
38
+ return vaultTxVbytes({
39
+ inputVbytes: vaultSpendInputVbytes(vault),
40
+ nIn: inputs,
41
+ outputScriptLens: outputs.map(({ script }) => script.length),
42
+ });
43
+ }