@mantle-agent/custody 0.0.0-stage → 0.1.1
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/README.md +18 -2
- package/dist/custody.d.ts +231 -0
- package/dist/custody.js +231 -0
- package/dist/custody.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/owner.d.ts +2 -0
- package/dist/owner.js +4 -0
- package/dist/owner.js.map +1 -0
- package/dist/policies.d.ts +36 -0
- package/dist/policies.js +52 -0
- package/dist/policies.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -0
- package/package.json +55 -3
- package/src/custody.ts +345 -0
- package/src/index.ts +3 -0
- package/src/owner.ts +3 -0
- package/src/policies.ts +74 -0
- package/src/server.ts +15 -0
package/README.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mantle-agent/custody
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Turnkey](https://www.turnkey.com) custody for Mantle Agent Accounts (Embedded Wallets model):
|
|
4
|
+
|
|
5
|
+
- one **sub-organization per owner**, the owner (passkey) as sole root; email/SMS auth and recovery disabled;
|
|
6
|
+
- one **wallet per agent**: `m/44'/60'/0'/0/0` is the agent EOA (root key), `/1` the session key;
|
|
7
|
+
- the platform joins each sub-org as a **non-root delegated-access user**, added from the owner's own session;
|
|
8
|
+
- a **session signing policy** that only lets the platform sign Calibur `SignedBatchedCall`s for that account and
|
|
9
|
+
chain, with that session key, executable only by the platform relayer, until the session expires.
|
|
10
|
+
|
|
11
|
+
Server side (`@mantle-agent/custody`): `turnkeyClient`, `createOwnerSubOrg`, `verifyCustody`, `readAgentWallet`,
|
|
12
|
+
`verifySessionPolicy`, `verifyOwnerStamp`, `sessionSigner`, `platformSelfRemove`.
|
|
13
|
+
|
|
14
|
+
Browser side (`@mantle-agent/custody/owner`, no Node-only dependencies): `addPlatformDelegate`,
|
|
15
|
+
`createAgentWallet`, `grantSessionSigning`, `renewSessionSigning`, `removePlatformDelegate`, `rootSigner`,
|
|
16
|
+
`sessionSigningPolicy`.
|
|
17
|
+
|
|
18
|
+
Notes from production use: Turnkey matches `signWith` case-sensitively (signers checksum it), the passkey stamp
|
|
19
|
+
header is `X-Stamp-Webauthn`, and a freshly created sub-org can take a few seconds to become visible.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createAccountWithAddress } from "@turnkey/viem";
|
|
2
|
+
import type { TCreatePoliciesBody, TCreateSubOrganizationBody, TCreateUsersBody, TCreateWalletAccountsBody, TCreateWalletBody, TDeletePoliciesBody, TDeleteUsersBody, TGetOrganizationConfigsBody, TGetPolicyBody, TGetUserBody, TGetUsersBody, TGetWalletAccountsBody, v1AuthenticatorParamsV2 } from "@turnkey/sdk-types";
|
|
3
|
+
import { type Address } from "viem";
|
|
4
|
+
import type { RootSigner, Signer } from "@mantle-agent/core";
|
|
5
|
+
import { type SessionSigningScope } from "./policies.js";
|
|
6
|
+
export declare const TURNKEY_API_BASE_URL = "https://api.turnkey.com";
|
|
7
|
+
/** BIP-32 layout inside each agent's wallet: index 0 is the agent EOA (root), 1.. are session keys. */
|
|
8
|
+
export declare const ROOT_KEY_PATH = "m/44'/60'/0'/0/0";
|
|
9
|
+
export declare const sessionKeyPath: (index: number) => string;
|
|
10
|
+
/**
|
|
11
|
+
* The calls each role makes. Turnkey's server client and its browser/passkey clients share these method names,
|
|
12
|
+
* so owner flows run unchanged on the confirmation page (passkey) and in tests (API key).
|
|
13
|
+
*/
|
|
14
|
+
export type ParentApi = {
|
|
15
|
+
createSubOrganization(body: TCreateSubOrganizationBody): Promise<{
|
|
16
|
+
subOrganizationId: string;
|
|
17
|
+
wallet?: {
|
|
18
|
+
walletId: string;
|
|
19
|
+
addresses: string[];
|
|
20
|
+
};
|
|
21
|
+
rootUserIds?: string[];
|
|
22
|
+
}>;
|
|
23
|
+
getOrganizationConfigs(body: TGetOrganizationConfigsBody): Promise<{
|
|
24
|
+
configs: {
|
|
25
|
+
quorum?: {
|
|
26
|
+
threshold: number;
|
|
27
|
+
userIds: string[];
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}>;
|
|
31
|
+
getUsers(body: TGetUsersBody): Promise<{
|
|
32
|
+
users: {
|
|
33
|
+
userId: string;
|
|
34
|
+
userName: string;
|
|
35
|
+
}[];
|
|
36
|
+
}>;
|
|
37
|
+
getUser(body: TGetUserBody): Promise<{
|
|
38
|
+
user: {
|
|
39
|
+
userId: string;
|
|
40
|
+
apiKeys: {
|
|
41
|
+
credential: {
|
|
42
|
+
publicKey: string;
|
|
43
|
+
};
|
|
44
|
+
}[];
|
|
45
|
+
};
|
|
46
|
+
}>;
|
|
47
|
+
getWalletAccounts(body: TGetWalletAccountsBody): Promise<{
|
|
48
|
+
accounts: {
|
|
49
|
+
walletId: string;
|
|
50
|
+
path: string;
|
|
51
|
+
address: string;
|
|
52
|
+
addressFormat: string;
|
|
53
|
+
curve: string;
|
|
54
|
+
}[];
|
|
55
|
+
}>;
|
|
56
|
+
getPolicy(body: TGetPolicyBody): Promise<{
|
|
57
|
+
policy: {
|
|
58
|
+
policyId: string;
|
|
59
|
+
effect: string;
|
|
60
|
+
consensus: string;
|
|
61
|
+
condition: string;
|
|
62
|
+
time?: string;
|
|
63
|
+
};
|
|
64
|
+
}>;
|
|
65
|
+
};
|
|
66
|
+
export type OwnerApi = {
|
|
67
|
+
createWallet(body: TCreateWalletBody): Promise<{
|
|
68
|
+
walletId: string;
|
|
69
|
+
addresses: string[];
|
|
70
|
+
}>;
|
|
71
|
+
createWalletAccounts(body: TCreateWalletAccountsBody): Promise<{
|
|
72
|
+
addresses: string[];
|
|
73
|
+
}>;
|
|
74
|
+
createUsers(body: TCreateUsersBody): Promise<{
|
|
75
|
+
userIds: string[];
|
|
76
|
+
}>;
|
|
77
|
+
createPolicies(body: TCreatePoliciesBody): Promise<{
|
|
78
|
+
policyIds: string[];
|
|
79
|
+
}>;
|
|
80
|
+
deletePolicies(body: TDeletePoliciesBody): Promise<unknown>;
|
|
81
|
+
deleteUsers(body: TDeleteUsersBody): Promise<unknown>;
|
|
82
|
+
};
|
|
83
|
+
export type DelegateApi = Pick<OwnerApi, "deleteUsers">;
|
|
84
|
+
/** How the owner proves who they are: a passkey in production, an API key in tests and the PoC. */
|
|
85
|
+
export type OwnerCredential = {
|
|
86
|
+
kind: "passkey";
|
|
87
|
+
authenticator: v1AuthenticatorParamsV2;
|
|
88
|
+
} | {
|
|
89
|
+
kind: "apiKey";
|
|
90
|
+
publicKey: string;
|
|
91
|
+
};
|
|
92
|
+
export type OwnerSubOrg = {
|
|
93
|
+
subOrgId: string;
|
|
94
|
+
ownerUserId: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Backend step (parent org): the owner's own space, with the owner as the sole root user. All of the owner's
|
|
98
|
+
* agents live here as separate wallets (PRD IDN-04). Email/SMS auth and recovery are disabled so the parent org
|
|
99
|
+
* can never initiate an auth flow into it; the parent keeps read access only.
|
|
100
|
+
*/
|
|
101
|
+
export declare function createOwnerSubOrg(parent: ParentApi, params: {
|
|
102
|
+
name: string;
|
|
103
|
+
ownerName: string;
|
|
104
|
+
owner: OwnerCredential;
|
|
105
|
+
}): Promise<OwnerSubOrg>;
|
|
106
|
+
/** The wallet an agent gets: root key (agent EOA) at index 0, first session key at index 1. */
|
|
107
|
+
export declare function agentWalletParams(params: {
|
|
108
|
+
subOrgId: string;
|
|
109
|
+
agentId: string;
|
|
110
|
+
}): TCreateWalletBody;
|
|
111
|
+
export type AgentWallet = {
|
|
112
|
+
walletId: string;
|
|
113
|
+
/** Agent EOA: becomes the 7702 account. Controlled only by the owner. */
|
|
114
|
+
rootAddress: Address;
|
|
115
|
+
sessionAddress: Address;
|
|
116
|
+
};
|
|
117
|
+
/** Owner step (confirmation page): create the agent's wallet in the owner's sub-org. */
|
|
118
|
+
export declare function createAgentWallet(owner: OwnerApi, params: {
|
|
119
|
+
subOrgId: string;
|
|
120
|
+
agentId: string;
|
|
121
|
+
}): Promise<AgentWallet>;
|
|
122
|
+
/**
|
|
123
|
+
* Backend check: read the wallet back from Turnkey (parent read access) instead of trusting addresses the page
|
|
124
|
+
* reports, and require the root/session layout.
|
|
125
|
+
*/
|
|
126
|
+
export declare function readAgentWallet(reader: ParentApi, params: {
|
|
127
|
+
subOrgId: string;
|
|
128
|
+
walletId: string;
|
|
129
|
+
}): Promise<AgentWallet>;
|
|
130
|
+
/**
|
|
131
|
+
* Owner step (PRD WAL-07 key rotation): a new session key at `sessionKeyPath(index)` in the agent's wallet. A
|
|
132
|
+
* revoked key can never be granted again (the hook keeps it REVOKED), so re-authorizing needs a fresh one.
|
|
133
|
+
*/
|
|
134
|
+
export declare function addSessionKey(owner: OwnerApi, params: {
|
|
135
|
+
subOrgId: string;
|
|
136
|
+
walletId: string;
|
|
137
|
+
index: number;
|
|
138
|
+
}): Promise<Address>;
|
|
139
|
+
/** Backend check: the rotated session key, read back from Turnkey at the exact path. */
|
|
140
|
+
export declare function readSessionKey(reader: ParentApi, params: {
|
|
141
|
+
subOrgId: string;
|
|
142
|
+
walletId: string;
|
|
143
|
+
index: number;
|
|
144
|
+
}): Promise<Address>;
|
|
145
|
+
/**
|
|
146
|
+
* Backend check: the policy the owner created must be exactly the one we asked for. A page that swapped in a
|
|
147
|
+
* broader policy (another key, no expiry, any executor) is caught here before the session goes live.
|
|
148
|
+
*/
|
|
149
|
+
export declare function verifySessionPolicy(reader: ParentApi, params: {
|
|
150
|
+
subOrgId: string;
|
|
151
|
+
policyId: string;
|
|
152
|
+
scope: SessionSigningScope;
|
|
153
|
+
}): Promise<void>;
|
|
154
|
+
/** A signed (stamped) Turnkey request produced on the owner's device, e.g. by TurnkeyClient.stampGetWhoami. */
|
|
155
|
+
export type SignedTurnkeyRequest = {
|
|
156
|
+
body: string;
|
|
157
|
+
url: string;
|
|
158
|
+
stamp: {
|
|
159
|
+
stampHeaderName: string;
|
|
160
|
+
stampHeaderValue: string;
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Owner login: forward a whoami request stamped with the owner's passkey (or session key) to Turnkey, which
|
|
165
|
+
* verifies the stamp. Only the whoami endpoint is forwarded, always to Turnkey, whatever URL the client sent.
|
|
166
|
+
*/
|
|
167
|
+
export declare function verifyOwnerStamp(signed: SignedTurnkeyRequest, opts?: {
|
|
168
|
+
baseUrl?: string;
|
|
169
|
+
fetchImpl?: typeof fetch;
|
|
170
|
+
}): Promise<{
|
|
171
|
+
organizationId: string;
|
|
172
|
+
userId: string;
|
|
173
|
+
}>;
|
|
174
|
+
/**
|
|
175
|
+
* Owner step (runs in the owner's authenticated session): add the platform as a non-root API-key user with no
|
|
176
|
+
* permissions except deleting itself. Doing this from the owner's session means the platform is never in the
|
|
177
|
+
* root quorum, not even transiently.
|
|
178
|
+
*/
|
|
179
|
+
export declare function addPlatformDelegate(owner: OwnerApi, params: {
|
|
180
|
+
subOrgId: string;
|
|
181
|
+
platformApiPublicKey: string;
|
|
182
|
+
userName?: string;
|
|
183
|
+
}): Promise<{
|
|
184
|
+
daUserId: string;
|
|
185
|
+
policyIds: string[];
|
|
186
|
+
}>;
|
|
187
|
+
/** Owner step: authorize the platform to sign with one session key, scoped as in `sessionSigningPolicy`. */
|
|
188
|
+
export declare function grantSessionSigning(owner: OwnerApi, params: {
|
|
189
|
+
subOrgId: string;
|
|
190
|
+
scope: SessionSigningScope;
|
|
191
|
+
}): Promise<string>;
|
|
192
|
+
/** Owner step: same-key renewal. The new policy is created before the old one is removed, so signing never gaps. */
|
|
193
|
+
export declare function renewSessionSigning(owner: OwnerApi, params: {
|
|
194
|
+
subOrgId: string;
|
|
195
|
+
previousPolicyId: string;
|
|
196
|
+
scope: SessionSigningScope;
|
|
197
|
+
}): Promise<string>;
|
|
198
|
+
/** Owner kill switch #2 (design 3.4): remove the platform user; this owner's agent can no longer sign. No gas. */
|
|
199
|
+
export declare function removePlatformDelegate(owner: OwnerApi, params: {
|
|
200
|
+
subOrgId: string;
|
|
201
|
+
daUserId: string;
|
|
202
|
+
}): Promise<void>;
|
|
203
|
+
/** Platform kill switch #1 (design 3.4): the platform removes itself from a sub-org. Run across all sub-orgs. */
|
|
204
|
+
export declare function platformSelfRemove(delegate: DelegateApi, params: {
|
|
205
|
+
subOrgId: string;
|
|
206
|
+
daUserId: string;
|
|
207
|
+
}): Promise<unknown>;
|
|
208
|
+
/**
|
|
209
|
+
* PRD risk "custody keeps root": after setup, the owner must be the only root user and the platform user must
|
|
210
|
+
* exist outside the quorum, holding the platform key. Trading stays blocked until this passes.
|
|
211
|
+
*/
|
|
212
|
+
export declare function verifyCustody(reader: ParentApi, params: {
|
|
213
|
+
subOrgId: string;
|
|
214
|
+
ownerUserId: string;
|
|
215
|
+
daUserId: string;
|
|
216
|
+
platformApiPublicKey: string;
|
|
217
|
+
}): Promise<void>;
|
|
218
|
+
type AnyTurnkeyClient = Parameters<typeof createAccountWithAddress>[0]["client"];
|
|
219
|
+
/** Wallet Service side: the session key, reachable only through the platform DA credentials and its policy. */
|
|
220
|
+
export declare function sessionSigner(params: {
|
|
221
|
+
client: AnyTurnkeyClient;
|
|
222
|
+
subOrgId: string;
|
|
223
|
+
sessionAddress: Address;
|
|
224
|
+
}): Signer;
|
|
225
|
+
/** Owner side: the root key (agent EOA). In production `client` is the owner's passkey session. */
|
|
226
|
+
export declare function rootSigner(params: {
|
|
227
|
+
client: AnyTurnkeyClient;
|
|
228
|
+
subOrgId: string;
|
|
229
|
+
rootAddress: Address;
|
|
230
|
+
}): RootSigner;
|
|
231
|
+
export {};
|
package/dist/custody.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createAccountWithAddress } from "@turnkey/viem";
|
|
2
|
+
import { getAddress } from "viem";
|
|
3
|
+
import { daSelfDeletePolicy, sessionSigningPolicy } from "./policies.js";
|
|
4
|
+
export const TURNKEY_API_BASE_URL = "https://api.turnkey.com";
|
|
5
|
+
/** BIP-32 layout inside each agent's wallet: index 0 is the agent EOA (root), 1.. are session keys. */
|
|
6
|
+
export const ROOT_KEY_PATH = "m/44'/60'/0'/0/0";
|
|
7
|
+
export const sessionKeyPath = (index) => `m/44'/60'/0'/0/${index}`;
|
|
8
|
+
const ethAccount = (path, name) => ({
|
|
9
|
+
curve: "CURVE_SECP256K1",
|
|
10
|
+
pathFormat: "PATH_FORMAT_BIP32",
|
|
11
|
+
path,
|
|
12
|
+
addressFormat: "ADDRESS_FORMAT_ETHEREUM",
|
|
13
|
+
name,
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Backend step (parent org): the owner's own space, with the owner as the sole root user. All of the owner's
|
|
17
|
+
* agents live here as separate wallets (PRD IDN-04). Email/SMS auth and recovery are disabled so the parent org
|
|
18
|
+
* can never initiate an auth flow into it; the parent keeps read access only.
|
|
19
|
+
*/
|
|
20
|
+
export async function createOwnerSubOrg(parent, params) {
|
|
21
|
+
const res = await parent.createSubOrganization({
|
|
22
|
+
subOrganizationName: params.name,
|
|
23
|
+
rootUsers: [
|
|
24
|
+
{
|
|
25
|
+
userName: params.ownerName,
|
|
26
|
+
apiKeys: params.owner.kind === "apiKey"
|
|
27
|
+
? [{ apiKeyName: "owner", publicKey: params.owner.publicKey, curveType: "API_KEY_CURVE_P256" }]
|
|
28
|
+
: [],
|
|
29
|
+
authenticators: params.owner.kind === "passkey" ? [params.owner.authenticator] : [],
|
|
30
|
+
oauthProviders: [],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
rootQuorumThreshold: 1,
|
|
34
|
+
disableEmailRecovery: true,
|
|
35
|
+
disableEmailAuth: true,
|
|
36
|
+
disableSmsAuth: true,
|
|
37
|
+
disableOtpEmailAuth: true,
|
|
38
|
+
});
|
|
39
|
+
const ownerUserId = res.rootUserIds?.[0];
|
|
40
|
+
if (!ownerUserId)
|
|
41
|
+
throw new Error("sub-organization created without a root user");
|
|
42
|
+
return { subOrgId: res.subOrganizationId, ownerUserId };
|
|
43
|
+
}
|
|
44
|
+
/** The wallet an agent gets: root key (agent EOA) at index 0, first session key at index 1. */
|
|
45
|
+
export function agentWalletParams(params) {
|
|
46
|
+
return {
|
|
47
|
+
organizationId: params.subOrgId,
|
|
48
|
+
walletName: `agent:${params.agentId}`,
|
|
49
|
+
accounts: [ethAccount(ROOT_KEY_PATH, "root"), ethAccount(sessionKeyPath(1), "session-1")],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Owner step (confirmation page): create the agent's wallet in the owner's sub-org. */
|
|
53
|
+
export async function createAgentWallet(owner, params) {
|
|
54
|
+
const { walletId, addresses } = await owner.createWallet(agentWalletParams(params));
|
|
55
|
+
const [root, session] = addresses;
|
|
56
|
+
if (!root || !session)
|
|
57
|
+
throw new Error("wallet created without the expected accounts");
|
|
58
|
+
return { walletId, rootAddress: getAddress(root), sessionAddress: getAddress(session) };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Backend check: read the wallet back from Turnkey (parent read access) instead of trusting addresses the page
|
|
62
|
+
* reports, and require the root/session layout.
|
|
63
|
+
*/
|
|
64
|
+
export async function readAgentWallet(reader, params) {
|
|
65
|
+
const { accounts } = await reader.getWalletAccounts({ organizationId: params.subOrgId, walletId: params.walletId });
|
|
66
|
+
const at = (path) => accounts.find((a) => a.walletId === params.walletId &&
|
|
67
|
+
a.path === path &&
|
|
68
|
+
a.addressFormat === "ADDRESS_FORMAT_ETHEREUM" &&
|
|
69
|
+
a.curve === "CURVE_SECP256K1");
|
|
70
|
+
const root = at(ROOT_KEY_PATH);
|
|
71
|
+
const session = at(sessionKeyPath(1));
|
|
72
|
+
if (!root || !session)
|
|
73
|
+
throw new Error("wallet does not have the agent key layout");
|
|
74
|
+
return { walletId: params.walletId, rootAddress: getAddress(root.address), sessionAddress: getAddress(session.address) };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Owner step (PRD WAL-07 key rotation): a new session key at `sessionKeyPath(index)` in the agent's wallet. A
|
|
78
|
+
* revoked key can never be granted again (the hook keeps it REVOKED), so re-authorizing needs a fresh one.
|
|
79
|
+
*/
|
|
80
|
+
export async function addSessionKey(owner, params) {
|
|
81
|
+
const { addresses } = await owner.createWalletAccounts({
|
|
82
|
+
organizationId: params.subOrgId,
|
|
83
|
+
walletId: params.walletId,
|
|
84
|
+
accounts: [ethAccount(sessionKeyPath(params.index), `session-${params.index}`)],
|
|
85
|
+
});
|
|
86
|
+
if (!addresses[0])
|
|
87
|
+
throw new Error("session key was not created");
|
|
88
|
+
return getAddress(addresses[0]);
|
|
89
|
+
}
|
|
90
|
+
/** Backend check: the rotated session key, read back from Turnkey at the exact path. */
|
|
91
|
+
export async function readSessionKey(reader, params) {
|
|
92
|
+
const { accounts } = await reader.getWalletAccounts({ organizationId: params.subOrgId, walletId: params.walletId });
|
|
93
|
+
const key = accounts.find((a) => a.walletId === params.walletId &&
|
|
94
|
+
a.path === sessionKeyPath(params.index) &&
|
|
95
|
+
a.addressFormat === "ADDRESS_FORMAT_ETHEREUM" &&
|
|
96
|
+
a.curve === "CURVE_SECP256K1");
|
|
97
|
+
if (!key)
|
|
98
|
+
throw new Error(`no session key at ${sessionKeyPath(params.index)}`);
|
|
99
|
+
return getAddress(key.address);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Backend check: the policy the owner created must be exactly the one we asked for. A page that swapped in a
|
|
103
|
+
* broader policy (another key, no expiry, any executor) is caught here before the session goes live.
|
|
104
|
+
*/
|
|
105
|
+
export async function verifySessionPolicy(reader, params) {
|
|
106
|
+
const { policy } = await reader.getPolicy({ organizationId: params.subOrgId, policyId: params.policyId });
|
|
107
|
+
const expected = sessionSigningPolicy(params.scope);
|
|
108
|
+
const mismatched = ["effect", "consensus", "condition", "time"].filter((k) => (policy[k] ?? "") !== (expected[k] ?? ""));
|
|
109
|
+
if (mismatched.length > 0)
|
|
110
|
+
throw new Error(`session policy differs from the prepared one: ${mismatched.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Owner login: forward a whoami request stamped with the owner's passkey (or session key) to Turnkey, which
|
|
114
|
+
* verifies the stamp. Only the whoami endpoint is forwarded, always to Turnkey, whatever URL the client sent.
|
|
115
|
+
*/
|
|
116
|
+
export async function verifyOwnerStamp(signed, opts = {}) {
|
|
117
|
+
// API-key stamper: "X-Stamp"; passkey (WebAuthn) stamper: "X-Stamp-Webauthn". Header names are case-insensitive.
|
|
118
|
+
const allowedHeaders = new Set(["x-stamp", "x-stamp-webauthn"]);
|
|
119
|
+
if (!allowedHeaders.has(signed.stamp.stampHeaderName.toLowerCase()))
|
|
120
|
+
throw new Error("unsupported stamp header");
|
|
121
|
+
const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? TURNKEY_API_BASE_URL}/public/v1/query/whoami`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json", [signed.stamp.stampHeaderName]: signed.stamp.stampHeaderValue },
|
|
124
|
+
body: signed.body,
|
|
125
|
+
});
|
|
126
|
+
if (!res.ok)
|
|
127
|
+
throw new Error(`turnkey rejected the stamp: ${res.status} ${(await res.text().catch(() => "")).slice(0, 300)}`);
|
|
128
|
+
const who = (await res.json());
|
|
129
|
+
if (!who.organizationId || !who.userId)
|
|
130
|
+
throw new Error("unexpected whoami response");
|
|
131
|
+
return { organizationId: who.organizationId, userId: who.userId };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Owner step (runs in the owner's authenticated session): add the platform as a non-root API-key user with no
|
|
135
|
+
* permissions except deleting itself. Doing this from the owner's session means the platform is never in the
|
|
136
|
+
* root quorum, not even transiently.
|
|
137
|
+
*/
|
|
138
|
+
export async function addPlatformDelegate(owner, params) {
|
|
139
|
+
const { userIds } = await owner.createUsers({
|
|
140
|
+
organizationId: params.subOrgId,
|
|
141
|
+
users: [
|
|
142
|
+
{
|
|
143
|
+
userName: params.userName ?? "mantle-agent-platform",
|
|
144
|
+
apiKeys: [
|
|
145
|
+
{ apiKeyName: "platform", publicKey: params.platformApiPublicKey, curveType: "API_KEY_CURVE_P256" },
|
|
146
|
+
],
|
|
147
|
+
authenticators: [],
|
|
148
|
+
oauthProviders: [],
|
|
149
|
+
userTags: [],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
const daUserId = userIds[0];
|
|
154
|
+
if (!daUserId)
|
|
155
|
+
throw new Error("delegate user was not created");
|
|
156
|
+
const { policyIds } = await owner.createPolicies({
|
|
157
|
+
organizationId: params.subOrgId,
|
|
158
|
+
policies: [daSelfDeletePolicy(daUserId)],
|
|
159
|
+
});
|
|
160
|
+
return { daUserId, policyIds };
|
|
161
|
+
}
|
|
162
|
+
/** Owner step: authorize the platform to sign with one session key, scoped as in `sessionSigningPolicy`. */
|
|
163
|
+
export async function grantSessionSigning(owner, params) {
|
|
164
|
+
const { policyIds } = await owner.createPolicies({
|
|
165
|
+
organizationId: params.subOrgId,
|
|
166
|
+
policies: [sessionSigningPolicy(params.scope)],
|
|
167
|
+
});
|
|
168
|
+
const policyId = policyIds[0];
|
|
169
|
+
if (!policyId)
|
|
170
|
+
throw new Error("session policy was not created");
|
|
171
|
+
return policyId;
|
|
172
|
+
}
|
|
173
|
+
/** Owner step: same-key renewal. The new policy is created before the old one is removed, so signing never gaps. */
|
|
174
|
+
export async function renewSessionSigning(owner, params) {
|
|
175
|
+
const policyId = await grantSessionSigning(owner, params);
|
|
176
|
+
await owner.deletePolicies({ organizationId: params.subOrgId, policyIds: [params.previousPolicyId] });
|
|
177
|
+
return policyId;
|
|
178
|
+
}
|
|
179
|
+
/** Owner kill switch #2 (design 3.4): remove the platform user; this owner's agent can no longer sign. No gas. */
|
|
180
|
+
export async function removePlatformDelegate(owner, params) {
|
|
181
|
+
await owner.deleteUsers({ organizationId: params.subOrgId, userIds: [params.daUserId] });
|
|
182
|
+
}
|
|
183
|
+
/** Platform kill switch #1 (design 3.4): the platform removes itself from a sub-org. Run across all sub-orgs. */
|
|
184
|
+
export async function platformSelfRemove(delegate, params) {
|
|
185
|
+
return delegate.deleteUsers({ organizationId: params.subOrgId, userIds: [params.daUserId] });
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* PRD risk "custody keeps root": after setup, the owner must be the only root user and the platform user must
|
|
189
|
+
* exist outside the quorum, holding the platform key. Trading stays blocked until this passes.
|
|
190
|
+
*/
|
|
191
|
+
export async function verifyCustody(reader, params) {
|
|
192
|
+
const { configs } = await reader.getOrganizationConfigs({ organizationId: params.subOrgId });
|
|
193
|
+
const quorum = configs.quorum;
|
|
194
|
+
if (!quorum)
|
|
195
|
+
throw new Error("sub-organization has no root quorum");
|
|
196
|
+
if (quorum.userIds.length !== 1 || quorum.userIds[0] !== params.ownerUserId || quorum.threshold !== 1) {
|
|
197
|
+
throw new Error(`root quorum must be exactly the owner; got ${JSON.stringify(quorum)}`);
|
|
198
|
+
}
|
|
199
|
+
if (quorum.userIds.includes(params.daUserId))
|
|
200
|
+
throw new Error("platform user is in the root quorum");
|
|
201
|
+
// The delegate must hold the platform's key, not one the page made up.
|
|
202
|
+
const { user } = await reader.getUser({ organizationId: params.subOrgId, userId: params.daUserId });
|
|
203
|
+
if (!user.apiKeys.some((k) => k.credential.publicKey === params.platformApiPublicKey)) {
|
|
204
|
+
throw new Error("platform user does not hold the platform API key");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Wallet Service side: the session key, reachable only through the platform DA credentials and its policy. */
|
|
208
|
+
export function sessionSigner(params) {
|
|
209
|
+
// Turnkey matches signWith case-sensitively against the checksummed address it stores.
|
|
210
|
+
const address = getAddress(params.sessionAddress);
|
|
211
|
+
return createAccountWithAddress({
|
|
212
|
+
client: params.client,
|
|
213
|
+
organizationId: params.subOrgId,
|
|
214
|
+
signWith: address,
|
|
215
|
+
ethereumAddress: address,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Owner side: the root key (agent EOA). In production `client` is the owner's passkey session. */
|
|
219
|
+
export function rootSigner(params) {
|
|
220
|
+
const address = getAddress(params.rootAddress);
|
|
221
|
+
const account = createAccountWithAddress({
|
|
222
|
+
client: params.client,
|
|
223
|
+
organizationId: params.subOrgId,
|
|
224
|
+
signWith: address,
|
|
225
|
+
ethereumAddress: address,
|
|
226
|
+
});
|
|
227
|
+
if (!account.signAuthorization)
|
|
228
|
+
throw new Error("Turnkey account cannot sign 7702 authorizations");
|
|
229
|
+
return account;
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=custody.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"custody.js","sourceRoot":"","sources":["../src/custody.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC"}
|
package/dist/owner.d.ts
ADDED
package/dist/owner.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"owner.js","sourceRoot":"","sources":["../src/owner.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Address, type Hex } from "viem";
|
|
2
|
+
import type { v1CreatePolicyIntentV3 } from "@turnkey/sdk-types";
|
|
3
|
+
export type PolicyIntent = v1CreatePolicyIntentV3;
|
|
4
|
+
/** Everything the signing layer pins for one session. Mirrors what the account and AgentPolicyHook enforce. */
|
|
5
|
+
export type SessionSigningScope = {
|
|
6
|
+
/** Turnkey user id of the platform's delegated-access (DA) user inside the owner's sub-org. */
|
|
7
|
+
daUserId: string;
|
|
8
|
+
/** Session key address (a wallet account in the owner's sub-org). */
|
|
9
|
+
sessionAddress: Address;
|
|
10
|
+
/** Calibur keyHash of the session key. */
|
|
11
|
+
keyHash: Hex;
|
|
12
|
+
/** The agent EOA (7702-delegated account); it is the EIP-712 verifyingContract. */
|
|
13
|
+
account: Address;
|
|
14
|
+
chainId: number;
|
|
15
|
+
/** Relayer address(es) allowed as SignedBatchedCall.executor. */
|
|
16
|
+
executors: readonly Address[];
|
|
17
|
+
/** Unix seconds; the policy stops applying afterwards (Turnkey trusted server time). */
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The only thing the platform may do with a user's keys: have the session key sign a Calibur SignedBatchedCall
|
|
22
|
+
* for this account, executable only by our relayer, until the session expires.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately coarse (design: business rules live in AgentPolicyHook only). It isolates keys, so a leaked
|
|
25
|
+
* platform API key cannot sign with the root key, sign arbitrary messages or 7702 authorizations, or produce
|
|
26
|
+
* calls someone else could submit.
|
|
27
|
+
*
|
|
28
|
+
* Hex in EIP-712 conditions must be lowercase (Turnkey policy engine requirement); wallet account addresses are
|
|
29
|
+
* compared in their stored checksummed form.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sessionSigningPolicy(scope: SessionSigningScope): PolicyIntent;
|
|
32
|
+
/**
|
|
33
|
+
* Lets the DA user delete itself: the platform-wide emergency stop (no gas, immediate) and the response to a
|
|
34
|
+
* leaked platform key.
|
|
35
|
+
*/
|
|
36
|
+
export declare function daSelfDeletePolicy(daUserId: string): PolicyIntent;
|
package/dist/policies.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getAddress } from "viem";
|
|
2
|
+
const quote = (s) => `'${s}'`;
|
|
3
|
+
/**
|
|
4
|
+
* The only thing the platform may do with a user's keys: have the session key sign a Calibur SignedBatchedCall
|
|
5
|
+
* for this account, executable only by our relayer, until the session expires.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately coarse (design: business rules live in AgentPolicyHook only). It isolates keys, so a leaked
|
|
8
|
+
* platform API key cannot sign with the root key, sign arbitrary messages or 7702 authorizations, or produce
|
|
9
|
+
* calls someone else could submit.
|
|
10
|
+
*
|
|
11
|
+
* Hex in EIP-712 conditions must be lowercase (Turnkey policy engine requirement); wallet account addresses are
|
|
12
|
+
* compared in their stored checksummed form.
|
|
13
|
+
*/
|
|
14
|
+
export function sessionSigningPolicy(scope) {
|
|
15
|
+
if (scope.executors.length === 0)
|
|
16
|
+
throw new Error("at least one executor is required");
|
|
17
|
+
const executors = scope.executors.map((e) => quote(e.toLowerCase())).join(", ");
|
|
18
|
+
const condition = [
|
|
19
|
+
"activity.type == 'ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2'",
|
|
20
|
+
"activity.params.encoding == 'PAYLOAD_ENCODING_EIP712'",
|
|
21
|
+
`wallet_account.address == ${quote(getAddress(scope.sessionAddress))}`,
|
|
22
|
+
"eth.eip_712.primary_type == 'SignedBatchedCall'",
|
|
23
|
+
"eth.eip_712.domain.name == 'Calibur'",
|
|
24
|
+
"eth.eip_712.domain.version == '1.0.0'",
|
|
25
|
+
`eth.eip_712.domain.chain_id == ${scope.chainId}`,
|
|
26
|
+
`eth.eip_712.domain.verifying_contract == ${quote(scope.account.toLowerCase())}`,
|
|
27
|
+
`eth.eip_712.message['keyHash'] == ${quote(scope.keyHash.toLowerCase())}`,
|
|
28
|
+
`eth.eip_712.message['executor'] in [${executors}]`,
|
|
29
|
+
].join(" && ");
|
|
30
|
+
return {
|
|
31
|
+
policyName: `session:${scope.keyHash.toLowerCase()}`,
|
|
32
|
+
effect: "EFFECT_ALLOW",
|
|
33
|
+
consensus: `approvers.any(user, user.id == ${quote(scope.daUserId)})`,
|
|
34
|
+
condition,
|
|
35
|
+
time: `time.now < Timestamp(${quote(new Date(scope.expiresAt * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"))})`,
|
|
36
|
+
notes: `Agent session signing for ${scope.account} on chain ${scope.chainId}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Lets the DA user delete itself: the platform-wide emergency stop (no gas, immediate) and the response to a
|
|
41
|
+
* leaked platform key.
|
|
42
|
+
*/
|
|
43
|
+
export function daSelfDeletePolicy(daUserId) {
|
|
44
|
+
return {
|
|
45
|
+
policyName: "platform:self-delete",
|
|
46
|
+
effect: "EFFECT_ALLOW",
|
|
47
|
+
consensus: `approvers.any(user, user.id == ${quote(daUserId)})`,
|
|
48
|
+
condition: `activity.type == 'ACTIVITY_TYPE_DELETE_USERS' && activity.params.user_ids.count() == 1 && ${quote(daUserId)} in activity.params.user_ids`,
|
|
49
|
+
notes: "Platform delegated-access user may remove itself (emergency stop / key leak)",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=policies.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policies.js","sourceRoot":"","sources":["../src/policies.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAA0B,MAAM,MAAM,CAAC;AAsB1D,MAAM,KAAK,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;AAEtC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAA0B;IAC7D,IAAI,KAAK,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACvF,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChF,MAAM,SAAS,GAAG;QAChB,sDAAsD;QACtD,uDAAuD;QACvD,6BAA6B,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC,EAAE;QACtE,iDAAiD;QACjD,sCAAsC;QACtC,uCAAuC;QACvC,kCAAkC,KAAK,CAAC,OAAO,EAAE;QACjD,4CAA4C,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE;QAChF,qCAAqC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE;QACzE,uCAAuC,SAAS,GAAG;KACpD,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAEf,OAAO;QACL,UAAU,EAAE,WAAW,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE;QACpD,MAAM,EAAE,cAAc;QACtB,SAAS,EAAE,kCAAkC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG;QACrE,SAAS;QACT,IAAI,EAAE,wBAAwB,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,GAAG;QAChH,KAAK,EAAE,6BAA6B,KAAK,CAAC,OAAO,aAAa,KAAK,CAAC,OAAO,EAAE;KAC9E,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,QAAgB;IACjD,OAAO;QACL,UAAU,EAAE,sBAAsB;QAClC,MAAM,EAAE,cAAc;QACtB,SAAS,EAAE,kCAAkC,KAAK,CAAC,QAAQ,CAAC,GAAG;QAC/D,SAAS,EAAE,6FAA6F,KAAK,CAAC,QAAQ,CAAC,8BAA8B;QACrJ,KAAK,EAAE,8EAA8E;KACtF,CAAC;AACJ,CAAC"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ApiKeyCredentials = {
|
|
2
|
+
apiPublicKey: string;
|
|
3
|
+
apiPrivateKey: string;
|
|
4
|
+
};
|
|
5
|
+
/** Node only: a Turnkey API client authenticated with a P-256 API key (parent org, platform DA user, test owner). */
|
|
6
|
+
export declare function turnkeyClient(params: ApiKeyCredentials & {
|
|
7
|
+
organizationId: string;
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
}): import("@turnkey/sdk-server").TurnkeyApiClient;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Turnkey } from "@turnkey/sdk-server";
|
|
2
|
+
import { TURNKEY_API_BASE_URL } from "./custody.js";
|
|
3
|
+
/** Node only: a Turnkey API client authenticated with a P-256 API key (parent org, platform DA user, test owner). */
|
|
4
|
+
export function turnkeyClient(params) {
|
|
5
|
+
return new Turnkey({
|
|
6
|
+
apiBaseUrl: params.baseUrl ?? TURNKEY_API_BASE_URL,
|
|
7
|
+
apiPublicKey: params.apiPublicKey,
|
|
8
|
+
apiPrivateKey: params.apiPrivateKey,
|
|
9
|
+
defaultOrganizationId: params.organizationId,
|
|
10
|
+
}).apiClient();
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAIpD,qHAAqH;AACrH,MAAM,UAAU,aAAa,CAAC,MAAwE;IACpG,OAAO,IAAI,OAAO,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC,OAAO,IAAI,oBAAoB;QAClD,YAAY,EAAE,MAAM,CAAC,YAAY;QACjC,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,qBAAqB,EAAE,MAAM,CAAC,cAAc;KAC7C,CAAC,CAAC,SAAS,EAAE,CAAC;AACjB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mantle-agent/custody",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Turnkey custody for Mantle Agent Accounts: owner sub-orgs, agent wallets, delegated-access session signing policies and read-back verification. Browser-safe entry at /owner.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/mantle-xyz/mantle-agentic-meme-launchpad-account.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"source": "./src/index.ts",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./owner": {
|
|
20
|
+
"source": "./src/owner.ts",
|
|
21
|
+
"types": "./dist/owner.d.ts",
|
|
22
|
+
"default": "./dist/owner.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@turnkey/crypto": "^2.13.3",
|
|
39
|
+
"@turnkey/sdk-server": "^8.8.0",
|
|
40
|
+
"@turnkey/sdk-types": "^1.11.0",
|
|
41
|
+
"@turnkey/viem": "^0.14.48",
|
|
42
|
+
"viem": "^2.57.14",
|
|
43
|
+
"@mantle-agent/core": "0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@turnkey/api-key-stamper": "^0.6.18",
|
|
47
|
+
"@turnkey/http": "^6.7.0",
|
|
48
|
+
"@types/node": "^22.20.5",
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"vitest": "^5.0.5",
|
|
51
|
+
"@mantle-agent/testkit": "0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc -p tsconfig.json",
|
|
55
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
56
|
+
"test": "vitest run"
|
|
57
|
+
}
|
|
6
58
|
}
|
package/src/custody.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { createAccountWithAddress } from "@turnkey/viem";
|
|
2
|
+
import type {
|
|
3
|
+
TCreatePoliciesBody,
|
|
4
|
+
TCreateSubOrganizationBody,
|
|
5
|
+
TCreateUsersBody,
|
|
6
|
+
TCreateWalletAccountsBody,
|
|
7
|
+
TCreateWalletBody,
|
|
8
|
+
TDeletePoliciesBody,
|
|
9
|
+
TDeleteUsersBody,
|
|
10
|
+
TGetOrganizationConfigsBody,
|
|
11
|
+
TGetPolicyBody,
|
|
12
|
+
TGetUserBody,
|
|
13
|
+
TGetUsersBody,
|
|
14
|
+
TGetWalletAccountsBody,
|
|
15
|
+
v1AuthenticatorParamsV2,
|
|
16
|
+
} from "@turnkey/sdk-types";
|
|
17
|
+
import { getAddress, type Address } from "viem";
|
|
18
|
+
import type { RootSigner, Signer } from "@mantle-agent/core";
|
|
19
|
+
import { daSelfDeletePolicy, sessionSigningPolicy, type SessionSigningScope } from "./policies.js";
|
|
20
|
+
|
|
21
|
+
export const TURNKEY_API_BASE_URL = "https://api.turnkey.com";
|
|
22
|
+
|
|
23
|
+
/** BIP-32 layout inside each agent's wallet: index 0 is the agent EOA (root), 1.. are session keys. */
|
|
24
|
+
export const ROOT_KEY_PATH = "m/44'/60'/0'/0/0";
|
|
25
|
+
export const sessionKeyPath = (index: number) => `m/44'/60'/0'/0/${index}`;
|
|
26
|
+
|
|
27
|
+
const ethAccount = (path: string, name: string) => ({
|
|
28
|
+
curve: "CURVE_SECP256K1" as const,
|
|
29
|
+
pathFormat: "PATH_FORMAT_BIP32" as const,
|
|
30
|
+
path,
|
|
31
|
+
addressFormat: "ADDRESS_FORMAT_ETHEREUM" as const,
|
|
32
|
+
name,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The calls each role makes. Turnkey's server client and its browser/passkey clients share these method names,
|
|
37
|
+
* so owner flows run unchanged on the confirmation page (passkey) and in tests (API key).
|
|
38
|
+
*/
|
|
39
|
+
export type ParentApi = {
|
|
40
|
+
createSubOrganization(body: TCreateSubOrganizationBody): Promise<{
|
|
41
|
+
subOrganizationId: string;
|
|
42
|
+
wallet?: { walletId: string; addresses: string[] };
|
|
43
|
+
rootUserIds?: string[];
|
|
44
|
+
}>;
|
|
45
|
+
getOrganizationConfigs(body: TGetOrganizationConfigsBody): Promise<{
|
|
46
|
+
configs: { quorum?: { threshold: number; userIds: string[] } };
|
|
47
|
+
}>;
|
|
48
|
+
getUsers(body: TGetUsersBody): Promise<{ users: { userId: string; userName: string }[] }>;
|
|
49
|
+
getUser(body: TGetUserBody): Promise<{ user: { userId: string; apiKeys: { credential: { publicKey: string } }[] } }>;
|
|
50
|
+
getWalletAccounts(body: TGetWalletAccountsBody): Promise<{
|
|
51
|
+
accounts: { walletId: string; path: string; address: string; addressFormat: string; curve: string }[];
|
|
52
|
+
}>;
|
|
53
|
+
getPolicy(body: TGetPolicyBody): Promise<{
|
|
54
|
+
policy: { policyId: string; effect: string; consensus: string; condition: string; time?: string };
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type OwnerApi = {
|
|
59
|
+
createWallet(body: TCreateWalletBody): Promise<{ walletId: string; addresses: string[] }>;
|
|
60
|
+
createWalletAccounts(body: TCreateWalletAccountsBody): Promise<{ addresses: string[] }>;
|
|
61
|
+
createUsers(body: TCreateUsersBody): Promise<{ userIds: string[] }>;
|
|
62
|
+
createPolicies(body: TCreatePoliciesBody): Promise<{ policyIds: string[] }>;
|
|
63
|
+
deletePolicies(body: TDeletePoliciesBody): Promise<unknown>;
|
|
64
|
+
deleteUsers(body: TDeleteUsersBody): Promise<unknown>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type DelegateApi = Pick<OwnerApi, "deleteUsers">;
|
|
68
|
+
|
|
69
|
+
/** How the owner proves who they are: a passkey in production, an API key in tests and the PoC. */
|
|
70
|
+
export type OwnerCredential =
|
|
71
|
+
| { kind: "passkey"; authenticator: v1AuthenticatorParamsV2 }
|
|
72
|
+
| { kind: "apiKey"; publicKey: string };
|
|
73
|
+
|
|
74
|
+
export type OwnerSubOrg = { subOrgId: string; ownerUserId: string };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Backend step (parent org): the owner's own space, with the owner as the sole root user. All of the owner's
|
|
78
|
+
* agents live here as separate wallets (PRD IDN-04). Email/SMS auth and recovery are disabled so the parent org
|
|
79
|
+
* can never initiate an auth flow into it; the parent keeps read access only.
|
|
80
|
+
*/
|
|
81
|
+
export async function createOwnerSubOrg(
|
|
82
|
+
parent: ParentApi,
|
|
83
|
+
params: { name: string; ownerName: string; owner: OwnerCredential },
|
|
84
|
+
): Promise<OwnerSubOrg> {
|
|
85
|
+
const res = await parent.createSubOrganization({
|
|
86
|
+
subOrganizationName: params.name,
|
|
87
|
+
rootUsers: [
|
|
88
|
+
{
|
|
89
|
+
userName: params.ownerName,
|
|
90
|
+
apiKeys:
|
|
91
|
+
params.owner.kind === "apiKey"
|
|
92
|
+
? [{ apiKeyName: "owner", publicKey: params.owner.publicKey, curveType: "API_KEY_CURVE_P256" }]
|
|
93
|
+
: [],
|
|
94
|
+
authenticators: params.owner.kind === "passkey" ? [params.owner.authenticator] : [],
|
|
95
|
+
oauthProviders: [],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
rootQuorumThreshold: 1,
|
|
99
|
+
disableEmailRecovery: true,
|
|
100
|
+
disableEmailAuth: true,
|
|
101
|
+
disableSmsAuth: true,
|
|
102
|
+
disableOtpEmailAuth: true,
|
|
103
|
+
});
|
|
104
|
+
const ownerUserId = res.rootUserIds?.[0];
|
|
105
|
+
if (!ownerUserId) throw new Error("sub-organization created without a root user");
|
|
106
|
+
return { subOrgId: res.subOrganizationId, ownerUserId };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The wallet an agent gets: root key (agent EOA) at index 0, first session key at index 1. */
|
|
110
|
+
export function agentWalletParams(params: { subOrgId: string; agentId: string }): TCreateWalletBody {
|
|
111
|
+
return {
|
|
112
|
+
organizationId: params.subOrgId,
|
|
113
|
+
walletName: `agent:${params.agentId}`,
|
|
114
|
+
accounts: [ethAccount(ROOT_KEY_PATH, "root"), ethAccount(sessionKeyPath(1), "session-1")],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type AgentWallet = {
|
|
119
|
+
walletId: string;
|
|
120
|
+
/** Agent EOA: becomes the 7702 account. Controlled only by the owner. */
|
|
121
|
+
rootAddress: Address;
|
|
122
|
+
sessionAddress: Address;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Owner step (confirmation page): create the agent's wallet in the owner's sub-org. */
|
|
126
|
+
export async function createAgentWallet(
|
|
127
|
+
owner: OwnerApi,
|
|
128
|
+
params: { subOrgId: string; agentId: string },
|
|
129
|
+
): Promise<AgentWallet> {
|
|
130
|
+
const { walletId, addresses } = await owner.createWallet(agentWalletParams(params));
|
|
131
|
+
const [root, session] = addresses;
|
|
132
|
+
if (!root || !session) throw new Error("wallet created without the expected accounts");
|
|
133
|
+
return { walletId, rootAddress: getAddress(root), sessionAddress: getAddress(session) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Backend check: read the wallet back from Turnkey (parent read access) instead of trusting addresses the page
|
|
138
|
+
* reports, and require the root/session layout.
|
|
139
|
+
*/
|
|
140
|
+
export async function readAgentWallet(
|
|
141
|
+
reader: ParentApi,
|
|
142
|
+
params: { subOrgId: string; walletId: string },
|
|
143
|
+
): Promise<AgentWallet> {
|
|
144
|
+
const { accounts } = await reader.getWalletAccounts({ organizationId: params.subOrgId, walletId: params.walletId });
|
|
145
|
+
const at = (path: string) =>
|
|
146
|
+
accounts.find(
|
|
147
|
+
(a) =>
|
|
148
|
+
a.walletId === params.walletId &&
|
|
149
|
+
a.path === path &&
|
|
150
|
+
a.addressFormat === "ADDRESS_FORMAT_ETHEREUM" &&
|
|
151
|
+
a.curve === "CURVE_SECP256K1",
|
|
152
|
+
);
|
|
153
|
+
const root = at(ROOT_KEY_PATH);
|
|
154
|
+
const session = at(sessionKeyPath(1));
|
|
155
|
+
if (!root || !session) throw new Error("wallet does not have the agent key layout");
|
|
156
|
+
return { walletId: params.walletId, rootAddress: getAddress(root.address), sessionAddress: getAddress(session.address) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Owner step (PRD WAL-07 key rotation): a new session key at `sessionKeyPath(index)` in the agent's wallet. A
|
|
161
|
+
* revoked key can never be granted again (the hook keeps it REVOKED), so re-authorizing needs a fresh one.
|
|
162
|
+
*/
|
|
163
|
+
export async function addSessionKey(owner: OwnerApi, params: { subOrgId: string; walletId: string; index: number }): Promise<Address> {
|
|
164
|
+
const { addresses } = await owner.createWalletAccounts({
|
|
165
|
+
organizationId: params.subOrgId,
|
|
166
|
+
walletId: params.walletId,
|
|
167
|
+
accounts: [ethAccount(sessionKeyPath(params.index), `session-${params.index}`)],
|
|
168
|
+
});
|
|
169
|
+
if (!addresses[0]) throw new Error("session key was not created");
|
|
170
|
+
return getAddress(addresses[0]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Backend check: the rotated session key, read back from Turnkey at the exact path. */
|
|
174
|
+
export async function readSessionKey(reader: ParentApi, params: { subOrgId: string; walletId: string; index: number }): Promise<Address> {
|
|
175
|
+
const { accounts } = await reader.getWalletAccounts({ organizationId: params.subOrgId, walletId: params.walletId });
|
|
176
|
+
const key = accounts.find(
|
|
177
|
+
(a) =>
|
|
178
|
+
a.walletId === params.walletId &&
|
|
179
|
+
a.path === sessionKeyPath(params.index) &&
|
|
180
|
+
a.addressFormat === "ADDRESS_FORMAT_ETHEREUM" &&
|
|
181
|
+
a.curve === "CURVE_SECP256K1",
|
|
182
|
+
);
|
|
183
|
+
if (!key) throw new Error(`no session key at ${sessionKeyPath(params.index)}`);
|
|
184
|
+
return getAddress(key.address);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Backend check: the policy the owner created must be exactly the one we asked for. A page that swapped in a
|
|
189
|
+
* broader policy (another key, no expiry, any executor) is caught here before the session goes live.
|
|
190
|
+
*/
|
|
191
|
+
export async function verifySessionPolicy(
|
|
192
|
+
reader: ParentApi,
|
|
193
|
+
params: { subOrgId: string; policyId: string; scope: SessionSigningScope },
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const { policy } = await reader.getPolicy({ organizationId: params.subOrgId, policyId: params.policyId });
|
|
196
|
+
const expected = sessionSigningPolicy(params.scope);
|
|
197
|
+
const mismatched = (["effect", "consensus", "condition", "time"] as const).filter(
|
|
198
|
+
(k) => (policy[k] ?? "") !== (expected[k] ?? ""),
|
|
199
|
+
);
|
|
200
|
+
if (mismatched.length > 0) throw new Error(`session policy differs from the prepared one: ${mismatched.join(", ")}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A signed (stamped) Turnkey request produced on the owner's device, e.g. by TurnkeyClient.stampGetWhoami. */
|
|
204
|
+
export type SignedTurnkeyRequest = {
|
|
205
|
+
body: string;
|
|
206
|
+
url: string;
|
|
207
|
+
stamp: { stampHeaderName: string; stampHeaderValue: string };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Owner login: forward a whoami request stamped with the owner's passkey (or session key) to Turnkey, which
|
|
212
|
+
* verifies the stamp. Only the whoami endpoint is forwarded, always to Turnkey, whatever URL the client sent.
|
|
213
|
+
*/
|
|
214
|
+
export async function verifyOwnerStamp(
|
|
215
|
+
signed: SignedTurnkeyRequest,
|
|
216
|
+
opts: { baseUrl?: string; fetchImpl?: typeof fetch } = {},
|
|
217
|
+
): Promise<{ organizationId: string; userId: string }> {
|
|
218
|
+
// API-key stamper: "X-Stamp"; passkey (WebAuthn) stamper: "X-Stamp-Webauthn". Header names are case-insensitive.
|
|
219
|
+
const allowedHeaders = new Set(["x-stamp", "x-stamp-webauthn"]);
|
|
220
|
+
if (!allowedHeaders.has(signed.stamp.stampHeaderName.toLowerCase())) throw new Error("unsupported stamp header");
|
|
221
|
+
const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? TURNKEY_API_BASE_URL}/public/v1/query/whoami`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: { "content-type": "application/json", [signed.stamp.stampHeaderName]: signed.stamp.stampHeaderValue },
|
|
224
|
+
body: signed.body,
|
|
225
|
+
});
|
|
226
|
+
if (!res.ok) throw new Error(`turnkey rejected the stamp: ${res.status} ${(await res.text().catch(() => "")).slice(0, 300)}`);
|
|
227
|
+
const who = (await res.json()) as { organizationId?: string; userId?: string };
|
|
228
|
+
if (!who.organizationId || !who.userId) throw new Error("unexpected whoami response");
|
|
229
|
+
return { organizationId: who.organizationId, userId: who.userId };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Owner step (runs in the owner's authenticated session): add the platform as a non-root API-key user with no
|
|
234
|
+
* permissions except deleting itself. Doing this from the owner's session means the platform is never in the
|
|
235
|
+
* root quorum, not even transiently.
|
|
236
|
+
*/
|
|
237
|
+
export async function addPlatformDelegate(
|
|
238
|
+
owner: OwnerApi,
|
|
239
|
+
params: { subOrgId: string; platformApiPublicKey: string; userName?: string },
|
|
240
|
+
): Promise<{ daUserId: string; policyIds: string[] }> {
|
|
241
|
+
const { userIds } = await owner.createUsers({
|
|
242
|
+
organizationId: params.subOrgId,
|
|
243
|
+
users: [
|
|
244
|
+
{
|
|
245
|
+
userName: params.userName ?? "mantle-agent-platform",
|
|
246
|
+
apiKeys: [
|
|
247
|
+
{ apiKeyName: "platform", publicKey: params.platformApiPublicKey, curveType: "API_KEY_CURVE_P256" },
|
|
248
|
+
],
|
|
249
|
+
authenticators: [],
|
|
250
|
+
oauthProviders: [],
|
|
251
|
+
userTags: [],
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
const daUserId = userIds[0];
|
|
256
|
+
if (!daUserId) throw new Error("delegate user was not created");
|
|
257
|
+
const { policyIds } = await owner.createPolicies({
|
|
258
|
+
organizationId: params.subOrgId,
|
|
259
|
+
policies: [daSelfDeletePolicy(daUserId)],
|
|
260
|
+
});
|
|
261
|
+
return { daUserId, policyIds };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Owner step: authorize the platform to sign with one session key, scoped as in `sessionSigningPolicy`. */
|
|
265
|
+
export async function grantSessionSigning(
|
|
266
|
+
owner: OwnerApi,
|
|
267
|
+
params: { subOrgId: string; scope: SessionSigningScope },
|
|
268
|
+
): Promise<string> {
|
|
269
|
+
const { policyIds } = await owner.createPolicies({
|
|
270
|
+
organizationId: params.subOrgId,
|
|
271
|
+
policies: [sessionSigningPolicy(params.scope)],
|
|
272
|
+
});
|
|
273
|
+
const policyId = policyIds[0];
|
|
274
|
+
if (!policyId) throw new Error("session policy was not created");
|
|
275
|
+
return policyId;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Owner step: same-key renewal. The new policy is created before the old one is removed, so signing never gaps. */
|
|
279
|
+
export async function renewSessionSigning(
|
|
280
|
+
owner: OwnerApi,
|
|
281
|
+
params: { subOrgId: string; previousPolicyId: string; scope: SessionSigningScope },
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
const policyId = await grantSessionSigning(owner, params);
|
|
284
|
+
await owner.deletePolicies({ organizationId: params.subOrgId, policyIds: [params.previousPolicyId] });
|
|
285
|
+
return policyId;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Owner kill switch #2 (design 3.4): remove the platform user; this owner's agent can no longer sign. No gas. */
|
|
289
|
+
export async function removePlatformDelegate(owner: OwnerApi, params: { subOrgId: string; daUserId: string }) {
|
|
290
|
+
await owner.deleteUsers({ organizationId: params.subOrgId, userIds: [params.daUserId] });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Platform kill switch #1 (design 3.4): the platform removes itself from a sub-org. Run across all sub-orgs. */
|
|
294
|
+
export async function platformSelfRemove(delegate: DelegateApi, params: { subOrgId: string; daUserId: string }) {
|
|
295
|
+
return delegate.deleteUsers({ organizationId: params.subOrgId, userIds: [params.daUserId] });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* PRD risk "custody keeps root": after setup, the owner must be the only root user and the platform user must
|
|
300
|
+
* exist outside the quorum, holding the platform key. Trading stays blocked until this passes.
|
|
301
|
+
*/
|
|
302
|
+
export async function verifyCustody(
|
|
303
|
+
reader: ParentApi,
|
|
304
|
+
params: { subOrgId: string; ownerUserId: string; daUserId: string; platformApiPublicKey: string },
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
const { configs } = await reader.getOrganizationConfigs({ organizationId: params.subOrgId });
|
|
307
|
+
const quorum = configs.quorum;
|
|
308
|
+
if (!quorum) throw new Error("sub-organization has no root quorum");
|
|
309
|
+
if (quorum.userIds.length !== 1 || quorum.userIds[0] !== params.ownerUserId || quorum.threshold !== 1) {
|
|
310
|
+
throw new Error(`root quorum must be exactly the owner; got ${JSON.stringify(quorum)}`);
|
|
311
|
+
}
|
|
312
|
+
if (quorum.userIds.includes(params.daUserId)) throw new Error("platform user is in the root quorum");
|
|
313
|
+
// The delegate must hold the platform's key, not one the page made up.
|
|
314
|
+
const { user } = await reader.getUser({ organizationId: params.subOrgId, userId: params.daUserId });
|
|
315
|
+
if (!user.apiKeys.some((k) => k.credential.publicKey === params.platformApiPublicKey)) {
|
|
316
|
+
throw new Error("platform user does not hold the platform API key");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
type AnyTurnkeyClient = Parameters<typeof createAccountWithAddress>[0]["client"];
|
|
321
|
+
|
|
322
|
+
/** Wallet Service side: the session key, reachable only through the platform DA credentials and its policy. */
|
|
323
|
+
export function sessionSigner(params: { client: AnyTurnkeyClient; subOrgId: string; sessionAddress: Address }): Signer {
|
|
324
|
+
// Turnkey matches signWith case-sensitively against the checksummed address it stores.
|
|
325
|
+
const address = getAddress(params.sessionAddress);
|
|
326
|
+
return createAccountWithAddress({
|
|
327
|
+
client: params.client,
|
|
328
|
+
organizationId: params.subOrgId,
|
|
329
|
+
signWith: address,
|
|
330
|
+
ethereumAddress: address,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Owner side: the root key (agent EOA). In production `client` is the owner's passkey session. */
|
|
335
|
+
export function rootSigner(params: { client: AnyTurnkeyClient; subOrgId: string; rootAddress: Address }): RootSigner {
|
|
336
|
+
const address = getAddress(params.rootAddress);
|
|
337
|
+
const account = createAccountWithAddress({
|
|
338
|
+
client: params.client,
|
|
339
|
+
organizationId: params.subOrgId,
|
|
340
|
+
signWith: address,
|
|
341
|
+
ethereumAddress: address,
|
|
342
|
+
});
|
|
343
|
+
if (!account.signAuthorization) throw new Error("Turnkey account cannot sign 7702 authorizations");
|
|
344
|
+
return account as RootSigner;
|
|
345
|
+
}
|
package/src/index.ts
ADDED
package/src/owner.ts
ADDED
package/src/policies.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getAddress, type Address, type Hex } from "viem";
|
|
2
|
+
import type { v1CreatePolicyIntentV3 } from "@turnkey/sdk-types";
|
|
3
|
+
|
|
4
|
+
export type PolicyIntent = v1CreatePolicyIntentV3;
|
|
5
|
+
|
|
6
|
+
/** Everything the signing layer pins for one session. Mirrors what the account and AgentPolicyHook enforce. */
|
|
7
|
+
export type SessionSigningScope = {
|
|
8
|
+
/** Turnkey user id of the platform's delegated-access (DA) user inside the owner's sub-org. */
|
|
9
|
+
daUserId: string;
|
|
10
|
+
/** Session key address (a wallet account in the owner's sub-org). */
|
|
11
|
+
sessionAddress: Address;
|
|
12
|
+
/** Calibur keyHash of the session key. */
|
|
13
|
+
keyHash: Hex;
|
|
14
|
+
/** The agent EOA (7702-delegated account); it is the EIP-712 verifyingContract. */
|
|
15
|
+
account: Address;
|
|
16
|
+
chainId: number;
|
|
17
|
+
/** Relayer address(es) allowed as SignedBatchedCall.executor. */
|
|
18
|
+
executors: readonly Address[];
|
|
19
|
+
/** Unix seconds; the policy stops applying afterwards (Turnkey trusted server time). */
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const quote = (s: string) => `'${s}'`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The only thing the platform may do with a user's keys: have the session key sign a Calibur SignedBatchedCall
|
|
27
|
+
* for this account, executable only by our relayer, until the session expires.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately coarse (design: business rules live in AgentPolicyHook only). It isolates keys, so a leaked
|
|
30
|
+
* platform API key cannot sign with the root key, sign arbitrary messages or 7702 authorizations, or produce
|
|
31
|
+
* calls someone else could submit.
|
|
32
|
+
*
|
|
33
|
+
* Hex in EIP-712 conditions must be lowercase (Turnkey policy engine requirement); wallet account addresses are
|
|
34
|
+
* compared in their stored checksummed form.
|
|
35
|
+
*/
|
|
36
|
+
export function sessionSigningPolicy(scope: SessionSigningScope): PolicyIntent {
|
|
37
|
+
if (scope.executors.length === 0) throw new Error("at least one executor is required");
|
|
38
|
+
const executors = scope.executors.map((e) => quote(e.toLowerCase())).join(", ");
|
|
39
|
+
const condition = [
|
|
40
|
+
"activity.type == 'ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2'",
|
|
41
|
+
"activity.params.encoding == 'PAYLOAD_ENCODING_EIP712'",
|
|
42
|
+
`wallet_account.address == ${quote(getAddress(scope.sessionAddress))}`,
|
|
43
|
+
"eth.eip_712.primary_type == 'SignedBatchedCall'",
|
|
44
|
+
"eth.eip_712.domain.name == 'Calibur'",
|
|
45
|
+
"eth.eip_712.domain.version == '1.0.0'",
|
|
46
|
+
`eth.eip_712.domain.chain_id == ${scope.chainId}`,
|
|
47
|
+
`eth.eip_712.domain.verifying_contract == ${quote(scope.account.toLowerCase())}`,
|
|
48
|
+
`eth.eip_712.message['keyHash'] == ${quote(scope.keyHash.toLowerCase())}`,
|
|
49
|
+
`eth.eip_712.message['executor'] in [${executors}]`,
|
|
50
|
+
].join(" && ");
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
policyName: `session:${scope.keyHash.toLowerCase()}`,
|
|
54
|
+
effect: "EFFECT_ALLOW",
|
|
55
|
+
consensus: `approvers.any(user, user.id == ${quote(scope.daUserId)})`,
|
|
56
|
+
condition,
|
|
57
|
+
time: `time.now < Timestamp(${quote(new Date(scope.expiresAt * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"))})`,
|
|
58
|
+
notes: `Agent session signing for ${scope.account} on chain ${scope.chainId}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Lets the DA user delete itself: the platform-wide emergency stop (no gas, immediate) and the response to a
|
|
64
|
+
* leaked platform key.
|
|
65
|
+
*/
|
|
66
|
+
export function daSelfDeletePolicy(daUserId: string): PolicyIntent {
|
|
67
|
+
return {
|
|
68
|
+
policyName: "platform:self-delete",
|
|
69
|
+
effect: "EFFECT_ALLOW",
|
|
70
|
+
consensus: `approvers.any(user, user.id == ${quote(daUserId)})`,
|
|
71
|
+
condition: `activity.type == 'ACTIVITY_TYPE_DELETE_USERS' && activity.params.user_ids.count() == 1 && ${quote(daUserId)} in activity.params.user_ids`,
|
|
72
|
+
notes: "Platform delegated-access user may remove itself (emergency stop / key leak)",
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Turnkey } from "@turnkey/sdk-server";
|
|
2
|
+
import { TURNKEY_API_BASE_URL } from "./custody.js";
|
|
3
|
+
|
|
4
|
+
export type ApiKeyCredentials = { apiPublicKey: string; apiPrivateKey: string };
|
|
5
|
+
|
|
6
|
+
/** Node only: a Turnkey API client authenticated with a P-256 API key (parent org, platform DA user, test owner). */
|
|
7
|
+
export function turnkeyClient(params: ApiKeyCredentials & { organizationId: string; baseUrl?: string }) {
|
|
8
|
+
return new Turnkey({
|
|
9
|
+
apiBaseUrl: params.baseUrl ?? TURNKEY_API_BASE_URL,
|
|
10
|
+
apiPublicKey: params.apiPublicKey,
|
|
11
|
+
apiPrivateKey: params.apiPrivateKey,
|
|
12
|
+
defaultOrganizationId: params.organizationId,
|
|
13
|
+
}).apiClient();
|
|
14
|
+
}
|
|
15
|
+
|