getcofund 0.1.0 → 0.2.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,100 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ApiPolicyTypeSchema, } from "../api-types.js";
3
+ import { WRAPPER_ENTRYPOINT } from "../eip712.js";
4
+ import { btcMessageSigner, evmWallet, readKeystore } from "../keys.js";
5
+ import { signPolicyCreation } from "../policies.js";
6
+ import { approval, chainPins, client, keystorePath, pickCofund, pinnedCompany, USAGE, } from "./context.js";
7
+ /** The members `list` names, by id or email, in that order. */
8
+ function pickSigners(members, list) {
9
+ return list.split(",").map((wanted) => {
10
+ const key = wanted.trim().toLowerCase();
11
+ const member = members.find((m) => m.id === key || m.email.toLowerCase() === key);
12
+ if (!member) {
13
+ throw new Error(`No member ${wanted.trim()} in this cofund`);
14
+ }
15
+ return member;
16
+ });
17
+ }
18
+ /** A transfer policy by default, a call policy with --wrapper. */
19
+ function policyType(values) {
20
+ if (values.type) {
21
+ return ApiPolicyTypeSchema.parse(values.type);
22
+ }
23
+ return values.wrapper ? "Treasury_Management" : "Operational_Expense";
24
+ }
25
+ function policyLines(policy, company) {
26
+ const names = policy.signers.map((s) => s.name ?? s.email).join(", ");
27
+ return [
28
+ `policy ${policy.name} (${policy.type})`,
29
+ `vault ${policy.vault.address} on ${policy.vault.chain}`,
30
+ `signers ${names}; ${policy.threshold} of ${policy.signers.length} must sign`,
31
+ policy.wrapper
32
+ ? `calls ${policy.wrapper}, function ${WRAPPER_ENTRYPOINT}`
33
+ : `cap ${policy.maxAmount} base units of ${policy.token} per transfer`,
34
+ `company "${company}"`,
35
+ ];
36
+ }
37
+ /**
38
+ * `cofund policies create`: build the policy's activation from the flags, sign
39
+ * it with the local key and create the policy. It activates at once when one
40
+ * admin may activate it, else it awaits the other admins.
41
+ */
42
+ export async function policiesCreate(io, values) {
43
+ const threshold = Number(values.threshold);
44
+ const transfer = values.token && values["max-amount"];
45
+ if (!(values.vault &&
46
+ values.name &&
47
+ values.signers &&
48
+ Number.isInteger(threshold)) ||
49
+ Boolean(transfer) === Boolean(values.wrapper)) {
50
+ io.err(USAGE);
51
+ return 1;
52
+ }
53
+ const keystore = readKeystore(keystorePath(io, values.keystore));
54
+ const api = client(io);
55
+ const cofundId = pickCofund(await api.me(), values.cofund);
56
+ const company = pinnedCompany(keystore, cofundId);
57
+ const [vaults, members] = await Promise.all([
58
+ api.vaults(cofundId),
59
+ api.members(cofundId),
60
+ ]);
61
+ const vault = vaults.find((v) => v.id === values.vault);
62
+ if (!vault) {
63
+ io.err(`No vault ${values.vault} in this cofund`);
64
+ return 1;
65
+ }
66
+ const common = {
67
+ id: randomUUID(),
68
+ vault,
69
+ name: values.name,
70
+ type: policyType(values),
71
+ signers: pickSigners(members, values.signers),
72
+ threshold,
73
+ };
74
+ const policy = values.wrapper
75
+ ? { ...common, wrapper: values.wrapper }
76
+ : {
77
+ ...common,
78
+ token: values.token,
79
+ maxAmount: values["max-amount"],
80
+ };
81
+ for (const line of policyLines(policy, company)) {
82
+ io.out(line);
83
+ }
84
+ const approve = approval(io, values.yes, "Sign and create this policy?");
85
+ if (!approve) {
86
+ io.err("Pass --yes to create without a prompt");
87
+ return 1;
88
+ }
89
+ if (!(await approve())) {
90
+ io.err("Not created");
91
+ return 1;
92
+ }
93
+ const body = await signPolicyCreation(policy, company, chainPins(io), {
94
+ evm: evmWallet(keystore),
95
+ btc: btcMessageSigner(keystore),
96
+ });
97
+ const created = await api.createPolicy(cofundId, body);
98
+ io.out(`created ${created.id} (${created.status})`);
99
+ return 0;
100
+ }
package/dist/cli.d.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  * cofund keys init [--force]
6
6
  * cofund me
7
7
  * cofund invite accept [--invite <id>] [--company <name>]
8
+ * cofund invite create --email <email> --first-name <name> --last-name <name>
9
+ * --position <title> [--admin] [--chains A,B] [--yes]
10
+ * cofund policies create --vault <id> --name <name> --signers <id|email,…>
11
+ * --threshold <n> (--token <address|BTC> --max-amount <base units> |
12
+ * --wrapper <address>) [--type <PolicyType>] [--yes]
8
13
  * cofund requests [list]
9
14
  * cofund requests show <id>
10
15
  * cofund requests sign <id> [--yes]
package/dist/cli.js CHANGED
@@ -5,6 +5,11 @@
5
5
  * cofund keys init [--force]
6
6
  * cofund me
7
7
  * cofund invite accept [--invite <id>] [--company <name>]
8
+ * cofund invite create --email <email> --first-name <name> --last-name <name>
9
+ * --position <title> [--admin] [--chains A,B] [--yes]
10
+ * cofund policies create --vault <id> --name <name> --signers <id|email,…>
11
+ * --threshold <n> (--token <address|BTC> --max-amount <base units> |
12
+ * --wrapper <address>) [--type <PolicyType>] [--yes]
8
13
  * cofund requests [list]
9
14
  * cofund requests show <id>
10
15
  * cofund requests sign <id> [--yes]
@@ -27,8 +32,9 @@ import { createInterface } from "node:readline/promises";
27
32
  import { pathToFileURL } from "node:url";
28
33
  import { parseArgs } from "node:util";
29
34
  import { client, USAGE } from "./cli/context.js";
30
- import { inviteAccept } from "./cli/invite.js";
35
+ import { inviteAccept, inviteCreate } from "./cli/invite.js";
31
36
  import { keysInit } from "./cli/keys.js";
37
+ import { policiesCreate } from "./cli/policies.js";
32
38
  import { requestsCommand } from "./cli/requests.js";
33
39
  import { stocksBuy } from "./cli/stocks.js";
34
40
  import { webhooksCommand } from "./cli/webhooks.js";
@@ -45,6 +51,20 @@ export async function runCli(argv, io) {
45
51
  policy: { type: "string" },
46
52
  spend: { type: "string" },
47
53
  events: { type: "string" },
54
+ vault: { type: "string" },
55
+ name: { type: "string" },
56
+ type: { type: "string" },
57
+ signers: { type: "string" },
58
+ threshold: { type: "string" },
59
+ token: { type: "string" },
60
+ "max-amount": { type: "string" },
61
+ wrapper: { type: "string" },
62
+ email: { type: "string" },
63
+ "first-name": { type: "string" },
64
+ "last-name": { type: "string" },
65
+ position: { type: "string" },
66
+ admin: { type: "boolean" },
67
+ chains: { type: "string" },
48
68
  yes: { type: "boolean", short: "y" },
49
69
  help: { type: "boolean", short: "h" },
50
70
  },
@@ -73,6 +93,10 @@ export async function runCli(argv, io) {
73
93
  return 0;
74
94
  case "invite accept":
75
95
  return await inviteAccept(io, values);
96
+ case "invite create":
97
+ return await inviteCreate(io, values);
98
+ case "policies create":
99
+ return await policiesCreate(io, values);
76
100
  default:
77
101
  io.err(USAGE);
78
102
  return 1;
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AcceptInviteRequest, type CreateWebhookRequest, type ProposeCallRequest, type ProposeTransferRequest } from "./api-types.ts";
1
+ import { type AcceptInviteRequest, type CreateInviteRequest, type CreatePolicyRequest, type CreateWebhookRequest, type ProposeCallRequest, type ProposeTransferRequest } from "./api-types.ts";
2
2
  export declare class CofundApiError extends Error {
3
3
  readonly status: number;
4
4
  constructor(status: number, message: string);
@@ -168,9 +168,71 @@ export declare class CofundClient {
168
168
  wrapper: string | null;
169
169
  func: "entry" | null;
170
170
  }>;
171
+ /**
172
+ * Admins only: create a policy with the creator's activation signature
173
+ * (`signPolicyCreation`). It activates at once when the activation rule
174
+ * lets one admin do it, else it awaits the other admins' approvals as a
175
+ * `policy` request with the same id. Safe to retry: the same `id` returns
176
+ * the same policy.
177
+ */
178
+ createPolicy(cofundId: string, body: CreatePolicyRequest): Promise<{
179
+ id: string;
180
+ name: string;
181
+ type: "Contractor_Stipend" | "Operational_Expense" | "Treasury_Management";
182
+ status: "PENDING" | "ACTIVE" | "INACTIVE" | "FAILED" | "AWAITING_SIGNATURES";
183
+ threshold: number;
184
+ vault: {
185
+ id: string;
186
+ chain: "ETHEREUM" | "BASE" | "ARBITRUM" | "ROBINHOOD";
187
+ address: string;
188
+ } | {
189
+ id: string;
190
+ chain: "BTC";
191
+ address: string;
192
+ };
193
+ signers: {
194
+ id: string;
195
+ name: string | null;
196
+ }[];
197
+ token: {
198
+ address: string;
199
+ symbol: string;
200
+ decimals: number;
201
+ } | null;
202
+ maxAmount: string | null;
203
+ recipients: string[];
204
+ wrapper: string | null;
205
+ func: "entry" | null;
206
+ }>;
207
+ /** Admins only: the user id an invite to `email` must seat; null for a new account. */
208
+ inviteUserId(cofundId: string, email: string): Promise<{
209
+ userId: string | null;
210
+ }>;
211
+ /**
212
+ * Admins only: invite a member with the admin's `InviteUser` per chain
213
+ * (`signInviteCreation`). Safe to retry: the same `id` returns the same
214
+ * invite.
215
+ */
216
+ createInvite(cofundId: string, body: CreateInviteRequest): Promise<{
217
+ id: string;
218
+ cofundId: string;
219
+ member: {
220
+ id: string;
221
+ email: string;
222
+ isAdmin: boolean;
223
+ position: string;
224
+ };
225
+ chains: {
226
+ chain: "ETHEREUM" | "BASE" | "ARBITRUM" | "ROBINHOOD";
227
+ id: string;
228
+ status: "FAILED" | "AWAITING_SIGNATURES" | "COMPLETED" | "AWAITING_SUBMISSION" | "PROCESSING" | "EXPIRED" | "CANCELED" | "REJECTED";
229
+ expiresAt: string;
230
+ }[];
231
+ }>;
171
232
  /** Admins only. */
172
233
  members(cofundId: string): Promise<{
173
234
  id: string;
235
+ onchainId: string;
174
236
  email: string;
175
237
  name: string | null;
176
238
  isAdmin: boolean;
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { AcceptInviteResultSchema, ApiCallRequestSchema, ApiErrorSchema, ApiInviteSchema, ApiMemberSchema, ApiMeSchema, ApiPolicySchema, ApiRequestSchema, ApiTransferRequestSchema, ApiVaultDetailSchema, ApiVaultSchema, ApiWebhookSchema, CreatedWebhookSchema, DeleteWebhookResultSchema, RejectResultSchema, } from "./api-types.js";
2
+ import { AcceptInviteResultSchema, ApiCallRequestSchema, ApiCreatedInviteSchema, ApiErrorSchema, ApiInviteSchema, ApiMemberSchema, ApiMeSchema, ApiPolicySchema, ApiRequestSchema, ApiTransferRequestSchema, ApiVaultDetailSchema, ApiVaultSchema, ApiWebhookSchema, CreatedWebhookSchema, DeleteWebhookResultSchema, InviteUserIdSchema, RejectResultSchema, } from "./api-types.js";
3
3
  const TRAILING_SLASHES = /\/+$/;
4
4
  /** Gateway errors a retry of an idempotent proposal may outlast. */
5
5
  const RETRYABLE = new Set([502, 503, 504]);
@@ -106,6 +106,30 @@ export class CofundClient {
106
106
  policy(cofundId, policyId) {
107
107
  return this.call(ApiPolicySchema, `/cofunds/${cofundId}/policies/${policyId}`);
108
108
  }
109
+ /**
110
+ * Admins only: create a policy with the creator's activation signature
111
+ * (`signPolicyCreation`). It activates at once when the activation rule
112
+ * lets one admin do it, else it awaits the other admins' approvals as a
113
+ * `policy` request with the same id. Safe to retry: the same `id` returns
114
+ * the same policy.
115
+ */
116
+ createPolicy(cofundId, body) {
117
+ return this.call(ApiPolicySchema, `/cofunds/${cofundId}/policies`, body, {
118
+ retries: 2,
119
+ });
120
+ }
121
+ /** Admins only: the user id an invite to `email` must seat; null for a new account. */
122
+ inviteUserId(cofundId, email) {
123
+ return this.call(InviteUserIdSchema, `/cofunds/${cofundId}/invites/user-id`, { email });
124
+ }
125
+ /**
126
+ * Admins only: invite a member with the admin's `InviteUser` per chain
127
+ * (`signInviteCreation`). Safe to retry: the same `id` returns the same
128
+ * invite.
129
+ */
130
+ createInvite(cofundId, body) {
131
+ return this.call(ApiCreatedInviteSchema, `/cofunds/${cofundId}/invites`, body, { retries: 2 });
132
+ }
109
133
  /** Admins only. */
110
134
  members(cofundId) {
111
135
  return this.call(z.array(ApiMemberSchema), `/cofunds/${cofundId}/members`);
package/dist/eip712.d.ts CHANGED
@@ -87,6 +87,20 @@ export declare function buildCofundDomain({ chainId, state, }: {
87
87
  export declare const INVITE_USER_TYPES: TypeSet;
88
88
  /** `CFHelpersUsersv1.MAX_INVITE_LIFETIME`: the furthest out an `InviteUser`'s `expiresAt` may be. */
89
89
  export declare const MAX_INVITE_LIFETIME_SECONDS: number;
90
+ /**
91
+ * Headroom under the contract's ceiling. `_requireWindow` rejects
92
+ * `expiresAt > block.timestamp + MAX_INVITE_LIFETIME`, and `block.timestamp`
93
+ * at inclusion is the sequencer's clock, not the signer's: signing exactly
94
+ * `now + 30 days` reverts with `InvalidParams` whenever the chain is even one
95
+ * second behind (clock skew, a lagging L2 sequencer). Anvil stamps blocks from
96
+ * the same wall clock, so no local stack ever shows it. A full day of headroom
97
+ * (so 29 days signed) is far beyond any skew.
98
+ */
99
+ export declare const CHAIN_CLOCK_SKEW_MARGIN_SECONDS: number;
100
+ /** What an inviting admin signs: the ceiling minus the skew margin. */
101
+ export declare const INVITE_LIFETIME_SECONDS: number;
102
+ /** An invite's `expiresAt`: unix SECONDS, as `uint256 expiresAt` wants them. */
103
+ export declare function inviteExpiresAt(now?: Date, lifetimeSeconds?: number): string;
90
104
  export declare const REMOVE_USER_TYPES: TypeSet;
91
105
  /**
92
106
  * Closing a client's onboarding phase (§4.3, round 4).
package/dist/eip712.js CHANGED
@@ -51,6 +51,22 @@ export const INVITE_USER_TYPES = Object.freeze({
51
51
  });
52
52
  /** `CFHelpersUsersv1.MAX_INVITE_LIFETIME`: the furthest out an `InviteUser`'s `expiresAt` may be. */
53
53
  export const MAX_INVITE_LIFETIME_SECONDS = 30 * 24 * 60 * 60;
54
+ /**
55
+ * Headroom under the contract's ceiling. `_requireWindow` rejects
56
+ * `expiresAt > block.timestamp + MAX_INVITE_LIFETIME`, and `block.timestamp`
57
+ * at inclusion is the sequencer's clock, not the signer's: signing exactly
58
+ * `now + 30 days` reverts with `InvalidParams` whenever the chain is even one
59
+ * second behind (clock skew, a lagging L2 sequencer). Anvil stamps blocks from
60
+ * the same wall clock, so no local stack ever shows it. A full day of headroom
61
+ * (so 29 days signed) is far beyond any skew.
62
+ */
63
+ export const CHAIN_CLOCK_SKEW_MARGIN_SECONDS = 24 * 60 * 60;
64
+ /** What an inviting admin signs: the ceiling minus the skew margin. */
65
+ export const INVITE_LIFETIME_SECONDS = MAX_INVITE_LIFETIME_SECONDS - CHAIN_CLOCK_SKEW_MARGIN_SECONDS;
66
+ /** An invite's `expiresAt`: unix SECONDS, as `uint256 expiresAt` wants them. */
67
+ export function inviteExpiresAt(now = new Date(), lifetimeSeconds = INVITE_LIFETIME_SECONDS) {
68
+ return String(Math.floor(now.getTime() / 1000) + lifetimeSeconds);
69
+ }
54
70
  export const REMOVE_USER_TYPES = Object.freeze({
55
71
  RemoveUser: [
56
72
  { name: "company", type: "string" },
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./client.ts";
5
5
  export * from "./eip712.ts";
6
6
  export * from "./invite.ts";
7
7
  export * from "./keys.ts";
8
+ export * from "./policies.ts";
8
9
  export * from "./requests.ts";
9
10
  export * from "./stocks/index.ts";
10
11
  export * from "./webhooks.ts";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./client.js";
5
5
  export * from "./eip712.js";
6
6
  export * from "./invite.js";
7
7
  export * from "./keys.js";
8
+ export * from "./policies.js";
8
9
  export * from "./requests.js";
9
10
  export * from "./stocks/index.js";
10
11
  export * from "./webhooks.js";
package/dist/invite.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { Wallet } from "ethers";
2
- import type { AcceptInviteRequest, ApiInvite } from "./api-types.ts";
2
+ import { type AcceptInviteRequest, type ApiInvite, type CreateInviteRequest, type EvmChainName } from "./api-types.ts";
3
3
  import { type ChainPins } from "./chains.ts";
4
4
  import { type InviteUserMessage } from "./eip712.ts";
5
+ import type { TypedDataSigner } from "./requests.ts";
5
6
  /** What the invitee expects before signing anything. */
6
7
  export type InviteExpectations = {
7
8
  /** The caller's own user id (from `/me`). */
@@ -25,3 +26,29 @@ export declare function checkInvite(invite: ApiInvite, expected: InviteExpectati
25
26
  }[];
26
27
  /** Sign each checked `InviteUser` with the local EVM key. */
27
28
  export declare function signInviteAcceptance(checked: ReturnType<typeof checkInvite>, wallet: Wallet): Promise<AcceptInviteRequest["evm"]>;
29
+ /** An invite to create, as the inviting admin states it. */
30
+ export type NewInvite = {
31
+ /** A fresh UUID: the invite's id and every chain's `authId`; a retry reuses it. */
32
+ id: string;
33
+ /** The inviting admin's own user id (`/me`), signed as `callerId`. */
34
+ callerId: string;
35
+ /** `inviteUserId(email)`'s answer, else `id`. */
36
+ userId: string;
37
+ email: string;
38
+ firstName: string;
39
+ lastName: string;
40
+ position: string;
41
+ isAdmin: boolean;
42
+ /** The cofund's EVM vault chains: one seat on each. */
43
+ chains: EvmChainName[];
44
+ /** Unix seconds; 29 days out by default (`INVITE_LIFETIME_SECONDS`). */
45
+ expiresAt?: string;
46
+ now?: Date;
47
+ };
48
+ /**
49
+ * The POST /invites body for `invite`: on each chain the `InviteUser` built
50
+ * here from the admin's own inputs, under the pinned domain and the pinned
51
+ * `company`, signed with the local EVM key. The invitee and any co-signing
52
+ * admins later sign the same message.
53
+ */
54
+ export declare function signInviteCreation(invite: NewInvite, company: string, pins: ChainPins, wallet: TypedDataSigner): Promise<CreateInviteRequest>;
package/dist/invite.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { CreateInviteRequestSchema, } from "./api-types.js";
1
2
  import { pinnedDomain, sameDomain } from "./chains.js";
2
- import { inviteUserTypes, MAX_INVITE_LIFETIME_SECONDS, } from "./eip712.js";
3
+ import { inviteExpiresAt, inviteUserTypes, MAX_INVITE_LIFETIME_SECONDS, } from "./eip712.js";
3
4
  /** The decoded message, field by field: nothing the types do not name. */
4
5
  function rebuildMessage(m) {
5
6
  return {
@@ -69,3 +70,41 @@ export async function signInviteAcceptance(checked, wallet) {
69
70
  signature: await wallet.signTypedData(domain, inviteUserTypes(), message),
70
71
  })));
71
72
  }
73
+ /**
74
+ * The POST /invites body for `invite`: on each chain the `InviteUser` built
75
+ * here from the admin's own inputs, under the pinned domain and the pinned
76
+ * `company`, signed with the local EVM key. The invitee and any co-signing
77
+ * admins later sign the same message.
78
+ */
79
+ export async function signInviteCreation(invite, company, pins, wallet) {
80
+ const now = invite.now ?? new Date();
81
+ const expiresAt = invite.expiresAt ?? inviteExpiresAt(now);
82
+ const problem = inviteWindowProblem(expiresAt, now);
83
+ if (problem) {
84
+ throw new Error(`The invite cannot be signed: ${problem}`);
85
+ }
86
+ const message = {
87
+ company,
88
+ callerId: invite.callerId,
89
+ userId: invite.userId,
90
+ userIsAdmin: invite.isAdmin,
91
+ userPosition: invite.position,
92
+ expiresAt,
93
+ authId: invite.id,
94
+ };
95
+ const chains = await Promise.all(invite.chains.map(async (chain) => ({
96
+ chain,
97
+ expiresAt,
98
+ signature: await wallet.signTypedData(pinnedDomain(pins, chain), inviteUserTypes(), message),
99
+ })));
100
+ return CreateInviteRequestSchema.parse({
101
+ id: invite.id,
102
+ email: invite.email,
103
+ firstName: invite.firstName,
104
+ lastName: invite.lastName,
105
+ position: invite.position,
106
+ isAdmin: invite.isAdmin,
107
+ userId: invite.userId,
108
+ chains,
109
+ });
110
+ }
@@ -0,0 +1,69 @@
1
+ import { type ApiMember, type ApiPolicyType, type ApiVault, type CreatePolicyRequest, type PolicyType } from "./api-types.ts";
2
+ import { type BtcPolicyActivationData } from "./btc-policy-activation.ts";
3
+ import { type ChainPins } from "./chains.ts";
4
+ import type { LocalSigners } from "./requests.ts";
5
+ /**
6
+ * Policy activations: what an admin signs to create a policy or to approve one.
7
+ * One builder for both, so the creator signs exactly what each approving admin
8
+ * later rebuilds and the app verifies.
9
+ */
10
+ /** What an activation commits to, besides its signers. */
11
+ export type PolicyActivationFields = {
12
+ /** The policy's id, also its activation's `authId`. */
13
+ id: string;
14
+ name: string;
15
+ type: PolicyType;
16
+ threshold: number;
17
+ /** Transfer policies: the token (an EVM address, or `BTC`), the cap per transfer and the allowlist (empty: any). */
18
+ token: string | null;
19
+ maxAmount: string | null;
20
+ recipients: string[];
21
+ /** Call policies: the wrapper; its one function is `entry`. */
22
+ wrapper: string | null;
23
+ };
24
+ /**
25
+ * The EVM activation: a call policy names its wrapper and `entry`, a transfer
26
+ * policy its token, cap and allowlist. `signerIds` are the signers' on-chain
27
+ * user ids.
28
+ */
29
+ export declare function evmPolicyActivation(company: string, fields: PolicyActivationFields & {
30
+ signerIds: string[];
31
+ }): {
32
+ types: Record<string, {
33
+ name: string;
34
+ type: string;
35
+ }[]>;
36
+ message: Record<string, unknown>;
37
+ };
38
+ /** The BTC activation; each signer's Cofund leaf key is derived here from their account xpub. */
39
+ export declare function btcPolicyActivation(company: string, fields: PolicyActivationFields & {
40
+ signerXpubs: string[];
41
+ }): BtcPolicyActivationData;
42
+ /** A policy to create, as its creator states it. */
43
+ export type NewPolicy = {
44
+ /** A fresh UUID: the policy's id and its activation's `authId`; a retry reuses it. */
45
+ id: string;
46
+ /** From `vaults()`. */
47
+ vault: ApiVault;
48
+ name: string;
49
+ type: ApiPolicyType;
50
+ /** The members who sign, from `members()`: the activation names their `onchainId` (EVM) or BTC key. */
51
+ signers: ApiMember[];
52
+ threshold: number;
53
+ } & ({
54
+ token: string;
55
+ maxAmount: string;
56
+ wrapper?: undefined;
57
+ } | {
58
+ wrapper: string;
59
+ token?: undefined;
60
+ maxAmount?: undefined;
61
+ });
62
+ /**
63
+ * The POST /policies body for `policy`: its activation built here from the
64
+ * creator's own inputs, under the pinned domain (EVM) and the pinned
65
+ * `company`, and signed with the local key: EIP-712 on EVM, the BTC key's
66
+ * Cofund leaf on BTC. The server builds the same activation and checks the
67
+ * signature against the creator's key.
68
+ */
69
+ export declare function signPolicyCreation(policy: NewPolicy, company: string, pins: ChainPins, signers: LocalSigners): Promise<CreatePolicyRequest>;
@@ -0,0 +1,132 @@
1
+ import { sha256 } from "@noble/hashes/sha2";
2
+ import { bytesToHex } from "@noble/hashes/utils";
3
+ import { ethers } from "ethers";
4
+ import { CreatePolicyRequestSchema, } from "./api-types.js";
5
+ import { btcPolicyActivationMessage, } from "./btc-policy-activation.js";
6
+ import { pinnedDomain } from "./chains.js";
7
+ import { transactionPolicyActivationTypes, transferPolicyActivationTypes, WRAPPER_ENTRYPOINT, } from "./eip712.js";
8
+ import { btcCofundLeafKey } from "./keys.js";
9
+ const byCodepoint = (a, b) => (a < b ? -1 : Number(a > b));
10
+ /**
11
+ * The EVM activation: a call policy names its wrapper and `entry`, a transfer
12
+ * policy its token, cap and allowlist. `signerIds` are the signers' on-chain
13
+ * user ids.
14
+ */
15
+ export function evmPolicyActivation(company, fields) {
16
+ const common = {
17
+ company,
18
+ policyId: fields.id,
19
+ title: fields.name,
20
+ policyType: fields.type,
21
+ signerIds: [...fields.signerIds].sort(byCodepoint),
22
+ threshold: String(fields.threshold),
23
+ authId: fields.id,
24
+ };
25
+ if (fields.wrapper) {
26
+ return {
27
+ types: transactionPolicyActivationTypes(),
28
+ message: { ...common, wrapper: fields.wrapper, func: WRAPPER_ENTRYPOINT },
29
+ };
30
+ }
31
+ return {
32
+ types: transferPolicyActivationTypes(),
33
+ message: {
34
+ ...common,
35
+ token: fields.token ?? ethers.ZeroAddress,
36
+ maxAmount: fields.maxAmount ?? "0",
37
+ recipients: fields.recipients,
38
+ },
39
+ };
40
+ }
41
+ /** The BTC activation; each signer's Cofund leaf key is derived here from their account xpub. */
42
+ export function btcPolicyActivation(company, fields) {
43
+ const isTransaction = fields.wrapper !== null;
44
+ return {
45
+ clientId: bytesToHex(sha256(new TextEncoder().encode(company))),
46
+ policyId: fields.id,
47
+ authId: fields.id,
48
+ policyType: fields.type,
49
+ policyTitle: fields.name,
50
+ policySignerKeys: fields.signerXpubs
51
+ .map(btcCofundLeafKey)
52
+ .sort(byCodepoint),
53
+ policyThreshold: fields.threshold,
54
+ txWrapper: fields.wrapper ?? "",
55
+ txFunc: isTransaction ? WRAPPER_ENTRYPOINT : "",
56
+ isTransaction,
57
+ maxAmount: isTransaction ? "0" : (fields.maxAmount ?? "0"),
58
+ tokenIdentifier: isTransaction ? "" : (fields.token ?? ""),
59
+ recipients: isTransaction ? [] : [...fields.recipients].sort(byCodepoint),
60
+ };
61
+ }
62
+ /** Each signer's BTC account xpub in this cofund. */
63
+ function btcXpubs(signers) {
64
+ return signers.map((signer) => {
65
+ const key = signer.keys.find(({ chain }) => chain === "BTC");
66
+ if (!key) {
67
+ throw new Error(`${signer.name ?? signer.id} has no Bitcoin key`);
68
+ }
69
+ return key.pubKey;
70
+ });
71
+ }
72
+ /**
73
+ * The POST /policies body for `policy`: its activation built here from the
74
+ * creator's own inputs, under the pinned domain (EVM) and the pinned
75
+ * `company`, and signed with the local key: EIP-712 on EVM, the BTC key's
76
+ * Cofund leaf on BTC. The server builds the same activation and checks the
77
+ * signature against the creator's key.
78
+ */
79
+ export async function signPolicyCreation(policy, company, pins, signers) {
80
+ const fields = {
81
+ id: policy.id,
82
+ name: policy.name,
83
+ type: policy.type,
84
+ threshold: policy.threshold,
85
+ token: policy.token ?? null,
86
+ maxAmount: policy.maxAmount ?? null,
87
+ recipients: [],
88
+ wrapper: policy.wrapper ?? null,
89
+ };
90
+ const common = {
91
+ id: policy.id,
92
+ vaultId: policy.vault.id,
93
+ name: policy.name,
94
+ type: policy.type,
95
+ signers: policy.signers.map(({ id }) => id),
96
+ threshold: policy.threshold,
97
+ };
98
+ const { vault } = policy;
99
+ if (vault.chain === "BTC") {
100
+ if (policy.wrapper !== undefined || policy.token !== "BTC") {
101
+ throw new Error("A Bitcoin policy sends BTC, and binds no wrapper");
102
+ }
103
+ if (!signers.btc) {
104
+ throw new Error("A Bitcoin policy needs the BTC key");
105
+ }
106
+ const data = btcPolicyActivation(company, {
107
+ ...fields,
108
+ signerXpubs: btcXpubs(policy.signers),
109
+ });
110
+ return CreatePolicyRequestSchema.parse({
111
+ ...common,
112
+ chain: "BTC",
113
+ token: "BTC",
114
+ maxAmount: policy.maxAmount,
115
+ signature: await signers.btc.signMessage(btcPolicyActivationMessage(data)),
116
+ });
117
+ }
118
+ const { types, message } = evmPolicyActivation(company, {
119
+ ...fields,
120
+ signerIds: policy.signers.map(({ onchainId }) => onchainId),
121
+ });
122
+ const signature = await signers.evm.signTypedData(pinnedDomain(pins, vault.chain), types, message);
123
+ return CreatePolicyRequestSchema.parse(policy.wrapper === undefined
124
+ ? {
125
+ ...common,
126
+ chain: vault.chain,
127
+ token: policy.token,
128
+ maxAmount: policy.maxAmount,
129
+ signature,
130
+ }
131
+ : { ...common, chain: vault.chain, wrapper: policy.wrapper, signature });
132
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ApiInviteRequest, ApiPolicy, ApiPolicyRequest, ApiRequest, ApiToken, ChainName, EvmChainName, PolicyType } from "./api-types.ts";
2
2
  import { type ChainPins } from "./chains.ts";
3
3
  import { type AllowanceMessage, type Eip712Domain } from "./eip712.ts";
4
- import { type BtcMessageSigner } from "./keys.ts";
4
+ import type { BtcMessageSigner } from "./keys.ts";
5
5
  import { type DecodedStockSwap } from "./stocks/index.ts";
6
6
  /**
7
7
  * Local signers for transfers, calls, policy activations and admin invites.