@getcofund/cli 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.
@@ -0,0 +1,88 @@
1
+ import { cofundClientId, readActiveAdmins, readClientVault, readOnboardingComplete, readPolicy, readRequiredAdminInviteSignatures, readUser, } from "@getcofund/protocol/evm";
2
+ import { evmWallet } from "@getcofund/protocol/keys";
3
+ import { amountText, memberIds, policyIds, seatOf, seatText, signerNames, tokenInfo, } from "../chain.js";
4
+ import { evmChain, keystoreIfAny, line, } from "../context.js";
5
+ import { pickCompany } from "../keystore.js";
6
+ import { cofundLine } from "./lines.js";
7
+ /** `show`, `members`, `policies show`: the cofund as State holds it, read through the RPC. */
8
+ async function cofundOf(io, flags) {
9
+ const keys = keystoreIfAny(io, flags);
10
+ const company = pickCompany(keys, flags.company);
11
+ const evm = await evmChain(io, flags);
12
+ const clientId = cofundClientId(company);
13
+ const vault = await readClientVault(evm.reader, evm.deployment, clientId);
14
+ if (!vault) {
15
+ throw new Error(`No cofund "${company}" on chain ${evm.deployment.chainId}`);
16
+ }
17
+ return { keys, company, evm, clientId, vault };
18
+ }
19
+ /** `show`: the cofund, its vault, its phase, its admins, its policies, and this key's seat. */
20
+ export async function show(io, flags) {
21
+ const { keys, company, evm, clientId, vault } = await cofundOf(io, flags);
22
+ const { reader, deployment } = evm;
23
+ const [onboarded, admins, required, policies] = await Promise.all([
24
+ readOnboardingComplete(reader, deployment, clientId),
25
+ readActiveAdmins(reader, deployment, clientId),
26
+ readRequiredAdminInviteSignatures(reader, deployment, clientId),
27
+ policyIds(evm, clientId),
28
+ ]);
29
+ io.out(cofundLine(company));
30
+ io.out(line("vault", `${vault.vault}, tier ${vault.tier}`));
31
+ io.out(line("phase", onboarded
32
+ ? "onboarding complete"
33
+ : "onboarding open: one admin signature seats an admin or activates a policy"));
34
+ io.out(line("admins", `${admins} active; an admin seat takes ${required} admin signature${required === 1 ? "" : "s"}`));
35
+ const states = await Promise.all(policies.map(async (id) => {
36
+ const policy = await readPolicy(reader, deployment, clientId, id);
37
+ return `${id} (${policy?.active ? "active" : "inactive"})`;
38
+ }));
39
+ io.out(line("policies", states.length === 0 ? "none" : states.join(", ")));
40
+ if (keys) {
41
+ const seat = await seatOf(evm, clientId, evmWallet(keys).address);
42
+ io.out(line("you", seat ? seatText(seat) : `${keys.evm.address} (no seat)`));
43
+ }
44
+ return 0;
45
+ }
46
+ /** `members`: everyone ever seated, as State holds them now. */
47
+ export async function members(io, flags) {
48
+ const { evm, clientId } = await cofundOf(io, flags);
49
+ for (const userId of await memberIds(evm, clientId)) {
50
+ const user = await readUser(evm.reader, evm.deployment, clientId, userId);
51
+ if (user) {
52
+ io.out(seatText({ userId, user }));
53
+ }
54
+ }
55
+ return 0;
56
+ }
57
+ /** A policy's bounds as every preview prints them. */
58
+ export async function boundsLines(evm, bounds) {
59
+ if ("wrapper" in bounds) {
60
+ return [
61
+ line("calls", `wrapper ${bounds.wrapper}, function ${bounds.func}`),
62
+ ];
63
+ }
64
+ const token = await tokenInfo(evm, bounds.token);
65
+ const { recipients } = bounds;
66
+ return [
67
+ line("cap", `${amountText(bounds.maxAmount, token)} per transfer`),
68
+ line("to", recipients.length === 0 ? "any address" : `only ${recipients.join(", ")}`),
69
+ ];
70
+ }
71
+ /** `policies show <id>`: a policy as State holds it, its signers by user id. */
72
+ export async function policiesShow(io, flags, policyId) {
73
+ if (!policyId) {
74
+ throw new Error("policies show takes a policy id (`show` lists them)");
75
+ }
76
+ const { evm, clientId } = await cofundOf(io, flags);
77
+ const policy = await readPolicy(evm.reader, evm.deployment, clientId, policyId);
78
+ if (!policy) {
79
+ throw new Error(`No policy ${policyId} in this cofund`);
80
+ }
81
+ const signers = signerNames(policy.signerIds, await memberIds(evm, clientId));
82
+ io.out(line("policy", `${policyId}: "${policy.title}" (${policy.policyType}), ${policy.active ? "active" : "inactive"}`));
83
+ io.out(line("signers", `${signers.join(", ")}; ${policy.threshold} of ${signers.length} must sign`));
84
+ for (const bound of await boundsLines(evm, policy.isTransaction ? policy.transaction : policy.transfer)) {
85
+ io.out(bound);
86
+ }
87
+ return 0;
88
+ }
@@ -0,0 +1,87 @@
1
+ import { evmWallet } from "@getcofund/protocol/keys";
2
+ import { approved, evmChain, keystoreIfAny, line, send, sender, } from "../context.js";
3
+ import { keystorePath, pinCompany, requirePinned } from "../keystore.js";
4
+ import { loadSignatures, signMessage, writeSignatureFile, } from "../signature-file.js";
5
+ import { cofundLine, signedByLines } from "./lines.js";
6
+ /** The message the files carry (checked against the arguments), or a new one. */
7
+ async function messageOf(spec, flags, { evm, signed, keys, }) {
8
+ if (signed.length === 0) {
9
+ if (!keys) {
10
+ throw new Error("No keystore to sign with: pass --keystore, or files");
11
+ }
12
+ return await spec.fresh(keys, evm);
13
+ }
14
+ const message = signed[0].message;
15
+ spec.matches(message);
16
+ if (flags.company !== undefined && flags.company !== message.company) {
17
+ throw new Error(`The files are for company "${message.company}", not "${flags.company}"`);
18
+ }
19
+ return message;
20
+ }
21
+ /**
22
+ * This key, when its signature counts for the call; null when it does not
23
+ * and the files are sent as they are. Refused when it does not count and
24
+ * there is nothing else to send (`--out`, or no files).
25
+ */
26
+ async function signerOf(spec, flags, { evm, message, signed, keys, }) {
27
+ const role = keys
28
+ ? await spec.role(evm, message, evmWallet(keys).address, signed)
29
+ : { not: "there is no keystore to sign with" };
30
+ if ("as" in role && keys) {
31
+ if (signed.length > 0) {
32
+ requirePinned(keys, message.company, flags.company);
33
+ }
34
+ return { account: evmWallet(keys), as: role.as };
35
+ }
36
+ if (flags.out || signed.length === 0) {
37
+ throw new Error(`Your signature does not count here: ${"not" in role ? role.not : ""}`);
38
+ }
39
+ return null;
40
+ }
41
+ export async function signOrSend(io, flags, spec) {
42
+ const evm = await evmChain(io, flags);
43
+ const signed = await loadSignatures(spec.files, evm.deployment, [spec.type]);
44
+ const keys = keystoreIfAny(io, flags);
45
+ const message = await messageOf(spec, flags, { evm, signed, keys });
46
+ const signer = await signerOf(spec, flags, { evm, message, signed, keys });
47
+ for (const text of [
48
+ cofundLine(message.company),
49
+ ...(await spec.lines(evm, message)),
50
+ ...(await signedByLines(evm, message.company, signed)),
51
+ ...(signer
52
+ ? [line("you", `sign as the ${signer.as}, ${signer.account.address}`)]
53
+ : []),
54
+ ]) {
55
+ io.out(text);
56
+ }
57
+ const mine = signer
58
+ ? await signMessage(signer.account, evm.deployment, {
59
+ type: spec.type,
60
+ message,
61
+ })
62
+ : null;
63
+ if (flags.out && mine) {
64
+ if (!(await approved(io, flags, { question: "Sign it?", action: "sign it" }))) {
65
+ return 1;
66
+ }
67
+ writeSignatureFile(flags.out, mine);
68
+ io.out(line("wrote", flags.out));
69
+ return 0;
70
+ }
71
+ const all = [
72
+ ...signed,
73
+ ...(mine
74
+ ? [{ signer: mine.signer, signature: mine.signature }]
75
+ : []),
76
+ ];
77
+ const via = sender(io, flags, evm);
78
+ const call = await spec.build(evm, message, all);
79
+ io.out(line("call", `${spec.entry} ${call.to}`));
80
+ const sent = await send(io, flags, { evm, via, call }, { question: spec.question, action: "send it" });
81
+ if (sent === 0 && mine && spec.pinOnSend) {
82
+ const path = keystorePath(io.env, flags.keystore);
83
+ pinCompany(path, message.company);
84
+ io.out(line("pinned", `company "${message.company}" in ${path}`));
85
+ }
86
+ return sent;
87
+ }
@@ -0,0 +1,168 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { cofundClientId, executeTransferCall, readClientVault, readContract, readPolicy, sameAddress, stateAbi, } from "@getcofund/protocol/evm";
3
+ import { evmWallet } from "@getcofund/protocol/keys";
4
+ import { getAddress } from "viem";
5
+ import { amountText, baseUnits, memberIds, signerNames, tokenInfo, } from "../chain.js";
6
+ import { approved, evmChain, keystore, line, send, sender, } from "../context.js";
7
+ import { pickCompany, requirePinned } from "../keystore.js";
8
+ import { loadSignatures, signMessage, writeSignatureFile, } from "../signature-file.js";
9
+ import { USAGE } from "../usage.js";
10
+ import { cofundLine, signedByLines } from "./lines.js";
11
+ /**
12
+ * `transfers sign`: a signer of a transfer policy signs a transfer, new from
13
+ * the flags or the one in a file. `transfers send`: sent from the vault once
14
+ * signed often enough.
15
+ */
16
+ /** The policy a vault message spends under: State's, active, of the kind the message needs. */
17
+ export async function spendingPolicy(evm, company, policyId, kind) {
18
+ const policy = await readPolicy(evm.reader, evm.deployment, cofundClientId(company), policyId);
19
+ if (!policy?.active) {
20
+ throw new Error(`Policy ${policyId} is not an active policy of this cofund`);
21
+ }
22
+ if (policy.isTransaction !== (kind === "call")) {
23
+ throw new Error(`Policy ${policyId} is a ${policy.isTransaction ? "call" : "transfer"} policy: use \`${policy.isTransaction ? "calls" : "transfers"} sign\``);
24
+ }
25
+ return policy;
26
+ }
27
+ /** `me` must be an active signer of the policy, as the vault resolves its signers. */
28
+ export async function requirePolicySigner(evm, company, policyId, me) {
29
+ const [ok] = await readContract(evm.reader, {
30
+ address: evm.deployment.state,
31
+ abi: stateAbi,
32
+ functionName: "resolvePolicySigner",
33
+ args: [cofundClientId(company), policyId, me],
34
+ });
35
+ if (!ok) {
36
+ throw new Error(`${me} is not an active signer of policy ${policyId}`);
37
+ }
38
+ }
39
+ /** The policy line every vault message shows: its title, its signers, its threshold. */
40
+ export async function policyLine(evm, company, policyId) {
41
+ const clientId = cofundClientId(company);
42
+ const policy = await readPolicy(evm.reader, evm.deployment, clientId, policyId);
43
+ if (!policy) {
44
+ return line("policy", `${policyId} (no such policy)`);
45
+ }
46
+ const signers = signerNames(policy.signerIds, await memberIds(evm, clientId));
47
+ const inactive = policy.active ? "" : " (inactive)";
48
+ return line("policy", `${policyId}: "${policy.title}"${inactive}, ${policy.threshold} of ${signers.join(", ")} must sign`);
49
+ }
50
+ /** The vault a message spends from. */
51
+ export async function vaultLine(evm, company) {
52
+ const vault = await readClientVault(evm.reader, evm.deployment, cofundClientId(company));
53
+ return line("from", vault ? `the vault ${vault.vault}` : "no vault");
54
+ }
55
+ /**
56
+ * A message signed by this key, new or from files, after the checks its
57
+ * command makes: the signer's own file, which `send` takes with the others.
58
+ */
59
+ export async function signVaultMessage(io, flags, { evm, keys, type, message, signed, lines, instructions, }) {
60
+ const { out } = flags;
61
+ if (!out) {
62
+ throw new Error("Pass --out <file> for the signature");
63
+ }
64
+ const signer = evmWallet(keys);
65
+ await requirePolicySigner(evm, message.company, message.policyId, signer.address);
66
+ if (signed.some((each) => sameAddress(each.signer, signer.address))) {
67
+ throw new Error("The files hold your signature already");
68
+ }
69
+ for (const text of [
70
+ ...lines,
71
+ ...(await signedByLines(evm, message.company, signed)),
72
+ line("you", `sign as ${signer.address}`),
73
+ ]) {
74
+ io.out(text);
75
+ }
76
+ if (!(await approved(io, flags, { question: "Sign it?", action: "sign it" }))) {
77
+ return 1;
78
+ }
79
+ writeSignatureFile(out, await signMessage(signer, evm.deployment, { type, message, instructions }));
80
+ io.out(line("wrote", `${out}: send it with the others' once signed often enough`));
81
+ return 0;
82
+ }
83
+ async function transferLines(evm, message) {
84
+ const token = await tokenInfo(evm, getAddress(message.token));
85
+ return [
86
+ cofundLine(message.company),
87
+ await policyLine(evm, message.company, message.policyId),
88
+ line("transfer", amountText(BigInt(message.amount), token)),
89
+ line("to", message.recipient),
90
+ await vaultLine(evm, message.company),
91
+ ];
92
+ }
93
+ /** What a signer refuses to sign: a transfer its policy can never send (over the cap, off the allowlist). */
94
+ async function checkTransfer(evm, message) {
95
+ const policy = await spendingPolicy(evm, message.company, message.policyId, "transfer");
96
+ const { maxAmount, recipients } = policy.transfer;
97
+ if (BigInt(message.amount) > maxAmount) {
98
+ throw new Error(`${message.amount} base units are over the policy's cap of ${maxAmount}`);
99
+ }
100
+ if (recipients.length > 0 &&
101
+ !recipients.some((r) => sameAddress(r, message.recipient))) {
102
+ throw new Error(`${message.recipient} is not on the policy's allowlist`);
103
+ }
104
+ }
105
+ export async function transfersSign(io, flags, files) {
106
+ const keys = keystore(io, flags);
107
+ const evm = await evmChain(io, flags);
108
+ let message;
109
+ let signed = [];
110
+ if (files.length > 0) {
111
+ signed = await loadSignatures(files, evm.deployment, ["Transfer"]);
112
+ message = signed[0].message;
113
+ requirePinned(keys, message.company, flags.company);
114
+ }
115
+ else {
116
+ const { policy: policyId, to, amount } = flags;
117
+ if (!(policyId && to && amount)) {
118
+ io.err(USAGE);
119
+ return 1;
120
+ }
121
+ const company = pickCompany(keys, flags.company);
122
+ const policy = await spendingPolicy(evm, company, policyId, "transfer");
123
+ const token = await tokenInfo(evm, policy.transfer.token);
124
+ message = {
125
+ company,
126
+ policyId,
127
+ messageType: policy.policyType,
128
+ amount: baseUnits(amount, token, "--amount").toString(),
129
+ token: policy.transfer.token,
130
+ recipient: getAddress(to),
131
+ authId: randomUUID(),
132
+ };
133
+ }
134
+ await checkTransfer(evm, message);
135
+ return await signVaultMessage(io, flags, {
136
+ evm,
137
+ keys,
138
+ type: "Transfer",
139
+ message,
140
+ signed,
141
+ lines: await transferLines(evm, message),
142
+ });
143
+ }
144
+ export async function transfersSend(io, flags, files) {
145
+ if (files.length === 0) {
146
+ io.err(USAGE);
147
+ return 1;
148
+ }
149
+ const evm = await evmChain(io, flags);
150
+ const signed = await loadSignatures(files, evm.deployment, ["Transfer"]);
151
+ const message = signed[0].message;
152
+ const via = sender(io, flags, evm);
153
+ for (const text of [
154
+ ...(await transferLines(evm, message)),
155
+ ...(await signedByLines(evm, message.company, signed)),
156
+ ]) {
157
+ io.out(text);
158
+ }
159
+ const call = await executeTransferCall(evm, {
160
+ message,
161
+ signatures: signed.map(({ signature }) => signature),
162
+ });
163
+ io.out(line("call", `executeTransfer on the vault ${call.to}`));
164
+ return await send(io, flags, { evm, via, call }, {
165
+ question: "Send this transfer?",
166
+ action: "send it",
167
+ });
168
+ }
@@ -0,0 +1,231 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CallRefusedError, CallRevertedError, directSubmitter, guardSubmitter, loadEvmDeployment, policiesAbi, revertDataOf, revertReason, sponsorGuardAbi, stateAbi, usersAbi, vaultAbi, } from "@getcofund/protocol/evm";
3
+ import { evmWallet } from "@getcofund/protocol/keys";
4
+ import { createPublicClient, createWalletClient, custom, defineChain, encodeFunctionData, http, } from "viem";
5
+ import { privateKeyToAccount } from "viem/accounts";
6
+ import { keystorePath, readKeystore } from "./keystore.js";
7
+ /** A line of a preview: the label padded to one column, as every command prints them. */
8
+ export const line = (label, value) => `${label.padEnd(9)} ${value}`;
9
+ const SETTINGS = {
10
+ deployment: { flag: "--deployment <file>", env: "COFUND_DEPLOYMENT" },
11
+ rpc: { flag: "--rpc <url>", env: "COFUND_RPC_URL" },
12
+ esplora: { flag: "--esplora <url>", env: "COFUND_ESPLORA_URL" },
13
+ };
14
+ /** A setting from its flag, else its environment variable, else an error saying what is missing. */
15
+ export function setting(io, flags, name) {
16
+ const value = flags[name] ?? io.env[SETTINGS[name].env];
17
+ if (!value) {
18
+ const { flag, env } = SETTINGS[name];
19
+ throw new Error(`No ${name}: pass ${flag} or set ${env}`);
20
+ }
21
+ return value;
22
+ }
23
+ /** The keystore at --keystore, COFUND_KEYSTORE or the default path. */
24
+ export function keystore(io, flags) {
25
+ return readKeystore(keystorePath(io.env, flags.keystore));
26
+ }
27
+ /** The keystore when there is one; null when there is none to read. */
28
+ export function keystoreIfAny(io, flags) {
29
+ try {
30
+ return keystore(io, flags);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * The deployment file read and checked against its chain (its domain, the
38
+ * helpers State registers, the chain id), with a reader at the RPC URL.
39
+ */
40
+ export async function evmChain(io, flags) {
41
+ const path = setting(io, flags, "deployment");
42
+ const url = setting(io, flags, "rpc");
43
+ let config;
44
+ try {
45
+ config = JSON.parse(readFileSync(path, "utf8"));
46
+ }
47
+ catch (error) {
48
+ throw new Error(`Could not read the deployment ${path}: ${error instanceof Error ? error.message : String(error)}`);
49
+ }
50
+ const chain = defineChain({
51
+ id: Number(config.chainId),
52
+ name: `chain ${config.chainId}`,
53
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
54
+ rpcUrls: { default: { http: [url] } },
55
+ });
56
+ const transport = io.provider ? custom(io.provider(url)) : http(url);
57
+ const reader = createPublicClient({ chain, transport });
58
+ const deployment = await loadEvmDeployment(config, reader);
59
+ return { deployment, reader, chain, transport };
60
+ }
61
+ const PRIVATE_KEY = /^0x[0-9a-fA-F]{64}$/;
62
+ /** A private key from a file: a bare hex key, or a keystore's EVM key. */
63
+ function sponsorKey(path) {
64
+ const text = readFileSync(path, "utf8").trim();
65
+ const key = text.startsWith("{")
66
+ ? JSON.parse(text).evm?.privateKey
67
+ : text;
68
+ const hex = key?.startsWith("0x") ? key : `0x${key}`;
69
+ if (!PRIVATE_KEY.test(hex)) {
70
+ throw new Error(`${path} holds no private key: a 32-byte hex key, or a keystore`);
71
+ }
72
+ return hex;
73
+ }
74
+ /**
75
+ * `--via direct` (the default): the keystore's EVM key sends the call and
76
+ * pays gas. `--via guard`: the call wrapped in the deployment's guard's
77
+ * `relay`, sent by the key in --sponsor-key-file, which the guard must accept
78
+ * as a sponsor.
79
+ */
80
+ export function sender(io, flags, evm) {
81
+ const via = flags.via ?? "direct";
82
+ const sponsorFile = flags["sponsor-key-file"];
83
+ if (via === "direct") {
84
+ if (sponsorFile) {
85
+ throw new Error("--sponsor-key-file is for --via guard");
86
+ }
87
+ const account = evmWallet(keystore(io, flags));
88
+ return {
89
+ address: account.address,
90
+ submitter: directSubmitter(createWalletClient({
91
+ account,
92
+ chain: evm.chain,
93
+ transport: evm.transport,
94
+ })),
95
+ transaction: (call) => call,
96
+ line: line("via", `direct: ${account.address} sends it and pays gas`),
97
+ };
98
+ }
99
+ if (via !== "guard") {
100
+ throw new Error("--via is direct or guard");
101
+ }
102
+ if (!sponsorFile) {
103
+ throw new Error("--via guard sends from a sponsor's key: pass --sponsor-key-file <file>");
104
+ }
105
+ const account = privateKeyToAccount(sponsorKey(sponsorFile));
106
+ const guard = evm.deployment.sponsorGuard;
107
+ return {
108
+ address: account.address,
109
+ submitter: guardSubmitter(createWalletClient({
110
+ account,
111
+ chain: evm.chain,
112
+ transport: evm.transport,
113
+ }), evm.deployment),
114
+ transaction: (call) => ({
115
+ ...call,
116
+ to: guard,
117
+ data: encodeFunctionData({
118
+ abi: sponsorGuardAbi,
119
+ functionName: "relay",
120
+ args: [call.to, call.data],
121
+ }),
122
+ }),
123
+ line: line("via", `guard ${guard}: sponsor ${account.address} relays it and pays gas`),
124
+ };
125
+ }
126
+ /** Yes from --yes or the person at the terminal; a refusal is said before it returns false. */
127
+ export async function approved(io, flags, { question, action }) {
128
+ if (flags.yes) {
129
+ return true;
130
+ }
131
+ if (!io.confirm) {
132
+ io.err(`Nothing was done: no terminal to confirm in. Pass --yes to ${action} without a prompt.`);
133
+ return false;
134
+ }
135
+ if (!(await io.confirm(question))) {
136
+ io.err("Nothing was done.");
137
+ return false;
138
+ }
139
+ return true;
140
+ }
141
+ const PROTOCOL_ERRORS = [
142
+ usersAbi,
143
+ policiesAbi,
144
+ vaultAbi,
145
+ sponsorGuardAbi,
146
+ stateAbi,
147
+ ];
148
+ /**
149
+ * The transaction run as an `eth_call` from its sender, before anyone is
150
+ * asked: the checks that turn on the sender (the platform gate, the guard's
151
+ * sponsors, the vault's broadcasters) are the chain's. Its revert reason, or
152
+ * null when it would go through.
153
+ */
154
+ async function preflight(reader, from, transaction) {
155
+ try {
156
+ await reader.request({
157
+ method: "eth_call",
158
+ params: [
159
+ {
160
+ from,
161
+ to: transaction.to,
162
+ data: transaction.data,
163
+ ...(transaction.value !== undefined && {
164
+ value: `0x${transaction.value.toString(16)}`,
165
+ }),
166
+ },
167
+ "latest",
168
+ ],
169
+ });
170
+ return null;
171
+ }
172
+ catch (error) {
173
+ const data = revertDataOf(error);
174
+ if (data === null) {
175
+ throw error;
176
+ }
177
+ return revertReason(data, { errors: PROTOCOL_ERRORS });
178
+ }
179
+ }
180
+ /**
181
+ * Send a checked call: its sender said, the transaction run from that sender
182
+ * first (a revert is shown and nothing is sent), approved, sent, and waited
183
+ * for. 0 once it lands.
184
+ */
185
+ export async function send(io, flags, { evm, via, call }, prompt) {
186
+ io.out(via.line);
187
+ const reason = await preflight(evm.reader, via.address, via.transaction(call));
188
+ if (reason !== null) {
189
+ io.err(line("refused", `the chain reverts it from ${via.address}: ${reason}`));
190
+ io.err("Nothing was sent.");
191
+ return 1;
192
+ }
193
+ if (!(await approved(io, flags, prompt))) {
194
+ return 1;
195
+ }
196
+ const hash = await via.submitter.send(call);
197
+ io.out(line("sent", hash));
198
+ const receipt = await evm.reader.waitForTransactionReceipt({ hash });
199
+ if (receipt.status !== "success") {
200
+ io.err(line("reverted", `in block ${receipt.blockNumber}`));
201
+ return 1;
202
+ }
203
+ io.out(line("landed", `block ${receipt.blockNumber}`));
204
+ return 0;
205
+ }
206
+ /** URLs, which can carry an RPC's or an Esplora's key. */
207
+ const URLS = /\b(?:https?|wss?):\/\/\S+/gi;
208
+ /**
209
+ * How a failure prints: a refusal as the contract's error and why, and that
210
+ * nothing went out; anything else as its message (viem's short one and its
211
+ * details), with every URL in it hidden.
212
+ */
213
+ export function errorLines(error) {
214
+ if (error instanceof CallRefusedError) {
215
+ return [line("refused", error.message), "Nothing was sent."];
216
+ }
217
+ if (error instanceof CallRevertedError) {
218
+ return [
219
+ line("refused", `the chain reverts it: ${error.reason}`),
220
+ "Nothing was sent.",
221
+ ];
222
+ }
223
+ if (!(error instanceof Error)) {
224
+ return [String(error).replace(URLS, "<url>")];
225
+ }
226
+ const { shortMessage, details } = error;
227
+ const text = shortMessage
228
+ ? `${shortMessage}${details ? ` (${details})` : ""}`
229
+ : error.message;
230
+ return [text.replace(URLS, "<url>")];
231
+ }
@@ -0,0 +1,74 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { cofundClientId } from "@getcofund/protocol/evm";
5
+ /**
6
+ * The keystore file the API CLI `cofund` keeps too: the same format
7
+ * (`Keystore`), the same `COFUND_KEYSTORE` and default path, written with mode
8
+ * 0600, so one key serves both. Its `cofunds` pins the `company` this key
9
+ * signs as; this CLI pins a cofund under its client id.
10
+ */
11
+ export function keystorePath(env, flag) {
12
+ return (flag ?? env.COFUND_KEYSTORE ?? join(homedir(), ".cofund", "keystore.json"));
13
+ }
14
+ export function readKeystore(path) {
15
+ let text;
16
+ try {
17
+ text = readFileSync(path, "utf8");
18
+ }
19
+ catch {
20
+ throw new Error(`No keystore at ${path}: make one with \`cofund-chain keys init\``);
21
+ }
22
+ const keystore = JSON.parse(text);
23
+ if (keystore.version !== 1) {
24
+ throw new Error(`${path}: unsupported keystore version`);
25
+ }
26
+ return keystore;
27
+ }
28
+ export function writeKeystore(path, keystore) {
29
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
30
+ writeFileSync(path, `${JSON.stringify(keystore, null, 2)}\n`, {
31
+ mode: 0o600,
32
+ });
33
+ chmodSync(path, 0o600);
34
+ }
35
+ /** Pin `company` in the keystore at `path`: what this key signs as in that cofund. */
36
+ export function pinCompany(path, company) {
37
+ const keystore = readKeystore(path);
38
+ writeKeystore(path, {
39
+ ...keystore,
40
+ cofunds: { ...keystore.cofunds, [cofundClientId(company)]: { company } },
41
+ });
42
+ }
43
+ /** Every company the keystore pins, once each, whichever CLI pinned it. */
44
+ export function pinnedCompanies(keystore) {
45
+ return [
46
+ ...new Set(Object.values(keystore?.cofunds ?? {}).map(({ company }) => company)),
47
+ ];
48
+ }
49
+ /** The cofund a command acts in: --company, or the keystore's only pinned company. */
50
+ export function pickCompany(keystore, flag) {
51
+ if (flag) {
52
+ return flag;
53
+ }
54
+ const pinned = pinnedCompanies(keystore);
55
+ if (pinned.length === 1) {
56
+ return pinned[0];
57
+ }
58
+ if (pinned.length === 0) {
59
+ throw new Error("No cofund is pinned in the keystore: pass --company, or found or join one");
60
+ }
61
+ throw new Error(`Pass --company, one of:\n${pinned.map((c) => ` ${c}`).join("\n")}`);
62
+ }
63
+ /**
64
+ * Whether this key may sign for `company`: pinned in the keystore, or named
65
+ * with --company. A file for a cofund the key never pinned is refused.
66
+ */
67
+ export function requirePinned(keystore, company, flag) {
68
+ if (flag !== undefined && flag !== company) {
69
+ throw new Error(`The file is for company "${company}", not "${flag}"`);
70
+ }
71
+ if (flag === undefined && !pinnedCompanies(keystore).includes(company)) {
72
+ throw new Error(`This key has not pinned company "${company}": pass --company "${company}" to sign for it`);
73
+ }
74
+ }