@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.
- package/LICENSE +21 -0
- package/README.md +414 -2
- package/dist/btc/index.d.ts +12 -0
- package/dist/btc/index.js +13 -0
- package/dist/btc/psbt.d.ts +121 -0
- package/dist/btc/psbt.js +377 -0
- package/dist/btc/reader.d.ts +42 -0
- package/dist/btc/reader.js +85 -0
- package/dist/btc/select.d.ts +48 -0
- package/dist/btc/select.js +59 -0
- package/dist/btc/spend-rules.d.ts +24 -0
- package/dist/btc/spend-rules.js +43 -0
- package/dist/btc/spend.d.ts +136 -0
- package/dist/btc/spend.js +180 -0
- package/dist/btc/types.d.ts +65 -0
- package/dist/btc/types.js +62 -0
- package/dist/btc/vault/bip341-nums.d.ts +35 -0
- package/dist/btc/vault/bip341-nums.js +121 -0
- package/dist/btc/vault/build.d.ts +5 -0
- package/dist/btc/vault/build.js +136 -0
- package/dist/btc/vault/canonical-order.d.ts +10 -0
- package/dist/btc/vault/canonical-order.js +18 -0
- package/dist/btc/vault/from-contract-id.d.ts +9 -0
- package/dist/btc/vault/from-contract-id.js +74 -0
- package/dist/btc/vault/helpers.d.ts +73 -0
- package/dist/btc/vault/helpers.js +129 -0
- package/dist/btc/vault/index.d.ts +21 -0
- package/dist/btc/vault/index.js +21 -0
- package/dist/btc/vault/limits.d.ts +9 -0
- package/dist/btc/vault/limits.js +9 -0
- package/dist/btc/vault/participant-order.d.ts +69 -0
- package/dist/btc/vault/participant-order.js +135 -0
- package/dist/btc/vault/script.d.ts +13 -0
- package/dist/btc/vault/script.js +39 -0
- package/dist/btc/vault/spend.d.ts +63 -0
- package/dist/btc/vault/spend.js +93 -0
- package/dist/btc/vault/tx-size.d.ts +66 -0
- package/dist/btc/vault/tx-size.js +71 -0
- package/dist/btc/vault/types.d.ts +49 -0
- package/dist/btc/vault/types.js +1 -0
- package/dist/btc/vault/vault-policy.d.ts +109 -0
- package/dist/btc/vault/vault-policy.js +213 -0
- package/dist/btc/vault/vaultContractUtil.d.ts +80 -0
- package/dist/btc/vault/vaultContractUtil.js +87 -0
- package/dist/btc/vault/xpub.d.ts +28 -0
- package/dist/btc/vault/xpub.js +41 -0
- package/dist/btc/witness.d.ts +101 -0
- package/dist/btc/witness.js +439 -0
- package/dist/btc-policies/activation.d.ts +76 -0
- package/dist/btc-policies/activation.js +153 -0
- package/dist/btc-policies/index.d.ts +7 -0
- package/dist/btc-policies/index.js +8 -0
- package/dist/btc-policies/policy.d.ts +55 -0
- package/dist/btc-policies/policy.js +210 -0
- package/dist/evm/abis/index.d.ts +10 -0
- package/dist/evm/abis/index.js +11 -0
- package/dist/evm/abis/policies.d.ts +279 -0
- package/dist/evm/abis/policies.js +363 -0
- package/dist/evm/abis/sponsor-guard.d.ts +201 -0
- package/dist/evm/abis/sponsor-guard.js +263 -0
- package/dist/evm/abis/state.d.ts +989 -0
- package/dist/evm/abis/state.js +1290 -0
- package/dist/evm/abis/users.d.ts +408 -0
- package/dist/evm/abis/users.js +532 -0
- package/dist/evm/abis/vault-factory.d.ts +92 -0
- package/dist/evm/abis/vault-factory.js +126 -0
- package/dist/evm/abis/vault.d.ts +343 -0
- package/dist/evm/abis/vault.js +444 -0
- package/dist/evm/address.d.ts +2 -0
- package/dist/evm/address.js +4 -0
- package/dist/evm/calls/common.d.ts +30 -0
- package/dist/evm/calls/common.js +51 -0
- package/dist/evm/calls/index.d.ts +11 -0
- package/dist/evm/calls/index.js +4 -0
- package/dist/evm/calls/policies.d.ts +25 -0
- package/dist/evm/calls/policies.js +173 -0
- package/dist/evm/calls/users.d.ts +61 -0
- package/dist/evm/calls/users.js +353 -0
- package/dist/evm/calls/vault.d.ts +25 -0
- package/dist/evm/calls/vault.js +184 -0
- package/dist/evm/chain.d.ts +74 -0
- package/dist/evm/chain.js +48 -0
- package/dist/evm/company-name.d.ts +46 -0
- package/dist/evm/company-name.js +64 -0
- package/dist/evm/deployment.d.ts +47 -0
- package/dist/evm/deployment.js +110 -0
- package/dist/evm/eip712.d.ts +271 -0
- package/dist/evm/eip712.js +224 -0
- package/dist/evm/index.d.ts +20 -0
- package/dist/evm/index.js +21 -0
- package/dist/evm/reads.d.ts +64 -0
- package/dist/evm/reads.js +131 -0
- package/dist/evm/signatures.d.ts +57 -0
- package/dist/evm/signatures.js +105 -0
- package/dist/evm/simulate/balances.d.ts +76 -0
- package/dist/evm/simulate/balances.js +201 -0
- package/dist/evm/simulate/describe.d.ts +79 -0
- package/dist/evm/simulate/describe.js +188 -0
- package/dist/evm/simulate/index.d.ts +12 -0
- package/dist/evm/simulate/index.js +13 -0
- package/dist/evm/simulate/own-node.d.ts +62 -0
- package/dist/evm/simulate/own-node.js +122 -0
- package/dist/evm/simulate/request.d.ts +86 -0
- package/dist/evm/simulate/request.js +235 -0
- package/dist/evm/simulate/revert.d.ts +29 -0
- package/dist/evm/simulate/revert.js +192 -0
- package/dist/evm/simulate/run.d.ts +51 -0
- package/dist/evm/simulate/run.js +214 -0
- package/dist/evm/stocks/decode-stock-swap.d.ts +32 -0
- package/dist/evm/stocks/decode-stock-swap.js +79 -0
- package/dist/evm/stocks/encode-swap.d.ts +45 -0
- package/dist/evm/stocks/encode-swap.js +45 -0
- package/dist/evm/stocks/index.d.ts +9 -0
- package/dist/evm/stocks/index.js +10 -0
- package/dist/evm/stocks/quote.d.ts +19 -0
- package/dist/evm/stocks/quote.js +46 -0
- package/dist/evm/stocks/stock-pools.d.ts +61 -0
- package/dist/evm/stocks/stock-pools.js +179 -0
- package/dist/evm/submitters.d.ts +32 -0
- package/dist/evm/submitters.js +105 -0
- package/dist/evm/types.d.ts +146 -0
- package/dist/evm/types.js +128 -0
- package/dist/keys/index.d.ts +6 -0
- package/dist/keys/index.js +7 -0
- package/dist/keys/keys.d.ts +63 -0
- package/dist/keys/keys.js +80 -0
- package/dist/keys/xpub-ownership.d.ts +10 -0
- package/dist/keys/xpub-ownership.js +13 -0
- package/package.json +60 -3
|
@@ -0,0 +1,213 @@
|
|
|
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 { hex } from "@scure/base";
|
|
16
|
+
import * as btc from "@scure/btc-signer";
|
|
17
|
+
import { taprootTweakPubkey } from "@scure/btc-signer/utils.js";
|
|
18
|
+
import { deriveBip341NumsKey } from "./bip341-nums.js";
|
|
19
|
+
import { buildAdminRequiredThenKofN } from "./build.js";
|
|
20
|
+
import { assertDistinctLeafKeys } from "./helpers.js";
|
|
21
|
+
import { ORDERING_ICU_EN_V1, xpubComparatorFor, } from "./participant-order.js";
|
|
22
|
+
import { buildKofNScript } from "./script.js";
|
|
23
|
+
import { COFUND_LEAF_INDEX, deriveChildFromXpub } from "./xpub.js";
|
|
24
|
+
const COFUND_INDEX = COFUND_LEAF_INDEX;
|
|
25
|
+
// ── Sorting ──────────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Sort account vault users deterministically:
|
|
28
|
+
* admins first, then members. Within each group, sort by accountXpub.
|
|
29
|
+
*
|
|
30
|
+
* `ordering` is not optional and must come from the vault's own contract (see
|
|
31
|
+
* {@link participantOrderingOf}). This order IS part of the vault's address —
|
|
32
|
+
* it fixes the key order inside the tapscript, and for ADMINS_REQUIRED it fixes
|
|
33
|
+
* which admin owns which derivation branch — so sorting a roster under the
|
|
34
|
+
* wrong rule yields a different, unfunded address.
|
|
35
|
+
*/
|
|
36
|
+
export function sortAccountUsers(users, ordering) {
|
|
37
|
+
const compareXpub = xpubComparatorFor(ordering);
|
|
38
|
+
return [...users].sort((a, b) => {
|
|
39
|
+
if (a.role !== b.role) {
|
|
40
|
+
return a.role === "admin" ? -1 : 1;
|
|
41
|
+
}
|
|
42
|
+
return compareXpub(a.accountXpub, b.accountXpub);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// ── Builder ──────────────────────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Build an account-based vault from xpubs.
|
|
48
|
+
*
|
|
49
|
+
* Returns the standard `BuiltVault` so all downstream code
|
|
50
|
+
* (taprootUtils, getVaultDetails, finalizeTx, cron) works unchanged.
|
|
51
|
+
*
|
|
52
|
+
* For ADMINS_REQUIRED vaults, leaf `i` derives keys at branch `2*i`,
|
|
53
|
+
* producing a unique key namespace per leaf. This satisfies the Ledger
|
|
54
|
+
* restriction that the same key must not appear in multiple scripts.
|
|
55
|
+
*
|
|
56
|
+
* For NAR vaults (ALL / ADMINS_INCLUDED), a single leaf at branch 0.
|
|
57
|
+
*/
|
|
58
|
+
export function buildAccountBasedVault(users, vaultType, network, options) {
|
|
59
|
+
const threshold = options.threshold ?? 2;
|
|
60
|
+
const sorted = sortAccountUsers(users, options.participantOrdering ?? ORDERING_ICU_EN_V1);
|
|
61
|
+
const admins = sorted.filter((u) => u.role === "admin");
|
|
62
|
+
if (threshold < 2 || threshold > sorted.length) {
|
|
63
|
+
throw new Error(`Invalid threshold ${threshold}: must be between 2 and ${sorted.length}`);
|
|
64
|
+
}
|
|
65
|
+
// BIP341 NUMS point adapted to Ledger's xpub-backed wildcard policy. The
|
|
66
|
+
// public recipe reconstructs the same /0/COFUND_ACCOUNT child everywhere.
|
|
67
|
+
const { internalXonly: internalKeyXonly } = deriveBip341NumsKey(options.nums, network);
|
|
68
|
+
const drafts = [];
|
|
69
|
+
const isNAR = vaultType === "ALL" || vaultType === "ADMINS_INCLUDED";
|
|
70
|
+
if (isNAR) {
|
|
71
|
+
// Single leaf, all keys derived at branch 0
|
|
72
|
+
const leafUsers = sorted.map((u) => {
|
|
73
|
+
const { compressedPubKey, xonly } = deriveChildFromXpub(u.accountXpub, 0, COFUND_INDEX);
|
|
74
|
+
return {
|
|
75
|
+
id: u.id,
|
|
76
|
+
role: u.role,
|
|
77
|
+
xonly,
|
|
78
|
+
pubCompressed: compressedPubKey,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
const allXonly = leafUsers.map((u) => u.xonly);
|
|
82
|
+
const script = buildKofNScript(allXonly, threshold);
|
|
83
|
+
drafts.push({ threshold, signers: leafUsers, script });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// ADMINS_REQUIRED: one leaf per admin, unique branch per leaf
|
|
87
|
+
if (admins.length === 0) {
|
|
88
|
+
throw new Error("ADMINS_REQUIRED vault needs at least one admin");
|
|
89
|
+
}
|
|
90
|
+
for (let leafIdx = 0; leafIdx < admins.length; leafIdx++) {
|
|
91
|
+
const admin = admins[leafIdx];
|
|
92
|
+
const branch = 2 * leafIdx; // unique namespace per leaf
|
|
93
|
+
// Derive all users' keys at this leaf's branch
|
|
94
|
+
const leafUsers = sorted.map((u) => {
|
|
95
|
+
const { compressedPubKey, xonly } = deriveChildFromXpub(u.accountXpub, branch, COFUND_INDEX);
|
|
96
|
+
return {
|
|
97
|
+
id: u.id,
|
|
98
|
+
role: u.role,
|
|
99
|
+
xonly,
|
|
100
|
+
pubCompressed: compressedPubKey,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
const adminUser = leafUsers.find((u) => u.id === admin.id);
|
|
104
|
+
const bodyUsers = leafUsers.filter((u) => u.id !== admin.id);
|
|
105
|
+
const signersInScriptOrder = [adminUser, ...bodyUsers];
|
|
106
|
+
const allXonly = [adminUser.xonly, ...bodyUsers.map((u) => u.xonly)];
|
|
107
|
+
const script = buildAdminRequiredThenKofN(adminUser.xonly, allXonly, threshold);
|
|
108
|
+
drafts.push({ threshold, signers: signersInScriptOrder, script });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ── Taproot tree + P2TR ────────────────────────────────────────────
|
|
112
|
+
// Before the address exists: no leaf may hold one key in two slots.
|
|
113
|
+
if (!options.reconstructingExistingVault) {
|
|
114
|
+
assertDistinctLeafKeys(drafts);
|
|
115
|
+
}
|
|
116
|
+
const leavesList = drafts.map((d) => ({
|
|
117
|
+
script: d.script,
|
|
118
|
+
version: 0xc0,
|
|
119
|
+
}));
|
|
120
|
+
const tree = btc.taprootListToTree(leavesList);
|
|
121
|
+
const res = btc.p2tr(internalKeyXonly, tree, options.btcNetwork, true);
|
|
122
|
+
// ── Build Leaf[] with control blocks ───────────────────────────────
|
|
123
|
+
const byScript = new Map(drafts.map((d) => [hex.encode(d.script), d]));
|
|
124
|
+
const [, parity] = taprootTweakPubkey(internalKeyXonly, res.tapMerkleRoot);
|
|
125
|
+
const outLeaves = res.leaves.map((leaf) => {
|
|
126
|
+
const key = hex.encode(leaf.script);
|
|
127
|
+
const d = byScript.get(key);
|
|
128
|
+
const tweakedParity = res.tweakedPubkey.length === 33
|
|
129
|
+
? res.tweakedPubkey[0] & 1 // 0x02=>0, 0x03=>1
|
|
130
|
+
: 0;
|
|
131
|
+
const baseVer = (leaf.version ?? 0xc0) & 0xfe;
|
|
132
|
+
const cbVersion = baseVer | tweakedParity;
|
|
133
|
+
return {
|
|
134
|
+
threshold: d.threshold,
|
|
135
|
+
signers: d.signers,
|
|
136
|
+
script: d.script,
|
|
137
|
+
leafHash: leaf.hash,
|
|
138
|
+
controlBlockStruct: {
|
|
139
|
+
version: cbVersion,
|
|
140
|
+
internalKey: res.tapInternalKey,
|
|
141
|
+
merklePath: leaf.path,
|
|
142
|
+
parity,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
merkleRoot: res.tapMerkleRoot,
|
|
148
|
+
tweakedPubkey: res.tweakedPubkey,
|
|
149
|
+
scriptPubKey: res.script,
|
|
150
|
+
address: res.address,
|
|
151
|
+
leaves: outLeaves,
|
|
152
|
+
internalPubKey: internalKeyXonly,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Map each built leaf back to the derivation branch its keys came from.
|
|
157
|
+
*
|
|
158
|
+
* The forward assignment lives in `buildAccountBasedVault` above: admin `i`
|
|
159
|
+
* in *sorted-admin* order owns branch `2*i`, and every signer's key inside
|
|
160
|
+
* that leaf is derived from that leaf's branch. The inverse is not the array
|
|
161
|
+
* index — `btc.taprootListToTree` returns leaves in Huffman tree-walk order,
|
|
162
|
+
* which diverges from creation order at 3 or more leaves. A leaf's branch can
|
|
163
|
+
* only be recovered from its *contents*, so this matches the leaf's admin key
|
|
164
|
+
* (script position 0) against each sorted admin's candidate key.
|
|
165
|
+
*
|
|
166
|
+
* Every consumer that needs a leaf's branch must go through here: the
|
|
167
|
+
* software signer's key derivation, and the Ledger PSBT's BIP-371
|
|
168
|
+
* tap-bip32-derivation records (which are malformed if a key is paired with a
|
|
169
|
+
* leaf that does not contain it).
|
|
170
|
+
*
|
|
171
|
+
* NAR vaults (ALL / ADMINS_INCLUDED) are a single leaf at branch 0.
|
|
172
|
+
*
|
|
173
|
+
* `ordering` must be the rule the vault was CREATED under, read off its own
|
|
174
|
+
* contract. Sorting the admins under a different rule permutes the branch
|
|
175
|
+
* assignment, so the signer derives a key that is not in the leaf it is paired
|
|
176
|
+
* with — a malformed PSBT on the Ledger path and a signature that cannot
|
|
177
|
+
* satisfy the script on the software path.
|
|
178
|
+
*/
|
|
179
|
+
export function buildLeafBranchMap(leaves, vaultType, accountXpubs, ordering) {
|
|
180
|
+
const map = new Map();
|
|
181
|
+
if (vaultType === "ALL" || vaultType === "ADMINS_INCLUDED") {
|
|
182
|
+
for (const leaf of leaves) {
|
|
183
|
+
map.set(leaf, 0);
|
|
184
|
+
}
|
|
185
|
+
return map;
|
|
186
|
+
}
|
|
187
|
+
if (!accountXpubs) {
|
|
188
|
+
return map;
|
|
189
|
+
}
|
|
190
|
+
const sortedAdmins = sortAccountUsers(accountXpubs, ordering).filter((u) => u.role === "admin");
|
|
191
|
+
const branchByAdminKey = new Map();
|
|
192
|
+
for (let adminIdx = 0; adminIdx < sortedAdmins.length; adminIdx++) {
|
|
193
|
+
const branch = 2 * adminIdx;
|
|
194
|
+
const { xonly } = deriveChildFromXpub(sortedAdmins[adminIdx].accountXpub, branch, COFUND_INDEX);
|
|
195
|
+
branchByAdminKey.set(hex.encode(xonly), branch);
|
|
196
|
+
}
|
|
197
|
+
for (const leaf of leaves) {
|
|
198
|
+
// Leaf signers are built admin-first (`[adminUser, ...bodyUsers]`), and
|
|
199
|
+
// the script encodes that same admin at position 0.
|
|
200
|
+
const branch = branchByAdminKey.get(hex.encode(leaf.signers[0].xonly));
|
|
201
|
+
if (branch !== undefined) {
|
|
202
|
+
map.set(leaf, branch);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return map;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Compute the unspendable internal key x-only for a given network.
|
|
209
|
+
* Used by the serializer to store a backward-compatible aggregateKey.
|
|
210
|
+
*/
|
|
211
|
+
export function getUnspendableInternalKeyXonly(network, numsRecipe) {
|
|
212
|
+
return deriveBip341NumsKey(numsRecipe, network).internalXonly;
|
|
213
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Bip341NumsRecipe } from "./bip341-nums.ts";
|
|
2
|
+
import { type ParticipantOrdering } from "./participant-order.ts";
|
|
3
|
+
import type { Role, User, VaultType } from "./types.ts";
|
|
4
|
+
export type VaultContractIdData = {
|
|
5
|
+
aggregateKey: string;
|
|
6
|
+
vaultType: VaultType;
|
|
7
|
+
companyUsers: User[];
|
|
8
|
+
/** True for account-based vaults (xpub-derived, unspendable internal key) */
|
|
9
|
+
accountBased?: boolean;
|
|
10
|
+
/** Network — only present for account-based vaults */
|
|
11
|
+
network?: "mainnet" | "testnet";
|
|
12
|
+
/** Account xpubs per user — only present for account-based vaults */
|
|
13
|
+
accountXpubs?: {
|
|
14
|
+
id: string;
|
|
15
|
+
role: Role;
|
|
16
|
+
accountXpub: string;
|
|
17
|
+
}[];
|
|
18
|
+
/** Public proof material for the account vault's BIP341 NUMS key. */
|
|
19
|
+
nums?: Bip341NumsRecipe;
|
|
20
|
+
/** Signature threshold; absent on vaults created before it was configurable (= 2) */
|
|
21
|
+
threshold?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Rule the roster was ordered under when the address was derived. Always
|
|
24
|
+
* resolved (never undefined) on the way out — see {@link participantOrderingOf}.
|
|
25
|
+
*/
|
|
26
|
+
participantOrdering: ParticipantOrdering;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Input to the legacy serializer.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately NOT {@link VaultContractIdData}: the legacy format stores x-only
|
|
32
|
+
* keys inline and orders them by `hex(xonly)`, so it has no participant xpub
|
|
33
|
+
* ordering to record. Lowercase hex sorts identically under every collation,
|
|
34
|
+
* which is why the legacy format was never exposed to the locale defect that
|
|
35
|
+
* `participant-order.ts` fixes.
|
|
36
|
+
*/
|
|
37
|
+
type LegacyVaultContractInput = {
|
|
38
|
+
aggregateKey: string;
|
|
39
|
+
vaultType: VaultType;
|
|
40
|
+
companyUsers: User[];
|
|
41
|
+
threshold?: number;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Serializes vault contract data for storage in the database (legacy format).
|
|
45
|
+
*/
|
|
46
|
+
export declare function serializeVaultContractData(data: LegacyVaultContractInput): string;
|
|
47
|
+
export type AccountBasedContractData = {
|
|
48
|
+
accountBased: true;
|
|
49
|
+
vaultType: VaultType;
|
|
50
|
+
network: "mainnet" | "testnet";
|
|
51
|
+
nums: Bip341NumsRecipe;
|
|
52
|
+
companyUsers: {
|
|
53
|
+
id: string;
|
|
54
|
+
role: Role;
|
|
55
|
+
accountXpub: string;
|
|
56
|
+
}[];
|
|
57
|
+
/** Signature threshold; absent on vaults created before it was configurable (= 2) */
|
|
58
|
+
threshold?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Rule the roster is ordered under. Required — the address cannot be
|
|
61
|
+
* reproduced without it, and omitting it silently means "the historical
|
|
62
|
+
* `localeCompare` ordering" forever.
|
|
63
|
+
*/
|
|
64
|
+
participantOrdering: ParticipantOrdering;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Serializes account-based vault contract data.
|
|
68
|
+
* Only stores xpubs + roles — everything else is deterministic.
|
|
69
|
+
*
|
|
70
|
+
* `companyUsers` is written in the caller's order and re-sorted on every read,
|
|
71
|
+
* so the stored order is not load-bearing; `participantOrdering` is what fixes
|
|
72
|
+
* the address.
|
|
73
|
+
*/
|
|
74
|
+
export declare function serializeAccountBasedData(data: AccountBasedContractData): string;
|
|
75
|
+
/**
|
|
76
|
+
* Deserializes vault contract data from database storage.
|
|
77
|
+
* Auto-detects legacy vs account-based format.
|
|
78
|
+
*/
|
|
79
|
+
export declare function deserializeVaultContractData(json: string): VaultContractIdData;
|
|
80
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
|
2
|
+
import { participantOrderingOf, } from "./participant-order.js";
|
|
3
|
+
import { getUnspendableInternalKeyXonly } from "./vault-policy.js";
|
|
4
|
+
import { COFUND_LEAF_INDEX, deriveChildFromXpub } from "./xpub.js";
|
|
5
|
+
/**
|
|
6
|
+
* Serializes vault contract data for storage in the database (legacy format).
|
|
7
|
+
*/
|
|
8
|
+
export function serializeVaultContractData(data) {
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
...data,
|
|
11
|
+
companyUsers: data.companyUsers.map((u) => ({
|
|
12
|
+
id: u.id,
|
|
13
|
+
role: u.role,
|
|
14
|
+
xonly: bytesToHex(u.xonly),
|
|
15
|
+
})),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Serializes account-based vault contract data.
|
|
20
|
+
* Only stores xpubs + roles — everything else is deterministic.
|
|
21
|
+
*
|
|
22
|
+
* `companyUsers` is written in the caller's order and re-sorted on every read,
|
|
23
|
+
* so the stored order is not load-bearing; `participantOrdering` is what fixes
|
|
24
|
+
* the address.
|
|
25
|
+
*/
|
|
26
|
+
export function serializeAccountBasedData(data) {
|
|
27
|
+
// Validate the complete public NUMS derivation before persisting its recipe.
|
|
28
|
+
getUnspendableInternalKeyXonly(data.network, data.nums);
|
|
29
|
+
return JSON.stringify(data);
|
|
30
|
+
}
|
|
31
|
+
// ── Deserializer (auto-detects format) ───────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Deserializes vault contract data from database storage.
|
|
34
|
+
* Auto-detects legacy vs account-based format.
|
|
35
|
+
*/
|
|
36
|
+
export function deserializeVaultContractData(json) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = JSON.parse(json);
|
|
39
|
+
// Account-based format: { accountBased: true, ... }
|
|
40
|
+
if (raw.accountBased === true) {
|
|
41
|
+
const { vaultType, network, nums, companyUsers, threshold, participantOrdering, } = raw;
|
|
42
|
+
const cofundIndex = COFUND_LEAF_INDEX;
|
|
43
|
+
const users = companyUsers.map((u) => {
|
|
44
|
+
const { compressedPubKey, xonly } = deriveChildFromXpub(u.accountXpub, 0, cofundIndex);
|
|
45
|
+
return {
|
|
46
|
+
id: u.id,
|
|
47
|
+
role: u.role,
|
|
48
|
+
xonly,
|
|
49
|
+
pubCompressed: compressedPubKey,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
// Unspendable internal key as the "aggregateKey"
|
|
53
|
+
// Missing or malformed NUMS proof material fails closed here. Old
|
|
54
|
+
// development account records must be recreated before funding.
|
|
55
|
+
const internalXonly = getUnspendableInternalKeyXonly(network, nums);
|
|
56
|
+
return {
|
|
57
|
+
aggregateKey: bytesToHex(internalXonly),
|
|
58
|
+
vaultType,
|
|
59
|
+
companyUsers: users,
|
|
60
|
+
accountBased: true,
|
|
61
|
+
network,
|
|
62
|
+
accountXpubs: companyUsers,
|
|
63
|
+
nums,
|
|
64
|
+
threshold,
|
|
65
|
+
// Absent on every vault created before the rule was recorded, and those
|
|
66
|
+
// were all ordered by `localeCompare` — so resolve, never default at the
|
|
67
|
+
// call site.
|
|
68
|
+
participantOrdering: participantOrderingOf(participantOrdering),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Legacy format: { aggregateKey, vaultType, companyUsers: [{ xonly: hex }] }
|
|
72
|
+
const initialData = raw;
|
|
73
|
+
return {
|
|
74
|
+
...initialData,
|
|
75
|
+
companyUsers: initialData.companyUsers.map((u) => ({
|
|
76
|
+
...u,
|
|
77
|
+
xonly: hexToBytes(u.xonly),
|
|
78
|
+
})),
|
|
79
|
+
// Legacy vaults order by `hex(xonly)`, not by xpub, so this is inert for
|
|
80
|
+
// them. Set so the field is never undefined on a decoded contract.
|
|
81
|
+
participantOrdering: participantOrderingOf(undefined),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error("Invalid vault contract data JSON");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cofund leaf's index, `m/86'/coin'/0'/<branch>/<this>`: the key a vault's
|
|
3
|
+
* taproot leaves are built from and every BTC approval is signed with.
|
|
4
|
+
*/
|
|
5
|
+
export declare const COFUND_LEAF_INDEX = 376864517;
|
|
6
|
+
/** BIP32 version bytes: xprv / xpub on mainnet, tprv / tpub off it. */
|
|
7
|
+
export declare const BIP32_VERSIONS: {
|
|
8
|
+
readonly mainnet: {
|
|
9
|
+
readonly private: 76066276;
|
|
10
|
+
readonly public: 76067358;
|
|
11
|
+
};
|
|
12
|
+
readonly testnet: {
|
|
13
|
+
readonly private: 70615956;
|
|
14
|
+
readonly public: 70617039;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Derive a child public key from an account-level xpub at a given
|
|
19
|
+
* branch/index (default /0/0).
|
|
20
|
+
*
|
|
21
|
+
* Mirrors the ledger-test-app's `derivePubkeysFromXpub`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function deriveChildFromXpub(accountXpub: string, branch?: number, index?: number): {
|
|
24
|
+
compressedPubKey: Uint8Array;
|
|
25
|
+
xonly: Uint8Array;
|
|
26
|
+
};
|
|
27
|
+
/** The Cofund leaf's compressed public key, hex, of an account xpub (tpub off mainnet). */
|
|
28
|
+
export declare function btcCofundLeafKey(xpub: string): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { bytesToHex } from "@noble/hashes/utils";
|
|
2
|
+
import { HDKey } from "@scure/bip32";
|
|
3
|
+
/**
|
|
4
|
+
* The Cofund leaf's index, `m/86'/coin'/0'/<branch>/<this>`: the key a vault's
|
|
5
|
+
* taproot leaves are built from and every BTC approval is signed with.
|
|
6
|
+
*/
|
|
7
|
+
export const COFUND_LEAF_INDEX = 376_864_517;
|
|
8
|
+
/** BIP32 version bytes: xprv / xpub on mainnet, tprv / tpub off it. */
|
|
9
|
+
export const BIP32_VERSIONS = {
|
|
10
|
+
mainnet: { private: 0x04_88_ad_e4, public: 0x04_88_b2_1e },
|
|
11
|
+
testnet: { private: 0x04_35_83_94, public: 0x04_35_87_cf },
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Derive a child public key from an account-level xpub at a given
|
|
15
|
+
* branch/index (default /0/0).
|
|
16
|
+
*
|
|
17
|
+
* Mirrors the ledger-test-app's `derivePubkeysFromXpub`.
|
|
18
|
+
*/
|
|
19
|
+
export function deriveChildFromXpub(accountXpub, branch = 0, index = 0) {
|
|
20
|
+
const versions = BIP32_VERSIONS[accountXpub.startsWith("tpub") ? "testnet" : "mainnet"];
|
|
21
|
+
const account = HDKey.fromExtendedKey(accountXpub, versions);
|
|
22
|
+
const derived = account.deriveChild(branch).deriveChild(index);
|
|
23
|
+
if (!derived.publicKey) {
|
|
24
|
+
throw new Error("Failed to derive public key from xpub");
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
compressedPubKey: derived.publicKey, // 33 bytes (02/03 + x)
|
|
28
|
+
xonly: derived.publicKey.slice(1), // 32 bytes (x-only / BIP-340)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The Cofund leaf's compressed public key, hex, of an account xpub (tpub off mainnet). */
|
|
32
|
+
export function btcCofundLeafKey(xpub) {
|
|
33
|
+
const versions = BIP32_VERSIONS[xpub.startsWith("xpub") ? "mainnet" : "testnet"];
|
|
34
|
+
const leaf = HDKey.fromExtendedKey(xpub, versions)
|
|
35
|
+
.deriveChild(0)
|
|
36
|
+
.deriveChild(COFUND_LEAF_INDEX);
|
|
37
|
+
if (!leaf.publicKey) {
|
|
38
|
+
throw new Error("Could not derive the Cofund leaf of a BTC key");
|
|
39
|
+
}
|
|
40
|
+
return bytesToHex(leaf.publicKey);
|
|
41
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** biome-ignore-all lint/suspicious/noBitwiseOperators: a control block's first byte is the leaf version plus the output key's parity. */
|
|
2
|
+
import * as btc from "@scure/btc-signer";
|
|
3
|
+
import type { Leaf, User, VaultType } from "./vault/types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* A vault spend's witness, assembled from the signatures collected for it:
|
|
6
|
+
* the script-path witness `[signatures… | leaf script | control block]` once
|
|
7
|
+
* one leaf has its threshold on every input. The app's finalizer (over its
|
|
8
|
+
* stored partials) and `finalizePsbt` (over a PSBT's `tapScriptSig`s) both
|
|
9
|
+
* assemble through here.
|
|
10
|
+
*
|
|
11
|
+
* Every count is read off the tapscript itself, never the leaf's metadata,
|
|
12
|
+
* and counts distinct keys: BIP-342 counts a repeated key twice, so a vault
|
|
13
|
+
* built before the leaf-key uniqueness invariant could otherwise meet its
|
|
14
|
+
* threshold with one signature.
|
|
15
|
+
*/
|
|
16
|
+
/** Verified signatures over a spend: leaf hash (hex) → input index → signer id → BIP-340 signature. */
|
|
17
|
+
export type SpendPartials = Map<string, Map<number, Map<string, Uint8Array>>>;
|
|
18
|
+
/** A spend being finalized: the unsigned transaction (`buildVaultSpend`), the vault's leaves and the signatures collected. */
|
|
19
|
+
export type WitnessSpend = {
|
|
20
|
+
tx: btc.Transaction;
|
|
21
|
+
candidates: Leaf[];
|
|
22
|
+
partials: SpendPartials;
|
|
23
|
+
};
|
|
24
|
+
/** The finalizer's scope: one leaf, and the signers whose signatures it may use. */
|
|
25
|
+
export type FinalizationPlan = {
|
|
26
|
+
leafHash: string;
|
|
27
|
+
approverIds: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
/** The control block of a leaf: its version plus the output key's parity, the internal key, and the merkle path. */
|
|
30
|
+
export declare function encodeCBFromStruct(cbStruct: {
|
|
31
|
+
version: number;
|
|
32
|
+
internalKey: Uint8Array;
|
|
33
|
+
merklePath: Uint8Array[];
|
|
34
|
+
parity?: number;
|
|
35
|
+
}): Uint8Array;
|
|
36
|
+
/**
|
|
37
|
+
* Does `sig` verify as `signerXonly`'s BIP340 signature over `msg`?
|
|
38
|
+
*
|
|
39
|
+
* The single answer to "did this key actually approve THIS exact transaction".
|
|
40
|
+
* Malformed input makes noble throw, which is easy to swallow by accident —
|
|
41
|
+
* always returning a boolean makes it hard for a call site to treat an
|
|
42
|
+
* unverifiable signature as an approval.
|
|
43
|
+
*/
|
|
44
|
+
export declare function verifyPartialSignatureAgainstMsg(msg: Uint8Array, signerXonly: Uint8Array, sig: Uint8Array): boolean;
|
|
45
|
+
export type LeafScriptPolicy = {
|
|
46
|
+
kind: "non-admin-required";
|
|
47
|
+
signatureKeys: Uint8Array[];
|
|
48
|
+
threshold: number;
|
|
49
|
+
} | {
|
|
50
|
+
kind: "admin-required";
|
|
51
|
+
signatureKeys: Uint8Array[];
|
|
52
|
+
threshold: number;
|
|
53
|
+
adminKey: Uint8Array;
|
|
54
|
+
bodyKeys: Uint8Array[];
|
|
55
|
+
bodyThreshold: number;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Decode the policy Bitcoin actually executes, rather than trusting metadata.
|
|
59
|
+
*
|
|
60
|
+
* The hard threshold is committed by the tapscript. `Leaf.threshold` is useful
|
|
61
|
+
* descriptive metadata, but it must never be the authority used to decide how
|
|
62
|
+
* many signatures enter a witness.
|
|
63
|
+
*/
|
|
64
|
+
export declare function leafScriptPolicy(leaf: Leaf): LeafScriptPolicy | null;
|
|
65
|
+
/** Match each script key to one leaf signer slot, preserving duplicate slots. */
|
|
66
|
+
export declare function signerSlotsForScript(leaf: Leaf, signatureKeys: Uint8Array[]): Array<User | undefined>;
|
|
67
|
+
export declare function signatureSlotCountForLeaf(leaf: Leaf): number | null;
|
|
68
|
+
export declare function orderedSigStackForLeaf(spend: {
|
|
69
|
+
tx: btc.Transaction;
|
|
70
|
+
}, leaf: Leaf, inputIndex: number, bucket: Map<string, Uint8Array>): {
|
|
71
|
+
stack: Uint8Array[];
|
|
72
|
+
nonEmpty: number;
|
|
73
|
+
};
|
|
74
|
+
export declare function sigStackSolvesLeaf(spend: {
|
|
75
|
+
tx: btc.Transaction;
|
|
76
|
+
}, leaf: Leaf, sigStack: Uint8Array[], inputIndex: number): boolean;
|
|
77
|
+
/** An ADMINS_REQUIRED leaf: its admin, then its body signers in script order, and the body's k. */
|
|
78
|
+
export declare function decodeAdminRequiredLeaf(leaf: Leaf): {
|
|
79
|
+
adminId: string;
|
|
80
|
+
adminKey: Uint8Array;
|
|
81
|
+
bodyOrderIds: string[];
|
|
82
|
+
bodyKeys: Uint8Array[];
|
|
83
|
+
k: number;
|
|
84
|
+
} | null;
|
|
85
|
+
/**
|
|
86
|
+
* Select the exact hard-threshold signer identities available for one leaf.
|
|
87
|
+
* Returns null when the eligible set cannot satisfy the on-chain script.
|
|
88
|
+
*/
|
|
89
|
+
export declare function selectHardThresholdSignerIds(leaf: Leaf, vaultType: VaultType, eligibleSignerIds: ReadonlySet<string>): string[] | null;
|
|
90
|
+
/** Write `leaf`'s witness for input `inputIndex`: the signature stack, the leaf script and its control block. */
|
|
91
|
+
export declare function finalizeWithWitness(tx: btc.Transaction, inputIndex: number, leaf: Leaf, sigStack: Uint8Array[]): Uint8Array<ArrayBufferLike>[];
|
|
92
|
+
/**
|
|
93
|
+
* Finalize every input with the first candidate leaf that the collected
|
|
94
|
+
* signatures solve on every input — the template's rules: k of the leaf's
|
|
95
|
+
* keys, and on an ADMINS_REQUIRED leaf its admin plus k − 1 of the rest.
|
|
96
|
+
* With a plan, only its leaf and its approvers' signatures count.
|
|
97
|
+
*/
|
|
98
|
+
export declare function tryFinalizeAny(spend: WitnessSpend, vaultType: VaultType, plan?: FinalizationPlan): {
|
|
99
|
+
finalized: boolean;
|
|
100
|
+
usedLeaf?: Leaf;
|
|
101
|
+
};
|