@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,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;
|