@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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { Address as AddressSchema, COMPLETE_ONBOARDING_TYPES, Hex as HexSchema, INVITE_USER_TYPES, POLICY_DEACTIVATION_TYPES, REMOVE_USER_TYPES, ROTATE_USER_TYPES, sameAddress, TRANSACTION_POLICY_ACTIVATION_TYPES, TRANSACTION_TYPES, TRANSFER_POLICY_ACTIVATION_TYPES, TRANSFER_TYPES, tryRecover, typedData, typedDataDigest, } from "@getcofund/protocol/evm";
|
|
3
|
+
import { keccak256 } from "viem";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* A signature as co-signers pass it around: a JSON file holding the message,
|
|
7
|
+
* its EIP-712 type, the domain it was signed under, the signer's address and
|
|
8
|
+
* the signature (and a `Transaction`'s instructions, which it signs by hash).
|
|
9
|
+
* A file is checked when it is loaded: the message is its type's shape, the
|
|
10
|
+
* domain is the deployment's, the signature recovers to the address the file
|
|
11
|
+
* claims, and every file a command takes is for the same call. The type's
|
|
12
|
+
* fields are always this package's, never the file's.
|
|
13
|
+
*/
|
|
14
|
+
export const SIGNATURE_TYPES = {
|
|
15
|
+
InviteUser: INVITE_USER_TYPES,
|
|
16
|
+
RemoveUser: REMOVE_USER_TYPES,
|
|
17
|
+
RotateUser: ROTATE_USER_TYPES,
|
|
18
|
+
CompleteOnboarding: COMPLETE_ONBOARDING_TYPES,
|
|
19
|
+
TransferPolicyActivation: TRANSFER_POLICY_ACTIVATION_TYPES,
|
|
20
|
+
TransactionPolicyActivation: TRANSACTION_POLICY_ACTIVATION_TYPES,
|
|
21
|
+
PolicyDeactivation: POLICY_DEACTIVATION_TYPES,
|
|
22
|
+
Transfer: TRANSFER_TYPES,
|
|
23
|
+
Transaction: TRANSACTION_TYPES,
|
|
24
|
+
};
|
|
25
|
+
const TYPE_NAMES = Object.keys(SIGNATURE_TYPES);
|
|
26
|
+
const FileSchema = z.strictObject({
|
|
27
|
+
version: z.literal(1),
|
|
28
|
+
type: z.enum(TYPE_NAMES),
|
|
29
|
+
domain: z.strictObject({
|
|
30
|
+
name: z.string(),
|
|
31
|
+
version: z.string(),
|
|
32
|
+
chainId: z.number().int().positive(),
|
|
33
|
+
verifyingContract: AddressSchema,
|
|
34
|
+
}),
|
|
35
|
+
message: z.record(z.string(), z.unknown()),
|
|
36
|
+
signer: AddressSchema,
|
|
37
|
+
signature: HexSchema,
|
|
38
|
+
instructions: HexSchema.optional(),
|
|
39
|
+
});
|
|
40
|
+
const UINT256 = /^\d{1,78}$/;
|
|
41
|
+
const BYTES32 = /^0x[0-9a-fA-F]{64}$/;
|
|
42
|
+
const ARRAY = /\[\]$/;
|
|
43
|
+
/** Why `value` is not an EIP-712 `type` of `types`, or null when it is. */
|
|
44
|
+
function fieldProblem(type, value, types) {
|
|
45
|
+
if (ARRAY.test(type)) {
|
|
46
|
+
if (!Array.isArray(value)) {
|
|
47
|
+
return "not a list";
|
|
48
|
+
}
|
|
49
|
+
for (const item of value) {
|
|
50
|
+
const problem = fieldProblem(type.replace(ARRAY, ""), item, types);
|
|
51
|
+
if (problem) {
|
|
52
|
+
return problem;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const struct = types[type];
|
|
58
|
+
if (struct) {
|
|
59
|
+
return structProblem(struct, value, types);
|
|
60
|
+
}
|
|
61
|
+
const ok = {
|
|
62
|
+
string: typeof value === "string",
|
|
63
|
+
bool: typeof value === "boolean",
|
|
64
|
+
address: AddressSchema.safeParse(value).success,
|
|
65
|
+
uint256: typeof value === "string" &&
|
|
66
|
+
UINT256.test(value) &&
|
|
67
|
+
BigInt(value) < 2n ** 256n,
|
|
68
|
+
bytes32: typeof value === "string" && BYTES32.test(value),
|
|
69
|
+
}[type];
|
|
70
|
+
return ok ? null : `not a ${type}`;
|
|
71
|
+
}
|
|
72
|
+
/** Why `value` is not exactly the struct `fields` (no field missing, none extra), or null. */
|
|
73
|
+
function structProblem(fields, value, types) {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
75
|
+
return "not an object";
|
|
76
|
+
}
|
|
77
|
+
const names = new Set(fields.map(({ name }) => name));
|
|
78
|
+
const extra = Object.keys(value).find((key) => !names.has(key));
|
|
79
|
+
if (extra) {
|
|
80
|
+
return `${extra} is no field of it`;
|
|
81
|
+
}
|
|
82
|
+
for (const { name, type } of fields) {
|
|
83
|
+
const problem = fieldProblem(type, value[name], types);
|
|
84
|
+
if (problem) {
|
|
85
|
+
return `${name} is ${problem}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/** The digest of `type`'s `message` under the deployment's domain: what a key signs. */
|
|
91
|
+
export function digestOf(deployment, type, message) {
|
|
92
|
+
return typedDataDigest(typedData(deployment.eip712Domain, SIGNATURE_TYPES[type], message));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* `signer`'s signature over `type`'s `message` under the deployment's domain,
|
|
96
|
+
* as a file's contents (a `Transaction`'s with its `instructions`).
|
|
97
|
+
*/
|
|
98
|
+
export async function signMessage(signer, deployment, { type, message, instructions, }) {
|
|
99
|
+
const signature = await signer.signTypedData(typedData(deployment.eip712Domain, SIGNATURE_TYPES[type], message));
|
|
100
|
+
return {
|
|
101
|
+
version: 1,
|
|
102
|
+
type,
|
|
103
|
+
domain: deployment.eip712Domain,
|
|
104
|
+
message: message,
|
|
105
|
+
signer: signer.address,
|
|
106
|
+
signature,
|
|
107
|
+
...(instructions !== undefined && { instructions }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Write a signature file; an existing file is never overwritten. */
|
|
111
|
+
export function writeSignatureFile(path, file) {
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { flag: "wx" });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code === "EEXIST") {
|
|
117
|
+
throw new Error(`${path} exists; pass another --out`);
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The file at `path` as a signature file's shape, or why it is not one. */
|
|
123
|
+
function readFile(path) {
|
|
124
|
+
let json;
|
|
125
|
+
try {
|
|
126
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
throw new Error(`${path}: not a signature file (${error instanceof Error ? error.message : String(error)})`);
|
|
130
|
+
}
|
|
131
|
+
const parsed = FileSchema.safeParse(json);
|
|
132
|
+
if (!parsed.success) {
|
|
133
|
+
const issue = parsed.error.issues[0];
|
|
134
|
+
throw new Error(`${path}: not a signature file (${issue.path.join(".") || "file"}: ${issue.message})`);
|
|
135
|
+
}
|
|
136
|
+
return parsed.data;
|
|
137
|
+
}
|
|
138
|
+
/** The file's domain must be the deployment's. */
|
|
139
|
+
function checkDomain(path, { domain }, deployment) {
|
|
140
|
+
const mine = deployment.eip712Domain;
|
|
141
|
+
if (domain.name !== mine.name ||
|
|
142
|
+
domain.version !== mine.version ||
|
|
143
|
+
domain.chainId !== mine.chainId ||
|
|
144
|
+
!sameAddress(domain.verifyingContract, mine.verifyingContract)) {
|
|
145
|
+
throw new Error(`${path} was signed under another domain ("${domain.name}" ${domain.version} on chain ${domain.chainId} at ${domain.verifyingContract}), not this deployment's ("${mine.name}" ${mine.version} on chain ${mine.chainId} at ${mine.verifyingContract})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** A `Transaction` carries the instructions it signs by hash; no other file carries any. */
|
|
149
|
+
function checkInstructions(path, file) {
|
|
150
|
+
if (file.type !== "Transaction") {
|
|
151
|
+
if (file.instructions !== undefined) {
|
|
152
|
+
throw new Error(`${path}: only a Transaction file carries instructions`);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (file.instructions === undefined) {
|
|
157
|
+
throw new Error(`${path}: a Transaction file carries its instructions`);
|
|
158
|
+
}
|
|
159
|
+
if (keccak256(file.instructions) !==
|
|
160
|
+
String(file.message.instructionsHash).toLowerCase()) {
|
|
161
|
+
throw new Error(`${path}: its instructions are not the ones signed (their keccak256 is not the message's instructionsHash)`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** One file read and checked on its own: its type, its shape, its domain, its signer. */
|
|
165
|
+
async function loadOne(path, deployment, expected) {
|
|
166
|
+
const file = readFile(path);
|
|
167
|
+
if (!expected.includes(file.type)) {
|
|
168
|
+
throw new Error(`${path} signs a ${file.type}; this command takes ${expected.join(" or ")}: a file for another call`);
|
|
169
|
+
}
|
|
170
|
+
const types = SIGNATURE_TYPES[file.type];
|
|
171
|
+
const problem = structProblem(types[file.type], file.message, types);
|
|
172
|
+
if (problem) {
|
|
173
|
+
throw new Error(`${path}: not a ${file.type} message: ${problem}`);
|
|
174
|
+
}
|
|
175
|
+
checkDomain(path, file, deployment);
|
|
176
|
+
checkInstructions(path, file);
|
|
177
|
+
const digest = digestOf(deployment, file.type, file.message);
|
|
178
|
+
const recovered = await tryRecover(digest, file.signature);
|
|
179
|
+
if ("failure" in recovered) {
|
|
180
|
+
throw new Error(`${path}: the signature recovers to no address (${recovered.failure})`);
|
|
181
|
+
}
|
|
182
|
+
if (!sameAddress(recovered.signer, file.signer)) {
|
|
183
|
+
throw new Error(`${path}: the signature is ${recovered.signer}'s, not ${file.signer}'s as the file claims`);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
path,
|
|
187
|
+
type: file.type,
|
|
188
|
+
message: file.message,
|
|
189
|
+
signer: recovered.signer,
|
|
190
|
+
signature: file.signature,
|
|
191
|
+
...(file.instructions !== undefined && {
|
|
192
|
+
instructions: file.instructions,
|
|
193
|
+
}),
|
|
194
|
+
digest,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Signature files for one call, each checked (`loadOne`), and all over the
|
|
199
|
+
* same message: a file for another call, or of a type the command does not
|
|
200
|
+
* take, is refused.
|
|
201
|
+
*/
|
|
202
|
+
export async function loadSignatures(paths, deployment, expected) {
|
|
203
|
+
const loaded = [];
|
|
204
|
+
for (const path of paths) {
|
|
205
|
+
const signed = await loadOne(path, deployment, expected);
|
|
206
|
+
const [first] = loaded;
|
|
207
|
+
if (first &&
|
|
208
|
+
(signed.type !== first.type || signed.digest !== first.digest)) {
|
|
209
|
+
throw new Error(`${path} signs another ${signed.type} than ${first.path}: a file for another call`);
|
|
210
|
+
}
|
|
211
|
+
loaded.push(signed);
|
|
212
|
+
}
|
|
213
|
+
return loaded;
|
|
214
|
+
}
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const USAGE = `usage: cofund-chain <command> [flags]
|
|
2
|
+
|
|
3
|
+
keys
|
|
4
|
+
keys init [--force]
|
|
5
|
+
keys show
|
|
6
|
+
|
|
7
|
+
a cofund on an EVM chain
|
|
8
|
+
found --name <name> [--company <company>] [--tier <tier>]
|
|
9
|
+
show
|
|
10
|
+
members
|
|
11
|
+
policies show <id>
|
|
12
|
+
invite --user-id <id> --position <title> [--admin] --out <file>
|
|
13
|
+
invite cosign <file>... --out <file>
|
|
14
|
+
join <file>...
|
|
15
|
+
members remove <user-id> [<file>...] [--out <file>]
|
|
16
|
+
members rotate [<file>...] [--new-address <address>] [--user-id <id>]
|
|
17
|
+
[--out <file>]
|
|
18
|
+
policies sign (--name <title> --signers <user-id,...> --threshold <n>
|
|
19
|
+
(--token <address> --max-amount <amount> [--recipients <address,...>]
|
|
20
|
+
| --wrapper <address>) [--type <policy type>] | <file>...) --out <file>
|
|
21
|
+
policies activate <file>...
|
|
22
|
+
policies deactivate <id> [<file>] [--out <file>]
|
|
23
|
+
transfers sign (--policy <id> --to <address> --amount <amount> | <file>...)
|
|
24
|
+
--out <file>
|
|
25
|
+
transfers send <file>...
|
|
26
|
+
calls sign (--policy <id> --instructions <hex>
|
|
27
|
+
[--allowance <token>:<amount>]... | <file>...) --out <file>
|
|
28
|
+
calls send <file>...
|
|
29
|
+
calls simulate <file>...
|
|
30
|
+
onboarding complete [<file>] [--out <file>]
|
|
31
|
+
|
|
32
|
+
a Bitcoin vault
|
|
33
|
+
btc vault create --xpubs <xpub,...> --threshold <k>
|
|
34
|
+
[--type ALL|ADMINS_INCLUDED|ADMINS_REQUIRED] [--admins <xpub,...>]
|
|
35
|
+
--out <file>
|
|
36
|
+
btc spend build --vault <file> --to <address> --amount <sats>
|
|
37
|
+
[--fee-rate <sat/vB>] --out <file>
|
|
38
|
+
btc spend sign <psbt> --vault <file> --to <address> --amount <sats>
|
|
39
|
+
--out <file>
|
|
40
|
+
btc spend combine <psbt>... --out <file>
|
|
41
|
+
btc spend finalize <psbt> --vault <file> --out <file>
|
|
42
|
+
btc spend broadcast <hex or file>
|
|
43
|
+
|
|
44
|
+
flags
|
|
45
|
+
--keystore <file> the keystore (COFUND_KEYSTORE; ~/.cofund/keystore.json)
|
|
46
|
+
--deployment <file> a deployments/<chainId>.json (COFUND_DEPLOYMENT)
|
|
47
|
+
--rpc <url> the EVM chain's RPC (COFUND_RPC_URL)
|
|
48
|
+
--esplora <url> an Esplora API for Bitcoin (COFUND_ESPLORA_URL)
|
|
49
|
+
--via direct|guard direct (the default): the keystore's EVM key sends
|
|
50
|
+
and pays gas; guard: relayed by the deployment's
|
|
51
|
+
guard, sent from --sponsor-key-file
|
|
52
|
+
--sponsor-key-file <f> a sponsor's key: a hex private key, or a keystore
|
|
53
|
+
--company <company> the cofund, when the keystore pins several
|
|
54
|
+
--yes act without asking
|
|
55
|
+
|
|
56
|
+
A signature is a JSON file; a command that sends takes every signer's file.
|
|
57
|
+
On EVM, amounts (--amount, --max-amount, --allowance) are whole tokens,
|
|
58
|
+
converted with the token's decimals; previews show base units too. On
|
|
59
|
+
Bitcoin, --amount is sats.
|
|
60
|
+
A PSBT is a base64 file; a signer states what it expects the spend to pay
|
|
61
|
+
(--to, --amount) and signs nothing else. COFUND_BTC_NETWORK (mainnet, testnet
|
|
62
|
+
or regtest) picks the network of new keys and vaults; regtest uses testnet keys.`;
|
package/package.json
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getcofund/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "cofund-chain: the Cofund protocol from the command line, on the chain alone. Found a cofund, seat members, activate policies, sign and send transfers, and spend Bitcoin vaults through PSBTs, with local keys and signature files.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Strata-Labs/cofund-webapp.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"cofund-chain": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"viem": "^2.56.0",
|
|
27
|
+
"zod": "^4.3.6",
|
|
28
|
+
"@getcofund/protocol": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "24.10.13",
|
|
32
|
+
"typescript": "5.9.3",
|
|
33
|
+
"vitest": "^4.1.5"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"smoke": "bash scripts/smoke-pack.sh",
|
|
40
|
+
"local-up": "node scripts/local.ts up",
|
|
41
|
+
"local-down": "node scripts/local.ts down"
|
|
42
|
+
}
|
|
43
|
+
}
|