@getcofund/cli 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +226 -1
- package/dist/chain.js +115 -0
- package/dist/cli.js +153 -0
- package/dist/commands/btc.js +293 -0
- package/dist/commands/calls.js +194 -0
- package/dist/commands/found.js +61 -0
- package/dist/commands/invite.js +153 -0
- package/dist/commands/keys.js +36 -0
- package/dist/commands/lines.js +29 -0
- package/dist/commands/members.js +145 -0
- package/dist/commands/onboarding.js +40 -0
- package/dist/commands/policies.js +193 -0
- package/dist/commands/reads.js +88 -0
- package/dist/commands/sign-or-send.js +87 -0
- package/dist/commands/transfers.js +168 -0
- package/dist/context.js +231 -0
- package/dist/keystore.js +74 -0
- package/dist/signature-file.js +214 -0
- package/dist/usage.js +62 -0
- package/package.json +41 -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,228 @@
|
|
|
1
1
|
# @getcofund/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`cofund-chain`: the Cofund protocol from the command line, on
|
|
4
|
+
[`@getcofund/protocol`](https://www.npmjs.com/package/@getcofund/protocol) alone. It reads from and sends to the
|
|
5
|
+
chain and nowhere else: no Cofund API, no server. Co-signers pass signatures
|
|
6
|
+
and PSBTs to each other as files, over whatever channel they choose.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm i -g @getcofund/cli
|
|
10
|
+
cofund-chain --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node 20 or later.
|
|
14
|
+
|
|
15
|
+
## Settings
|
|
16
|
+
|
|
17
|
+
| Setting | Flag | Environment |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| The keystore | `--keystore <file>` | `COFUND_KEYSTORE` (default `~/.cofund/keystore.json`) |
|
|
20
|
+
| The EVM deployment: a `deployments/<chainId>.json` | `--deployment <file>` | `COFUND_DEPLOYMENT` |
|
|
21
|
+
| The EVM chain's RPC | `--rpc <url>` | `COFUND_RPC_URL` |
|
|
22
|
+
| An Esplora API, for Bitcoin | `--esplora <url>` | `COFUND_ESPLORA_URL` |
|
|
23
|
+
|
|
24
|
+
A flag wins over its variable; a command that needs a setting neither gives
|
|
25
|
+
says which is missing. The deployment is checked against its chain before
|
|
26
|
+
anything is signed under it (`loadEvmDeployment`): its domain, the chain id,
|
|
27
|
+
and every helper State registers.
|
|
28
|
+
|
|
29
|
+
`--via direct` (the default) sends a call from the keystore's EVM key, which
|
|
30
|
+
pays gas. `--via guard --sponsor-key-file <file>` wraps it in the
|
|
31
|
+
deployment's guard (`sponsorGuard.relay`) and sends it from the sponsor's key
|
|
32
|
+
(a hex private key, or a keystore), which the guard must accept. Founding a
|
|
33
|
+
cofund (`createClient`) and seating a member (`addUser`) only accept the guard
|
|
34
|
+
or a Cofund admin as their sender, so they go through the guard.
|
|
35
|
+
|
|
36
|
+
## The keystore
|
|
37
|
+
|
|
38
|
+
The same file as the API CLI `cofund`'s (`Keystore` from
|
|
39
|
+
`@getcofund/protocol/keys`), with mode 0600: one EVM key and one BIP-86
|
|
40
|
+
Bitcoin account, generated locally, so one key serves both CLIs. It pins the
|
|
41
|
+
company each cofund's messages carry: `found` and `join` pin it, and a command
|
|
42
|
+
acts in the keystore's only pinned cofund unless `--company` names another.
|
|
43
|
+
A key signs a file for a cofund it has not pinned only when `--company` names
|
|
44
|
+
that cofund.
|
|
45
|
+
|
|
46
|
+
`COFUND_BTC_NETWORK` (`mainnet`, `testnet` or `regtest`) picks the network of
|
|
47
|
+
new keys and vaults; regtest uses testnet keys.
|
|
48
|
+
|
|
49
|
+
## Signature files
|
|
50
|
+
|
|
51
|
+
A signature is a JSON file:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"version": 1,
|
|
56
|
+
"type": "Transfer",
|
|
57
|
+
"domain": { "name": "Cofund", "version": "1", "chainId": 1, "verifyingContract": "0xD59e…02C0" },
|
|
58
|
+
"message": { "company": "acme", "policyId": "…", "messageType": "Operational_Expense", "amount": "50000000", "token": "0x…", "recipient": "0x…", "authId": "…" },
|
|
59
|
+
"signer": "0x…",
|
|
60
|
+
"signature": "0x…"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`type` is one of the protocol's EIP-712 types (`InviteUser`, `RemoveUser`,
|
|
65
|
+
`RotateUser`, `CompleteOnboarding`, `TransferPolicyActivation`,
|
|
66
|
+
`TransactionPolicyActivation`, `PolicyDeactivation`, `Transfer`,
|
|
67
|
+
`Transaction`); a `Transaction` file also carries the `instructions` it signs
|
|
68
|
+
by hash. Every file is checked when it is loaded: the message is exactly its
|
|
69
|
+
type's fields (the types are always the package's, never the file's), the
|
|
70
|
+
domain is the deployment's, the signature recovers to the `signer` the file
|
|
71
|
+
claims, and every file a command takes is of a type it takes and over the
|
|
72
|
+
same message. Files are never overwritten.
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
Every command that signs or sends prints what it is about to do, checked by
|
|
77
|
+
the package's builders, and asks before it acts unless `--yes` is given. A
|
|
78
|
+
call is also run as an `eth_call` from its sender before anyone is asked, so
|
|
79
|
+
the checks that turn on the sender (the guard's sponsors, the platform gate,
|
|
80
|
+
the vault's broadcasters) are shown too. A refusal is shown with the
|
|
81
|
+
contract's error and why, and nothing is sent.
|
|
82
|
+
|
|
83
|
+
| Command | Does |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `keys init [--force]` | Make the keystore |
|
|
86
|
+
| `keys show` | Its EVM address, its Bitcoin account xpub, the cofunds it pins |
|
|
87
|
+
| `found --name <name> [--company <company>] [--tier <tier>]` | Sign the founder's own seat and send `createClient`; the company is the name normalized unless `--company` gives it, the tier `BUSINESS` unless `--tier` names another |
|
|
88
|
+
| `show` | The cofund, its vault, its phase, its admins, its policies, this key's seat |
|
|
89
|
+
| `members` | Everyone ever seated, as State holds them now |
|
|
90
|
+
| `policies show <id>` | A policy, its signers by user id |
|
|
91
|
+
| `invite --user-id <id> --position <title> [--admin] --out <file>` | An admin signs a seat |
|
|
92
|
+
| `invite cosign <file>… --out <file>` | Another admin co-signs an admin seat |
|
|
93
|
+
| `join <file>…` | The invitee signs its half and sends `addUser` with the files' signatures |
|
|
94
|
+
| `members remove <user-id> [<file>…] [--out <file>]` | With `--out`, sign; without, send with the files' signatures, this key's added if it is an admin's not among them |
|
|
95
|
+
| `members rotate [<file>…] [--new-address <address>] [--user-id <id>] [--out <file>]` | The current key (or, for a lost key, admins) signs with `--out`; the new key adds its own and sends |
|
|
96
|
+
| `policies sign (--name … --signers <id,…> --threshold <n> (--token <address> --max-amount <amount> [--recipients <address,…>] \| --wrapper <address>) [--type <policy type>] \| <file>…) --out <file>` | An admin signs a policy's activation, new or the files' |
|
|
97
|
+
| `policies activate <file>…` | Send it with the admins' signatures |
|
|
98
|
+
| `policies deactivate <id> [<file>] [--out <file>]` | One admin's signature, this key's or the file's, sent |
|
|
99
|
+
| `transfers sign (--policy <id> --to <address> --amount <amount> \| <file>…) --out <file>` | A signer of a transfer policy signs a transfer |
|
|
100
|
+
| `transfers send <file>…` | Send it from the vault once signed often enough |
|
|
101
|
+
| `calls sign (--policy <id> --instructions <hex> [--allowance <token>:<amount>]… \| <file>…) --out <file>` | A signer of a call policy signs a call through its wrapper |
|
|
102
|
+
| `calls send <file>…` | Send it from the vault |
|
|
103
|
+
| `calls simulate <file>…` | Its effect on every balance it moves, simulated (`eth_simulateV1`) before the threshold is met |
|
|
104
|
+
| `onboarding complete [<file>] [--out <file>]` | One admin's `CompleteOnboarding`, sent |
|
|
105
|
+
| `btc vault create --xpubs <xpub,…> --threshold <k> [--type ALL\|ADMINS_INCLUDED\|ADMINS_REQUIRED] [--admins <xpub,…>] --out <file>` | A vault's definition from its members' account xpubs, and its address |
|
|
106
|
+
| `btc spend build --vault <file> --to <address> --amount <sats> [--fee-rate <sat/vB>] --out <file>` | The vault's confirmed coins selected through Esplora, and the unsigned PSBT |
|
|
107
|
+
| `btc spend sign <psbt> --vault <file> --to <address> --amount <sats> --out <file>` | Sign it, after checking it pays exactly that and spends only the vault |
|
|
108
|
+
| `btc spend combine <psbt>… --out <file>` | One PSBT with every signature |
|
|
109
|
+
| `btc spend finalize <psbt> --vault <file> --out <file>` | The witnesses, once a leaf has its threshold; writes the transaction's hex |
|
|
110
|
+
| `btc spend broadcast <hex or file>` | Send it through Esplora |
|
|
111
|
+
|
|
112
|
+
On EVM, amounts are whole tokens, converted with the token's own decimals,
|
|
113
|
+
and every preview shows the base units too. On Bitcoin they are sats. A vault
|
|
114
|
+
file holds `{ id, address, definition }`; every command that takes one
|
|
115
|
+
rebuilds the vault from its definition (`verifyBtcVault`) and refuses one
|
|
116
|
+
that does not derive its address. A PSBT file is base64.
|
|
117
|
+
|
|
118
|
+
## A cofund, end to end
|
|
119
|
+
|
|
120
|
+
Three people, three machines. The deployment is one the founder trusts, its
|
|
121
|
+
guard accepts the sponsor's key, and each person's shell has
|
|
122
|
+
`COFUND_DEPLOYMENT` and `COFUND_RPC_URL` set.
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
# Each of them.
|
|
126
|
+
cofund-chain keys init
|
|
127
|
+
|
|
128
|
+
# Ada founds Acme through the guard, and invites Ben as an admin and Cy as a member.
|
|
129
|
+
cofund-chain found --name Acme --via guard --sponsor-key-file sponsor.key
|
|
130
|
+
cofund-chain invite --user-id ben --position CFO --admin --out ben.json
|
|
131
|
+
cofund-chain invite --user-id cy --position Engineer --out cy.json
|
|
132
|
+
|
|
133
|
+
# Ben and Cy join with their invites (and pin "acme").
|
|
134
|
+
cofund-chain join ben.json --via guard --sponsor-key-file sponsor.key
|
|
135
|
+
cofund-chain join cy.json --via guard --sponsor-key-file sponsor.key
|
|
136
|
+
|
|
137
|
+
# A transfer policy: Ben and Cy, two of two, up to 1000 USDC a transfer.
|
|
138
|
+
cofund-chain policies sign --name Operations --signers ben,cy --threshold 2 \
|
|
139
|
+
--token 0xA0b8…eB48 --max-amount 1000 --out ops-ada.json # Ada
|
|
140
|
+
cofund-chain policies sign ops-ada.json --out ops-ben.json # Ben
|
|
141
|
+
cofund-chain policies activate ops-ada.json ops-ben.json # anyone
|
|
142
|
+
cofund-chain show # lists its id
|
|
143
|
+
|
|
144
|
+
# A transfer: both signers sign, either sends it from the vault.
|
|
145
|
+
cofund-chain transfers sign --policy <id> --to 0x… --amount 50 --out t-ben.json # Ben
|
|
146
|
+
cofund-chain transfers sign t-ben.json --out t-cy.json # Cy
|
|
147
|
+
cofund-chain transfers send t-ben.json t-cy.json # Cy
|
|
148
|
+
|
|
149
|
+
# Cy moves to a new key: the old key signs, the new keystore adds its own and sends.
|
|
150
|
+
cofund-chain members rotate --new-address 0x… --out rotate.json
|
|
151
|
+
cofund-chain members rotate rotate.json --company acme --keystore new.json
|
|
152
|
+
|
|
153
|
+
# Ada closes the founding phase: from now on admin seats and policies take the admins' consensus.
|
|
154
|
+
cofund-chain onboarding complete
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## A Bitcoin vault, end to end
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
# Each member: COFUND_BTC_NETWORK=testnet cofund-chain keys init, and shares the xpub keys show prints.
|
|
161
|
+
cofund-chain btc vault create --xpubs tpubA…,tpubB…,tpubC… --threshold 2 --out vault.json
|
|
162
|
+
# Fund the address it prints. Then, with COFUND_ESPLORA_URL set:
|
|
163
|
+
cofund-chain btc spend build --vault vault.json --to tb1q… --amount 700000 --out spend.psbt
|
|
164
|
+
# Two members, each on their machine, sign what they agreed it pays:
|
|
165
|
+
cofund-chain btc spend sign spend.psbt --vault vault.json --to tb1q… --amount 700000 --out b.psbt
|
|
166
|
+
cofund-chain btc spend sign spend.psbt --vault vault.json --to tb1q… --amount 700000 --out c.psbt
|
|
167
|
+
# Whoever collects them:
|
|
168
|
+
cofund-chain btc spend combine b.psbt c.psbt --out signed.psbt
|
|
169
|
+
cofund-chain btc spend finalize signed.psbt --vault vault.json --out spend.hex
|
|
170
|
+
cofund-chain btc spend broadcast spend.hex
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Local stack
|
|
174
|
+
|
|
175
|
+
From this repo, with Foundry, a cofund-solidity checkout and (for `--btc`)
|
|
176
|
+
Docker:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
# 1. Up: an Anvil with cofund-solidity's e2e contracts and a guard sponsor's key.
|
|
180
|
+
# Progress goes to stderr, the exports to stdout. A rerun reuses what is up.
|
|
181
|
+
pnpm -s --filter @getcofund/cli local-up # [--port <n>] [--btc]
|
|
182
|
+
|
|
183
|
+
# 2. The exports in this shell (fish: `pnpm -s --filter @getcofund/cli local-up | source`).
|
|
184
|
+
eval "$(pnpm -s --filter @getcofund/cli local-up)"
|
|
185
|
+
|
|
186
|
+
# 3. cofund-chain against it (here: `node packages/cli/src/cli.ts`).
|
|
187
|
+
cofund-chain keys init
|
|
188
|
+
cast rpc anvil_setBalance <its evm address> 0x56bc75e2d63100000 --rpc-url "$COFUND_RPC_URL"
|
|
189
|
+
cofund-chain found --name Acme --via guard --sponsor-key-file ~/.cofund/local/sponsor.key
|
|
190
|
+
|
|
191
|
+
# Done: stops only what local-up started.
|
|
192
|
+
pnpm --filter @getcofund/cli local-down
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`local-up` starts Anvil on a free port (or `--port`, or `LOCAL_ANVIL_PORT`),
|
|
196
|
+
never on one where a node it did not start answers, runs cofund-solidity's
|
|
197
|
+
`script/e2e-deploy.sh` on it, and writes `<chainId>.json` and `sponsor.key`
|
|
198
|
+
(Anvil account 10, one of the guard's sponsors) into its state dir,
|
|
199
|
+
`COFUND_LOCAL_DIR` (default `~/.cofund/local`). For Bitcoin it uses the shared
|
|
200
|
+
regtest Esplora at `http://127.0.0.1:3002` when it answers; otherwise `--btc`
|
|
201
|
+
starts a bitcoind and an electrs of its own in Docker. cofund-solidity is
|
|
202
|
+
`COFUND_SOLIDITY_DIR`, else the sibling of this repo's main checkout (as
|
|
203
|
+
`apps/cofund/scripts/e2e-up.sh` finds it).
|
|
204
|
+
|
|
205
|
+
## Tests
|
|
206
|
+
|
|
207
|
+
`pnpm --filter @getcofund/cli test` runs the unit tests: each command's
|
|
208
|
+
arguments, previews and refusals against an in-memory chain, the signature
|
|
209
|
+
file checks, and the settings' precedence. Two integration tests run the
|
|
210
|
+
`cofund-chain` binary, one process per command, against real chains:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
# The EVM: cofund-solidity's e2e deploy script on a throwaway Anvil, a cofund's life
|
|
214
|
+
# sent directly and through the guard. Needs Foundry.
|
|
215
|
+
CLI_ANVIL=1 COFUND_SOLIDITY_DIR=../cofund-solidity pnpm --filter @getcofund/cli test
|
|
216
|
+
|
|
217
|
+
# Bitcoin: a bitcoind -regtest and an electrs of its own in Docker, a 2-of-3 vault
|
|
218
|
+
# created, funded, spent through PSBTs signed by two keystores, broadcast and mined.
|
|
219
|
+
CLI_REGTEST=1 pnpm --filter @getcofund/cli test
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`pnpm --filter @getcofund/cli smoke` packs the package, installs it outside
|
|
223
|
+
the repo next to the packed protocol, and runs `cofund-chain --help` and
|
|
224
|
+
`keys init`.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
package/dist/chain.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { formatUnitsAmount, readContract, readUser, readUserIdByAddress, stateAbi, } from "@getcofund/protocol/evm";
|
|
2
|
+
import { decodeEventLog, encodeEventTopics, erc20Abi, keccak256, stringToHex, } from "viem";
|
|
3
|
+
/** The seat `address` holds in the cofund, active or not; null when none. */
|
|
4
|
+
export async function seatOf(evm, clientId, address) {
|
|
5
|
+
const userId = await readUserIdByAddress(evm.reader, evm.deployment, clientId, address);
|
|
6
|
+
if (userId === null) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const user = await readUser(evm.reader, evm.deployment, clientId, userId);
|
|
10
|
+
return user ? { userId, user } : null;
|
|
11
|
+
}
|
|
12
|
+
/** `address`'s seat, which must be an active admin's, or why not. */
|
|
13
|
+
export async function requireAdmin(evm, clientId, address) {
|
|
14
|
+
const seat = await seatOf(evm, clientId, address);
|
|
15
|
+
if (!seat) {
|
|
16
|
+
throw new Error(`${address} holds no seat in this cofund`);
|
|
17
|
+
}
|
|
18
|
+
if (!(seat.user.active && seat.user.isAdmin)) {
|
|
19
|
+
throw new Error(`${address} (${seat.userId}) is not an active admin of this cofund`);
|
|
20
|
+
}
|
|
21
|
+
return seat;
|
|
22
|
+
}
|
|
23
|
+
/** A seat as previews name it: `id (admin, CFO, 0x…)`. */
|
|
24
|
+
export function seatText({ userId, user }) {
|
|
25
|
+
const role = user.isAdmin ? "admin" : "member";
|
|
26
|
+
const state = user.active ? "" : ", removed";
|
|
27
|
+
return `${userId} (${role}, ${user.position}, ${user.address}${state})`;
|
|
28
|
+
}
|
|
29
|
+
/** A symbol a token claims, shown only when it is a plain one. */
|
|
30
|
+
const SYMBOL = /^[\w.$-]{1,32}$/;
|
|
31
|
+
export async function tokenInfo(evm, address) {
|
|
32
|
+
let decimals;
|
|
33
|
+
try {
|
|
34
|
+
decimals = await readContract(evm.reader, {
|
|
35
|
+
address,
|
|
36
|
+
abi: erc20Abi,
|
|
37
|
+
functionName: "decimals",
|
|
38
|
+
args: [],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`${address} reports no decimals: it is not a token this CLI can take an amount in`);
|
|
43
|
+
}
|
|
44
|
+
const symbol = await readContract(evm.reader, {
|
|
45
|
+
address,
|
|
46
|
+
abi: erc20Abi,
|
|
47
|
+
functionName: "symbol",
|
|
48
|
+
args: [],
|
|
49
|
+
}).catch(() => null);
|
|
50
|
+
return {
|
|
51
|
+
address,
|
|
52
|
+
symbol: symbol && SYMBOL.test(symbol) ? symbol : null,
|
|
53
|
+
decimals,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const AMOUNT = /^(\d*)\.?(\d*)$/;
|
|
57
|
+
const NOT_ZERO = /[^0]/;
|
|
58
|
+
/**
|
|
59
|
+
* `--flag`'s whole-token amount in base units, exactly: positive, and no
|
|
60
|
+
* finer than the token's decimals (never rounded).
|
|
61
|
+
*/
|
|
62
|
+
export function baseUnits(amount, token, flag) {
|
|
63
|
+
const match = AMOUNT.exec(amount);
|
|
64
|
+
const fraction = match?.[2] ?? "";
|
|
65
|
+
if (!match ||
|
|
66
|
+
match[1].length + fraction.length === 0 ||
|
|
67
|
+
NOT_ZERO.test(fraction.slice(token.decimals))) {
|
|
68
|
+
throw new Error(`${flag} takes whole tokens, to at most ${token.decimals} decimals, not ${amount}`);
|
|
69
|
+
}
|
|
70
|
+
const units = BigInt(`${match[1] || "0"}${fraction.padEnd(token.decimals, "0").slice(0, token.decimals)}`);
|
|
71
|
+
if (units <= 0n) {
|
|
72
|
+
throw new Error(`${flag} must be positive`);
|
|
73
|
+
}
|
|
74
|
+
return units;
|
|
75
|
+
}
|
|
76
|
+
/** `units` of `token` as every preview prints them: whole tokens and base units. */
|
|
77
|
+
export function amountText(units, token) {
|
|
78
|
+
const symbol = token.symbol ? `"${token.symbol}"` : "tokens";
|
|
79
|
+
return `${formatUnitsAmount(units, token.decimals)} ${symbol} (${units} base units of ${token.address})`;
|
|
80
|
+
}
|
|
81
|
+
/** State's `events` for the cofund, oldest first: there is no index, so they are read from the chain. */
|
|
82
|
+
async function stateEvents(evm, clientId, events) {
|
|
83
|
+
const logs = (await evm.reader.request({
|
|
84
|
+
method: "eth_getLogs",
|
|
85
|
+
params: [
|
|
86
|
+
{
|
|
87
|
+
address: evm.deployment.state,
|
|
88
|
+
fromBlock: "earliest",
|
|
89
|
+
toBlock: "latest",
|
|
90
|
+
topics: [
|
|
91
|
+
events.map((eventName) => encodeEventTopics({ abi: stateAbi, eventName })[0]),
|
|
92
|
+
clientId,
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}));
|
|
97
|
+
return logs.map((log) => decodeEventLog({ abi: stateAbi, data: log.data, topics: log.topics }));
|
|
98
|
+
}
|
|
99
|
+
/** Every user id ever seated in the cofund, the founder first. */
|
|
100
|
+
export async function memberIds(evm, clientId) {
|
|
101
|
+
const ids = (await stateEvents(evm, clientId, ["NewClientCreated", "UserAdded"])).map((event) => event.eventName === "NewClientCreated"
|
|
102
|
+
? event.args.adminId
|
|
103
|
+
: event.args.userId);
|
|
104
|
+
return [...new Set(ids)];
|
|
105
|
+
}
|
|
106
|
+
/** Every policy id ever activated in the cofund. */
|
|
107
|
+
export async function policyIds(evm, clientId) {
|
|
108
|
+
const ids = (await stateEvents(evm, clientId, ["PolicyActivated"])).map((event) => event.args.policyId);
|
|
109
|
+
return [...new Set(ids)];
|
|
110
|
+
}
|
|
111
|
+
/** A policy's signer ids (State keeps `keccak256(userId)`) named by the members they hash. */
|
|
112
|
+
export function signerNames(signerIds, members) {
|
|
113
|
+
const byHash = new Map(members.map((id) => [keccak256(stringToHex(id)), id]));
|
|
114
|
+
return signerIds.map((hash) => byHash.get(hash) ?? hash);
|
|
115
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `cofund-chain`: the Cofund protocol from the command line, on
|
|
4
|
+
* `@getcofund/protocol` alone. It reads and sends to the chain and nowhere
|
|
5
|
+
* else; co-signers pass signatures and PSBTs as files. `--help` lists the
|
|
6
|
+
* commands.
|
|
7
|
+
*/
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
9
|
+
import { createInterface } from "node:readline/promises";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { btcSpendBroadcast, btcSpendBuild, btcSpendCombine, btcSpendFinalize, btcSpendSign, btcVaultCreate, } from "./commands/btc.js";
|
|
13
|
+
import { callsSend, callsSign, callsSimulate } from "./commands/calls.js";
|
|
14
|
+
import { found } from "./commands/found.js";
|
|
15
|
+
import { invite, inviteCosign, join } from "./commands/invite.js";
|
|
16
|
+
import { keysInit, keysShow } from "./commands/keys.js";
|
|
17
|
+
import { membersRemove, membersRotate } from "./commands/members.js";
|
|
18
|
+
import { onboardingComplete } from "./commands/onboarding.js";
|
|
19
|
+
import { policiesActivate, policiesDeactivate, policiesSign, } from "./commands/policies.js";
|
|
20
|
+
import { members, policiesShow, show } from "./commands/reads.js";
|
|
21
|
+
import { transfersSend, transfersSign } from "./commands/transfers.js";
|
|
22
|
+
import { errorLines } from "./context.js";
|
|
23
|
+
import { USAGE } from "./usage.js";
|
|
24
|
+
const OPTIONS = {
|
|
25
|
+
keystore: { type: "string" },
|
|
26
|
+
deployment: { type: "string" },
|
|
27
|
+
rpc: { type: "string" },
|
|
28
|
+
esplora: { type: "string" },
|
|
29
|
+
via: { type: "string" },
|
|
30
|
+
"sponsor-key-file": { type: "string" },
|
|
31
|
+
company: { type: "string" },
|
|
32
|
+
out: { type: "string" },
|
|
33
|
+
yes: { type: "boolean", short: "y" },
|
|
34
|
+
force: { type: "boolean" },
|
|
35
|
+
help: { type: "boolean", short: "h" },
|
|
36
|
+
name: { type: "string" },
|
|
37
|
+
tier: { type: "string" },
|
|
38
|
+
"user-id": { type: "string" },
|
|
39
|
+
position: { type: "string" },
|
|
40
|
+
admin: { type: "boolean" },
|
|
41
|
+
"new-address": { type: "string" },
|
|
42
|
+
signers: { type: "string" },
|
|
43
|
+
threshold: { type: "string" },
|
|
44
|
+
token: { type: "string" },
|
|
45
|
+
"max-amount": { type: "string" },
|
|
46
|
+
recipients: { type: "string" },
|
|
47
|
+
wrapper: { type: "string" },
|
|
48
|
+
type: { type: "string" },
|
|
49
|
+
policy: { type: "string" },
|
|
50
|
+
to: { type: "string" },
|
|
51
|
+
amount: { type: "string" },
|
|
52
|
+
instructions: { type: "string" },
|
|
53
|
+
allowance: { type: "string", multiple: true },
|
|
54
|
+
xpubs: { type: "string" },
|
|
55
|
+
admins: { type: "string" },
|
|
56
|
+
vault: { type: "string" },
|
|
57
|
+
"fee-rate": { type: "string" },
|
|
58
|
+
};
|
|
59
|
+
/** A command that takes no positionals. */
|
|
60
|
+
const bare = (run) => (io, flags, args) => args.length === 0 ? run(io, flags) : usage(io);
|
|
61
|
+
/** Every command by its name, the longest name a line starts with winning. */
|
|
62
|
+
const COMMANDS = {
|
|
63
|
+
"keys init": bare(keysInit),
|
|
64
|
+
"keys show": bare(keysShow),
|
|
65
|
+
found: bare(found),
|
|
66
|
+
show: bare(show),
|
|
67
|
+
members: bare(members),
|
|
68
|
+
"members remove": (io, flags, [userId, ...files]) => membersRemove(io, flags, userId, files),
|
|
69
|
+
"members rotate": membersRotate,
|
|
70
|
+
invite: bare(invite),
|
|
71
|
+
"invite cosign": inviteCosign,
|
|
72
|
+
join,
|
|
73
|
+
"policies show": (io, flags, [id, ...rest]) => rest.length === 0 ? policiesShow(io, flags, id) : usage(io),
|
|
74
|
+
"policies sign": policiesSign,
|
|
75
|
+
"policies activate": policiesActivate,
|
|
76
|
+
"policies deactivate": (io, flags, [id, ...files]) => policiesDeactivate(io, flags, id, files),
|
|
77
|
+
"transfers sign": transfersSign,
|
|
78
|
+
"transfers send": transfersSend,
|
|
79
|
+
"calls sign": callsSign,
|
|
80
|
+
"calls send": callsSend,
|
|
81
|
+
"calls simulate": callsSimulate,
|
|
82
|
+
"onboarding complete": onboardingComplete,
|
|
83
|
+
"btc vault create": bare(btcVaultCreate),
|
|
84
|
+
"btc spend build": bare(btcSpendBuild),
|
|
85
|
+
"btc spend sign": btcSpendSign,
|
|
86
|
+
"btc spend combine": btcSpendCombine,
|
|
87
|
+
"btc spend finalize": btcSpendFinalize,
|
|
88
|
+
"btc spend broadcast": btcSpendBroadcast,
|
|
89
|
+
};
|
|
90
|
+
function dispatch(io, flags, positionals) {
|
|
91
|
+
for (const words of [3, 2, 1]) {
|
|
92
|
+
const name = positionals.slice(0, words).join(" ");
|
|
93
|
+
if (positionals.length >= words && Object.hasOwn(COMMANDS, name)) {
|
|
94
|
+
return COMMANDS[name](io, flags, positionals.slice(words));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return usage(io);
|
|
98
|
+
}
|
|
99
|
+
function usage(io) {
|
|
100
|
+
io.err(USAGE);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
export async function runCli(argv, io) {
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = parseArgs({
|
|
107
|
+
args: argv,
|
|
108
|
+
allowPositionals: true,
|
|
109
|
+
options: OPTIONS,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
114
|
+
io.err("`cofund-chain --help` lists the commands and their flags");
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const { values, positionals } = parsed;
|
|
118
|
+
if (values.help) {
|
|
119
|
+
io.out(USAGE);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return await dispatch(io, values, positionals);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
for (const text of errorLines(error)) {
|
|
127
|
+
io.err(text);
|
|
128
|
+
}
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (process.argv[1] &&
|
|
133
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
134
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
135
|
+
runCli(process.argv.slice(2), {
|
|
136
|
+
env: process.env,
|
|
137
|
+
out: (text) => process.stdout.write(`${text}\n`),
|
|
138
|
+
err: (text) => process.stderr.write(`${text}\n`),
|
|
139
|
+
confirm: tty
|
|
140
|
+
? async (question) => {
|
|
141
|
+
const rl = createInterface({
|
|
142
|
+
input: process.stdin,
|
|
143
|
+
output: process.stdout,
|
|
144
|
+
});
|
|
145
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
146
|
+
rl.close();
|
|
147
|
+
return answer.trim().toLowerCase() === "y";
|
|
148
|
+
}
|
|
149
|
+
: undefined,
|
|
150
|
+
}).then((code) => {
|
|
151
|
+
process.exitCode = code;
|
|
152
|
+
});
|
|
153
|
+
}
|