otto-intel-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/adapter/cdp-signer.d.ts +133 -0
- package/dist/adapter/cdp-signer.js +356 -0
- package/dist/adapter/cdp-signer.js.map +1 -0
- package/dist/adapter/chain.d.ts +36 -0
- package/dist/adapter/chain.js +65 -0
- package/dist/adapter/chain.js.map +1 -0
- package/dist/adapter/erc20.d.ts +39 -0
- package/dist/adapter/erc20.js +17 -0
- package/dist/adapter/erc20.js.map +1 -0
- package/dist/adapter/lifi-decode.d.ts +52 -0
- package/dist/adapter/lifi-decode.js +149 -0
- package/dist/adapter/lifi-decode.js.map +1 -0
- package/dist/adapter/refusal.d.ts +21 -0
- package/dist/adapter/refusal.js +55 -0
- package/dist/adapter/refusal.js.map +1 -0
- package/dist/adapter/sent-step.d.ts +15 -0
- package/dist/adapter/sent-step.js +6 -0
- package/dist/adapter/sent-step.js.map +1 -0
- package/dist/adapter/verify.d.ts +149 -0
- package/dist/adapter/verify.js +432 -0
- package/dist/adapter/verify.js.map +1 -0
- package/dist/adapter-cdp-index.d.ts +6 -0
- package/dist/adapter-cdp-index.js +7 -0
- package/dist/adapter-index.d.ts +15 -0
- package/dist/adapter-index.js +15 -0
- package/dist/artifact-id.d.ts +16 -0
- package/dist/artifact-id.js +60 -0
- package/dist/boot-redaction.d.ts +16 -0
- package/dist/boot-redaction.js +54 -0
- package/dist/data-source.d.ts +7 -0
- package/dist/data-source.js +2 -0
- package/dist/errors.d.ts +70 -0
- package/dist/errors.js +221 -0
- package/dist/execution-config.d.ts +232 -0
- package/dist/execution-config.js +443 -0
- package/dist/execution-delegated-definition.d.ts +165 -0
- package/dist/execution-delegated-definition.js +116 -0
- package/dist/execution-delegation-admin-definition.d.ts +208 -0
- package/dist/execution-delegation-admin-definition.js +170 -0
- package/dist/execution-delegation-admin.d.ts +74 -0
- package/dist/execution-delegation-admin.js +290 -0
- package/dist/execution-delegation-policy.d.ts +257 -0
- package/dist/execution-delegation-policy.js +279 -0
- package/dist/execution-delegation.d.ts +190 -0
- package/dist/execution-delegation.js +545 -0
- package/dist/execution-errors.d.ts +9 -0
- package/dist/execution-errors.js +128 -0
- package/dist/execution-index.d.ts +17 -0
- package/dist/execution-index.js +16 -0
- package/dist/execution-intent.d.ts +14 -0
- package/dist/execution-intent.js +36 -0
- package/dist/execution-registration.d.ts +113 -0
- package/dist/execution-registration.js +161 -0
- package/dist/execution-tool-definitions.d.ts +1103 -0
- package/dist/execution-tool-definitions.js +1051 -0
- package/dist/execution-tools.d.ts +85 -0
- package/dist/execution-tools.js +690 -0
- package/dist/execution-types.d.ts +274 -0
- package/dist/execution-types.js +157 -0
- package/dist/hyperliquid-info-client.d.ts +31 -0
- package/dist/hyperliquid-info-client.js +121 -0
- package/dist/hyperliquid-order-assertions.d.ts +89 -0
- package/dist/hyperliquid-order-assertions.js +325 -0
- package/dist/hyperliquid-order-builder.d.ts +47 -0
- package/dist/hyperliquid-order-builder.js +55 -0
- package/dist/lifi-bridge-assertions.d.ts +49 -0
- package/dist/lifi-bridge-assertions.js +303 -0
- package/dist/lifi-execution-client.d.ts +74 -0
- package/dist/lifi-execution-client.js +165 -0
- package/dist/lifi-fee-assertions.d.ts +32 -0
- package/dist/lifi-fee-assertions.js +103 -0
- package/dist/lifi-swap-assertions.d.ts +32 -0
- package/dist/lifi-swap-assertions.js +214 -0
- package/dist/local-config.d.ts +9 -0
- package/dist/local-config.js +45 -0
- package/dist/mcp-server.d.ts +4 -0
- package/dist/mcp-server.js +44 -0
- package/dist/polymarket-clob-client.d.ts +73 -0
- package/dist/polymarket-clob-client.js +186 -0
- package/dist/polymarket-order-assertions.d.ts +60 -0
- package/dist/polymarket-order-assertions.js +273 -0
- package/dist/polymarket-order-builder.d.ts +43 -0
- package/dist/polymarket-order-builder.js +74 -0
- package/dist/prepared-artifacts.d.ts +45 -0
- package/dist/prepared-artifacts.js +92 -0
- package/dist/stdio-main.d.ts +2 -0
- package/dist/stdio-main.js +20 -0
- package/dist/tool-definitions.d.ts +20 -0
- package/dist/tool-definitions.js +162 -0
- package/dist/x402-read-source.d.ts +11 -0
- package/dist/x402-read-source.js +77 -0
- package/package.json +162 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execution-delegation.ts — the delegated-send path on the hosted MCP (Model B, browser plane, PR-1).
|
|
3
|
+
*
|
|
4
|
+
* The human minted a time-boxed CDP delegation in a browser (PR-2); this is what Otto's server does for
|
|
5
|
+
* that human's agent, headlessly. It holds Otto's developer-side CDP access (never a user key), and it
|
|
6
|
+
* submits ONLY a plan the user-side verifier accepts for THIS end user, under a fence that is READ BACK
|
|
7
|
+
* before any authority is exercised. Order per call, as a server invariant:
|
|
8
|
+
*
|
|
9
|
+
* 0. caller authentication — the per-request header, compared in constant time, BEFORE anything is
|
|
10
|
+
* parsed or read (a wrong caller learns nothing and causes no CDP read);
|
|
11
|
+
* 1. the artifact was PREPARED BY THIS PROCESS (`prepared-artifacts.ts`) — else refuse; then the
|
|
12
|
+
* per-artifact idempotency record (sent → replayed, halted → never retried), then one delegated
|
|
13
|
+
* plan at a time per end user (the rig lock serialises revocation, not sends);
|
|
14
|
+
* 2. FENCE: the project policy is present, named and digests to v1 — else refuse;
|
|
15
|
+
* 3. GRANT: the user's delegation reads back active past the envelope's own expiry — else refuse;
|
|
16
|
+
* 4. ACCOUNT: the address the caller prepared for is one of THIS end user's CDP accounts, as CDP reports
|
|
17
|
+
* them — never an address the caller binds on its own (a Dynamic-connected address is never a CDP
|
|
18
|
+
* end-user address by construction);
|
|
19
|
+
* 5. VERIFY: the 13-check verifier over the envelope, for exactly the request that produced it, with the
|
|
20
|
+
* server-confirmed address; then the fence's own bound (allow-listed token, reviewed spender, cap);
|
|
21
|
+
* 6. SEND: `CdpDelegatedSigner.sendPlan` — sealed receipts, halt clears the allowance, fail-closed once
|
|
22
|
+
* revocation was requested.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here logs the header, the secret, or a credential. Refusals are machine-readable codes; upstream
|
|
25
|
+
* error bodies are never relayed.
|
|
26
|
+
*/
|
|
27
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
28
|
+
import { getAddress } from 'viem';
|
|
29
|
+
import { CDP_CAPABILITIES, CdpDelegatedSigner, DelegatedPlanHaltedError, createCdpRig, revocationRequested, } from './adapter/cdp-signer.js';
|
|
30
|
+
import { isAdapterRefusal } from './adapter/refusal.js';
|
|
31
|
+
import { defaultAdapterPolicy, verifySwapEnvelope } from './adapter/verify.js';
|
|
32
|
+
import { DELEGATION_AUTH_HEADER, SUBMIT_UNDER_DELEGATION_INPUT_SCHEMA, } from './execution-delegated-definition.js';
|
|
33
|
+
import { assertWithinModelBFence, MODEL_B_CHAIN_ID, MODEL_B_PER_SWAP_CAP_USD, MODEL_B_POLICY_NAME_V1, readBackModelBFence, } from './execution-delegation-policy.js';
|
|
34
|
+
import { EXECUTION_REFUSAL_CODES, ExecutionRefusal, isExecutionRefusal, refuse, } from './execution-errors.js';
|
|
35
|
+
import { artifactCommitmentDigest, canonicalJson, } from './execution-types.js';
|
|
36
|
+
import { DELEGATED_SUBMISSION_GRACE_MS, preparedHere } from './prepared-artifacts.js';
|
|
37
|
+
/* ------------------------------------------------------------------ *
|
|
38
|
+
* Environment — names only; values are the Founder's hand on Railway `otto-intel-mcp`, never Vercel.
|
|
39
|
+
* The developer API key already binds the CDP project for the SERVER's own signer paths. `OTTO_CDP_PROJECT_ID`
|
|
40
|
+
* is a separate, PUBLIC identifier (a UUID, never a secret): the ONE value the public fence-status tool
|
|
41
|
+
* publishes so the agent CLI (`otto-execute delegate`) mints to Otto's project and no other. It is not part
|
|
42
|
+
* of the five-var credential gate; when it is unset the public tool simply does not register.
|
|
43
|
+
* ------------------------------------------------------------------ */
|
|
44
|
+
export const DELEGATION_ENV_NAMES = Object.freeze([
|
|
45
|
+
'CDP_API_KEY_ID',
|
|
46
|
+
'CDP_API_KEY_SECRET',
|
|
47
|
+
'CDP_WALLET_SECRET',
|
|
48
|
+
'OTTO_DELEGATION_SERVER_SECRET',
|
|
49
|
+
'BASE_RPC_URL',
|
|
50
|
+
]);
|
|
51
|
+
/** The public CDP project id the fence-status tool publishes (a UUID; PUBLIC, never a secret). */
|
|
52
|
+
export const PUBLIC_PROJECT_ID_ENV_NAME = 'OTTO_CDP_PROJECT_ID';
|
|
53
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
54
|
+
const MIN_SERVER_SECRET_LENGTH = 32;
|
|
55
|
+
/**
|
|
56
|
+
* All five absent → the delegated tool does not exist (same shape as the OTTO_EXECUTION_MCP_ENABLED
|
|
57
|
+
* switch). Any present but not all → boot refuses, naming the MISSING variables (never a value).
|
|
58
|
+
*/
|
|
59
|
+
export function loadDelegationEnv(env = process.env) {
|
|
60
|
+
const present = DELEGATION_ENV_NAMES.filter((name) => (env[name]?.trim() ?? '') !== '');
|
|
61
|
+
if (present.length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
const missing = DELEGATION_ENV_NAMES.filter((name) => !present.includes(name));
|
|
64
|
+
if (missing.length > 0) {
|
|
65
|
+
throw new Error(`delegated send is partially configured: missing ${missing.join(', ')}`);
|
|
66
|
+
}
|
|
67
|
+
const serverSecret = env.OTTO_DELEGATION_SERVER_SECRET.trim();
|
|
68
|
+
if (serverSecret.length < MIN_SERVER_SECRET_LENGTH) {
|
|
69
|
+
throw new Error(`OTTO_DELEGATION_SERVER_SECRET must be at least ${MIN_SERVER_SECRET_LENGTH} characters`);
|
|
70
|
+
}
|
|
71
|
+
const rpcUrl = env.BASE_RPC_URL.trim();
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(rpcUrl);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error('BASE_RPC_URL must be a URL');
|
|
78
|
+
}
|
|
79
|
+
if (parsed.protocol !== 'https:')
|
|
80
|
+
throw new Error('BASE_RPC_URL must use https');
|
|
81
|
+
const rawProjectId = env[PUBLIC_PROJECT_ID_ENV_NAME]?.trim() ?? '';
|
|
82
|
+
if (rawProjectId !== '' && !UUID_PATTERN.test(rawProjectId)) {
|
|
83
|
+
throw new Error(`${PUBLIC_PROJECT_ID_ENV_NAME} must be a UUID (a public CDP project id)`);
|
|
84
|
+
}
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
credentials: Object.freeze({
|
|
87
|
+
apiKeyId: env.CDP_API_KEY_ID.trim(),
|
|
88
|
+
apiKeySecret: env.CDP_API_KEY_SECRET.trim(),
|
|
89
|
+
walletSecret: env.CDP_WALLET_SECRET.trim(),
|
|
90
|
+
}),
|
|
91
|
+
serverSecret,
|
|
92
|
+
rpcUrl,
|
|
93
|
+
...(rawProjectId !== '' ? { publicProjectId: rawProjectId } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function isRejectedToken(error) {
|
|
97
|
+
const e = error;
|
|
98
|
+
return (e.statusCode === 401 ||
|
|
99
|
+
e.statusCode === 403 ||
|
|
100
|
+
e.statusCode === 404 ||
|
|
101
|
+
['unauthorized', 'invalid_token', 'not_found', 'end_user_not_found'].includes(e.errorType ?? ''));
|
|
102
|
+
}
|
|
103
|
+
function isAbsentEndUser(error) {
|
|
104
|
+
const e = error;
|
|
105
|
+
return (e.statusCode === 404 || e.errorType === 'not_found' || e.errorType === 'end_user_not_found');
|
|
106
|
+
}
|
|
107
|
+
function isAbsentDelegation(error) {
|
|
108
|
+
const e = error;
|
|
109
|
+
return (e.statusCode === 404 ||
|
|
110
|
+
['delegation_not_found', 'delegation_revoked', 'delegation_expired'].includes(e.errorType ?? ''));
|
|
111
|
+
}
|
|
112
|
+
/** Build the delegation rig over a CDP client (real or structural mock) and a Base public client. */
|
|
113
|
+
export function createDelegationRig(cdp, pub) {
|
|
114
|
+
const rig = createCdpRig({
|
|
115
|
+
pub,
|
|
116
|
+
submit: async (user, transaction) => {
|
|
117
|
+
const result = await cdp.endUser.sendEvmTransaction({
|
|
118
|
+
userId: user.userId,
|
|
119
|
+
address: user.address,
|
|
120
|
+
transaction,
|
|
121
|
+
network: 'base',
|
|
122
|
+
});
|
|
123
|
+
return result.transactionHash;
|
|
124
|
+
},
|
|
125
|
+
revoke: async (userId) => {
|
|
126
|
+
await cdp.endUser.revokeDelegationForEndUser({ userId });
|
|
127
|
+
},
|
|
128
|
+
readDelegation: async (userId) => {
|
|
129
|
+
try {
|
|
130
|
+
return await cdp.endUser.getDelegationForEndUser({ userId });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (isAbsentDelegation(error))
|
|
134
|
+
return undefined;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return Object.freeze({
|
|
140
|
+
rig,
|
|
141
|
+
async listProjectPolicies() {
|
|
142
|
+
const listed = await cdp.policies.listPolicies({ scope: 'project' });
|
|
143
|
+
return listed.policies ?? [];
|
|
144
|
+
},
|
|
145
|
+
async readEndUser(userId) {
|
|
146
|
+
let user;
|
|
147
|
+
try {
|
|
148
|
+
user = await cdp.endUser.getEndUser({ userId });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (isAbsentEndUser(error))
|
|
152
|
+
return undefined;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
return endUserRecord(user);
|
|
156
|
+
},
|
|
157
|
+
async createProjectPolicy(body) {
|
|
158
|
+
const created = await cdp.policies.createPolicy({ policy: body });
|
|
159
|
+
return created.id;
|
|
160
|
+
},
|
|
161
|
+
async updateProjectPolicy(id, body) {
|
|
162
|
+
await cdp.policies.updatePolicy({
|
|
163
|
+
id,
|
|
164
|
+
policy: { description: body.description, rules: body.rules },
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
async readProjectPolicy(id) {
|
|
168
|
+
try {
|
|
169
|
+
const policy = await cdp.policies.getPolicyById({ id });
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
id: policy.id,
|
|
172
|
+
scope: policy.scope,
|
|
173
|
+
rules: policy.rules,
|
|
174
|
+
...(policy.description !== undefined ? { description: policy.description } : {}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const e = error;
|
|
179
|
+
if (e.statusCode === 404 || e.errorType === 'not_found')
|
|
180
|
+
return undefined;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async validateAccessToken(accessToken) {
|
|
185
|
+
let user;
|
|
186
|
+
try {
|
|
187
|
+
user = await cdp.endUser.validateAccessToken({ accessToken });
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (isRejectedToken(error))
|
|
191
|
+
return undefined;
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
return endUserRecord(user);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function endUserRecord(user) {
|
|
199
|
+
const addresses = user.evmAccountObjects?.map((account) => account.address) ?? user.evmAccounts ?? [];
|
|
200
|
+
return Object.freeze({
|
|
201
|
+
userId: user.userId,
|
|
202
|
+
evmAddresses: Object.freeze(addresses.map((a) => getAddress(a))),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Pull the caller's presented secret out of the MCP request headers (case-insensitive, first value). */
|
|
206
|
+
export function callerFromHeaders(headers) {
|
|
207
|
+
if (!headers)
|
|
208
|
+
return Object.freeze({});
|
|
209
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
210
|
+
if (name.toLowerCase() !== DELEGATION_AUTH_HEADER)
|
|
211
|
+
continue;
|
|
212
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
213
|
+
return Object.freeze(first && first.length > 0 ? { presentedSecret: first } : {});
|
|
214
|
+
}
|
|
215
|
+
return Object.freeze({});
|
|
216
|
+
}
|
|
217
|
+
/** Constant-time compare over fixed-length digests, so neither length nor content leaks by timing. */
|
|
218
|
+
function secretsMatch(presented, expected) {
|
|
219
|
+
if (!expected || typeof presented !== 'string' || presented.length === 0)
|
|
220
|
+
return false;
|
|
221
|
+
const a = createHash('sha256').update(presented).digest();
|
|
222
|
+
const b = createHash('sha256').update(expected).digest();
|
|
223
|
+
return timingSafeEqual(a, b);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The caller check EVERY delegation tool runs first — constant-time, before parsing or any CDP read. One
|
|
227
|
+
* seam for the send tool and the admin tools (`execution-delegation-admin.ts`).
|
|
228
|
+
*/
|
|
229
|
+
export function assertDelegationCaller(services, caller) {
|
|
230
|
+
if (!secretsMatch(caller.presentedSecret, services.serverSecret)) {
|
|
231
|
+
refuse('DELEGATION_UNAUTHORIZED', `the ${DELEGATION_AUTH_HEADER} header is missing or not accepted`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/* ------------------------------------------------------------------ *
|
|
235
|
+
* Per-user in-flight: one delegated plan at a time per end user, per services instance.
|
|
236
|
+
* ------------------------------------------------------------------ */
|
|
237
|
+
const IN_FLIGHT = new WeakMap();
|
|
238
|
+
function acquireUser(services, userId) {
|
|
239
|
+
let users = IN_FLIGHT.get(services);
|
|
240
|
+
if (!users) {
|
|
241
|
+
users = new Set();
|
|
242
|
+
IN_FLIGHT.set(services, users);
|
|
243
|
+
}
|
|
244
|
+
if (users.has(userId)) {
|
|
245
|
+
return refuse('DELEGATION_BUSY', 'a delegated plan for this end user is already in flight; retry after it settles');
|
|
246
|
+
}
|
|
247
|
+
users.add(userId);
|
|
248
|
+
return () => {
|
|
249
|
+
users.delete(userId);
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The request identity a submission record is bound to. The commitment is RECOMPUTED from the envelope, never
|
|
254
|
+
* read from its field. Addresses are checksum-normalized before hashing — the same normalization the verifier
|
|
255
|
+
* applies — so a retry that merely re-cases an address is the SAME request (and replays), while any change the
|
|
256
|
+
* verifier would see as different (amount, token, account, floor, chain, client_ref, user) is not.
|
|
257
|
+
*/
|
|
258
|
+
function requestFingerprint(input, commitment) {
|
|
259
|
+
const prepareInput = {
|
|
260
|
+
...input.prepareInput,
|
|
261
|
+
accountProfile: {
|
|
262
|
+
...input.prepareInput.accountProfile,
|
|
263
|
+
address: getAddress(input.prepareInput.accountProfile.address),
|
|
264
|
+
},
|
|
265
|
+
fromToken: getAddress(input.prepareInput.fromToken),
|
|
266
|
+
toToken: getAddress(input.prepareInput.toToken),
|
|
267
|
+
};
|
|
268
|
+
return createHash('sha256')
|
|
269
|
+
.update(canonicalJson({ user_id: input.userId, commitment, prepare_input: prepareInput }))
|
|
270
|
+
.digest('hex');
|
|
271
|
+
}
|
|
272
|
+
const SUBMISSIONS = new WeakMap();
|
|
273
|
+
function submissionsOf(services, nowMs) {
|
|
274
|
+
let records = SUBMISSIONS.get(services);
|
|
275
|
+
if (!records) {
|
|
276
|
+
records = new Map();
|
|
277
|
+
SUBMISSIONS.set(services, records);
|
|
278
|
+
}
|
|
279
|
+
for (const [id, record] of records)
|
|
280
|
+
if (record.expiresAtMs <= nowMs)
|
|
281
|
+
records.delete(id);
|
|
282
|
+
return records;
|
|
283
|
+
}
|
|
284
|
+
/* ------------------------------------------------------------------ *
|
|
285
|
+
* Error translation — the verifier's and signer's failures as machine-readable refusals.
|
|
286
|
+
* ------------------------------------------------------------------ */
|
|
287
|
+
function isExecutionCode(code) {
|
|
288
|
+
return EXECUTION_REFUSAL_CODES.includes(code);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The cause class of a halt: the signer's STRUCTURED `cause` only (set from the CDP `errorType`, an error's
|
|
292
|
+
* class or the receipt status at the signer's catch sites). The message text — which embeds upstream
|
|
293
|
+
* text — is never consulted, so upstream text can neither leak nor select the class.
|
|
294
|
+
*/
|
|
295
|
+
export function haltCause(error) {
|
|
296
|
+
return error.cause;
|
|
297
|
+
}
|
|
298
|
+
/** Map a verifier / signer failure to the tool's refusal taxonomy; anything else is rethrown. */
|
|
299
|
+
export function toDelegationRefusal(error) {
|
|
300
|
+
if (isExecutionRefusal(error))
|
|
301
|
+
return error;
|
|
302
|
+
if (isAdapterRefusal(error)) {
|
|
303
|
+
// The adapter's message already carries `[CODE] …`; codes shared with the constructor pass through by name.
|
|
304
|
+
return isExecutionCode(error.code)
|
|
305
|
+
? new ExecutionRefusal(error.code, error.message)
|
|
306
|
+
: new ExecutionRefusal('DELEGATED_PLAN_REFUSED', error.message);
|
|
307
|
+
}
|
|
308
|
+
if (error instanceof DelegatedPlanHaltedError) {
|
|
309
|
+
// The signer's message embeds the upstream failure text; only its CAUSE CLASS leaves this server.
|
|
310
|
+
const clear = error.allowanceCleared
|
|
311
|
+
? 'allowance cleared (read-back 0)'
|
|
312
|
+
: `allowance NOT confirmed cleared${error.clearTransactionHash ? ` (clear ${error.clearTransactionHash})` : ''}`;
|
|
313
|
+
return new ExecutionRefusal('DELEGATED_PLAN_HALTED', `delegated plan halted at step ${error.failedStep} (${haltCause(error)}); ${clear}; the artifact is not retried on this server — prepare again`);
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
/* ------------------------------------------------------------------ *
|
|
318
|
+
* The tool
|
|
319
|
+
* ------------------------------------------------------------------ */
|
|
320
|
+
function parseInput(rawInput) {
|
|
321
|
+
const parsed = SUBMIT_UNDER_DELEGATION_INPUT_SCHEMA.safeParse(rawInput);
|
|
322
|
+
if (!parsed.success) {
|
|
323
|
+
const issue = parsed.error.issues[0];
|
|
324
|
+
return refuse('DELEGATION_INPUT_INVALID', `input does not satisfy the delegated-send schema: ${issue ? `${issue.path.join('.') || '(root)'}: ${issue.message}` : 'unknown issue'}`);
|
|
325
|
+
}
|
|
326
|
+
return parsed.data;
|
|
327
|
+
}
|
|
328
|
+
function verifiedResult(input, user, verified, sent, fence, expiresAt) {
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
status: 'sent',
|
|
331
|
+
user_id: user.userId,
|
|
332
|
+
address: user.address,
|
|
333
|
+
chain_id: MODEL_B_CHAIN_ID,
|
|
334
|
+
artifact_id: verified.artifact.artifact_id,
|
|
335
|
+
steps: sent.map((step) => Object.freeze({
|
|
336
|
+
index: step.index,
|
|
337
|
+
kind: step.kind,
|
|
338
|
+
to: step.to,
|
|
339
|
+
transaction_hash: step.transaction_hash,
|
|
340
|
+
status: step.status,
|
|
341
|
+
block_number: step.block_number,
|
|
342
|
+
gas_used: step.gas_used,
|
|
343
|
+
})),
|
|
344
|
+
fence: Object.freeze({
|
|
345
|
+
policy_id: fence.id,
|
|
346
|
+
policy_name: MODEL_B_POLICY_NAME_V1,
|
|
347
|
+
rules_digest: fence.rulesDigest,
|
|
348
|
+
per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
|
|
349
|
+
}),
|
|
350
|
+
delegation: Object.freeze({ expires_at: expiresAt }),
|
|
351
|
+
verification: Object.freeze({
|
|
352
|
+
checks: verified.checks.map((check) => check.id),
|
|
353
|
+
gross: verified.gross.toString(),
|
|
354
|
+
min_amount_out: verified.minAmountOut.toString(),
|
|
355
|
+
otto_fee_atomic: verified.ottoFee.toString(),
|
|
356
|
+
otto_fee_recipient: defaultAdapterPolicy(MODEL_B_CHAIN_ID).attribution.ottoRecipient,
|
|
357
|
+
fee_config_version: input.envelope.fee_attribution.config_version,
|
|
358
|
+
receiver: verified.receiver,
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Submit a prepared swap under the end user's delegation — the order in the file header is the contract.
|
|
364
|
+
* `caller` is derived from the request headers by the registration layer; it never comes from arguments.
|
|
365
|
+
*/
|
|
366
|
+
export async function submitUnderDelegation(runtime, rawInput, caller, _signal) {
|
|
367
|
+
const services = runtime.delegation;
|
|
368
|
+
if (!services)
|
|
369
|
+
return refuse('DELEGATION_UNAVAILABLE', 'delegated send is not configured on this server');
|
|
370
|
+
// 0. Caller authentication — before parsing, before any CDP read.
|
|
371
|
+
assertDelegationCaller(services, caller);
|
|
372
|
+
const input = parseInput(rawInput);
|
|
373
|
+
const nowMs = runtime.nowMs();
|
|
374
|
+
// 1a. Only an artifact THIS process prepared — same id, same whole-envelope commitment — is submittable: the
|
|
375
|
+
// idempotency record below is process-local, and a process that did not prepare the artifact cannot know
|
|
376
|
+
// whether another one already sent it (a rolling replacement runs two at once). Refused before any read;
|
|
377
|
+
// the caller prepares again. The commitment is RECOMPUTED from the presented envelope (the field it
|
|
378
|
+
// carries is never trusted), so a tampered envelope under a known id is refused here, never replayed.
|
|
379
|
+
// Checked BEFORE the record on purpose: a prepared-here entry is never evicted and expires exactly when
|
|
380
|
+
// the record does (same clock value, same window), so a record can never exist without its entry.
|
|
381
|
+
const artifactId = input.envelope.artifact_id;
|
|
382
|
+
// The strict schema has validated the shape; the digest is over whatever was presented (a malformed envelope
|
|
383
|
+
// digests to nothing this process recorded and is refused).
|
|
384
|
+
const commitment = artifactCommitmentDigest(input.envelope);
|
|
385
|
+
if (!preparedHere(services, artifactId, commitment, nowMs)) {
|
|
386
|
+
return refuse('ARTIFACT_NOT_PREPARED_HERE', 'the artifact (this id with this content) was not prepared by this server process, or its freshness window has passed; its submission record cannot exist here — prepare again');
|
|
387
|
+
}
|
|
388
|
+
// 1b. Same artifact seen before? Replay its outcome — to the SAME request only; never send twice.
|
|
389
|
+
const fingerprint = requestFingerprint(input, commitment);
|
|
390
|
+
const records = submissionsOf(services, nowMs);
|
|
391
|
+
const seen = records.get(artifactId);
|
|
392
|
+
if (seen) {
|
|
393
|
+
if (seen.fingerprint !== fingerprint) {
|
|
394
|
+
return refuse('DELEGATION_REPLAY_MISMATCH', 'this artifact was already submitted on this server with a different request (user, account or intent); nothing is re-sent — prepare again');
|
|
395
|
+
}
|
|
396
|
+
if (seen.state.kind === 'sent')
|
|
397
|
+
return Object.freeze({ ...seen.state.result, replayed: true });
|
|
398
|
+
if (seen.state.kind === 'halted')
|
|
399
|
+
throw seen.state.refusal;
|
|
400
|
+
return refuse('DELEGATION_ARTIFACT_IN_FLIGHT', 'this artifact is being submitted right now; wait for its result');
|
|
401
|
+
}
|
|
402
|
+
const release = acquireUser(services, input.userId);
|
|
403
|
+
const submission = {
|
|
404
|
+
expiresAtMs: Date.parse(input.envelope.valid_until) + DELEGATED_SUBMISSION_GRACE_MS,
|
|
405
|
+
fingerprint,
|
|
406
|
+
state: { kind: 'in_flight' },
|
|
407
|
+
};
|
|
408
|
+
records.set(artifactId, submission);
|
|
409
|
+
try {
|
|
410
|
+
// 2. FENCE before authority.
|
|
411
|
+
const fence = readBackModelBFence(await services.rig.listProjectPolicies());
|
|
412
|
+
if (!fence.present)
|
|
413
|
+
return refuse('DELEGATION_FENCE_ABSENT', `the Model-B project policy is not in force (${fence.reason}): ${fence.detail}`);
|
|
414
|
+
// 3. GRANT active past the envelope's own expiry; fail-closed once revocation was ever requested here.
|
|
415
|
+
if (revocationRequested(services.rig.rig, input.userId)) {
|
|
416
|
+
return refuse('DELEGATION_NOT_ACTIVE', 'revocation was requested for this end user on this server; nothing is submitted for them again');
|
|
417
|
+
}
|
|
418
|
+
const grant = await services.rig.rig.readDelegation(input.userId);
|
|
419
|
+
if (!grant)
|
|
420
|
+
return refuse('DELEGATION_NOT_ACTIVE', 'no active delegation reads back for this end user');
|
|
421
|
+
const expiresAtMs = Date.parse(grant.expiresAt);
|
|
422
|
+
const validUntilMs = Date.parse(input.envelope.valid_until);
|
|
423
|
+
if (!Number.isFinite(expiresAtMs) ||
|
|
424
|
+
!Number.isFinite(validUntilMs) ||
|
|
425
|
+
expiresAtMs <= validUntilMs) {
|
|
426
|
+
return refuse('DELEGATION_NOT_ACTIVE', `the delegation expires at ${grant.expiresAt}, not after the envelope's valid_until ${input.envelope.valid_until}; renew the delegation or prepare again`);
|
|
427
|
+
}
|
|
428
|
+
// 4. ACCOUNT: the caller's prepared address must be one of THIS end user's accounts as CDP reports them.
|
|
429
|
+
const record = await services.rig.readEndUser(input.userId);
|
|
430
|
+
if (!record)
|
|
431
|
+
return refuse('DELEGATION_USER_UNKNOWN', 'CDP reports no end user with this id');
|
|
432
|
+
const address = getAddress(input.prepareInput.accountProfile.address);
|
|
433
|
+
if (!record.evmAddresses.includes(address)) {
|
|
434
|
+
return refuse('ACCOUNT_BINDING_MISMATCH', "the prepared account is not one of this end user's CDP accounts");
|
|
435
|
+
}
|
|
436
|
+
const user = Object.freeze({ userId: record.userId, address });
|
|
437
|
+
// 5. VERIFY with the server-confirmed address, for exactly the request that produced the envelope.
|
|
438
|
+
let verified;
|
|
439
|
+
try {
|
|
440
|
+
verified = verifySwapEnvelope(input.envelope, {
|
|
441
|
+
profile: { kind: 'eoa', address, chainId: MODEL_B_CHAIN_ID },
|
|
442
|
+
intent: {
|
|
443
|
+
fromToken: getAddress(input.prepareInput.fromToken),
|
|
444
|
+
toToken: getAddress(input.prepareInput.toToken),
|
|
445
|
+
fromAmount: BigInt(input.prepareInput.fromAmount),
|
|
446
|
+
minAmountOut: BigInt(input.prepareInput.slippage.minAmountOut),
|
|
447
|
+
},
|
|
448
|
+
capabilities: CDP_CAPABILITIES,
|
|
449
|
+
policy: defaultAdapterPolicy(MODEL_B_CHAIN_ID),
|
|
450
|
+
nowMs: runtime.nowMs(),
|
|
451
|
+
prepareInput: input.prepareInput,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
const refusal = toDelegationRefusal(error);
|
|
456
|
+
if (refusal)
|
|
457
|
+
throw refusal;
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
assertWithinModelBFence(verified, services.fenceBound?.perSwapCapUsd, services.fenceBound?.inputTokens);
|
|
461
|
+
// 6. SEND.
|
|
462
|
+
const signer = new CdpDelegatedSigner(services.rig.rig, user, {
|
|
463
|
+
sealedWait: services.sealedWait,
|
|
464
|
+
});
|
|
465
|
+
let sent;
|
|
466
|
+
try {
|
|
467
|
+
sent = await signer.sendPlan(verified);
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
// Something may have reached the chain: this artifact is never retried on this server.
|
|
471
|
+
const refusal = toDelegationRefusal(error) ??
|
|
472
|
+
new ExecutionRefusal('DELEGATED_PLAN_HALTED', 'delegated plan halted (upstream_failure); the artifact is not retried on this server — prepare again');
|
|
473
|
+
submission.state = { kind: 'halted', refusal };
|
|
474
|
+
throw refusal;
|
|
475
|
+
}
|
|
476
|
+
const result = verifiedResult(input, user, verified, sent, fence, grant.expiresAt);
|
|
477
|
+
submission.state = { kind: 'sent', result };
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
// A refusal BEFORE any send leaves no trace: the caller may fix the request and resubmit the same artifact.
|
|
482
|
+
if (submission.state.kind === 'in_flight')
|
|
483
|
+
records.delete(artifactId);
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
release();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/* ------------------------------------------------------------------ *
|
|
491
|
+
* Boot: the real services from the environment (hosted only).
|
|
492
|
+
* ------------------------------------------------------------------ */
|
|
493
|
+
/**
|
|
494
|
+
* `@coinbase/cdp-sdk` is an OPTIONAL dependency; it is loaded here, lazily, only when delegation is
|
|
495
|
+
* configured. Configured + absent = a named boot refusal (fail-closed), never a silent tool-less start.
|
|
496
|
+
*/
|
|
497
|
+
async function loadCdpSdk() {
|
|
498
|
+
try {
|
|
499
|
+
return await import('@coinbase/cdp-sdk');
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
503
|
+
throw new Error(`delegated send is configured (${DELEGATION_ENV_NAMES.join(', ')}) but @coinbase/cdp-sdk is not installed — refusing to boot (${reason})`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Open the delegation services from a loaded environment: the SDK client over Otto's developer
|
|
508
|
+
* credentials, a Base public client whose chain id is checked once at boot, and the fenced rig.
|
|
509
|
+
* Nothing here reads a policy, a grant or a user — every call does that itself, in order.
|
|
510
|
+
*/
|
|
511
|
+
export async function openDelegationServicesFromEnv(env) {
|
|
512
|
+
const { CdpClient } = await loadCdpSdk();
|
|
513
|
+
const { createPublicClient, http } = await import('viem');
|
|
514
|
+
const { base } = await import('viem/chains');
|
|
515
|
+
const cdp = new CdpClient({
|
|
516
|
+
apiKeyId: env.credentials.apiKeyId,
|
|
517
|
+
apiKeySecret: env.credentials.apiKeySecret,
|
|
518
|
+
walletSecret: env.credentials.walletSecret,
|
|
519
|
+
});
|
|
520
|
+
const pub = createPublicClient({ chain: base, transport: http(env.rpcUrl) });
|
|
521
|
+
let chainId;
|
|
522
|
+
try {
|
|
523
|
+
chainId = await pub.getChainId();
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// FIXED text, on purpose: viem's own message embeds the RPC URL, and an API key rides in that URL's
|
|
527
|
+
// path. The upstream error is never rethrown or printed; the boot refuses with a message that carries
|
|
528
|
+
// no value at all (`hosted-main.ts` redacts URLs a second time before anything reaches stderr).
|
|
529
|
+
throw new Error('BASE_RPC_URL did not answer eth_chainId at boot; refusing to boot (upstream detail withheld)');
|
|
530
|
+
}
|
|
531
|
+
if (chainId !== MODEL_B_CHAIN_ID) {
|
|
532
|
+
throw new Error(`BASE_RPC_URL reports chainId ${chainId}, expected ${MODEL_B_CHAIN_ID} (Base mainnet)`);
|
|
533
|
+
}
|
|
534
|
+
return Object.freeze({
|
|
535
|
+
rig: createDelegationRig(cdp, pub),
|
|
536
|
+
serverSecret: env.serverSecret,
|
|
537
|
+
...(env.publicProjectId ? { publicProjectId: env.publicProjectId } : {}),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/** `loadDelegationEnv` + `openDelegationServicesFromEnv`; undefined when the five variables are all absent. */
|
|
541
|
+
export async function createDelegationServicesFromEnv(env = process.env) {
|
|
542
|
+
const loaded = loadDelegationEnv(env);
|
|
543
|
+
return loaded ? openDelegationServicesFromEnv(loaded) : undefined;
|
|
544
|
+
}
|
|
545
|
+
//# sourceMappingURL=execution-delegation.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const EXECUTION_REFUSAL_CODES: readonly ["ATTRIBUTION_MISSING", "LIFI_FEE_CAP_EXCEEDED", "ACCOUNT_BINDING_MISMATCH", "DIGEST_MISMATCH", "ARTIFACT_INVALID", "STALE_ARTIFACT", "EXECUTION_RATE_LIMITED", "EXECUTION_CONCURRENCY_FULL", "UNSUPPORTED_ACCOUNT_PROFILE", "LIFI_QUOTE_FAILED", "LIFI_QUOTE_INVALID", "UNSUPPORTED_LIFI_ROUTE", "POLYMARKET_METADATA_FAILED", "POLYMARKET_METADATA_INVALID", "UNSUPPORTED_POLYMARKET_MARKET", "POLYMARKET_ORDER_INVALID", "POLYMARKET_BUILDER_FEE_UNRULED", "HYPERLIQUID_META_FAILED", "HYPERLIQUID_META_INVALID", "UNSUPPORTED_HYPERLIQUID_MARKET", "HYPERLIQUID_ORDER_INVALID", "DELEGATION_UNAVAILABLE", "DELEGATION_UNAUTHORIZED", "DELEGATION_INPUT_INVALID", "DELEGATION_BUSY", "DELEGATION_ARTIFACT_IN_FLIGHT", "ARTIFACT_NOT_PREPARED_HERE", "PREPARED_ARTIFACT_CAPACITY", "ARTIFACT_ID_COLLISION", "DELEGATION_REPLAY_MISMATCH", "DELEGATION_FENCE_ABSENT", "DELEGATION_FENCE_FOREIGN", "DELEGATION_FENCE_AMBIGUOUS", "DELEGATION_FENCE_NEWER", "DELEGATION_TOKEN_INVALID", "DELEGATION_REVOKE_NOT_CONFIRMED", "DELEGATION_NOT_ACTIVE", "DELEGATION_USER_UNKNOWN", "DELEGATION_OUTSIDE_FENCE", "DELEGATED_PLAN_REFUSED", "DELEGATED_PLAN_HALTED", "DELEGATION_UPSTREAM_FAILED"];
|
|
2
|
+
export type ExecutionRefusalCode = (typeof EXECUTION_REFUSAL_CODES)[number];
|
|
3
|
+
/** A fail-closed, machine-readable refusal. No upstream response bodies or secrets are retained. */
|
|
4
|
+
export declare class ExecutionRefusal extends Error {
|
|
5
|
+
readonly code: ExecutionRefusalCode;
|
|
6
|
+
constructor(code: ExecutionRefusalCode, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function refuse(code: ExecutionRefusalCode, message: string): never;
|
|
9
|
+
export declare function isExecutionRefusal(error: unknown): error is ExecutionRefusal;
|