@getcofund/protocol 0.0.0 → 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 +413 -1
  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 +61 -3
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Canonical participant ordering for account-based BTC vaults.
3
+ *
4
+ * A vault's taproot address is not stored anywhere — `Vault.contractId` holds
5
+ * only the roster (xpubs + roles), and every read path re-derives the address
6
+ * from it. Participant order decides the order of pushed keys inside the
7
+ * `CHECKSIGADD` tapscript, and for ADMINS_REQUIRED it also decides which admin
8
+ * owns which derivation branch (`2 * leafIdx`). So the comparator used to order
9
+ * the roster is part of the address: change it and a live vault silently
10
+ * repoints at an address nobody funded.
11
+ *
12
+ * That makes `String.prototype.localeCompare` unusable here, which is what this
13
+ * module replaces. With no explicit locale it resolves the runtime's default
14
+ * collation — the browser's UI language on the client, `LANG`/`LC_ALL` (or ICU's
15
+ * `en-US` fallback) on the server. Base58 is mixed-case alphanumeric, and
16
+ * several CLDR tailorings reorder plain ASCII:
17
+ *
18
+ * - `cs` treats `ch` as one letter sorting after `h`, so `ch…` > `d…`
19
+ * - `da` / `nb` fold `aa` to `å`, which sorts after `z`
20
+ * - `et` reorders `z` before `t`…`w`; `lt` reorders `y`; `hu` has digraphs
21
+ *
22
+ * Measured against a corpus of realistic `tpub…` strings, `lt` disagrees with
23
+ * `en` on ~3.5% of pairs and `et` on ~1.5%. A vault created by the server and
24
+ * displayed to a Czech-locale browser therefore rendered two different valid
25
+ * bech32m addresses for one `contractId`.
26
+ *
27
+ * Both orderings below are pure, total and frozen. Neither consults ICU, so
28
+ * they return the same result in every runtime, browser locale and ICU version.
29
+ */
30
+ import { compareCodepoint } from "./canonical-order.js";
31
+ /**
32
+ * Ordering rule a vault's address was derived under.
33
+ *
34
+ * Persisted in the `contractId` so a vault always re-derives under the rule it
35
+ * was created with. An absent field means {@link ORDERING_ICU_EN_V1} — every
36
+ * vault created before this field existed was ordered by `localeCompare`.
37
+ */
38
+ export const ORDERING_ICU_EN_V1 = "icu-en-v1";
39
+ /** Ordering rule for vaults created from now on. */
40
+ export const ORDERING_XPUB_CODEPOINT_V2 = "role-then-xpub-codepoint-v2";
41
+ /** The rule new vaults are created under. */
42
+ export const CURRENT_PARTICIPANT_ORDERING = ORDERING_XPUB_CODEPOINT_V2;
43
+ /**
44
+ * Read the ordering rule off a deserialized contract.
45
+ *
46
+ * Fails OPEN to v1 rather than throwing: an unrecognised value can only come
47
+ * from a contract written by a newer revision, and v1 is what every vault
48
+ * predating the field used. Callers that must not guess should compare the
49
+ * derived address against a known-good one instead.
50
+ */
51
+ export function participantOrderingOf(value) {
52
+ return value === ORDERING_XPUB_CODEPOINT_V2
53
+ ? ORDERING_XPUB_CODEPOINT_V2
54
+ : ORDERING_ICU_EN_V1;
55
+ }
56
+ // v2 orders by code point — `compareCodepoint` from `./canonical-order.ts`,
57
+ // the same comparator every other consensus-critical sort in the app uses.
58
+ // Base58 is ASCII, so code-unit order is byte order: digits, then `A`–`Z`, then
59
+ // `a`–`z`. Preferred over comparing the decoded 78-byte extended key because it
60
+ // cannot fail — a malformed xpub still orders deterministically instead of
61
+ // throwing inside a read path that renders a page.
62
+ // ── v1: frozen replica of the ICU en collation ───────────────────────────
63
+ /**
64
+ * Primary collation weight of one Base58 character.
65
+ *
66
+ * CLDR root sorts digits before letters and gives a letter one weight
67
+ * regardless of case — case is a tertiary difference, applied only after the
68
+ * whole primary sequence ties. That two-level structure is why a per-character
69
+ * rank table cannot stand in for this: under a flat table a case difference at
70
+ * position 0 would outrank a letter difference at position 1, and ICU orders
71
+ * `aC` after `Ab` precisely because it does not.
72
+ */
73
+ function primaryWeight(code) {
74
+ // "1"–"9" → 1–9. Base58 has no "0".
75
+ if (code >= 0x31 && code <= 0x39) {
76
+ return code - 0x30;
77
+ }
78
+ // "A"–"Z" → 100–125, case-folded onto the same weights as "a"–"z".
79
+ if (code >= 0x41 && code <= 0x5a) {
80
+ return 100 + (code - 0x41);
81
+ }
82
+ // "a"–"z" → 100–125.
83
+ if (code >= 0x61 && code <= 0x7a) {
84
+ return 100 + (code - 0x61);
85
+ }
86
+ // Anything else (never present in Base58) keeps a stable relative position
87
+ // by its own code point, above every letter.
88
+ return 1000 + code;
89
+ }
90
+ function isUpperAscii(code) {
91
+ return code >= 0x41 && code <= 0x5a;
92
+ }
93
+ /**
94
+ * The ordering that `localeCompare()` produced for Base58 strings on an
95
+ * `en`-family runtime — the rule every pre-v2 vault address was built under.
96
+ *
97
+ * Exists ONLY so those vaults keep re-deriving the address their UTXOs are
98
+ * locked to. Do not use it for anything new; new vaults use
99
+ * {@link compareCodepoint} via {@link ORDERING_XPUB_CODEPOINT_V2}.
100
+ *
101
+ * Verified equal to `Intl.Collator` for `en-US`, `en-GB`, `de-DE`, `fr-FR`,
102
+ * `ja-JP`, `zh-CN`, `ar-EG`, `he-IL`, `ru-RU`, `el-GR` and `hi-IN` across
103
+ * exhaustive two-character Base58 pairs and randomised full-length `tpub`
104
+ * corpora — see `participant-order.test.ts`. Those locales all inherit root
105
+ * collation for ASCII; the tailorings that diverge (`cs`, `da`, `nb`, `et`,
106
+ * `lt`, `hu`) are the bug this replaces, not behaviour to reproduce.
107
+ */
108
+ export function compareBase58IcuEnV1(a, b) {
109
+ const shared = Math.min(a.length, b.length);
110
+ // Primary pass: letter and digit identity across the whole string.
111
+ for (let i = 0; i < shared; i++) {
112
+ const diff = primaryWeight(a.charCodeAt(i)) - primaryWeight(b.charCodeAt(i));
113
+ if (diff !== 0) {
114
+ return diff < 0 ? -1 : 1;
115
+ }
116
+ }
117
+ if (a.length !== b.length) {
118
+ return a.length < b.length ? -1 : 1;
119
+ }
120
+ // Tertiary pass: case, lowercase before uppercase.
121
+ for (let i = 0; i < shared; i++) {
122
+ const aUpper = isUpperAscii(a.charCodeAt(i));
123
+ const bUpper = isUpperAscii(b.charCodeAt(i));
124
+ if (aUpper !== bUpper) {
125
+ return aUpper ? 1 : -1;
126
+ }
127
+ }
128
+ return 0;
129
+ }
130
+ /** The xpub comparator for a given ordering rule. */
131
+ export function xpubComparatorFor(ordering) {
132
+ return ordering === ORDERING_XPUB_CODEPOINT_V2
133
+ ? compareCodepoint
134
+ : compareBase58IcuEnV1;
135
+ }
@@ -0,0 +1,13 @@
1
+ import { type ScriptType } from "@scure/btc-signer";
2
+ /**
3
+ * The k-of-n CHECKSIG/CHECKSIGADD ladder, as an op list.
4
+ *
5
+ * This is the single implementation used by both the direct k-of-n builder and
6
+ * the ADMINS_REQUIRED builder in `build.ts`.
7
+ *
8
+ * Keys must already be sorted: ordering is the caller's policy decision, and it
9
+ * is mirrored into `Leaf.signers`, which is what the witness assembler walks.
10
+ */
11
+ export declare function kOfNOps(xonlys: Uint8Array[], k: number): ScriptType;
12
+ /** Serialise the k-of-n ladder to tapscript bytes. */
13
+ export declare function buildKofNScript(xonlys: Uint8Array[], k: number): Uint8Array;
@@ -0,0 +1,39 @@
1
+ import { Script } from "@scure/btc-signer";
2
+ import { BTC_VAULT_MAX_PARTICIPANTS } from "./limits.js";
3
+ /**
4
+ * The k-of-n CHECKSIG/CHECKSIGADD ladder, as an op list.
5
+ *
6
+ * This is the single implementation used by both the direct k-of-n builder and
7
+ * the ADMINS_REQUIRED builder in `build.ts`.
8
+ *
9
+ * Keys must already be sorted: ordering is the caller's policy decision, and it
10
+ * is mirrored into `Leaf.signers`, which is what the witness assembler walks.
11
+ */
12
+ export function kOfNOps(xonlys, k) {
13
+ // Script.encode truncates fractional numbers in the OP_1..OP_16 range.
14
+ if (!Number.isInteger(k) || k < 1 || k > xonlys.length) {
15
+ throw new Error(`invalid threshold ${k}: must be an integer in 1..${xonlys.length}`);
16
+ }
17
+ // Cofund caps every vault at fourteen participants so newly created account
18
+ // vaults remain within Ledger's fifteen-key policy after the NUMS internal key.
19
+ if (xonlys.length > BTC_VAULT_MAX_PARTICIPANTS) {
20
+ throw new Error(`too many keys: ${xonlys.length} exceeds the vault participant limit of ${BTC_VAULT_MAX_PARTICIPANTS}`);
21
+ }
22
+ const ops = [];
23
+ xonlys.forEach((pk, i) => {
24
+ // BIP-342 assigns non-32-byte pushes unknown public-key semantics, so reject
25
+ // them before they can become an unintended successful CHECKSIGADD.
26
+ if (pk.length !== 32) {
27
+ throw new Error(`x-only key #${i} must be 32 bytes, got ${pk.length}`);
28
+ }
29
+ ops.push(pk);
30
+ ops.push(i === 0 ? "CHECKSIG" : "CHECKSIGADD");
31
+ });
32
+ ops.push(k);
33
+ ops.push("NUMEQUAL");
34
+ return ops;
35
+ }
36
+ /** Serialise the k-of-n ladder to tapscript bytes. */
37
+ export function buildKofNScript(xonlys, k) {
38
+ return Script.encode(kOfNOps(xonlys, k));
39
+ }
@@ -0,0 +1,63 @@
1
+ import * as btc from "@scure/btc-signer";
2
+ import type { Leaf } from "./types.ts";
3
+ /**
4
+ * The unsigned spend of a vault, built once for everyone who signs or
5
+ * verifies it: the app's signing session (browser and server), the Ledger
6
+ * PSBT and the SDK's signer. A BIP-341 signature commits to every input's
7
+ * outpoint, amount, script and sequence, to the outputs and to the version and
8
+ * lock time, so these parties agree byte for byte or no signature verifies.
9
+ */
10
+ /** Opt-in RBF on every input. */
11
+ export declare const VAULT_SPEND_SEQUENCE = 4294967293;
12
+ export declare const VAULT_SPEND_VERSION = 2;
13
+ export declare const VAULT_SPEND_LOCKTIME = 0;
14
+ /** A vault UTXO the spend consumes: its id names it in the signing protocol. */
15
+ export type VaultSpendInput = {
16
+ id: string;
17
+ txid: string;
18
+ vout: number;
19
+ /** Sats. */
20
+ amount: number | bigint;
21
+ };
22
+ export type VaultSpendOutput = {
23
+ script: Uint8Array;
24
+ amount: bigint;
25
+ };
26
+ /**
27
+ * The inputs in the order every party signs: by utxo id, code point order.
28
+ * The input index is part of what each signature commits to.
29
+ */
30
+ export declare function vaultSpendInputOrder<T extends {
31
+ id: string;
32
+ }>(inputs: readonly T[]): T[];
33
+ /**
34
+ * `outputs` then the change back to the vault: the inputs less the outputs
35
+ * less `fee`, when that is positive. Throws when the inputs cannot pay.
36
+ */
37
+ export declare function withVaultChange(inputs: readonly VaultSpendInput[], outputs: readonly VaultSpendOutput[], vaultScript: Uint8Array, fee: number | bigint): VaultSpendOutput[];
38
+ /**
39
+ * The unsigned transaction spending vault UTXOs: inputs in
40
+ * {@link vaultSpendInputOrder}, each with sequence
41
+ * {@link VAULT_SPEND_SEQUENCE} and its witness UTXO (the vault's script and
42
+ * the input's amount), then `outputs` in the order given; version
43
+ * {@link VAULT_SPEND_VERSION}, lock time {@link VAULT_SPEND_LOCKTIME}. With
44
+ * `leaves`, each input also lists them as its tapleaf scripts (a PSBT's
45
+ * `tapLeafScript`), which the finalizer and a hardware signer read.
46
+ *
47
+ * Returns the inputs in transaction order, so input `i` is `inputs[i]`.
48
+ */
49
+ export declare function buildVaultSpend<Input extends VaultSpendInput>({ vaultScript, inputs, outputs, leaves, }: {
50
+ vaultScript: Uint8Array;
51
+ inputs: readonly Input[];
52
+ outputs: readonly VaultSpendOutput[];
53
+ leaves?: readonly Leaf[];
54
+ }): {
55
+ tx: btc.Transaction;
56
+ inputs: Input[];
57
+ };
58
+ /**
59
+ * The BIP-341 script-path sighash (SIGHASH_DEFAULT) a signer of `leafScript`
60
+ * signs for input `index` of a {@link buildVaultSpend} transaction, every
61
+ * input's prevout read from its witness UTXO.
62
+ */
63
+ export declare function vaultSpendSighash(tx: btc.Transaction, index: number, leafScript: Uint8Array): Uint8Array;
@@ -0,0 +1,93 @@
1
+ import * as btc from "@scure/btc-signer";
2
+ import { TAP_LEAF_VERSION } from "@scure/btc-signer/payment.js";
3
+ import { compareCodepoint } from "./canonical-order.js";
4
+ /**
5
+ * The unsigned spend of a vault, built once for everyone who signs or
6
+ * verifies it: the app's signing session (browser and server), the Ledger
7
+ * PSBT and the SDK's signer. A BIP-341 signature commits to every input's
8
+ * outpoint, amount, script and sequence, to the outputs and to the version and
9
+ * lock time, so these parties agree byte for byte or no signature verifies.
10
+ */
11
+ /** Opt-in RBF on every input. */
12
+ export const VAULT_SPEND_SEQUENCE = 0xff_ff_ff_fd;
13
+ export const VAULT_SPEND_VERSION = 2;
14
+ export const VAULT_SPEND_LOCKTIME = 0;
15
+ /**
16
+ * The inputs in the order every party signs: by utxo id, code point order.
17
+ * The input index is part of what each signature commits to.
18
+ */
19
+ export function vaultSpendInputOrder(inputs) {
20
+ return [...inputs].sort((a, b) => compareCodepoint(a.id, b.id));
21
+ }
22
+ /**
23
+ * `outputs` then the change back to the vault: the inputs less the outputs
24
+ * less `fee`, when that is positive. Throws when the inputs cannot pay.
25
+ */
26
+ export function withVaultChange(inputs, outputs, vaultScript, fee) {
27
+ const total = inputs.reduce((sum, input) => sum + BigInt(input.amount), 0n);
28
+ const spent = outputs.reduce((sum, output) => sum + output.amount, 0n);
29
+ const change = total - spent - BigInt(fee);
30
+ if (change < 0n) {
31
+ throw new Error(`Insufficient funds: have ${total}, need ${spent + BigInt(fee)}`);
32
+ }
33
+ return change > 0n
34
+ ? [...outputs, { script: vaultScript, amount: change }]
35
+ : [...outputs];
36
+ }
37
+ /**
38
+ * The unsigned transaction spending vault UTXOs: inputs in
39
+ * {@link vaultSpendInputOrder}, each with sequence
40
+ * {@link VAULT_SPEND_SEQUENCE} and its witness UTXO (the vault's script and
41
+ * the input's amount), then `outputs` in the order given; version
42
+ * {@link VAULT_SPEND_VERSION}, lock time {@link VAULT_SPEND_LOCKTIME}. With
43
+ * `leaves`, each input also lists them as its tapleaf scripts (a PSBT's
44
+ * `tapLeafScript`), which the finalizer and a hardware signer read.
45
+ *
46
+ * Returns the inputs in transaction order, so input `i` is `inputs[i]`.
47
+ */
48
+ export function buildVaultSpend({ vaultScript, inputs, outputs, leaves, }) {
49
+ const ordered = vaultSpendInputOrder(inputs);
50
+ const tx = new btc.Transaction({
51
+ version: VAULT_SPEND_VERSION,
52
+ lockTime: VAULT_SPEND_LOCKTIME,
53
+ });
54
+ for (const input of ordered) {
55
+ tx.addInput({
56
+ txid: input.txid,
57
+ index: input.vout,
58
+ sequence: VAULT_SPEND_SEQUENCE,
59
+ witnessUtxo: { script: vaultScript, amount: BigInt(input.amount) },
60
+ ...(leaves && {
61
+ tapLeafScript: leaves.map((leaf) => [
62
+ {
63
+ // A control block's first byte is the leaf version plus the
64
+ // output key's parity, which the struct keeps apart.
65
+ version: leaf.controlBlockStruct.version + leaf.controlBlockStruct.parity,
66
+ internalKey: leaf.controlBlockStruct.internalKey,
67
+ merklePath: leaf.controlBlockStruct.merklePath,
68
+ },
69
+ new Uint8Array([...leaf.script, TAP_LEAF_VERSION]),
70
+ ]),
71
+ }),
72
+ });
73
+ }
74
+ for (const output of outputs) {
75
+ tx.addOutput({ script: output.script, amount: output.amount });
76
+ }
77
+ return { tx, inputs: ordered };
78
+ }
79
+ /**
80
+ * The BIP-341 script-path sighash (SIGHASH_DEFAULT) a signer of `leafScript`
81
+ * signs for input `index` of a {@link buildVaultSpend} transaction, every
82
+ * input's prevout read from its witness UTXO.
83
+ */
84
+ export function vaultSpendSighash(tx, index, leafScript) {
85
+ const prevouts = Array.from({ length: tx.inputsLength }, (_, i) => {
86
+ const { witnessUtxo } = tx.getInput(i);
87
+ if (!witnessUtxo) {
88
+ throw new Error(`Input ${i} of the spend has no witness UTXO`);
89
+ }
90
+ return witnessUtxo;
91
+ });
92
+ return tx.preimageWitnessV1(index, prevouts.map(({ script }) => script), btc.SigHash.DEFAULT, prevouts.map(({ amount }) => amount), undefined, leafScript, TAP_LEAF_VERSION);
93
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Accurate BTC virtual-size (vbytes) estimation for cofund vault spends.
3
+ *
4
+ * The old flat `INPUT_VBYTES = 78` under-counted the real taproot SCRIPT-PATH
5
+ * witness (a 2-of-2 spend is ~108 vB/input: 2 schnorr sigs + the leaf script +
6
+ * the control block), so at 1 sat/vB (regtest/devnet) the estimated fee fell
7
+ * below the node's min-relay floor and broadcasts were rejected. These helpers
8
+ * size each input from the vault's REAL tapscript leaves instead.
9
+ *
10
+ * The sizing is exact for the witness, not an upper bound: a k-of-n leaf is
11
+ * sized for its k signatures and (n − k) empty slots, which is the only shape
12
+ * that can spend it. What headroom remains is deliberate and lives elsewhere —
13
+ * the app's `FEE_SAFETY_MULTIPLIER`, and its choice to price every output as
14
+ * P2TR even when a recipient is a cheaper type.
15
+ */
16
+ export declare const TX_OVERHEAD_VBYTES = 11;
17
+ /** vbytes of one output: 8 (value) + 1 (script-length prefix) + script bytes. */
18
+ export declare function outputVbytes(scriptLen: number): number;
19
+ type SizingLeaf = {
20
+ /** Signers in script order. Each occupies one witness slot, signed or not. */
21
+ signers: unknown[];
22
+ /** How many of those slots carry a real signature — the leaf's k. */
23
+ threshold: number;
24
+ script: Uint8Array;
25
+ controlBlockStruct: {
26
+ merklePath: Uint8Array[];
27
+ };
28
+ };
29
+ /**
30
+ * vsize of one taproot SCRIPT-PATH input spending `leaf`.
31
+ *
32
+ * The witness always has exactly one slot per signer, of which exactly
33
+ * `threshold` carry a 64-byte schnorr signature and the rest are EMPTY pushes
34
+ * costing a single length byte:
35
+ *
36
+ * [ k × 64-byte sig | (n − k) × empty | leaf script | control block ]
37
+ *
38
+ * Sizing for k rather than n is not optimistic — k is the only spendable shape.
39
+ * `orderedSigStackForLeaf` caps non-empty slots at the threshold and blanks any
40
+ * extra valid signature, and the leaf script ends in `OP_NUMEQUAL k`, which
41
+ * rejects a sum above k outright. Verified against the on-chain witness of tx
42
+ * `d512fd27…fc8c` (2-of-5, 172-byte script, depth 0): this predicts 341 witness
43
+ * bytes and the transaction carries exactly 341.
44
+ *
45
+ * `threshold` is clamped into [0, n], so a malformed value can only ever make
46
+ * this over-count — never under-count into a min-relay rejection.
47
+ *
48
+ * For a full-threshold leaf (k === n, e.g. 2-of-2) there are no empty slots and
49
+ * this is identical to the old all-signers sizing. It is only k < n vaults that
50
+ * change, where the old estimate carried (n − k) × 16 vB of phantom size per
51
+ * input — 48 vB/input on a 2-of-5.
52
+ *
53
+ * Control block = 1 (parity+version) + 32 (internal key) + 32 × merkle depth.
54
+ */
55
+ export declare function leafInputVbytes(leaf: SizingLeaf): number;
56
+ /**
57
+ * Total tx vsize: overhead + inputs (all spending the same vault) + outputs.
58
+ * `outputScriptLens` lets callers size mixed output types exactly; the common
59
+ * cofund output is P2TR (34-byte script → 43 vB).
60
+ */
61
+ export declare function vaultTxVbytes({ inputVbytes, nIn, outputScriptLens, }: {
62
+ inputVbytes: number;
63
+ nIn: number;
64
+ outputScriptLens: number[];
65
+ }): number;
66
+ export {};
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Accurate BTC virtual-size (vbytes) estimation for cofund vault spends.
3
+ *
4
+ * The old flat `INPUT_VBYTES = 78` under-counted the real taproot SCRIPT-PATH
5
+ * witness (a 2-of-2 spend is ~108 vB/input: 2 schnorr sigs + the leaf script +
6
+ * the control block), so at 1 sat/vB (regtest/devnet) the estimated fee fell
7
+ * below the node's min-relay floor and broadcasts were rejected. These helpers
8
+ * size each input from the vault's REAL tapscript leaves instead.
9
+ *
10
+ * The sizing is exact for the witness, not an upper bound: a k-of-n leaf is
11
+ * sized for its k signatures and (n − k) empty slots, which is the only shape
12
+ * that can spend it. What headroom remains is deliberate and lives elsewhere —
13
+ * the app's `FEE_SAFETY_MULTIPLIER`, and its choice to price every output as
14
+ * P2TR even when a recipient is a cheaper type.
15
+ */
16
+ // A taproot input's non-witness bytes (counted ×4 in weight): 36-byte outpoint
17
+ // + 1-byte empty scriptSig length + 4-byte sequence.
18
+ const NON_WITNESS_INPUT_VBYTES = 41;
19
+ // version(4) + segwit marker+flag(2 weight units = 0.5 vB) + in/out compactSize
20
+ // (1 each) + locktime(4), rounded up — exact for the small input/output counts
21
+ // cofund produces (well under the 253 threshold where compactSize grows).
22
+ export const TX_OVERHEAD_VBYTES = 11;
23
+ /** vbytes of one output: 8 (value) + 1 (script-length prefix) + script bytes. */
24
+ export function outputVbytes(scriptLen) {
25
+ return 8 + 1 + scriptLen;
26
+ }
27
+ /**
28
+ * vsize of one taproot SCRIPT-PATH input spending `leaf`.
29
+ *
30
+ * The witness always has exactly one slot per signer, of which exactly
31
+ * `threshold` carry a 64-byte schnorr signature and the rest are EMPTY pushes
32
+ * costing a single length byte:
33
+ *
34
+ * [ k × 64-byte sig | (n − k) × empty | leaf script | control block ]
35
+ *
36
+ * Sizing for k rather than n is not optimistic — k is the only spendable shape.
37
+ * `orderedSigStackForLeaf` caps non-empty slots at the threshold and blanks any
38
+ * extra valid signature, and the leaf script ends in `OP_NUMEQUAL k`, which
39
+ * rejects a sum above k outright. Verified against the on-chain witness of tx
40
+ * `d512fd27…fc8c` (2-of-5, 172-byte script, depth 0): this predicts 341 witness
41
+ * bytes and the transaction carries exactly 341.
42
+ *
43
+ * `threshold` is clamped into [0, n], so a malformed value can only ever make
44
+ * this over-count — never under-count into a min-relay rejection.
45
+ *
46
+ * For a full-threshold leaf (k === n, e.g. 2-of-2) there are no empty slots and
47
+ * this is identical to the old all-signers sizing. It is only k < n vaults that
48
+ * change, where the old estimate carried (n − k) × 16 vB of phantom size per
49
+ * input — 48 vB/input on a 2-of-5.
50
+ *
51
+ * Control block = 1 (parity+version) + 32 (internal key) + 32 × merkle depth.
52
+ */
53
+ export function leafInputVbytes(leaf) {
54
+ const controlBlockLen = 33 + 32 * leaf.controlBlockStruct.merklePath.length;
55
+ const signed = Math.max(0, Math.min(leaf.threshold, leaf.signers.length));
56
+ const witnessBytes = 1 + // stack item count
57
+ signed * (1 + 64) + // a 64-byte sig plus its length prefix
58
+ (leaf.signers.length - signed) + // empty push: length prefix only
59
+ (1 + leaf.script.length) + // leaf script
60
+ (1 + controlBlockLen); // control block
61
+ return NON_WITNESS_INPUT_VBYTES + Math.ceil(witnessBytes / 4);
62
+ }
63
+ /**
64
+ * Total tx vsize: overhead + inputs (all spending the same vault) + outputs.
65
+ * `outputScriptLens` lets callers size mixed output types exactly; the common
66
+ * cofund output is P2TR (34-byte script → 43 vB).
67
+ */
68
+ export function vaultTxVbytes({ inputVbytes, nIn, outputScriptLens, }) {
69
+ const outputs = outputScriptLens.reduce((s, len) => s + outputVbytes(len), 0);
70
+ return TX_OVERHEAD_VBYTES + nIn * inputVbytes + outputs;
71
+ }
@@ -0,0 +1,49 @@
1
+ export type Role = "admin" | "member";
2
+ export interface User {
3
+ id: string;
4
+ role: Role;
5
+ xonly: Uint8Array;
6
+ pubCompressed: Uint8Array;
7
+ }
8
+ export type VaultType = "ALL" | "ADMINS_INCLUDED" | "ADMINS_REQUIRED";
9
+ export interface VaultContract {
10
+ vaultType: VaultType;
11
+ internalKeyXonly: Uint8Array;
12
+ }
13
+ export interface Leaf {
14
+ threshold: number;
15
+ signers: User[];
16
+ script: Uint8Array;
17
+ leafHash: Uint8Array;
18
+ controlBlockStruct: {
19
+ parity: number;
20
+ version: number;
21
+ internalKey: Uint8Array;
22
+ merklePath: Uint8Array[];
23
+ };
24
+ }
25
+ export type BuildVaultOptions = {
26
+ /** Default 2 keeps pre-threshold vaults (contractId without the field)
27
+ * re-deriving to the same address. */
28
+ threshold?: number;
29
+ /**
30
+ * Rebuild a vault that ALREADY EXISTS on chain, skipping the leaf-key
31
+ * uniqueness invariant.
32
+ *
33
+ * Only `buildTaprootFromContractId` may set this. A vault created before the
34
+ * invariant existed may hold a duplicated roster; its address must still
35
+ * resolve or every read path (balances, deposit address, PDFs, cron, the
36
+ * sweep that moves the funds to safety) breaks and the coins become
37
+ * unreachable. Never set it on a creation path — that is the one place the
38
+ * invariant is load-bearing.
39
+ */
40
+ reconstructingExistingVault?: boolean;
41
+ };
42
+ export interface BuiltVault {
43
+ merkleRoot?: Uint8Array;
44
+ tweakedPubkey: Uint8Array;
45
+ scriptPubKey: Uint8Array;
46
+ address: string;
47
+ leaves: Leaf[];
48
+ internalPubKey: Uint8Array;
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Account-Based Vault Builder
3
+ *
4
+ * Builds BTC vaults from account-level xpubs. Everything is deterministic:
5
+ * given xpubs + roles + vaultType + network + NUMS recipe, the vault address
6
+ * is reproducible.
7
+ *
8
+ * Key differences from the legacy MuSig2 builder (build.ts):
9
+ * - Internal key: unspendable NUMS point (script-path-only, no key-path spend)
10
+ * - ADMINS_REQUIRED: each admin leaf uses a unique branch namespace
11
+ * (leaf i → branch 2*i) so Ledger sees distinct keys per script
12
+ * - All keys are derived from xpubs at runtime via deriveChildFromXpub
13
+ */
14
+ /** biome-ignore-all lint/suspicious/noBitwiseOperators: Taproot control-block bytes encode parity with bit masks. */
15
+ import type { BTC_NETWORK } from "@scure/btc-signer/utils.js";
16
+ import { type Bip341NumsRecipe } from "./bip341-nums.ts";
17
+ import { type ParticipantOrdering } from "./participant-order.ts";
18
+ import type { BuiltVault, Leaf, VaultType } from "./types.ts";
19
+ /** Minimal user input — everything else is derived deterministically. */
20
+ export type AccountVaultUser = {
21
+ id: string;
22
+ role: "admin" | "member";
23
+ accountXpub: string;
24
+ };
25
+ export type AccountVaultBuildOptions = {
26
+ nums: Bip341NumsRecipe;
27
+ btcNetwork?: BTC_NETWORK;
28
+ /**
29
+ * Signatures required to spend. The default preserves addresses for vaults
30
+ * created before the serialized contract included a threshold.
31
+ */
32
+ threshold?: number;
33
+ /**
34
+ * Rebuild a vault that ALREADY EXISTS on chain, skipping the leaf-key
35
+ * uniqueness invariant.
36
+ *
37
+ * Only `buildTaprootFromContractId` may set this. A vault created before the
38
+ * invariant existed may hold a duplicated roster; its address must still
39
+ * resolve or every read path (balances, deposit address, PDFs, cron, the
40
+ * sweep that moves the funds to safety) breaks and the coins become
41
+ * unreachable. Never set it on a creation path — that is the one place the
42
+ * invariant is load-bearing.
43
+ */
44
+ reconstructingExistingVault?: boolean;
45
+ /**
46
+ * Which participant-ordering rule to sort the roster under.
47
+ *
48
+ * Defaults to {@link ORDERING_ICU_EN_V1} because that is what every vault
49
+ * created before the rule was recorded used — so a caller that forgets to
50
+ * pass it reproduces a historical address rather than inventing a new one.
51
+ * Creation paths must pass {@link CURRENT_PARTICIPANT_ORDERING} explicitly and
52
+ * persist it in the contract.
53
+ */
54
+ participantOrdering?: ParticipantOrdering;
55
+ };
56
+ /**
57
+ * Sort account vault users deterministically:
58
+ * admins first, then members. Within each group, sort by accountXpub.
59
+ *
60
+ * `ordering` is not optional and must come from the vault's own contract (see
61
+ * {@link participantOrderingOf}). This order IS part of the vault's address —
62
+ * it fixes the key order inside the tapscript, and for ADMINS_REQUIRED it fixes
63
+ * which admin owns which derivation branch — so sorting a roster under the
64
+ * wrong rule yields a different, unfunded address.
65
+ */
66
+ export declare function sortAccountUsers(users: AccountVaultUser[], ordering: ParticipantOrdering): AccountVaultUser[];
67
+ /**
68
+ * Build an account-based vault from xpubs.
69
+ *
70
+ * Returns the standard `BuiltVault` so all downstream code
71
+ * (taprootUtils, getVaultDetails, finalizeTx, cron) works unchanged.
72
+ *
73
+ * For ADMINS_REQUIRED vaults, leaf `i` derives keys at branch `2*i`,
74
+ * producing a unique key namespace per leaf. This satisfies the Ledger
75
+ * restriction that the same key must not appear in multiple scripts.
76
+ *
77
+ * For NAR vaults (ALL / ADMINS_INCLUDED), a single leaf at branch 0.
78
+ */
79
+ export declare function buildAccountBasedVault(users: AccountVaultUser[], vaultType: VaultType, network: "mainnet" | "testnet", options: AccountVaultBuildOptions): BuiltVault;
80
+ /**
81
+ * Map each built leaf back to the derivation branch its keys came from.
82
+ *
83
+ * The forward assignment lives in `buildAccountBasedVault` above: admin `i`
84
+ * in *sorted-admin* order owns branch `2*i`, and every signer's key inside
85
+ * that leaf is derived from that leaf's branch. The inverse is not the array
86
+ * index — `btc.taprootListToTree` returns leaves in Huffman tree-walk order,
87
+ * which diverges from creation order at 3 or more leaves. A leaf's branch can
88
+ * only be recovered from its *contents*, so this matches the leaf's admin key
89
+ * (script position 0) against each sorted admin's candidate key.
90
+ *
91
+ * Every consumer that needs a leaf's branch must go through here: the
92
+ * software signer's key derivation, and the Ledger PSBT's BIP-371
93
+ * tap-bip32-derivation records (which are malformed if a key is paired with a
94
+ * leaf that does not contain it).
95
+ *
96
+ * NAR vaults (ALL / ADMINS_INCLUDED) are a single leaf at branch 0.
97
+ *
98
+ * `ordering` must be the rule the vault was CREATED under, read off its own
99
+ * contract. Sorting the admins under a different rule permutes the branch
100
+ * assignment, so the signer derives a key that is not in the leaf it is paired
101
+ * with — a malformed PSBT on the Ledger path and a signature that cannot
102
+ * satisfy the script on the software path.
103
+ */
104
+ export declare function buildLeafBranchMap(leaves: Leaf[], vaultType: VaultType, accountXpubs: AccountVaultUser[] | undefined, ordering: ParticipantOrdering): Map<Leaf, number>;
105
+ /**
106
+ * Compute the unspendable internal key x-only for a given network.
107
+ * Used by the serializer to store a backward-compatible aggregateKey.
108
+ */
109
+ export declare function getUnspendableInternalKeyXonly(network: "mainnet" | "testnet", numsRecipe: Bip341NumsRecipe): Uint8Array;