@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cofund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,415 @@
1
- # Temporary Holding Version
1
+ # @getcofund/protocol
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Cofund protocol on chain: Bitcoin vaults and the EVM contracts behind
4
+ State. It talks to the chain only. There is no Cofund API in it, no database,
5
+ and no Cofund deployment built in.
6
+
7
+ ```sh
8
+ npm i @getcofund/protocol viem
9
+ ```
10
+
11
+ ## Entries
12
+
13
+ | Entry | Holds |
14
+ |---|---|
15
+ | `@getcofund/protocol/evm` | The deployment config and its check, the EIP-712 messages (`InviteUser`, `Transfer`, `Transaction`, the policy activations and the rest) and their domain, a call builder per contract entry point with the contract's checks, State's reads, the two submitters, the contracts' ABIs, the company name and the client id it derives, a vault call's simulation over `eth_simulateV1` and how it reads, the StockSwap verb, and the values these are made of as zod schemas |
16
+ | `@getcofund/protocol/btc` | A vault's taproot tree and address from its definition, the vault checked against that definition, and its spends: coins selected, a PSBT built, checked and signed, combined, finalized and broadcast |
17
+ | `@getcofund/protocol/btc-policies` | Optional: Bitcoin spending policies (signers, a threshold, a cap, recipients) a spend is checked against before it is signed, and what an admin signs to activate one. No script enforces them; they bind only the parties that use them |
18
+ | `@getcofund/protocol/keys` | A member's keys (an EVM key and a BIP-86 Bitcoin account), the signers derived from them, and the proof that an xpub is the member's |
19
+
20
+ Every entry runs in a browser as well as in Node: nothing here reaches a Node
21
+ built-in. EVM code is written on viem.
22
+
23
+ ## What you bring
24
+
25
+ The package ships no deployment and picks no provider, key store or relayer.
26
+ You bring the deployment, a reader, signers and a submitter. Co-signers
27
+ exchange signatures over whatever channel you choose: every message is plain
28
+ EIP-712 typed data.
29
+
30
+ ### The deployment
31
+
32
+ The shape of cofund-solidity's `deployments/<chainId>.json`, which its deploy
33
+ script (`script/DeployCore.s.sol`) prints:
34
+
35
+ ```ts
36
+ type EvmDeployment = {
37
+ chainId: number;
38
+ state: Address; // CFHelpersStatev1, every message's verifying contract
39
+ users: Address; // helperContracts("users")
40
+ policies: Address; // helperContracts("policies")
41
+ vaults: Address; // the vault factory, helperContracts("vaults")
42
+ sponsorGuard: Address; // helperContracts("sponsorGuard")
43
+ eip712Domain: { name: string; version: string; chainId: number; verifyingContract: Address };
44
+ };
45
+ ```
46
+
47
+ `loadEvmDeployment(config, reader)` checks a config before anything is signed
48
+ under it, and refuses one that differs anywhere with every difference
49
+ (`DeploymentMismatchError.differences`): the domain's chain id and verifying
50
+ contract are the config's chain id and State, the reader is on that chain,
51
+ each helper is the one State registers, and the domain's name and version are
52
+ the ones the users helper reports (ERC-5267). Other fields in the file (gov,
53
+ the sponsor, dates) pass unread.
54
+
55
+ ```ts
56
+ import deployment1 from "./deployments/1.json" with { type: "json" };
57
+ const reader = createPublicClient({ chain: mainnet, transport: http(rpcUrl) });
58
+ const deployment = await loadEvmDeployment(deployment1, reader);
59
+ const ctx = { deployment, reader };
60
+ ```
61
+
62
+ ### The interfaces
63
+
64
+ ```ts
65
+ type EvmReader = { // a viem PublicClient, any EIP-1193 provider
66
+ request(args: { method: string; params?: unknown }): Promise<unknown>;
67
+ };
68
+ type TypedDataSigner = { // a viem Account: a local key, a Ledger, a KMS adapter
69
+ readonly address: Address;
70
+ signTypedData(data: TypedData): Promise<Hex>;
71
+ };
72
+ type EvmSubmitter = { // who sends a call and pays for it
73
+ send(call: { to: Address; data: Hex; value?: bigint }): Promise<Hash>;
74
+ };
75
+ ```
76
+
77
+ A viem `PublicClient` is an `EvmReader` and a viem `Account` a
78
+ `TypedDataSigner` as they are; a `WalletClient` signs through
79
+ `walletSigner(client)`. An ethers provider reads through
80
+ `{ request: ({ method, params }) => provider.send(method, params) }`.
81
+ `typedData(domain, types, message)` builds what a signer signs, with the
82
+ primary type read from `types`; `typedDataDigest` hashes it as the contracts
83
+ do.
84
+
85
+ ### The submitters
86
+
87
+ - **`directSubmitter(walletClient)`:** the wallet sends the call itself and
88
+ pays gas. The policies helper and the users helper's signature-only calls
89
+ (`removeUser`, `rotateUser`, `completeOnboarding`) accept any sender; a vault
90
+ accepts any active member of its cofund, a Cofund admin or the guard.
91
+ - **`guardSubmitter(walletClient, deployment)`:** the call wrapped in
92
+ `sponsorGuard.relay(target, data)`, sent by a wallet the deployment's guard
93
+ accepts as a sponsor. `createClient` and `addUser` accept only the guard or a
94
+ Cofund admin as their sender, so they go this way.
95
+
96
+ A call the chain refuses throws `CallRevertedError`, its `reason` the
97
+ contract's error by name. Sponsoring through a gas manager is an
98
+ `EvmSubmitter` you write.
99
+
100
+ ## Calls
101
+
102
+ One builder per contract entry point. Each reads what it needs through the
103
+ reader, checks the call as the contract will, and returns `{ to, data }` for a
104
+ submitter; a call the contract would revert is refused before anything is
105
+ sent, with `CallRefusedError`, its `reason` the error the contract would revert
106
+ with. Multi-signature arrays are put in the order the contracts walk them
107
+ (ascending by recovered address); a signer counted twice is refused. Every
108
+ signature is recovered as OpenZeppelin's `ECDSA` recovers it (65 bytes, low
109
+ `s`, `v` 27 or 28). The checks that turn on `msg.sender` are the submitter's.
110
+
111
+ | Builder | Takes | Refuses, as the contract does |
112
+ |---|---|---|
113
+ | `createClientCall` | the founder's `InviteUser` over themselves, the founder's address and signature, a tier | an invite window not open now or more than 30 days long (`InviteExpired`, `InvalidParams`); an empty `authId`; a message not naming the founder as admin, a signature not the founder's (`InvalidSignature`); a spent `authId` (`KeyExists`); an existing cofund (`ClientExists`); an unknown tier (`InvalidTier`); a taken seat or key (`UserExists`, `AddressExists`) |
114
+ | `addUserCall` | an `InviteUser`, the inviting admin's signature, co-signatures, the invitee's address and signature | the window; an inviting signature that is not the `callerId`'s seat (`UnauthorizedCaller`), an inactive or non-admin inviter; a spent `authId`; for an admin seat fewer than `requiredAdminInviteSignatures` (`ThresholdNotMet`) or a co-signer who is the inviter or no active admin; an invitee signature not the invitee's; a taken seat or key |
115
+ | `removeUserCall` | a `RemoveUser`, admin signatures | fewer than two for an admin while more than one admin is active (`ThresholdNotMet`); a signer who is no active admin; a spent `authId`; an unknown or removed user; the last admin (`InvalidSetting`) |
116
+ | `rotateUserCall` | a `RotateUser`, the new key's signature, and the current key's or (for a lost key) admins' | a new-key signature not the new address's; both authorizations at once (`InvalidParams`); a current key not the one State holds; too few admins; a new key that holds a seat |
117
+ | `completeOnboardingCall` | a `CompleteOnboarding`, an admin's signature | a stranger (`UnauthorizedCaller`), an inactive admin, a member; a spent `authId`; a closed phase (`OnboardingAlreadyComplete`) |
118
+ | `activateTransferPolicyCall`, `activateTransactionPolicyCall` | the activation, admin signatures | an unsupported type, an empty title, a zero threshold, fewer signers than the threshold, a zero token or wrapper; a spent `authId`; fewer than `requiredPolicyActivationSignatures` (`SignerCountMismatch`); a signer who is no active admin; an existing policy id, a signer id that is no active user, listed twice, a zero recipient |
119
+ | `deactivatePolicyCall` | a `PolicyDeactivation`, an admin's signature | a spent `authId`; a stranger, an inactive admin, a member (`UnauthorizedCaller`, `InactiveUser`, `InvalidAdmin`); a policy that is not active |
120
+ | `executeTransferCall` | a `Transfer`, the signers' signatures | a company with no vault; an inactive policy, a transaction policy, an amount over the cap, another token, another type, fewer signatures than the threshold, a recipient off the allowlist; a spent `authId`; a signer State does not resolve to an active signer of the policy; a vault short of the amount and the tier's fee on top |
121
+ | `executeTransactionCall` | a `Transaction`, its instructions, the signers' signatures | the same policy checks with the policy's wrapper; instructions other than the signed hash; an allowance of the zero address or of 0 |
122
+
123
+ ```ts
124
+ const message = { company, callerId: me, userId, userIsAdmin: false, userPosition: "Engineer", expiresAt, authId };
125
+ const call = await addUserCall(ctx, {
126
+ message,
127
+ adminSignature: await admin.signTypedData(typedData(deployment.eip712Domain, INVITE_USER_TYPES, message)),
128
+ invitee: member.address,
129
+ inviteeSignature: await member.signTypedData(typedData(deployment.eip712Domain, INVITE_USER_TYPES, message)),
130
+ });
131
+ await guardSubmitter(sponsorWallet, deployment).send(call);
132
+ ```
133
+
134
+ ## Reads
135
+
136
+ Typed State getters through the reader: `readUser`, `readUserIdByAddress`,
137
+ `readPolicy`, `readClientVault`, `readOnboardingComplete`, `readActiveAdmins`,
138
+ `readHelperContract`, `readAuthIdSpent`, the helpers' rules
139
+ (`readRequiredAdminInviteSignatures`, `readRequiredPolicyActivationSignatures`)
140
+ and `readPredictedVault`. There is no index: history or speed is yours to
141
+ bring. The ABIs (`stateAbi`, `usersAbi`, `policiesAbi`, `vaultAbi`,
142
+ `vaultFactoryAbi`, `sponsorGuardAbi`) are generated from cofund-solidity's
143
+ build by `scripts/gen-evm-abis.mjs`.
144
+
145
+ ## An open guard
146
+
147
+ Which guard a deployment registers, and whom it accepts, is the deployer's
148
+ choice. For a deployment where anyone may found a cofund or seat a member
149
+ (the signatures still authorize each call), a guard without the sponsor
150
+ allowlist:
151
+
152
+ ```solidity
153
+ // SPDX-License-Identifier: MIT
154
+ pragma solidity ^0.8.28;
155
+
156
+ interface IState {
157
+ function helperContracts(string calldata) external view returns (address);
158
+ function clientVaults(bytes32) external view returns (address, string memory);
159
+ }
160
+
161
+ /// CFSponsorGuardv1 without `isAuthorizedSponsor`: anyone relays, and pays gas.
162
+ contract OpenGuard {
163
+ error CallNotAllowed();
164
+ error GuardPaused();
165
+ error InvalidCall();
166
+ error UnauthorizedCaller();
167
+
168
+ IState public immutable state;
169
+ bool public paused;
170
+
171
+ constructor(address _state) { state = IState(_state); }
172
+
173
+ function setPaused(bool value) external {
174
+ if (msg.sender != state.helperContracts("gov")) revert UnauthorizedCaller();
175
+ paused = value;
176
+ }
177
+
178
+ function relay(address target, bytes calldata data) external payable returns (bytes memory) {
179
+ if (paused) revert GuardPaused();
180
+ if (data.length < 4) revert InvalidCall();
181
+ if (!_isHelper(target) && !_isKnownVault(target)) revert CallNotAllowed();
182
+ (bool ok, bytes memory ret) = target.call{value: msg.value}(data);
183
+ if (!ok) assembly { revert(add(ret, 0x20), mload(ret)) }
184
+ return ret;
185
+ }
186
+
187
+ function _isHelper(address target) internal view returns (bool) {
188
+ return target != address(0)
189
+ && (target == state.helperContracts("users") || target == state.helperContracts("policies"));
190
+ }
191
+
192
+ function _isKnownVault(address target) internal view returns (bool) {
193
+ if (target == address(0) || target.code.length == 0) return false;
194
+ (bool ok, bytes memory ret) = target.staticcall(abi.encodeWithSignature("clientId()"));
195
+ if (!ok || ret.length != 32) return false;
196
+ (address registered, ) = state.clientVaults(abi.decode(ret, (bytes32)));
197
+ return registered == target;
198
+ }
199
+ }
200
+ ```
201
+
202
+ Register it with `gov.setHelperContract("sponsorGuard", openGuard)` and put
203
+ its address in the deployment's `sponsorGuard`; `guardSubmitter` then works
204
+ with any wallet. It keeps the guard's one rule: it reaches the users and
205
+ policies helpers and the cofunds' own vaults, nothing else, never State.
206
+
207
+ ## Tests
208
+
209
+ `pnpm test` runs the unit tests: each builder's calldata against vectors
210
+ `cast calldata` encoded (`test/vectors/gen-calls.mjs`), each refusal, the
211
+ deployment check, the submitters, and `cofund-protocol`'s EVM message vectors
212
+ (`test/vectors/conformance`). The integration test deploys the contracts to a
213
+ throwaway Anvil with cofund-solidity's `script/e2e-deploy.sh` and runs a
214
+ cofund's life through this package, every call sent directly where the
215
+ contracts allow it and through the guard:
216
+
217
+ ```sh
218
+ PROTOCOL_ANVIL=1 COFUND_SOLIDITY_DIR=../cofund-solidity pnpm --filter @getcofund/protocol test
219
+ ```
220
+
221
+ It needs Foundry (`anvil`, `forge`, `cast`); the contracts build into
222
+ `COFUND_FOUNDRY_CACHE_DIR` (default under the OS temp dir), never into the
223
+ checkout.
224
+
225
+ The Bitcoin integration test builds a vault of each template from fresh keys,
226
+ funds it, spends it through PSBTs signed by separate signers, and checks the
227
+ outputs on chain. It mines, so give it a regtest bitcoind of its own:
228
+
229
+ ```sh
230
+ PROTOCOL_REGTEST_RPC=http://user:pass@127.0.0.1:18443 pnpm --filter @getcofund/protocol test
231
+ ```
232
+
233
+ ## Bitcoin spends
234
+
235
+ A spend is five plain functions on the vault verified from its definition
236
+ (`verifyBtcVault`): select the coins, build a PSBT, sign it, combine and
237
+ finalize it, broadcast it. Co-signers pass the PSBT around as bytes over any
238
+ channel; each checks it against its own copy of the vault, and against what it
239
+ was told the spend pays, before it signs.
240
+
241
+ ### A 2-of-3 vault, three signers on three machines
242
+
243
+ ```ts
244
+ import { base64 } from "@scure/base";
245
+ import {
246
+ broadcast,
247
+ buildSpendPsbt,
248
+ combinePsbts,
249
+ esploraReader,
250
+ extractTx,
251
+ finalizePsbt,
252
+ localLeafSigner,
253
+ selectCoins,
254
+ signPsbt,
255
+ vaultSpendInputVbytes,
256
+ verifyBtcVault,
257
+ } from "@getcofund/protocol/btc";
258
+
259
+ // Every machine: the vault, rebuilt from the definition its members share.
260
+ const vault = verifyBtcVault({ id: "treasury", address, definition }, "mainnet");
261
+ const reader = esploraReader("https://mempool.space/api");
262
+
263
+ // What the spend pays, agreed out of band. Each signer checks the PSBT against it.
264
+ const expectations = {
265
+ payees: [{ address: "bc1q…", amount: 1_000_000n }],
266
+ extraOutputs: [{ address: "bc1p…", amount: 5_000n }], // e.g. a fee you charge
267
+ maxFeeRate: 50, // sat/vB
268
+ };
269
+
270
+ // Machine A selects coins and builds the PSBT.
271
+ const utxos = (await reader.utxos(vault.address)).filter((u) => u.confirmed);
272
+ const { selected, fee } = selectCoins({
273
+ utxos,
274
+ amount: 1_000_000n,
275
+ extraOutputs: [5_000n],
276
+ feeRate: await reader.feeRate(),
277
+ inputVbytes: vaultSpendInputVbytes(vault),
278
+ });
279
+ const unsigned = base64.encode(
280
+ buildSpendPsbt({
281
+ vault,
282
+ inputs: selected,
283
+ payees: expectations.payees,
284
+ extraOutputs: expectations.extraOutputs,
285
+ fee,
286
+ }),
287
+ );
288
+ // …and sends `unsigned` to B and C.
289
+
290
+ // A, B and C each sign their own copy with their own key, and send it back.
291
+ const signer = localLeafSigner(accountXprv); // or a wallet, or a Ledger
292
+ const signed = base64.encode(
293
+ await signPsbt(base64.decode(unsigned), vault, signer, expectations),
294
+ );
295
+
296
+ // Whoever collects them: two signatures meet the threshold; a third is ignored.
297
+ const final = finalizePsbt(
298
+ combinePsbts([signedA, signedB, signedC].map((psbt) => base64.decode(psbt))),
299
+ vault,
300
+ );
301
+ const txid = await broadcast(extractTx(final), reader);
302
+ ```
303
+
304
+ ### Each step
305
+
306
+ | Function | Does |
307
+ |---|---|
308
+ | `selectCoins({ utxos, amount, feeRate, inputVbytes, payeeCount?, extraOutputs?, consolidate? })` | Coins for the spend, largest first, until they cover the outputs and the fee for that many inputs; with `consolidate`, every coin. The fee is the spend's vsize with a change output, each input sized by the vault's largest leaf (`vaultSpendInputVbytes`), at `feeRate`, times `FEE_SAFETY_MULTIPLIER`. It locks nothing: keeping two spends off the same coins is yours |
309
+ | `buildSpendPsbt({ vault, inputs, payees, extraOutputs?, fee })` | The unsigned PSBT: the payees, then the extra outputs, then change to the vault unless it is below `VAULT_CHANGE_DUST` (then it goes to the fee). The inputs are in their canonical order, and each carries its prevout (`witnessUtxo`), every leaf with its control block (`tapLeafScript`), the internal key and merkle root, and every leaf key's origin (`tapBip32Derivation`). The package adds no output of its own |
310
+ | `checkPsbt(psbt, vault, expectations)` | Every input spends the vault's script, none twice; the outputs are the payees, then the extra outputs, then at most change to the vault; the network fee is at most `maxFeeRate` (default 1000) sat/vB. Returns what the spend pays; throws on the first thing that does not hold |
311
+ | `signPsbt(psbt, vault, signer, expectations)` | `checkPsbt`, then a BIP-340 signature (`tapScriptSig`, SIGHASH_DEFAULT) per input for each leaf that holds the signer's key, each verified against that key before it is added |
312
+ | `combinePsbts(psbts)` | One PSBT with every signature; refuses PSBTs of different spends, or that disagree on a prevout |
313
+ | `finalizePsbt(psbt, vault)` | Each input's witness, once one leaf has its threshold on every input: k of the leaf's keys, and on an `ADMINS_REQUIRED` leaf its admin and k − 1 more. Only signatures that verify count. `ADMINS_INCLUDED` has the same script as `ALL`: its admin rule binds only those who apply it. Throws, saying what each leaf lacks, when none is met |
314
+ | `extractTx(psbt)` | The finalized transaction, as hex |
315
+ | `broadcast(hex, reader)` | Sends it through the reader; returns its txid |
316
+
317
+ The witness assembly the finalizer uses (`tryFinalizeAny`, `leafScriptPolicy`,
318
+ `finalizeWithWitness` and the rest) is exported too, for signatures collected
319
+ some other way.
320
+
321
+ ### What you bring
322
+
323
+ A **reader**, `BtcReader`: `utxos(address)`, `tx(txid)`, `feeRate()` and
324
+ `broadcast(hex)`. `esploraReader(baseUrl, { feeTarget? })` is one over any
325
+ Esplora API with `fetch` alone; a node's RPC or another indexer fits the same
326
+ four functions.
327
+
328
+ A **signer** per member, `BtcLeafSigner`: `xpub()`, the account xpub the
329
+ vault's definition lists for the member, and `signTaprootLeaf(sighash,
330
+ derivation)`, a BIP-340 signature by the key at `derivation` below that account
331
+ (`[branch, 376864517]`). `localLeafSigner(accountXprv)` holds an account key in
332
+ memory; derive one from a seed with `@scure/bip32`, e.g.
333
+ `HDKey.fromMasterSeed(seed).derive("m/86'/0'/0'").privateExtendedKey` (with
334
+ `BIP32_VERSIONS.testnet` off mainnet).
335
+
336
+ A vault's definition holds account xpubs, not their wallets' master
337
+ fingerprints, so each `tapBip32Derivation` names its key's origin from the
338
+ account: the account xpub's fingerprint, then `branch/376864517`. A wallet that
339
+ looks its keys up by master fingerprint finds them at
340
+ `m/86'/coin'/0'/branch/376864517`.
341
+
342
+ ## Bitcoin policies
343
+
344
+ `@getcofund/protocol/btc-policies` gives Bitcoin the policy shape the EVM
345
+ policies helper has: signers, a threshold, a cap per spend and an optional
346
+ recipient allowlist. No script checks them: a vault's script asks for k of its
347
+ keys and nothing else. A policy binds only the parties that check each spend
348
+ against it before they sign and wait for its signers before they finalize.
349
+ The checks are the ones Cofund's app makes on its Bitcoin transfers.
350
+
351
+ ```ts
352
+ import { checkPsbt, combinePsbts, finalizePsbt, signPsbt } from "@getcofund/protocol/btc";
353
+ import {
354
+ activationDataFor,
355
+ BtcPolicySchema,
356
+ btcPolicyActivationMessage,
357
+ checkSpendAgainstPolicy,
358
+ policySignersSigned,
359
+ verifyActivationSignature,
360
+ } from "@getcofund/protocol/btc-policies";
361
+ import { btcMessageSigner } from "@getcofund/protocol/keys";
362
+
363
+ // A plain value you store and share with its signers.
364
+ const policy = BtcPolicySchema.parse({
365
+ id: crypto.randomUUID(),
366
+ name: "Contractor payouts",
367
+ type: "Contractor_Stipend",
368
+ signers: [aliceXpub, bobXpub, carolXpub], // account xpubs the vault commits to
369
+ threshold: 2,
370
+ maxAmount: "5000000", // sats per spend
371
+ recipients: ["bc1q…"], // absent or empty: any
372
+ });
373
+
374
+ // Activation: an admin signs with their BTC key's Cofund leaf, and anyone
375
+ // checks the approval against that admin's account xpub.
376
+ const data = activationDataFor(policy, { company: "Acme Inc" });
377
+ const approval = await btcMessageSigner(keystore).signMessage(btcPolicyActivationMessage(data));
378
+ verifyActivationSignature(data, approval, adminXpub); // true
379
+
380
+ // Before signing: the PSBT checked, then the spend against the policy.
381
+ const summary = checkPsbt(psbt, vault, expectations);
382
+ checkSpendAgainstPolicy(summary, policy, vault); // throws, saying why
383
+ const signed = await signPsbt(psbt, vault, signer, expectations);
384
+
385
+ // Before finalizing: enough of the policy's signers, not any k of the vault's.
386
+ const combined = combinePsbts(copies);
387
+ if (policySignersSigned(combined, policy, vault)) {
388
+ finalizePsbt(combined, vault);
389
+ }
390
+ ```
391
+
392
+ | Function | Does |
393
+ |---|---|
394
+ | `BtcPolicySchema` | The policy: a UUID id, a name, a type, its signers (distinct account xpubs), a threshold no higher than their count, `maxAmount` in sats, and `recipients`, each once |
395
+ | `checkPolicyForVault(policy, vault)` | Throws unless the policy parses, every signer is a member of the vault, the threshold is at least the vault's k, and on an `ADMINS_INCLUDED` or `ADMINS_REQUIRED` vault an admin is among the signers |
396
+ | `checkSpendAgainstPolicy(summary, policy, vault)` | `checkPolicyForVault`, then: the spend (`checkPsbt`'s summary) is the vault's, its payees' total is at most `maxAmount`, and with recipients listed every payee is one of them, compared case-insensitively. The extra outputs, the change and the network fee are not counted |
397
+ | `policySignersSigned(psbt, policy, vault)` | The first leaf on which at least `threshold` of the policy's signers each have a signature that verifies over every input, each key counted once, and among whom the leaf's own rule can be met; `{ leafHash, signers }`, or null |
398
+ | `activationDataFor(policy, { company })` | The activation: `clientId` the company's sha256, the policy's id as `authId`, each signer's Cofund leaf key and the recipients in codepoint order, the cap, the token `BTC`, no wrapper |
399
+ | `verifyActivationSignature(data, signature, xpub)` | Whether the signature (64-byte compact, hex) is the Cofund leaf of `xpub` signing the activation message as a Bitcoin Signed Message; false, never a throw, on a malformed one |
400
+
401
+ `btcPolicyActivationDigest`, `btcPolicyActivationMessage`,
402
+ `btcPolicyActivationSigningHash` and `bitcoinSignedMessageHash` give the bytes
403
+ underneath: a length-prefixed sha256 of the fields, its hex as the message,
404
+ and the message's BIP-137 hash, which a Ledger's Bitcoin app signs too.
405
+
406
+ One call verifies one approval. Whose approvals activate a policy, and how
407
+ many, is yours: Cofund's app takes its cofund's admins, one while it is
408
+ onboarding and two after (every admin for a threshold-1 policy).
409
+
410
+ `finalizePsbt` uses every signature that verifies; to put only the policy's
411
+ signers on the witness, combine only their PSBTs.
412
+
413
+ ## License
414
+
415
+ MIT
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@getcofund/protocol/btc`: a vault's taproot tree from its definition, and
3
+ * its spends: coins selected, built into a PSBT, checked and signed, combined,
4
+ * finalized and broadcast.
5
+ */
6
+ export * from "./psbt.ts";
7
+ export * from "./reader.ts";
8
+ export * from "./select.ts";
9
+ export * from "./spend.ts";
10
+ export * from "./types.ts";
11
+ export * from "./vault/index.ts";
12
+ export * from "./witness.ts";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@getcofund/protocol/btc`: a vault's taproot tree from its definition, and
3
+ * its spends: coins selected, built into a PSBT, checked and signed, combined,
4
+ * finalized and broadcast.
5
+ */
6
+ // biome-ignore lint/performance/noBarrelFile: the package's `./btc` entry
7
+ export * from "./psbt.js";
8
+ export * from "./reader.js";
9
+ export * from "./select.js";
10
+ export * from "./spend.js";
11
+ export * from "./types.js";
12
+ export * from "./vault/index.js";
13
+ export * from "./witness.js";
@@ -0,0 +1,121 @@
1
+ import type { VerifiedBtcVault } from "./spend.ts";
2
+ /**
3
+ * A vault spend as a PSBT (BIP-174, with BIP-371's taproot fields): what
4
+ * co-signers pass around, over whatever channel they choose, until one leaf
5
+ * has its threshold.
6
+ *
7
+ * select (`selectCoins`) → `buildSpendPsbt` → `signPsbt`, once per signer →
8
+ * `combinePsbts` → `finalizePsbt` → `extractTx` → `broadcast`
9
+ *
10
+ * Every step takes the vault verified against its definition
11
+ * (`verifyBtcVault`): a signer reads the vault's script and leaves off its
12
+ * own rebuild of the tree, never off the PSBT.
13
+ */
14
+ /** An output: an address on the vault's network and the sats it is paid. */
15
+ export type BtcPayment = {
16
+ address: string;
17
+ amount: bigint | number;
18
+ };
19
+ /**
20
+ * Signs for a member's account key: a wallet, a Ledger, a local key. `xpub` is
21
+ * the account xpub the vault's definition lists for the member, and
22
+ * `signTaprootLeaf` returns a BIP-340 signature over `sighash` by the key at
23
+ * `derivation` below that account: `[branch, COFUND_LEAF_INDEX]`, the leaf's
24
+ * branch and the Cofund leaf index.
25
+ */
26
+ export type BtcLeafSigner = {
27
+ xpub(): Promise<string>;
28
+ signTaprootLeaf(sighash: Uint8Array, derivation: number[]): Promise<Uint8Array>;
29
+ };
30
+ /** A {@link BtcLeafSigner} over an account xprv (tprv off mainnet), `m/86'/coin'/0'`, held in memory. */
31
+ export declare function localLeafSigner(accountXprv: string): BtcLeafSigner;
32
+ /** Change below this many sats is left to the fee: a P2TR output under it is dust. */
33
+ export declare const VAULT_CHANGE_DUST = 330n;
34
+ export type SpendPsbtRequest = {
35
+ vault: VerifiedBtcVault;
36
+ /** The vault's coins it spends (`selectCoins`); `value` in sats. */
37
+ inputs: readonly {
38
+ txid: string;
39
+ vout: number;
40
+ value: bigint | number;
41
+ }[];
42
+ /** Paid first, in this order. */
43
+ payees: readonly BtcPayment[];
44
+ /** Paid after the payees, in this order: a fee to an address, say. The package adds none of its own. */
45
+ extraOutputs?: readonly BtcPayment[];
46
+ /** The network fee, in sats (`selectCoins`). */
47
+ fee: bigint | number;
48
+ };
49
+ /**
50
+ * The unsigned spend as a PSBT: the payees, then the extra outputs, then
51
+ * change back to the vault when it is not dust (the inputs less the outputs
52
+ * less `fee`); the inputs in their canonical order (`buildVaultSpend`, by
53
+ * `txid:vout`). Each input carries its prevout (`witnessUtxo`), every leaf of
54
+ * the vault with its control block (`tapLeafScript`), the internal key and
55
+ * merkle root, and each leaf key's origin (`tapBip32Derivation`).
56
+ */
57
+ export declare function buildSpendPsbt(request: SpendPsbtRequest): Uint8Array;
58
+ /** What a signer expects a spend to pay. */
59
+ export type PsbtExpectations = {
60
+ /** The first outputs, in this order. */
61
+ payees: readonly BtcPayment[];
62
+ /** The outputs right after the payees, in this order. */
63
+ extraOutputs?: readonly BtcPayment[];
64
+ /** The network fee's ceiling, in sat/vB of the spend's vsize (default 1000). */
65
+ maxFeeRate?: number;
66
+ };
67
+ /** What a checked PSBT spends and pays; sats throughout. */
68
+ export type PsbtSpendSummary = {
69
+ vault: string;
70
+ inputs: {
71
+ txid: string;
72
+ vout: number;
73
+ value: bigint;
74
+ }[];
75
+ payees: {
76
+ address: string;
77
+ amount: bigint;
78
+ }[];
79
+ extraOutputs: {
80
+ address: string;
81
+ amount: bigint;
82
+ }[];
83
+ /** Back to the vault; 0 without a change output. */
84
+ change: bigint;
85
+ fee: bigint;
86
+ /** As the fee is priced: every input witnessed by the vault's largest leaf. */
87
+ vsize: number;
88
+ };
89
+ /**
90
+ * Check a PSBT against the verified vault and what the consumer expects;
91
+ * throws on the first thing that does not hold:
92
+ *
93
+ * - every input spends the vault's script (`witnessUtxo`), and none twice: a
94
+ * prevout the PSBT misstates only makes every signature over it invalid,
95
+ * since BIP-341 commits to each prevout's script and amount;
96
+ * - the outputs are the payees, then the extra outputs, each its address and
97
+ * amount, then at most one more: change back to the vault;
98
+ * - the network fee (the inputs less the outputs) is at most `maxFeeRate`
99
+ * sat/vB of the spend's vsize as it is priced.
100
+ */
101
+ export declare function checkPsbt(psbt: Uint8Array, vault: VerifiedBtcVault, expectations: PsbtExpectations): PsbtSpendSummary;
102
+ /**
103
+ * The signer's BIP-340 signatures added to the PSBT (`tapScriptSig`), one per
104
+ * input for each leaf that holds the signer's key, over the BIP-341 sighash
105
+ * (SIGHASH_DEFAULT) of the spend with the verified leaf's script. First the
106
+ * PSBT is checked (`checkPsbt`); each signature is verified against the
107
+ * signer's key in the leaf before it is added.
108
+ */
109
+ export declare function signPsbt(psbt: Uint8Array, vault: VerifiedBtcVault, signer: BtcLeafSigner, expectations: PsbtExpectations): Promise<Uint8Array>;
110
+ /** One PSBT with the signatures of all of them; they must be the same spend of the same prevouts. */
111
+ export declare function combinePsbts(psbts: readonly Uint8Array[]): Uint8Array;
112
+ /**
113
+ * Assemble each input's witness once one leaf has its threshold on every
114
+ * input, by the template's rules (`tryFinalizeAny`): k of the leaf's keys, and
115
+ * on an ADMINS_REQUIRED leaf its admin plus k − 1 of the rest. Only signatures
116
+ * that verify over the spend count. Returns the finalized PSBT, its signing
117
+ * fields cleared; throws when no leaf is met.
118
+ */
119
+ export declare function finalizePsbt(psbt: Uint8Array, vault: VerifiedBtcVault): Uint8Array;
120
+ /** A finalized PSBT's transaction, as raw hex to broadcast. */
121
+ export declare function extractTx(psbt: Uint8Array): string;