otto-intel-mcp 0.1.2 → 0.1.3
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 +4 -2
- package/dist/adapter/cdp-signer.d.ts +44 -5
- package/dist/adapter/cdp-signer.js +168 -18
- package/dist/adapter/evm-call-failure.d.ts +29 -0
- package/dist/adapter/evm-call-failure.js +109 -0
- package/dist/adapter/refusal.d.ts +1 -1
- package/dist/adapter/refusal.js +13 -0
- package/dist/adapter/route-simulator.d.ts +91 -0
- package/dist/adapter/route-simulator.js +143 -0
- package/dist/executable-equities.d.ts +55 -0
- package/dist/executable-equities.js +116 -0
- package/dist/executable-yield-markets.d.ts +261 -0
- package/dist/executable-yield-markets.js +154 -0
- package/dist/execution-delegated-definition.d.ts +1 -0
- package/dist/execution-delegated-definition.js +13 -2
- package/dist/execution-delegation-admin-definition.d.ts +126 -5
- package/dist/execution-delegation-admin-definition.js +154 -5
- package/dist/execution-delegation-admin.d.ts +48 -2
- package/dist/execution-delegation-admin.js +314 -12
- package/dist/execution-delegation-cap-store.d.ts +369 -0
- package/dist/execution-delegation-cap-store.js +1083 -0
- package/dist/execution-delegation-policy.d.ts +153 -6
- package/dist/execution-delegation-policy.js +260 -16
- package/dist/execution-delegation.d.ts +35 -0
- package/dist/execution-delegation.js +224 -9
- package/dist/execution-errors.d.ts +1 -1
- package/dist/execution-errors.js +98 -0
- package/dist/execution-index.d.ts +3 -2
- package/dist/execution-index.js +3 -2
- package/dist/execution-registration.d.ts +51 -2
- package/dist/execution-registration.js +45 -2
- package/dist/execution-tools.d.ts +44 -1
- package/dist/execution-tools.js +157 -13
- package/dist/lifi-execution-client.d.ts +6 -0
- package/dist/lifi-execution-client.js +3 -0
- package/dist/stock-buy-definition.d.ts +338 -0
- package/dist/stock-buy-definition.js +127 -0
- package/dist/stock-buy-oracle.d.ts +8 -0
- package/dist/stock-buy-oracle.js +22 -0
- package/dist/stock-buy.d.ts +17 -0
- package/dist/stock-buy.js +164 -0
- package/dist/tool-definitions.js +1 -1
- package/dist/yield-deposit-definition.d.ts +323 -0
- package/dist/yield-deposit-definition.js +121 -0
- package/dist/yield-deposit.d.ts +16 -0
- package/dist/yield-deposit.js +141 -0
- package/dist/yield-withdraw-definition.d.ts +306 -0
- package/dist/yield-withdraw-definition.js +88 -0
- package/dist/yield-withdraw.d.ts +21 -0
- package/dist/yield-withdraw.js +120 -0
- package/package.json +28 -4
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* execution-delegation-admin.ts — the
|
|
2
|
+
* execution-delegation-admin.ts — the four delegation ADMIN tools on the hosted MCP (PR-1b, seat ruling D10 = (a)).
|
|
3
3
|
*
|
|
4
4
|
* otto_delegation_fence — the ONLY writer of the Model-B project policy: ensure-by-digest + read-back.
|
|
5
5
|
* otto_delegation_status — one user's grant/fence read-back, bound to the user a CDP access token resolves to.
|
|
6
6
|
* otto_delegation_revoke — developer-side revoke with read-back, fail-closed under the rig lock.
|
|
7
|
+
* otto_delegation_cap — reserves the expiry a delegation is minted with, records the chosen per-swap
|
|
8
|
+
* cap against it, and confirms after the mint that CDP kept that exact instant.
|
|
7
9
|
*
|
|
8
10
|
* Every tool: the request header FIRST (constant-time, before parsing or any CDP read — the same seam as
|
|
9
11
|
* `otto_submit_under_delegation`), then the strict schema, then the reads. Nothing here logs a token, the
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
* support_reason }` from an operator holding the server secret; the reason is written to the structured log
|
|
45
47
|
* through the boot redactor (URLs and configured secret values never land in the log), the token never is.
|
|
46
48
|
*/
|
|
47
|
-
import { type DelegationFenceResult, type DelegationFenceStatusResult, type DelegationRevokeResult, type DelegationStatusResult } from './execution-delegation-admin-definition.js';
|
|
49
|
+
import { type DelegationCapResult, type DelegationFenceResult, type DelegationFenceStatusResult, type DelegationRevokeResult, type DelegationStatusResult } from './execution-delegation-admin-definition.js';
|
|
48
50
|
import { type DelegationCaller } from './execution-delegation.js';
|
|
49
51
|
import type { ExecutionToolRuntime } from './execution-tools.js';
|
|
50
52
|
/** Read-back after a write: the engine propagates asynchronously (the proof kit waited 3 s flat). */
|
|
@@ -61,6 +63,50 @@ export declare function ensureDelegationFence(runtime: ExecutionToolRuntime, raw
|
|
|
61
63
|
export declare function readDelegationFenceStatus(runtime: ExecutionToolRuntime): Promise<DelegationFenceStatusResult>;
|
|
62
64
|
/** `otto_delegation_status` — one user's read-back, bound to the token's user. */
|
|
63
65
|
export declare function readDelegationStatus(runtime: ExecutionToolRuntime, rawInput: unknown, caller: DelegationCaller): Promise<DelegationStatusResult>;
|
|
66
|
+
/**
|
|
67
|
+
* `otto_delegation_cap` — RESERVE the expiry a delegation will be minted with, record the chosen per-swap
|
|
68
|
+
* cap against it, and CONFIRM the mint (Founder word `delegate cap choice`, 2026-09-02; round 3, after the
|
|
69
|
+
* codex gate NO-GO'd rounds 1 and 2 and the seat ruled the collision out rather than documenting it).
|
|
70
|
+
*
|
|
71
|
+
* ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
72
|
+
* 🔴 WHY THE ORDER IS RESERVE → MINT → CONFIRM, AND NOT MINT → RECORD
|
|
73
|
+
* ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
74
|
+
* The gate's second finding is the whole reason: an expiry is the ONLY thing a send can learn about a live
|
|
75
|
+
* grant, so keying the RECORD by anything else — r1's expiry-as-key, r2's mint key — fixes the record and
|
|
76
|
+
* not the send. Its probe minted a $10 replacement carrying the SAME expiry as a standing $5,000 row and
|
|
77
|
+
* watched a plan run at $5,000. Live confirmation of exactly that, on the dev project (2026-09-02T22:17Z):
|
|
78
|
+
* CDP refuses a second mint over a live grant (409 `already_exists`), but after a user-side revoke a
|
|
79
|
+
* re-mint at the SAME instant succeeds and reads back at that identical instant. Two grants for one user
|
|
80
|
+
* CAN share an expiry.
|
|
81
|
+
*
|
|
82
|
+
* So round 3 stops trying to tell them apart and makes them impossible:
|
|
83
|
+
*
|
|
84
|
+
* RESERVE — the SERVER picks the instant. It walks back from the second the person's chosen duration
|
|
85
|
+
* lands on and takes the first whole second no durable row for this user occupies, then writes the cap
|
|
86
|
+
* against it BEFORE any grant exists. The caller cannot choose the enforcement key, which is the
|
|
87
|
+
* property that makes it unique. The ladder only ever steps EARLIER (seat ruling): a permission must
|
|
88
|
+
* never outlive the duration the person chose, and the page shows the instant it actually got.
|
|
89
|
+
*
|
|
90
|
+
* MINT — the caller passes `mint_expires_at` to `createDelegation` verbatim. It is the reserved second
|
|
91
|
+
* plus one millisecond, because CDP refuses a bare `.000Z` (401 `unauthorized` "Wallet authentication
|
|
92
|
+
* error" — isolated on one session: `.000Z` refused, `.137Z` accepted, `.000Z` refused again) and
|
|
93
|
+
* truncates the filler away, storing exactly the reserved second.
|
|
94
|
+
*
|
|
95
|
+
* CONFIRM — the server re-reads the grant from CDP and refuses unless the instant CDP reports IS the
|
|
96
|
+
* reserved one. That is the assertion the whole construction rests on: if CDP ever normalises,
|
|
97
|
+
* truncates differently, or another actor's grant is what reads back, this is where it surfaces
|
|
98
|
+
* instead of silently binding a permission to a row it was never matched to.
|
|
99
|
+
*
|
|
100
|
+
* 🔴 A CONFIRM MISMATCH REVOKES THE FRESH GRANT, AND MARKS THE USER FAIL-CLOSED ON THIS SERVER. That is a
|
|
101
|
+
* heavy consequence and it is the intended one: the live permission is not the one this cap was chosen
|
|
102
|
+
* for, and the alternative — leaving it standing — is a permission whose bound nobody can name. If CDP's
|
|
103
|
+
* echo behaviour ever changed globally, every mint would land here and every delegated send would stop,
|
|
104
|
+
* which is the correct direction for a control that bounds a person's money.
|
|
105
|
+
*
|
|
106
|
+
* 🔴 NEITHER PHASE TAKES A USER ID OR AN EXPIRY FROM THE CALLER. The identity comes from
|
|
107
|
+
* `validateAccessToken`; the reserved instant comes from the store; the live instant comes from CDP.
|
|
108
|
+
*/
|
|
109
|
+
export declare function reserveDelegationCap(runtime: ExecutionToolRuntime, rawInput: unknown, caller: DelegationCaller): Promise<DelegationCapResult>;
|
|
64
110
|
/** What the support path writes to the structured log: the user id and the REDACTED reason — never a token. */
|
|
65
111
|
export interface SupportActionSink {
|
|
66
112
|
write(entry: {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* execution-delegation-admin.ts — the
|
|
2
|
+
* execution-delegation-admin.ts — the four delegation ADMIN tools on the hosted MCP (PR-1b, seat ruling D10 = (a)).
|
|
3
3
|
*
|
|
4
4
|
* otto_delegation_fence — the ONLY writer of the Model-B project policy: ensure-by-digest + read-back.
|
|
5
5
|
* otto_delegation_status — one user's grant/fence read-back, bound to the user a CDP access token resolves to.
|
|
6
6
|
* otto_delegation_revoke — developer-side revoke with read-back, fail-closed under the rig lock.
|
|
7
|
+
* otto_delegation_cap — reserves the expiry a delegation is minted with, records the chosen per-swap
|
|
8
|
+
* cap against it, and confirms after the mint that CDP kept that exact instant.
|
|
7
9
|
*
|
|
8
10
|
* Every tool: the request header FIRST (constant-time, before parsing or any CDP read — the same seam as
|
|
9
11
|
* `otto_submit_under_delegation`), then the strict schema, then the reads. Nothing here logs a token, the
|
|
@@ -47,9 +49,10 @@
|
|
|
47
49
|
import { getAddress } from 'viem';
|
|
48
50
|
import { revocationRequested } from './adapter/cdp-signer.js';
|
|
49
51
|
import { redactBootMessage } from './boot-redaction.js';
|
|
50
|
-
import { DELEGATION_FENCE_INPUT_SCHEMA, DELEGATION_REVOKE_INPUT_SCHEMA, DELEGATION_STATUS_INPUT_SCHEMA, } from './execution-delegation-admin-definition.js';
|
|
52
|
+
import { DELEGATION_CAP_INPUT_SCHEMA, DELEGATION_FENCE_INPUT_SCHEMA, DELEGATION_REVOKE_INPUT_SCHEMA, DELEGATION_STATUS_INPUT_SCHEMA, } from './execution-delegation-admin-definition.js';
|
|
51
53
|
import { assertDelegationCaller, } from './execution-delegation.js';
|
|
52
|
-
import { MODEL_B_PER_SWAP_CAP_USD, MODEL_B_POLICY_LINEAGE_PREFIX,
|
|
54
|
+
import { boundedDelegationExpiry, MODEL_B_PER_SWAP_CAP_MIN_USD, MODEL_B_PER_SWAP_CAP_USD, MODEL_B_POLICY_LINEAGE_PREFIX, MODEL_B_POLICY_NAME, MODEL_B_POLICY_VERSION, fenceIsCurrent, modelBLineageVersion, modelBProjectPolicyBody, policyRulesDigest, readBackModelBFence, } from './execution-delegation-policy.js';
|
|
55
|
+
import { DelegationCapExpiryUnavailableError, DELEGATION_MINT_PAUSED_MESSAGE, MODEL_B_CAP_RULESET_VERSION, } from './execution-delegation-cap-store.js';
|
|
53
56
|
import { refuse } from './execution-errors.js';
|
|
54
57
|
const DEFAULT_FENCE_WAIT = { pollMs: 500, maxPolls: 20 };
|
|
55
58
|
function servicesOf(runtime) {
|
|
@@ -110,7 +113,9 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
|
|
|
110
113
|
if (project.length > 1)
|
|
111
114
|
return refuseAmbiguous(project);
|
|
112
115
|
const before = readBackModelBFence(snapshot);
|
|
113
|
-
|
|
116
|
+
// A v1 fence reads back as PRESENT (swaps and deposits keep working under it) but is not current: it
|
|
117
|
+
// is upgraded in place below — the one write the Founder's `fence v2` word triggers, by running this tool.
|
|
118
|
+
if (before.present && fenceIsCurrent(before))
|
|
114
119
|
return fenceResult('unchanged', before.id, before.rulesDigest);
|
|
115
120
|
let action;
|
|
116
121
|
let targetId;
|
|
@@ -129,8 +134,12 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
|
|
|
129
134
|
if (againProject.length > 1)
|
|
130
135
|
return refuseAmbiguous(againProject);
|
|
131
136
|
const read = readBackModelBFence(again);
|
|
132
|
-
|
|
137
|
+
// Only a CURRENT fence is "unchanged" (codex gate r1 MEDIUM): a v1 fence that appeared during the
|
|
138
|
+
// ensure is present but not current, and this path never updates — it says so and asks for a re-run.
|
|
139
|
+
if (read.present && fenceIsCurrent(read))
|
|
133
140
|
return fenceResult('unchanged', read.id, read.rulesDigest);
|
|
141
|
+
if (read.present)
|
|
142
|
+
return refuse('DELEGATION_FENCE_ABSENT', `a v${read.version} fence of our lineage appeared during the ensure (id ${read.id}); this path never updates — run the fence tool again to upgrade it in place`);
|
|
134
143
|
const appeared = againProject[0];
|
|
135
144
|
if (!appeared)
|
|
136
145
|
return refuse('DELEGATION_FENCE_ABSENT', 'the create was refused as a duplicate but no project policy reads back; run the fence tool again');
|
|
@@ -175,7 +184,7 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
|
|
|
175
184
|
if (afterProject.length > 1)
|
|
176
185
|
return refuseAmbiguous(afterProject);
|
|
177
186
|
const after = readBackModelBFence(listed);
|
|
178
|
-
if (after.present && after.id === targetId)
|
|
187
|
+
if (after.present && fenceIsCurrent(after) && after.id === targetId)
|
|
179
188
|
return fenceResult(action, after.id, after.rulesDigest);
|
|
180
189
|
last = after.present
|
|
181
190
|
? `a v-current policy reads back under id ${after.id}, not the written id ${targetId}`
|
|
@@ -183,7 +192,7 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
|
|
|
183
192
|
if (poll + 1 < maxPolls)
|
|
184
193
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
185
194
|
}
|
|
186
|
-
return refuse('DELEGATION_FENCE_ABSENT', `the fence was ${action} (id ${targetId}) but does not read back as "${
|
|
195
|
+
return refuse('DELEGATION_FENCE_ABSENT', `the fence was ${action} (id ${targetId}) but does not read back as "${MODEL_B_POLICY_NAME}" with the v${MODEL_B_POLICY_VERSION} rules digest under that id after ${maxPolls} polls (${last}); nothing is submittable until it does`);
|
|
187
196
|
});
|
|
188
197
|
}
|
|
189
198
|
function fenceResult(action, id, rulesDigest) {
|
|
@@ -191,7 +200,7 @@ function fenceResult(action, id, rulesDigest) {
|
|
|
191
200
|
status: 'present',
|
|
192
201
|
action,
|
|
193
202
|
policy_id: id,
|
|
194
|
-
policy_name:
|
|
203
|
+
policy_name: MODEL_B_POLICY_NAME,
|
|
195
204
|
rules_digest: rulesDigest,
|
|
196
205
|
per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
|
|
197
206
|
});
|
|
@@ -205,11 +214,20 @@ export async function readDelegationFenceStatus(runtime) {
|
|
|
205
214
|
const fence = readBackModelBFence(await services.rig.listProjectPolicies());
|
|
206
215
|
return Object.freeze({
|
|
207
216
|
present: fence.present,
|
|
208
|
-
|
|
217
|
+
// The name that READS BACK (v1 until the fence tool upgrades it), so the page can tell which fence stands.
|
|
218
|
+
policy_name: fence.present ? fence.name : MODEL_B_POLICY_NAME,
|
|
209
219
|
per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
|
|
220
|
+
per_swap_cap_min_usd: MODEL_B_PER_SWAP_CAP_MIN_USD,
|
|
210
221
|
// The public authority binding: published present or absent, so the agent CLI mints to THIS project
|
|
211
222
|
// (and can still bind status/revoke to it after the fence is removed). Registration requires it set.
|
|
212
223
|
...(services.publicProjectId ? { project_id: services.publicProjectId } : {}),
|
|
224
|
+
ruleset_version: MODEL_B_CAP_RULESET_VERSION,
|
|
225
|
+
/**
|
|
226
|
+
* 🔴 THE PAUSE IS PUBLISHED, so the page and the CLI can SAY it before a person signs in rather than
|
|
227
|
+
* discovering it at the reserve. False means no new permission can be created on this worker; it says
|
|
228
|
+
* nothing about permissions that already stand — those keep working, and revoke always does.
|
|
229
|
+
*/
|
|
230
|
+
mint_enabled: services.mintEnabled,
|
|
213
231
|
...(fence.present ? { rules_digest: fence.rulesDigest } : { reason: fence.reason }),
|
|
214
232
|
});
|
|
215
233
|
}
|
|
@@ -232,24 +250,261 @@ export async function readDelegationStatus(runtime, rawInput, caller) {
|
|
|
232
250
|
const input = parseWith(DELEGATION_STATUS_INPUT_SCHEMA, rawInput);
|
|
233
251
|
const user = await userFromAccessToken(services, input.accessToken);
|
|
234
252
|
const fence = readBackModelBFence(await services.rig.listProjectPolicies());
|
|
235
|
-
const requestedHere = revocationRequested(services.rig.rig, user.userId);
|
|
236
253
|
const grant = await services.rig.rig.readDelegation(user.userId);
|
|
237
254
|
const expiresAtMs = grant ? Date.parse(grant.expiresAt) : Number.NaN;
|
|
255
|
+
// Per GRANT, not per user: a revoke aimed at a permission the person no longer holds says nothing
|
|
256
|
+
// about the one they hold now.
|
|
257
|
+
const requestedHere = grant !== undefined && revocationRequested(services.rig.rig, user.userId, grant.expiresAt);
|
|
238
258
|
const active = grant !== undefined &&
|
|
239
259
|
!requestedHere &&
|
|
240
260
|
Number.isFinite(expiresAtMs) &&
|
|
241
261
|
expiresAtMs > runtime.nowMs();
|
|
262
|
+
/**
|
|
263
|
+
* 🔴 THE CAP COMES FROM THE CONFIRMED-ACTIVE RECORD, AND ONLY WHEN IT NAMES THE GRANT THAT READS BACK
|
|
264
|
+
* — exactly the check the delegated send makes, so this surface and the send can never disagree about
|
|
265
|
+
* what is in force. A reservation that was never confirmed shows NO cap, because it authorises
|
|
266
|
+
* nothing. It is OMITTED, never defaulted: absence here means the send will refuse, and a page that
|
|
267
|
+
* showed the ceiling instead would tell the reader their permission allows $5,000 when it allows
|
|
268
|
+
* nothing at all.
|
|
269
|
+
*/
|
|
270
|
+
let recordedCapUsd;
|
|
271
|
+
if (grant && services.capStore.available) {
|
|
272
|
+
try {
|
|
273
|
+
const confirmed = await services.capStore.readActive(user.userId, runtime.nowMs());
|
|
274
|
+
if (confirmed &&
|
|
275
|
+
Date.parse(confirmed.expiresAt) === expiresAtMs &&
|
|
276
|
+
confirmed.requiredVersion <= MODEL_B_CAP_RULESET_VERSION) {
|
|
277
|
+
recordedCapUsd = confirmed.perSwapCapUsd;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// A read that FAILED is not "nothing is confirmed" — the send will refuse either way, and this
|
|
282
|
+
// surface says so by omitting the field rather than inventing a number in either direction.
|
|
283
|
+
recordedCapUsd = undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
242
286
|
return Object.freeze({
|
|
243
287
|
user_id: user.userId,
|
|
244
288
|
addresses: [...user.addresses],
|
|
245
289
|
fence: Object.freeze({
|
|
246
290
|
present: fence.present,
|
|
247
|
-
policy_name:
|
|
291
|
+
policy_name: fence.present ? fence.name : MODEL_B_POLICY_NAME,
|
|
248
292
|
per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
|
|
249
293
|
...(fence.present ? { rules_digest: fence.rulesDigest } : {}),
|
|
250
294
|
}),
|
|
251
|
-
delegation: Object.freeze({
|
|
295
|
+
delegation: Object.freeze({
|
|
296
|
+
active,
|
|
297
|
+
...(grant ? { expires_at: grant.expiresAt } : {}),
|
|
298
|
+
...(recordedCapUsd !== undefined ? { per_swap_cap_usd: recordedCapUsd } : {}),
|
|
299
|
+
}),
|
|
252
300
|
revocation_requested_here: requestedHere,
|
|
301
|
+
// What this worker implements — the page and the CLI refuse to reserve or mint against an older one.
|
|
302
|
+
ruleset_version: MODEL_B_CAP_RULESET_VERSION,
|
|
303
|
+
// Whether a NEW permission can be created here at all (the staged-deploy gate). Never gates this read,
|
|
304
|
+
// and never gates revoke.
|
|
305
|
+
mint_enabled: services.mintEnabled,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* `otto_delegation_cap` — RESERVE the expiry a delegation will be minted with, record the chosen per-swap
|
|
310
|
+
* cap against it, and CONFIRM the mint (Founder word `delegate cap choice`, 2026-09-02; round 3, after the
|
|
311
|
+
* codex gate NO-GO'd rounds 1 and 2 and the seat ruled the collision out rather than documenting it).
|
|
312
|
+
*
|
|
313
|
+
* ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
314
|
+
* 🔴 WHY THE ORDER IS RESERVE → MINT → CONFIRM, AND NOT MINT → RECORD
|
|
315
|
+
* ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
316
|
+
* The gate's second finding is the whole reason: an expiry is the ONLY thing a send can learn about a live
|
|
317
|
+
* grant, so keying the RECORD by anything else — r1's expiry-as-key, r2's mint key — fixes the record and
|
|
318
|
+
* not the send. Its probe minted a $10 replacement carrying the SAME expiry as a standing $5,000 row and
|
|
319
|
+
* watched a plan run at $5,000. Live confirmation of exactly that, on the dev project (2026-09-02T22:17Z):
|
|
320
|
+
* CDP refuses a second mint over a live grant (409 `already_exists`), but after a user-side revoke a
|
|
321
|
+
* re-mint at the SAME instant succeeds and reads back at that identical instant. Two grants for one user
|
|
322
|
+
* CAN share an expiry.
|
|
323
|
+
*
|
|
324
|
+
* So round 3 stops trying to tell them apart and makes them impossible:
|
|
325
|
+
*
|
|
326
|
+
* RESERVE — the SERVER picks the instant. It walks back from the second the person's chosen duration
|
|
327
|
+
* lands on and takes the first whole second no durable row for this user occupies, then writes the cap
|
|
328
|
+
* against it BEFORE any grant exists. The caller cannot choose the enforcement key, which is the
|
|
329
|
+
* property that makes it unique. The ladder only ever steps EARLIER (seat ruling): a permission must
|
|
330
|
+
* never outlive the duration the person chose, and the page shows the instant it actually got.
|
|
331
|
+
*
|
|
332
|
+
* MINT — the caller passes `mint_expires_at` to `createDelegation` verbatim. It is the reserved second
|
|
333
|
+
* plus one millisecond, because CDP refuses a bare `.000Z` (401 `unauthorized` "Wallet authentication
|
|
334
|
+
* error" — isolated on one session: `.000Z` refused, `.137Z` accepted, `.000Z` refused again) and
|
|
335
|
+
* truncates the filler away, storing exactly the reserved second.
|
|
336
|
+
*
|
|
337
|
+
* CONFIRM — the server re-reads the grant from CDP and refuses unless the instant CDP reports IS the
|
|
338
|
+
* reserved one. That is the assertion the whole construction rests on: if CDP ever normalises,
|
|
339
|
+
* truncates differently, or another actor's grant is what reads back, this is where it surfaces
|
|
340
|
+
* instead of silently binding a permission to a row it was never matched to.
|
|
341
|
+
*
|
|
342
|
+
* 🔴 A CONFIRM MISMATCH REVOKES THE FRESH GRANT, AND MARKS THE USER FAIL-CLOSED ON THIS SERVER. That is a
|
|
343
|
+
* heavy consequence and it is the intended one: the live permission is not the one this cap was chosen
|
|
344
|
+
* for, and the alternative — leaving it standing — is a permission whose bound nobody can name. If CDP's
|
|
345
|
+
* echo behaviour ever changed globally, every mint would land here and every delegated send would stop,
|
|
346
|
+
* which is the correct direction for a control that bounds a person's money.
|
|
347
|
+
*
|
|
348
|
+
* 🔴 NEITHER PHASE TAKES A USER ID OR AN EXPIRY FROM THE CALLER. The identity comes from
|
|
349
|
+
* `validateAccessToken`; the reserved instant comes from the store; the live instant comes from CDP.
|
|
350
|
+
*/
|
|
351
|
+
export async function reserveDelegationCap(runtime, rawInput, caller) {
|
|
352
|
+
const services = servicesOf(runtime);
|
|
353
|
+
assertDelegationCaller(services, caller);
|
|
354
|
+
const input = parseWith(DELEGATION_CAP_INPUT_SCHEMA, rawInput);
|
|
355
|
+
if (!services.capStore.available) {
|
|
356
|
+
return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', `the per-swap cap cannot be recorded on this server, so nothing would enforce it (${services.capStore.unavailableReason ?? 'store unavailable'})`);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* 🔴 THE STAGED-DEPLOY GATE, ON RESERVE ONLY (gate finding, CRITICAL 2 — the first rolling deployment).
|
|
360
|
+
*
|
|
361
|
+
* With `DELEGATION_MINT_ENABLED` false — the default in code — no NEW permission can be created on
|
|
362
|
+
* this worker, so there is no cap-bearing grant a still-serving cap-unaware binary could execute at
|
|
363
|
+
* the shared ceiling. That is the fence the ruleset handshake cannot be for the FIRST rollout, because
|
|
364
|
+
* the binary that would have to honour it predates it.
|
|
365
|
+
*
|
|
366
|
+
* 🔴 AND CONFIRM IS DELIBERATELY NOT GATED. Confirm binds a permission that ALREADY EXISTS at Coinbase
|
|
367
|
+
* to the reservation it was minted for. Refusing it would leave a live grant with no record — which is
|
|
368
|
+
* fail-closed for sending, but strictly worse for the person: an unbindable permission they have to
|
|
369
|
+
* find and revoke. The flag is flipped false→true by the runbook, so the only mid-flow flip is
|
|
370
|
+
* on→off, and letting an in-flight mint finish binding is the safe half of that.
|
|
371
|
+
*
|
|
372
|
+
* Sends under permissions that already stand are untouched, and REVOKE is never gated: pausing the
|
|
373
|
+
* creation of authority must never pause its withdrawal.
|
|
374
|
+
*/
|
|
375
|
+
if (input.phase === 'reserve' && !services.mintEnabled) {
|
|
376
|
+
return refuse('DELEGATION_MINT_PAUSED', DELEGATION_MINT_PAUSED_MESSAGE);
|
|
377
|
+
}
|
|
378
|
+
const user = await userFromAccessToken(services, input.accessToken);
|
|
379
|
+
return input.phase === 'reserve'
|
|
380
|
+
? reservePhase(services, runtime, user.userId, input)
|
|
381
|
+
: confirmPhase(services, runtime, user.userId, input);
|
|
382
|
+
}
|
|
383
|
+
/** The RESERVE phase: claim a free instant, record the cap against it, hand back what to mint with. */
|
|
384
|
+
async function reservePhase(services, runtime, userId, input) {
|
|
385
|
+
const nowMs = runtime.nowMs();
|
|
386
|
+
/**
|
|
387
|
+
* The LATEST instant this permission may carry — the person's own duration, on a whole second. The
|
|
388
|
+
* ladder never returns anything after it, so an offset can only ever shorten a grant.
|
|
389
|
+
*/
|
|
390
|
+
let latest;
|
|
391
|
+
try {
|
|
392
|
+
latest = boundedDelegationExpiry(input.requestedDays, nowMs);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
return refuse('DELEGATION_INPUT_INVALID', error instanceof Error ? error.message : 'the requested expiry is not usable');
|
|
396
|
+
}
|
|
397
|
+
let reservation;
|
|
398
|
+
try {
|
|
399
|
+
reservation = await services.capStore.reserve(userId, input.mintKey, latest.expiresAt, input.perSwapCapUsd, nowMs);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
if (error instanceof DelegationCapExpiryUnavailableError) {
|
|
403
|
+
return refuse('DELEGATION_CAP_EXPIRY_UNAVAILABLE', 'no free expiry instant is available for this delegation right now; nothing is reserved — try again in a moment');
|
|
404
|
+
}
|
|
405
|
+
// The claim (or the record beside it) failed. Nothing is cached either — the store writes disk
|
|
406
|
+
// first — so the honest answer is that nothing was reserved, never a success the caller mints against.
|
|
407
|
+
return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the per-swap cap could not be written durably; nothing is reserved and no permission should be minted');
|
|
408
|
+
}
|
|
409
|
+
return capResult('reserve', reservation.record, reservation.outcome === 'reserved');
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The CONFIRM phase: the instant CDP holds must BE the instant that was reserved, and the reservation
|
|
413
|
+
* must still be confirmable. Promotes it to confirmed-active in one durable write; on a mismatch it
|
|
414
|
+
* revokes the fresh grant.
|
|
415
|
+
*/
|
|
416
|
+
async function confirmPhase(services, runtime, userId, input) {
|
|
417
|
+
const nowMs = runtime.nowMs();
|
|
418
|
+
const grant = await services.rig.rig.readDelegation(userId);
|
|
419
|
+
const grantMs = grant ? Date.parse(grant.expiresAt) : Number.NaN;
|
|
420
|
+
if (!grant ||
|
|
421
|
+
!Number.isFinite(grantMs) ||
|
|
422
|
+
grantMs <= nowMs ||
|
|
423
|
+
revocationRequested(services.rig.rig, userId, grant.expiresAt)) {
|
|
424
|
+
return refuse('DELEGATION_NOT_ACTIVE', 'no active delegation reads back for this end user, so there is nothing to confirm');
|
|
425
|
+
}
|
|
426
|
+
let outcome;
|
|
427
|
+
try {
|
|
428
|
+
outcome = await services.capStore.confirm(userId, input.mintKey, grant.expiresAt, nowMs);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the reservation for this mint could not be read or promoted, so nothing is confirmed');
|
|
432
|
+
}
|
|
433
|
+
switch (outcome.outcome) {
|
|
434
|
+
case 'confirmed':
|
|
435
|
+
case 'already_confirmed':
|
|
436
|
+
return capResult('confirm', outcome.record, outcome.outcome === 'confirmed');
|
|
437
|
+
case 'unreserved':
|
|
438
|
+
return refuse('DELEGATION_CAP_UNRECORDED', 'no per-swap cap was reserved for this mint on this server, so what the person agreed to is unknown; mint the permission again');
|
|
439
|
+
case 'not_reservable':
|
|
440
|
+
return refuse('DELEGATION_CAP_UNRECORDED', `the reservation for this mint is ${outcome.state} and can no longer be confirmed; mint the permission again`);
|
|
441
|
+
case 'superseded':
|
|
442
|
+
/**
|
|
443
|
+
* 🔴 NEVER REVIVED. This reservation WAS confirmed and a later one replaced it; promoting it again
|
|
444
|
+
* would bring a replaced permission back, and on a wider cap that is the widening this whole unit
|
|
445
|
+
* exists to prevent.
|
|
446
|
+
*/
|
|
447
|
+
return refuse('DELEGATION_CAP_SUPERSEDED', 'this permission was replaced by a later one and is not brought back; mint the permission again');
|
|
448
|
+
case 'cancelled':
|
|
449
|
+
/**
|
|
450
|
+
* 🔴 THE PERSON PRESSED STOP WHILE THIS PERMISSION WAS BEING CREATED (gate finding, r4: "Add a
|
|
451
|
+
* durable revoke generation so Stop cancels already-reserved/in-flight mints"). The fresh grant
|
|
452
|
+
* is taken away rather than left standing — a Stop that leaves a permission behind is not a Stop.
|
|
453
|
+
*/
|
|
454
|
+
return revokeOnCancel(services, userId, nowMs);
|
|
455
|
+
case 'expiry_mismatch':
|
|
456
|
+
return revokeOnMismatch(services, userId, nowMs);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* A Stop landed between this mint's reservation and its confirmation. The grant exists at Coinbase and
|
|
461
|
+
* the person has said they do not want it, so it is revoked here and nothing is ever confirmed for it.
|
|
462
|
+
*/
|
|
463
|
+
async function revokeOnCancel(services, userId, nowMs) {
|
|
464
|
+
let revoked = false;
|
|
465
|
+
try {
|
|
466
|
+
await services.rig.rig.revoke(userId);
|
|
467
|
+
revoked = true;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// Issued and not confirmed. The refusal is the same either way — nothing is confirmed here.
|
|
471
|
+
}
|
|
472
|
+
void nowMs;
|
|
473
|
+
return refuse('DELEGATION_CAP_CANCELLED', `this permission was stopped while it was being created; ${revoked ? 'it has been revoked' : 'a revoke was issued and could not be confirmed'} and nothing is confirmed for it — allow a new one if you still want it`);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* 🔴 A MISMATCH TAKES THE FRESH GRANT AWAY. The live permission is not the one this cap was reserved
|
|
477
|
+
* for — CDP stored something else, or another actor's grant is what reads back — so it is revoked rather
|
|
478
|
+
* than left standing with a bound nobody can name. The revoke is per grant: it marks only the permission
|
|
479
|
+
* that actually read back, never the person.
|
|
480
|
+
*/
|
|
481
|
+
async function revokeOnMismatch(services, userId, nowMs) {
|
|
482
|
+
let revoked = false;
|
|
483
|
+
try {
|
|
484
|
+
await services.rig.rig.revoke(userId);
|
|
485
|
+
revoked = true;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// Issued and not confirmed. The refusal is the same either way — this server acts under nothing.
|
|
489
|
+
}
|
|
490
|
+
await services.capStore
|
|
491
|
+
.readActive(userId, nowMs)
|
|
492
|
+
.then((active) => (active ? services.capStore.revoke(userId, active.expiresAt, nowMs) : false))
|
|
493
|
+
.catch(() => false);
|
|
494
|
+
return refuse('DELEGATION_CAP_GRANT_CHANGED', `the delegation that reads back is not the one this per-swap cap was reserved for; ${revoked ? 'it has been revoked' : 'a revoke was issued and could not be confirmed'} and nothing is submitted under it — mint the permission again`);
|
|
495
|
+
}
|
|
496
|
+
/** One shape for both phases, so the two answers cannot drift apart. */
|
|
497
|
+
function capResult(phase, record, wroteIt) {
|
|
498
|
+
return Object.freeze({
|
|
499
|
+
phase,
|
|
500
|
+
user_id: record.userId,
|
|
501
|
+
expires_at: record.expiresAt,
|
|
502
|
+
// The value to mint with exists only on the reserve — by confirm the mint has already happened.
|
|
503
|
+
...(phase === 'reserve' ? { mint_expires_at: record.requestedExpiresAt } : {}),
|
|
504
|
+
per_swap_cap_usd: record.perSwapCapUsd,
|
|
505
|
+
state: record.state,
|
|
506
|
+
ruleset_version: record.requiredVersion,
|
|
507
|
+
recorded: wroteIt,
|
|
253
508
|
});
|
|
254
509
|
}
|
|
255
510
|
const STDOUT_SUPPORT_SINK = {
|
|
@@ -277,6 +532,53 @@ export async function revokeDelegation(runtime, rawInput, caller, supportSink =
|
|
|
277
532
|
ts: new Date(runtime.nowMs()).toISOString(),
|
|
278
533
|
});
|
|
279
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* ══════════════════════════════════════════════════════════════════════════════════════════════
|
|
537
|
+
* 🔴 THE DURABLE RECORD IS REVOKED IN THE SAME OPERATION, AND FIRST (gate finding, round 4 CRITICAL 1)
|
|
538
|
+
* ══════════════════════════════════════════════════════════════════════════════════════════════
|
|
539
|
+
* "Revoke does not transition the durable record. The normal revoke calls only `rig.revoke()` and never
|
|
540
|
+
* `capStore.revoke()` … a new grant can inherit the revoked grant's old $5,000 cap without
|
|
541
|
+
* confirmation. This is exactly the prohibited widening direction." Its probe on the shipped fixtures:
|
|
542
|
+
* revoke → fresh process → same-expiry re-mint → `afterReportedRevoke=confirmed_active, sendStatus=sent,
|
|
543
|
+
* enforcedOldCap=5000`.
|
|
544
|
+
*
|
|
545
|
+
* The in-process revocation marker dies with the process; the RECORD does not. So the record moves
|
|
546
|
+
* first, and the CDP call happens only after it has:
|
|
547
|
+
*
|
|
548
|
+
* · the grant is read FIRST, so the record and the marker name the SAME permission;
|
|
549
|
+
* · a durable transition that THROWS means nothing is revoked at Coinbase either — the caller is told
|
|
550
|
+
* to retry, rather than being left with a removed grant and a record that still says it stands;
|
|
551
|
+
* · a transition that finds nothing to change (`false`) is NOT a failure: the record was already
|
|
552
|
+
* revoked, or this person's live grant was minted out-of-model and was never recorded here. The
|
|
553
|
+
* CDP revoke still goes out — a person must always be able to take a permission away;
|
|
554
|
+
* · if the CDP revoke then fails, the record is ALREADY revoked. That is the safe direction (this
|
|
555
|
+
* server will submit nothing under it) and a retry re-issues the CDP call.
|
|
556
|
+
*
|
|
557
|
+
* 🔴 THE EXPIRY IT CONSUMED STAYS CONSUMED. The reservation row survives as `revoked`, so no later
|
|
558
|
+
* permission minted through Otto's page or CLI can land on that instant — which is what closes the
|
|
559
|
+
* probe above: the re-minted grant gets a DIFFERENT second, matches no confirmed record, and is
|
|
560
|
+
* refused rather than inheriting anything.
|
|
561
|
+
*/
|
|
562
|
+
if (services.capStore.available) {
|
|
563
|
+
try {
|
|
564
|
+
/**
|
|
565
|
+
* 🔴 ONE TRANSACTION, BOTH WRITES (gate r5 CRITICAL: "commits the generation bump and then performs
|
|
566
|
+
* the active-record revoke in a separate transaction … the process dies or stalls before
|
|
567
|
+
* `capStore.revoke()` … a fresh worker still reads the old record as `confirmed_active`").
|
|
568
|
+
*
|
|
569
|
+
* `stop` bumps the revoke generation — EVERY Stop, including one that finds no grant, the case the
|
|
570
|
+
* round-4 gate named, so a mint already in flight is cancelled at its confirm — AND moves whatever
|
|
571
|
+
* record is confirmed-active to `revoked`, inside one `BEGIN IMMEDIATE`. A crash between the two
|
|
572
|
+
* commits neither. It takes no expiry: a Stop means Otto acts under nothing for this person, which
|
|
573
|
+
* is also what the CDP-side revoke below does to whatever grant is live, so the two halves agree
|
|
574
|
+
* without a read in between that could see a different grant than either of them acts on.
|
|
575
|
+
*/
|
|
576
|
+
await services.capStore.stop(userId, runtime.nowMs());
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return refuse('DELEGATION_REVOKE_NOT_CONFIRMED', 'the permission could not be marked revoked durably, so nothing was revoked at Coinbase either — retry');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
280
582
|
try {
|
|
281
583
|
// Serialized on the rig, the lock held through the read-back; the user is marked revocation-requested BEFORE
|
|
282
584
|
// the raw revoke, so a plan queued behind this call is refused whatever the read-back says.
|