@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,136 @@
1
+ /** biome-ignore-all lint/suspicious/noBitwiseOperators: Taproot control-block bytes encode parity with bit masks. */
2
+ import { hex } from "@scure/base";
3
+ import * as btc from "@scure/btc-signer";
4
+ import { Script } from "@scure/btc-signer";
5
+ import { taprootTweakPubkey } from "@scure/btc-signer/utils.js";
6
+ import { assertDistinctLeafKeys, sortUsers } from "./helpers.js";
7
+ import { BTC_VAULT_MAX_PARTICIPANTS } from "./limits.js";
8
+ import { buildKofNScript, kOfNOps } from "./script.js";
9
+ function bytesEq(a, b) {
10
+ if (a.length !== b.length) {
11
+ return false;
12
+ }
13
+ for (let i = 0; i < a.length; i++) {
14
+ if (a[i] !== b[i]) {
15
+ return false;
16
+ }
17
+ }
18
+ return true;
19
+ }
20
+ export function buildAdminRequiredThenKofN(adminXonly, allXonly, t) {
21
+ if (t < 2) {
22
+ throw new Error("AR leaf requires t >= 2");
23
+ }
24
+ if (adminXonly.length !== 32) {
25
+ throw new Error("admin x-only must be 32 bytes");
26
+ }
27
+ if (allXonly.length > BTC_VAULT_MAX_PARTICIPANTS) {
28
+ throw new Error(`too many keys: ${allXonly.length} exceeds the vault participant limit of ${BTC_VAULT_MAX_PARTICIPANTS}`);
29
+ }
30
+ // exclude admin from the body set to prevent double-counting
31
+ const others = allXonly.filter((pk) => !bytesEq(pk, adminXonly));
32
+ if (others.length < t - 1) {
33
+ throw new Error(`invalid AR body: need ${t - 1} of ${others.length} (admin excluded)`);
34
+ }
35
+ // Script ops: <admin> CHECKSIGVERIFY + (t-1)-of-(others)
36
+ const ops = [
37
+ adminXonly,
38
+ "CHECKSIGVERIFY",
39
+ ...kOfNOps(others, t - 1),
40
+ ];
41
+ return Script.encode(ops);
42
+ }
43
+ export function buildVault(contract, companyUsers, network, options) {
44
+ const users = sortUsers(companyUsers);
45
+ const admins = users.filter((u) => u.role === "admin");
46
+ const allXonly = users.map((u) => u.xonly);
47
+ // Default 2 keeps pre-threshold vaults (contractId without the field)
48
+ // re-deriving to the same address.
49
+ const threshold = options?.threshold ?? 2;
50
+ if (threshold < 2 || threshold > users.length) {
51
+ throw new Error(`Invalid threshold ${threshold}: must be between 2 and ${users.length}`);
52
+ }
53
+ const minThreshold = threshold;
54
+ const drafts = [];
55
+ const isNAR = contract.vaultType === "ALL" || contract.vaultType === "ADMINS_INCLUDED";
56
+ if (isNAR) {
57
+ // NAR: one leaf per threshold over ALL users
58
+ const script = buildKofNScript(allXonly, minThreshold);
59
+ drafts.push({ threshold: minThreshold, signers: users, script });
60
+ }
61
+ else {
62
+ // AR: for each threshold and each admin, require that admin + (t-1)-of-all
63
+ if (admins.length === 0) {
64
+ throw new Error("ADMINS_REQUIRED vault needs at least one admin");
65
+ }
66
+ // your rule: min threshold is always 2, so t-1 >= 1
67
+ for (const admin of admins) {
68
+ const bodySigners = users.filter((user) => user.id !== admin.id);
69
+ const script = buildAdminRequiredThenKofN(admin.xonly, allXonly, minThreshold);
70
+ // The witness consumes the pinned admin first and then the body ladder.
71
+ // Preserve that same order in metadata so signing never interprets a
72
+ // script-ordered stack through the unrelated global x-only sort order.
73
+ drafts.push({
74
+ threshold: minThreshold,
75
+ signers: [admin, ...bodySigners],
76
+ script,
77
+ });
78
+ }
79
+ }
80
+ // console.log(`Built ${drafts.length} leaf scripts`);
81
+ // for (const d of drafts) {
82
+ // console.log(d.script);
83
+ // }
84
+ // Before the address exists: no leaf may hold one key in two slots. Legacy
85
+ // vaults store raw compressed pubkeys rather than xpubs, but the k-of-n
86
+ // ladder counts signatures identically, so a duplicate collapses it the same
87
+ // way.
88
+ if (!options?.reconstructingExistingVault) {
89
+ assertDistinctLeafKeys(drafts);
90
+ }
91
+ // 2) Convert to a Taproot script tree using btc.taprootListToTree
92
+ // NOTE: leaf version 0xc0 for tapscript v0
93
+ const leavesList = drafts.map((d) => ({
94
+ script: d.script,
95
+ version: 0xc0,
96
+ }));
97
+ console.log(`Building tree with ${leavesList.length} leaves...`);
98
+ const tree = btc.taprootListToTree(leavesList);
99
+ // 3) Build P2TR: tweaked pubkey, merkle root, control blocks, scriptPubKey, address
100
+ const res = btc.p2tr(contract.internalKeyXonly, tree, network, true);
101
+ // 4) Join p2tr outputs back to our drafts.
102
+ // btc.p2tr returns leaves[] with { hash, controlBlock, script?, version? } in the SAME order
103
+ // as leavesList. We index by position to merge.
104
+ const byScript = new Map(drafts.map((d) => [hex.encode(d.script), d]));
105
+ const [_, parity] = taprootTweakPubkey(contract.internalKeyXonly, res.tapMerkleRoot);
106
+ const outLeaves = res.leaves.map((leaf) => {
107
+ const key = hex.encode(leaf.script);
108
+ const d = byScript.get(key); //k-of-n metadata
109
+ const tweakedParity = res.tweakedPubkey.length === 33
110
+ ? res.tweakedPubkey[0] & 1 // 0x02=>0, 0x03=>1
111
+ : 0;
112
+ const baseVer = (leaf.version ?? 0xc0) & 0xfe; // LSB=0 for tapscript v0
113
+ const cbVersion = baseVer | tweakedParity;
114
+ const cbStruct = {
115
+ version: cbVersion,
116
+ internalKey: res.tapInternalKey, // x-only internal key (32B)
117
+ merklePath: leaf.path, // siblings from p2tr()
118
+ parity,
119
+ };
120
+ return {
121
+ threshold: d.threshold,
122
+ script: d.script,
123
+ leafHash: leaf.hash,
124
+ signers: d.signers,
125
+ controlBlockStruct: cbStruct,
126
+ };
127
+ });
128
+ return {
129
+ merkleRoot: res.tapMerkleRoot,
130
+ tweakedPubkey: res.tweakedPubkey,
131
+ scriptPubKey: res.script,
132
+ address: res.address,
133
+ leaves: outLeaves,
134
+ internalPubKey: contract.internalKeyXonly,
135
+ };
136
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Compare by UTF-16 code unit — what the `<` and `>` operators do.
3
+ *
4
+ * Total, pure, and identical in every runtime and locale. For ASCII inputs
5
+ * (UUIDs, hex digests, Base58) this is byte order: digits, then `A`–`Z`, then
6
+ * `a`–`z`. Anywhere a sort decides bytes that get hashed, signed or turned into
7
+ * an address, the comparator is part of the protocol: `localeCompare()` resolves
8
+ * the ambient collation, and several locales reorder plain ASCII.
9
+ */
10
+ export declare function compareCodepoint(a: string, b: string): number;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Compare by UTF-16 code unit — what the `<` and `>` operators do.
3
+ *
4
+ * Total, pure, and identical in every runtime and locale. For ASCII inputs
5
+ * (UUIDs, hex digests, Base58) this is byte order: digits, then `A`–`Z`, then
6
+ * `a`–`z`. Anywhere a sort decides bytes that get hashed, signed or turned into
7
+ * an address, the comparator is part of the protocol: `localeCompare()` resolves
8
+ * the ambient collation, and several locales reorder plain ASCII.
9
+ */
10
+ export function compareCodepoint(a, b) {
11
+ if (a < b) {
12
+ return -1;
13
+ }
14
+ if (a > b) {
15
+ return 1;
16
+ }
17
+ return 0;
18
+ }
@@ -0,0 +1,9 @@
1
+ import type { BTC_NETWORK } from "@scure/btc-signer/utils.js";
2
+ import type { BuiltVault } from "./types.ts";
3
+ /** Label that pairs with a `BTC_NETWORK` — only mainnet derives differently. */
4
+ export declare function networkLabelOf(btcNetwork: BTC_NETWORK): "mainnet" | "testnet";
5
+ /**
6
+ * Reconstructs a Taproot structure from a serialized contract ID against an
7
+ * explicit network. Auto-detects account-based vs legacy format.
8
+ */
9
+ export declare function buildTaprootForNetwork(contractId: string, btcNetwork: BTC_NETWORK): BuiltVault;
@@ -0,0 +1,74 @@
1
+ import { hex } from "@scure/base";
2
+ import { buildVault } from "./build.js";
3
+ import { findDuplicateLeafKeys } from "./helpers.js";
4
+ import { buildAccountBasedVault } from "./vault-policy.js";
5
+ import { deserializeVaultContractData } from "./vaultContractUtil.js";
6
+ // Reconstruction core, free of any process configuration: the app wraps it with
7
+ // its network for the request paths (`taprootUtils.ts`), and the SDK and the
8
+ // client-vault export pass their own.
9
+ /** Label that pairs with a `BTC_NETWORK` — only mainnet derives differently. */
10
+ export function networkLabelOf(btcNetwork) {
11
+ return btcNetwork.bech32 === "bc" ? "mainnet" : "testnet";
12
+ }
13
+ /**
14
+ * Reconstructs a Taproot structure from a serialized contract ID against an
15
+ * explicit network. Auto-detects account-based vs legacy format.
16
+ */
17
+ export function buildTaprootForNetwork(contractId, btcNetwork) {
18
+ const contractData = deserializeVaultContractData(contractId);
19
+ // Vaults created before the threshold was configurable omit the field — 2.
20
+ const threshold = contractData.threshold ?? 2;
21
+ // This vault already exists on chain and its address is fixed, so the
22
+ // leaf-key uniqueness invariant is reported rather than enforced: throwing
23
+ // here would break every read path — including the ones needed to move the
24
+ // funds out of a vault that IS collapsed. See `warnOnCollapsedRoster`.
25
+ let built;
26
+ // Account-based vaults: rebuild from xpubs via vault-policy builder
27
+ if (contractData.accountBased && contractData.accountXpubs) {
28
+ const network = contractData.network ?? networkLabelOf(btcNetwork);
29
+ if (!contractData.nums) {
30
+ throw new Error("Account vault is missing its BIP341 NUMS recipe");
31
+ }
32
+ built = buildAccountBasedVault(contractData.accountXpubs, contractData.vaultType, network, {
33
+ nums: contractData.nums,
34
+ btcNetwork,
35
+ reconstructingExistingVault: true,
36
+ threshold,
37
+ // The vault's own ordering rule, resolved by the deserializer. Never
38
+ // the process default — that is how a Czech-locale browser used to
39
+ // render a different address than the server for one contractId.
40
+ participantOrdering: contractData.participantOrdering,
41
+ });
42
+ }
43
+ else {
44
+ // Legacy vaults: MuSig2 aggregate internal key
45
+ const internalKeyXonly = hex.decode(contractData.aggregateKey);
46
+ const contract = {
47
+ vaultType: contractData.vaultType,
48
+ internalKeyXonly,
49
+ };
50
+ built = buildVault(contract, contractData.companyUsers, btcNetwork, {
51
+ reconstructingExistingVault: true,
52
+ threshold,
53
+ });
54
+ }
55
+ warnOnCollapsedRoster(built);
56
+ return built;
57
+ }
58
+ /**
59
+ * Log a vault whose signers share a key.
60
+ *
61
+ * New vaults can no longer be built this way, but one created before the
62
+ * invariant landed would silently keep a threshold that a single key can meet.
63
+ * Surfacing it in the logs is how we'd find out: the vault is not fixable in
64
+ * place — the roster is baked into the funded script — so the funds have to be
65
+ * moved to a newly constructed address.
66
+ */
67
+ function warnOnCollapsedRoster(built) {
68
+ // Logging must never be the reason an address fails to resolve: callers run
69
+ // this inside `.map()` over transaction rows and in PDF/email generation.
70
+ const duplicates = findDuplicateLeafKeys(built.leaves ?? []);
71
+ if (duplicates.length > 0) {
72
+ console.error(`SECURITY: BTC vault ${built.address} has signers sharing a key, so its threshold can be met by one signer. Move the funds to a newly created vault.`, duplicates);
73
+ }
74
+ }
@@ -0,0 +1,73 @@
1
+ import type { User, VaultType } from "./types.ts";
2
+ /**
3
+ * Sort users by x-only asc, then id (deterministic).
4
+ *
5
+ * Both comparisons are by code point. The primary key is lowercase hex, which
6
+ * every collation orders identically, but the `id` tie-break used to be
7
+ * `localeCompare` — and ids are UUIDs, which a `da`/`nb` runtime reorders
8
+ * because it folds `aa` to `å`. The tie only fires when two users share an
9
+ * x-only key, which `assertDistinctLeafKeys` now rejects at creation, so this
10
+ * could only ever have bitten a legacy vault with a duplicated roster. Code
11
+ * point matches what an `en`-family runtime already produced for UUIDs, so no
12
+ * existing address moves.
13
+ */
14
+ export declare function sortUsers(users: User[]): User[];
15
+ export declare function subsets<T>(arr: T[]): Iterable<T[]>;
16
+ export declare function isValidLeaf(vt: VaultType, t: number, S: User[]): boolean;
17
+ /** A leaf (or leaf draft) reduced to what the uniqueness check needs. */
18
+ type LeafKeys = {
19
+ signers: {
20
+ id: string;
21
+ xonly: Uint8Array;
22
+ }[];
23
+ };
24
+ export type DuplicateLeafKey = {
25
+ /** Hex x-only key that more than one signer slot resolved to. */
26
+ xonly: string;
27
+ /** Ids of every signer that resolved to it, in script order. */
28
+ signerIds: string[];
29
+ };
30
+ /**
31
+ * Find signer slots within a leaf that resolved to the SAME x-only key.
32
+ *
33
+ * Non-throwing counterpart to {@link assertDistinctLeafKeys}, for read paths
34
+ * that must keep working on an already-created vault.
35
+ */
36
+ export declare function findDuplicateLeafKeys(leaves: LeafKeys[]): DuplicateLeafKey[];
37
+ /**
38
+ * Every signer slot in a leaf must resolve to a DISTINCT x-only key.
39
+ *
40
+ * A tapscript threshold ladder counts signatures, not signers. BIP-342 does
41
+ * not require the keys or the signatures in a CHECKSIG/CHECKSIGADD chain to be
42
+ * distinct — every nonempty signature that verifies increments the counter —
43
+ * so when two company users share one account xpub they derive one leaf key,
44
+ * occupy two slots, and the holder of that key reaches threshold=2 alone. A
45
+ * hard 2-of-N collapses to 1-of-N and Bitcoin cannot tell the two users apart.
46
+ *
47
+ * Checked against the signer roster rather than the script's pushed keys,
48
+ * which is strictly stronger: an ADMINS_REQUIRED leaf filters keys equal to
49
+ * its admin out of the body, so a duplicate there is silently *dropped* from
50
+ * the cosigner set instead of double-counted — the script then encodes a
51
+ * narrower policy than the roster claims. Both outcomes are data errors.
52
+ *
53
+ * Runs before the address is derived. An address built from a duplicated
54
+ * roster cannot be un-built: per-user rows can be corrected afterwards, but
55
+ * the funded script stays collapsed and the funds have to be moved to a newly
56
+ * constructed vault.
57
+ */
58
+ export declare function assertDistinctLeafKeys(leaves: LeafKeys[]): void;
59
+ /**
60
+ * Thrown by {@link assertDistinctLeafKeys}. Carries an actionable message
61
+ * because it is a data problem an admin can fix, not an internal fault — the
62
+ * BTC deployer re-throws it verbatim instead of collapsing it into its generic
63
+ * "failed deploying" error.
64
+ */
65
+ export declare class DuplicateSignerKeyError extends Error {
66
+ readonly duplicates: DuplicateLeafKey[];
67
+ constructor(duplicates: DuplicateLeafKey[]);
68
+ }
69
+ export interface KeyAggResult {
70
+ aggPubXonly: Uint8Array;
71
+ }
72
+ export declare function aggregateKeys(hexKeys: (string | Uint8Array)[]): KeyAggResult;
73
+ export {};
@@ -0,0 +1,129 @@
1
+ import { hex } from "@scure/base";
2
+ import * as musig2 from "@scure/btc-signer/musig2.js";
3
+ import { compareCodepoint } from "./canonical-order.js";
4
+ /**
5
+ * Sort users by x-only asc, then id (deterministic).
6
+ *
7
+ * Both comparisons are by code point. The primary key is lowercase hex, which
8
+ * every collation orders identically, but the `id` tie-break used to be
9
+ * `localeCompare` — and ids are UUIDs, which a `da`/`nb` runtime reorders
10
+ * because it folds `aa` to `å`. The tie only fires when two users share an
11
+ * x-only key, which `assertDistinctLeafKeys` now rejects at creation, so this
12
+ * could only ever have bitten a legacy vault with a duplicated roster. Code
13
+ * point matches what an `en`-family runtime already produced for UUIDs, so no
14
+ * existing address moves.
15
+ */
16
+ export function sortUsers(users) {
17
+ return [...users].sort((a, b) => {
18
+ const byKey = compareCodepoint(hex.encode(a.xonly), hex.encode(b.xonly));
19
+ return byKey === 0 ? compareCodepoint(a.id, b.id) : byKey;
20
+ });
21
+ }
22
+ // All non-empty subsets
23
+ export function* subsets(arr) {
24
+ const n = arr.length;
25
+ for (let mask = 1; mask < 1 << n; mask++) {
26
+ const sub = [];
27
+ for (let i = 0; i < n; i++) {
28
+ if (mask & (1 << i)) {
29
+ sub.push(arr[i]);
30
+ }
31
+ }
32
+ yield sub;
33
+ }
34
+ }
35
+ // ADMINS_REQUIRED validity: include (t,S) iff members(S) <= t-1
36
+ export function isValidLeaf(vt, t, S) {
37
+ if (vt === "ALL" || vt === "ADMINS_INCLUDED") {
38
+ return true;
39
+ }
40
+ const members = S.filter((u) => u.role === "member").length;
41
+ return members <= t - 1;
42
+ }
43
+ /**
44
+ * Find signer slots within a leaf that resolved to the SAME x-only key.
45
+ *
46
+ * Non-throwing counterpart to {@link assertDistinctLeafKeys}, for read paths
47
+ * that must keep working on an already-created vault.
48
+ */
49
+ export function findDuplicateLeafKeys(leaves) {
50
+ const duplicates = [];
51
+ for (const leaf of leaves) {
52
+ const idsByKey = new Map();
53
+ for (const signer of leaf.signers) {
54
+ const key = hex.encode(signer.xonly);
55
+ const ids = idsByKey.get(key) ?? [];
56
+ ids.push(signer.id);
57
+ idsByKey.set(key, ids);
58
+ }
59
+ for (const [xonly, signerIds] of idsByKey) {
60
+ if (signerIds.length > 1) {
61
+ duplicates.push({ xonly, signerIds });
62
+ }
63
+ }
64
+ }
65
+ return duplicates;
66
+ }
67
+ /**
68
+ * Every signer slot in a leaf must resolve to a DISTINCT x-only key.
69
+ *
70
+ * A tapscript threshold ladder counts signatures, not signers. BIP-342 does
71
+ * not require the keys or the signatures in a CHECKSIG/CHECKSIGADD chain to be
72
+ * distinct — every nonempty signature that verifies increments the counter —
73
+ * so when two company users share one account xpub they derive one leaf key,
74
+ * occupy two slots, and the holder of that key reaches threshold=2 alone. A
75
+ * hard 2-of-N collapses to 1-of-N and Bitcoin cannot tell the two users apart.
76
+ *
77
+ * Checked against the signer roster rather than the script's pushed keys,
78
+ * which is strictly stronger: an ADMINS_REQUIRED leaf filters keys equal to
79
+ * its admin out of the body, so a duplicate there is silently *dropped* from
80
+ * the cosigner set instead of double-counted — the script then encodes a
81
+ * narrower policy than the roster claims. Both outcomes are data errors.
82
+ *
83
+ * Runs before the address is derived. An address built from a duplicated
84
+ * roster cannot be un-built: per-user rows can be corrected afterwards, but
85
+ * the funded script stays collapsed and the funds have to be moved to a newly
86
+ * constructed vault.
87
+ */
88
+ export function assertDistinctLeafKeys(leaves) {
89
+ const duplicates = findDuplicateLeafKeys(leaves);
90
+ if (duplicates.length > 0) {
91
+ throw new DuplicateSignerKeyError(duplicates);
92
+ }
93
+ }
94
+ /**
95
+ * Thrown by {@link assertDistinctLeafKeys}. Carries an actionable message
96
+ * because it is a data problem an admin can fix, not an internal fault — the
97
+ * BTC deployer re-throws it verbatim instead of collapsing it into its generic
98
+ * "failed deploying" error.
99
+ */
100
+ export class DuplicateSignerKeyError extends Error {
101
+ duplicates;
102
+ constructor(duplicates) {
103
+ const detail = duplicates
104
+ .map((d) => `${d.signerIds.join(" + ")} → ${d.xonly}`)
105
+ .join("; ");
106
+ super("Cannot create this Bitcoin vault: two or more signers share the same " +
107
+ "key, and a single signature from it would meet the vault's threshold " +
108
+ "on its own. Give each signer their own extended public key " +
109
+ `(${detail})`);
110
+ this.name = "DuplicateSignerKeyError";
111
+ this.duplicates = duplicates;
112
+ }
113
+ }
114
+ export function aggregateKeys(hexKeys) {
115
+ if (hexKeys.length < 2) {
116
+ throw new Error("Need at least 2 pubkeys for MuSig");
117
+ }
118
+ const pubKeys = hexKeys.map((k, i) => {
119
+ const bytes = typeof k === "string" ? hex.decode(k) : k;
120
+ if (bytes.length !== 33 || (bytes[0] !== 0x02 && bytes[0] !== 0x03)) {
121
+ throw new Error(`Pubkey #${i} must be compressed (33 bytes starting with 0x02/0x03)`);
122
+ }
123
+ return bytes;
124
+ });
125
+ const sortedKeys = musig2.sortKeys(pubKeys);
126
+ const ctx = musig2.keyAggregate(sortedKeys);
127
+ const aggPubXonly = musig2.keyAggExport(ctx);
128
+ return { aggPubXonly };
129
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A Bitcoin vault's taproot tree, built from its definition: the members'
3
+ * account xpubs, the template, the threshold, the participant ordering and the
4
+ * NUMS recipe. The app builds and reconstructs vaults with this code
5
+ * (`apps/cofund/src/lib/bitcoin_scriptpath/creation` re-exports it), and the
6
+ * SDK rebuilds a vault to check its address before signing for it.
7
+ */
8
+ export * from "./bip341-nums.ts";
9
+ export * from "./build.ts";
10
+ export * from "./canonical-order.ts";
11
+ export * from "./from-contract-id.ts";
12
+ export * from "./helpers.ts";
13
+ export * from "./limits.ts";
14
+ export * from "./participant-order.ts";
15
+ export * from "./script.ts";
16
+ export * from "./spend.ts";
17
+ export * from "./tx-size.ts";
18
+ export * from "./types.ts";
19
+ export * from "./vault-policy.ts";
20
+ export * from "./vaultContractUtil.ts";
21
+ export * from "./xpub.ts";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A Bitcoin vault's taproot tree, built from its definition: the members'
3
+ * account xpubs, the template, the threshold, the participant ordering and the
4
+ * NUMS recipe. The app builds and reconstructs vaults with this code
5
+ * (`apps/cofund/src/lib/bitcoin_scriptpath/creation` re-exports it), and the
6
+ * SDK rebuilds a vault to check its address before signing for it.
7
+ */
8
+ export * from "./bip341-nums.js";
9
+ export * from "./build.js";
10
+ export * from "./canonical-order.js";
11
+ export * from "./from-contract-id.js";
12
+ export * from "./helpers.js";
13
+ export * from "./limits.js";
14
+ export * from "./participant-order.js";
15
+ export * from "./script.js";
16
+ export * from "./spend.js";
17
+ export * from "./tx-size.js";
18
+ export * from "./types.js";
19
+ export * from "./vault-policy.js";
20
+ export * from "./vaultContractUtil.js";
21
+ export * from "./xpub.js";
@@ -0,0 +1,9 @@
1
+ /** Ledger Bitcoin app wallet-policy limit for supported hardware signers. */
2
+ export declare const LEDGER_MAX_POLICY_KEYS = 15;
3
+ /**
4
+ * One Ledger wallet-policy key is the NUMS-backed Taproot internal key, leaving
5
+ * fourteen slots for participant account xpubs. Cofund applies this as a
6
+ * universal product limit so every vault it creates remains Ledger-compatible
7
+ * at the key-count layer, regardless of the currently selected signer types.
8
+ */
9
+ export declare const BTC_VAULT_MAX_PARTICIPANTS: number;
@@ -0,0 +1,9 @@
1
+ /** Ledger Bitcoin app wallet-policy limit for supported hardware signers. */
2
+ export const LEDGER_MAX_POLICY_KEYS = 15;
3
+ /**
4
+ * One Ledger wallet-policy key is the NUMS-backed Taproot internal key, leaving
5
+ * fourteen slots for participant account xpubs. Cofund applies this as a
6
+ * universal product limit so every vault it creates remains Ledger-compatible
7
+ * at the key-count layer, regardless of the currently selected signer types.
8
+ */
9
+ export const BTC_VAULT_MAX_PARTICIPANTS = LEDGER_MAX_POLICY_KEYS - 1;
@@ -0,0 +1,69 @@
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
+ /**
31
+ * Ordering rule a vault's address was derived under.
32
+ *
33
+ * Persisted in the `contractId` so a vault always re-derives under the rule it
34
+ * was created with. An absent field means {@link ORDERING_ICU_EN_V1} — every
35
+ * vault created before this field existed was ordered by `localeCompare`.
36
+ */
37
+ export declare const ORDERING_ICU_EN_V1 = "icu-en-v1";
38
+ /** Ordering rule for vaults created from now on. */
39
+ export declare const ORDERING_XPUB_CODEPOINT_V2 = "role-then-xpub-codepoint-v2";
40
+ export type ParticipantOrdering = typeof ORDERING_ICU_EN_V1 | typeof ORDERING_XPUB_CODEPOINT_V2;
41
+ /** The rule new vaults are created under. */
42
+ export declare const CURRENT_PARTICIPANT_ORDERING: ParticipantOrdering;
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 declare function participantOrderingOf(value: string | undefined): ParticipantOrdering;
52
+ /**
53
+ * The ordering that `localeCompare()` produced for Base58 strings on an
54
+ * `en`-family runtime — the rule every pre-v2 vault address was built under.
55
+ *
56
+ * Exists ONLY so those vaults keep re-deriving the address their UTXOs are
57
+ * locked to. Do not use it for anything new; new vaults use
58
+ * {@link compareCodepoint} via {@link ORDERING_XPUB_CODEPOINT_V2}.
59
+ *
60
+ * Verified equal to `Intl.Collator` for `en-US`, `en-GB`, `de-DE`, `fr-FR`,
61
+ * `ja-JP`, `zh-CN`, `ar-EG`, `he-IL`, `ru-RU`, `el-GR` and `hi-IN` across
62
+ * exhaustive two-character Base58 pairs and randomised full-length `tpub`
63
+ * corpora — see `participant-order.test.ts`. Those locales all inherit root
64
+ * collation for ASCII; the tailorings that diverge (`cs`, `da`, `nb`, `et`,
65
+ * `lt`, `hu`) are the bug this replaces, not behaviour to reproduce.
66
+ */
67
+ export declare function compareBase58IcuEnV1(a: string, b: string): number;
68
+ /** The xpub comparator for a given ordering rule. */
69
+ export declare function xpubComparatorFor(ordering: ParticipantOrdering): (a: string, b: string) => number;