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.
Files changed (51) hide show
  1. package/README.md +4 -2
  2. package/dist/adapter/cdp-signer.d.ts +44 -5
  3. package/dist/adapter/cdp-signer.js +168 -18
  4. package/dist/adapter/evm-call-failure.d.ts +29 -0
  5. package/dist/adapter/evm-call-failure.js +109 -0
  6. package/dist/adapter/refusal.d.ts +1 -1
  7. package/dist/adapter/refusal.js +13 -0
  8. package/dist/adapter/route-simulator.d.ts +91 -0
  9. package/dist/adapter/route-simulator.js +143 -0
  10. package/dist/executable-equities.d.ts +55 -0
  11. package/dist/executable-equities.js +116 -0
  12. package/dist/executable-yield-markets.d.ts +261 -0
  13. package/dist/executable-yield-markets.js +154 -0
  14. package/dist/execution-delegated-definition.d.ts +1 -0
  15. package/dist/execution-delegated-definition.js +13 -2
  16. package/dist/execution-delegation-admin-definition.d.ts +126 -5
  17. package/dist/execution-delegation-admin-definition.js +154 -5
  18. package/dist/execution-delegation-admin.d.ts +48 -2
  19. package/dist/execution-delegation-admin.js +314 -12
  20. package/dist/execution-delegation-cap-store.d.ts +369 -0
  21. package/dist/execution-delegation-cap-store.js +1083 -0
  22. package/dist/execution-delegation-policy.d.ts +153 -6
  23. package/dist/execution-delegation-policy.js +260 -16
  24. package/dist/execution-delegation.d.ts +35 -0
  25. package/dist/execution-delegation.js +224 -9
  26. package/dist/execution-errors.d.ts +1 -1
  27. package/dist/execution-errors.js +98 -0
  28. package/dist/execution-index.d.ts +3 -2
  29. package/dist/execution-index.js +3 -2
  30. package/dist/execution-registration.d.ts +51 -2
  31. package/dist/execution-registration.js +45 -2
  32. package/dist/execution-tools.d.ts +44 -1
  33. package/dist/execution-tools.js +157 -13
  34. package/dist/lifi-execution-client.d.ts +6 -0
  35. package/dist/lifi-execution-client.js +3 -0
  36. package/dist/stock-buy-definition.d.ts +338 -0
  37. package/dist/stock-buy-definition.js +127 -0
  38. package/dist/stock-buy-oracle.d.ts +8 -0
  39. package/dist/stock-buy-oracle.js +22 -0
  40. package/dist/stock-buy.d.ts +17 -0
  41. package/dist/stock-buy.js +164 -0
  42. package/dist/tool-definitions.js +1 -1
  43. package/dist/yield-deposit-definition.d.ts +323 -0
  44. package/dist/yield-deposit-definition.js +121 -0
  45. package/dist/yield-deposit.d.ts +16 -0
  46. package/dist/yield-deposit.js +141 -0
  47. package/dist/yield-withdraw-definition.d.ts +306 -0
  48. package/dist/yield-withdraw-definition.js +88 -0
  49. package/dist/yield-withdraw.d.ts +21 -0
  50. package/dist/yield-withdraw.js +120 -0
  51. package/package.json +28 -4
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Market intelligence and trade construction for AI agents, over MCP: SEC filings and insider flow for US stocks, tokenized equities on Robinhood Chain, Base and Polymarket market context, plus four **constructors** that build a ready-to-sign swap, bridge, Polymarket order or Hyperliquid perp order for an address you supply. Pay per call in USDC on Base; on the hosted server the first call from your network address is free.
4
4
 
5
- - Hosted: `https://mcp.ottoai.services` — 21 tools.
5
+ - Hosted: `https://mcp.ottoai.services` — 22 tools.
6
6
  - Local: `npx otto-intel-mcp` — the 10 intelligence tools, paid from a wallet you configure.
7
7
  - Docs: https://docs.useotto.xyz/acp-swarm/otto-intel-mcp
8
8
 
@@ -96,6 +96,8 @@ A constructor builds the transaction; **your wallet signs and sends it.** Give i
96
96
  | `otto_prepare_polymarket_order` | A Polymarket CTF Exchange V2 limit order as typed data, tick and size validated against the live market | Your Polygon EOA |
97
97
  | `otto_prepare_perp_order` | A Hyperliquid perp limit order (optional take-profit / stop-loss), as the L1 action plus the exact EIP-712 recipe to sign | Your Hyperliquid account |
98
98
 
99
+ On a server with a chain view (the hosted rail), a quoted swap route is also **simulated from the address that will sign it** before it is prepared: the quote's shape is asserted first, then the asserted call is run against the node with the plan's own balance and allowance overridden. A route that reverts is re-quoted with that exchange denied; a route that cannot be simulated — a token whose storage layout has not been proven on-chain, a node that ignores overrides — is **refused by name rather than prepared unproven**. Today the proven tokens are USDC on Base and the two Yield Copilot vault shares (`gtUSDCp`, `steakUSDC`).
100
+
99
101
  To verify, sign and submit an envelope from the command line or from code, use the companion [`otto-execute`](https://www.npmjs.com/package/otto-execute) (CLI + library, `npm install otto-execute`) — see the [execution seam docs](https://docs.useotto.xyz/acp-swarm/execution-seam).
100
102
 
101
103
  ### Always free
@@ -231,6 +233,6 @@ Field-by-field documentation: https://docs.useotto.xyz/acp-swarm/execution-seam.
231
233
  - Docs: https://docs.useotto.xyz/acp-swarm/otto-intel-mcp · execution seam: https://docs.useotto.xyz/acp-swarm/execution-seam
232
234
  - The full Otto x402 catalog: https://x402.ottoai.services
233
235
  - Otto AI: https://useotto.xyz · X: [@useOttoAI](https://x.com/useOttoAI)
234
- - Changelog: 0.1.2 — the hosted front door (`GET /`, `/health`) reports the live tool registry (21 tools, never a literal), the local server carries the settlement receipt like the hosted one, constructor descriptions state the freshness window, `serverInfo.version` = the package version; 0.1.1 — README rewritten for first-time users and agents, source maps removed from the package, publish gated by a privacy audit; 0.1.0 — first release (hosted + local server, execution constructors, library entries).
236
+ - Changelog: 0.1.3 — swap-shaped constructors on a server with a chain view now SIMULATE the quoted route from the signing account before preparing it (a reverting exchange tool is re-quoted and denied; an unprovable route is refused by name — see the execution constructors section), and the library entries ship the fence-v2 policy lineage (`MODEL_B_POLICY_*_V2`, `MODEL_B_POLICY_DIGEST_BY_NAME`) that the `otto-execute` CLI needs to recognise the current fence; 0.1.2 — the hosted front door (`GET /`, `/health`) reports the live tool registry (21 tools, never a literal), the local server carries the settlement receipt like the hosted one, constructor descriptions state the freshness window, `serverInfo.version` = the package version; 0.1.1 — README rewritten for first-time users and agents, source maps removed from the package, publish gated by a privacy audit; 0.1.0 — first release (hosted + local server, execution constructors, library entries).
235
237
 
236
238
  Running the hosted server yourself, or building from the monorepo: see `SETUP.md` in the repository.
@@ -27,6 +27,7 @@
27
27
  import { type Address, type Hex, type PublicClient } from 'viem';
28
28
  import { type SealedWaitOptions } from './chain.js';
29
29
  import type { SentStep } from './sent-step.js';
30
+ import { AdapterRefusal } from './refusal.js';
30
31
  import { type SigningCapability, type VerifiedSwap } from './verify.js';
31
32
  export declare const CDP_CAPABILITIES: readonly SigningCapability[];
32
33
  export declare const CHAIN_ID: 8453;
@@ -63,14 +64,21 @@ export interface CdpRawOperations {
63
64
  expiresAt: string;
64
65
  } | undefined>;
65
66
  }
66
- /** True once `rig.revoke(userId)` has been called on this rig, whatever it returned. */
67
- export declare function revocationRequested(rig: CdpRig, userId: string): boolean;
67
+ /**
68
+ * True once revocation was requested on this rig for THIS grant — the one expiring at `expiresAt`.
69
+ * A user with no live grant, or with a different one, is not marked.
70
+ */
71
+ export declare function revocationRequested(rig: CdpRig, userId: string, expiresAt: string): boolean;
68
72
  /**
69
73
  * Why a delegated plan halted — a closed vocabulary the signer sets from STRUCTURED facts only (the CDP
70
74
  * `errorType`, an error's class, the receipt status, its own checks). Message text never selects it, so
71
75
  * upstream text cannot steer the class a consumer reports.
72
76
  */
73
- export type DelegatedHaltCause = 'policy_violation' | 'delegation_not_active' | 'artifact_no_longer_signable' | 'reverted' | 'receipt_not_sealed' | 'upstream_failure';
77
+ export type DelegatedHaltCause = 'policy_violation' | 'delegation_not_active' | 'artifact_no_longer_signable' | 'reverted'
78
+ /** A later step's gas estimate reverted after an earlier step had landed; nothing more was broadcast. */
79
+ | 'would_revert'
80
+ /** The node could not estimate a later step (timeout, rate limit, transport); nothing more was broadcast. */
81
+ | 'estimate_unavailable' | 'receipt_not_sealed' | 'upstream_failure';
74
82
  /**
75
83
  * A delegated plan halted; `cause` is the structured class above, `allowanceCleared` says whether the
76
84
  * router allowance is known to be zero again. The message is for the operator; a consumer that must not
@@ -103,19 +111,50 @@ export declare function openCdpRig(credentials: CdpDeveloperCredentials, rpcUrl?
103
111
  * This IS `rig.revoke`: serialized on the rig, lock held through the read-back, fail-closed afterwards.
104
112
  */
105
113
  export declare function revokeDelegationByUserId(rig: CdpRig, userId: string): Promise<void>;
114
+ /**
115
+ * A plan step whose gas estimate REVERTED. The node evaluates nothing of Coinbase's policy — the policy
116
+ * engine answers at submission, not at `eth_estimateGas` — so a reverting estimate is a transaction that
117
+ * would revert on chain, and broadcasting it anyway would spend the user's gas and buy nothing.
118
+ *
119
+ * 🔴 THIS IS WHAT THE STATIC 400,000 FALLBACK USED TO DO (2026-09-03, the first live delegated swap):
120
+ * the estimate reverted, the step went out at 400k, reverted on chain (234k gas burned), and only then
121
+ * did the halt path clear the allowance. 400k was also below every LI.FI swap's real need (~320–580k
122
+ * used, 1.1–1.4M quoted), so even a healthy route would have reverted under it. There is no fallback
123
+ * now: an estimate that reverts is a refusal, by name.
124
+ */
125
+ export declare class DelegatedTransactionWouldRevertError extends AdapterRefusal {
126
+ readonly stepKind: string;
127
+ constructor(stepKind: string, detail: string);
128
+ }
129
+ /**
130
+ * A plan step the node could not estimate AT ALL — a timeout, a rate limit, a dropped connection. Nothing
131
+ * is proven about the transaction, so nothing is broadcast without an estimate; but it is NOT a revert:
132
+ * the caller may retry, and no route is blamed (gate r7, finding 2).
133
+ */
134
+ export declare class DelegatedTransactionEstimateUnavailableError extends AdapterRefusal {
135
+ readonly stepKind: string;
136
+ constructor(stepKind: string, detail: string);
137
+ }
138
+ /** The bounded limit the halt path's `approve(spender, 0)` uses when the node cannot estimate it. */
139
+ export declare const ALLOWANCE_CLEAR_STATIC_GAS = 80000n;
106
140
  export declare class CdpDelegatedSigner {
107
141
  #private;
108
142
  readonly capabilities: readonly SigningCapability[];
109
143
  readonly chainId: 8453;
110
144
  constructor(rig: CdpRig, user: CdpEndUser, options?: {
111
145
  readonly sealedWait?: SealedWaitOptions;
146
+ readonly beforeEachStep?: () => Promise<void>;
147
+ readonly afterBuild?: () => Promise<void>;
148
+ readonly beforeSubmit?: () => void;
149
+ readonly grantExpiresAt?: string;
112
150
  });
113
151
  get address(): Address;
114
152
  get user(): CdpEndUser;
115
153
  /**
116
154
  * `send_evm_transaction`, under delegation: only a plan `assertPlanSignableBy` accepts for THIS user,
117
- * re-checked before every step AND again immediately before each hand-off to CDP (after the nonce /
118
- * fee / gas round trips, so an artifact that expires during preparation is never submitted). The plan
155
+ * re-checked before every step — together with the caller's async `beforeEachStep`, which re-reads the
156
+ * GRANT and its recorded per-swap cap — AND again immediately before each hand-off to CDP (after the
157
+ * nonce / fee / gas round trips, so an artifact that expires during preparation is never submitted). The plan
119
158
  * halts at the first step that does not succeed; after ANY failure a bounded `approve(spender, 0)` is
120
159
  * attempted under the same policy and its real outcome — confirmed by an allowance read-back — is
121
160
  * reported. A failed reset may leave a pre-existing allowance, so step 0 is not exempt.
@@ -28,7 +28,8 @@ import { createPublicClient, encodeFunctionData, http, serializeTransaction, } f
28
28
  import { base } from 'viem/chains';
29
29
  import { SealedReceiptTimeoutError, waitForSealedReceipt } from './chain.js';
30
30
  import { ERC20_APPROVE_ABI, readAllowance } from './erc20.js';
31
- import { isAdapterRefusal } from './refusal.js';
31
+ import { AdapterRefusal, isAdapterRefusal } from './refusal.js';
32
+ import { classifyEvmCallFailure, evmCallFailureDetail } from './evm-call-failure.js';
32
33
  import { assertPlanSignableBy } from './verify.js';
33
34
  export const CDP_CAPABILITIES = Object.freeze(['send_evm_transaction']);
34
35
  export const CHAIN_ID = 8453;
@@ -36,11 +37,25 @@ export const NETWORK = 'base';
36
37
  const RAW = new WeakMap();
37
38
  /** One lifecycle lock PER RIG — shared by every signer over it and by the rig's own public `revoke`. */
38
39
  const LOCKS = new WeakMap();
39
- /** Per rig: the users whose revocation has been REQUESTED (set before the raw revoke is issued; never cleared). */
40
+ /**
41
+ * Per rig: the GRANTS whose revocation has been REQUESTED, keyed `userId\nexpiryMs`.
42
+ *
43
+ * 🔴 PER GRANT, NEVER PER USER (gate finding, HIGH — round 3). It used to be a set of user ids, added to
44
+ * before the raw revoke and never cleared. Once revoke became available in every screen state, a Stop
45
+ * press on a screen with NO grant marked the person permanently and bricked every later permission of
46
+ * theirs on that process. The key is now the grant itself, so a revoke can only ever stop the
47
+ * permission it was aimed at, and a revoke that found nothing to revoke marks nothing at all.
48
+ */
40
49
  const REVOKE_REQUESTED = new WeakMap();
41
- /** True once `rig.revoke(userId)` has been called on this rig, whatever it returned. */
42
- export function revocationRequested(rig, userId) {
43
- return REVOKE_REQUESTED.get(rig)?.has(userId) ?? false;
50
+ function grantKey(userId, expiresAt) {
51
+ return `${userId}\n${Date.parse(expiresAt)}`;
52
+ }
53
+ /**
54
+ * True once revocation was requested on this rig for THIS grant — the one expiring at `expiresAt`.
55
+ * A user with no live grant, or with a different one, is not marked.
56
+ */
57
+ export function revocationRequested(rig, userId, expiresAt) {
58
+ return REVOKE_REQUESTED.get(rig)?.has(grantKey(userId, expiresAt)) ?? false;
44
59
  }
45
60
  /** Run `operation` after everything previously queued on this rig has settled; nothing else starts until it settles. */
46
61
  function serializedOn(rig, operation) {
@@ -82,6 +97,10 @@ const ABSENT_DELEGATION_ERROR_TYPES = ['delegation_not_found', 'delegation_revok
82
97
  export function haltCauseOf(error) {
83
98
  if (error instanceof RevocationRequestedError)
84
99
  return 'delegation_not_active';
100
+ if (error instanceof DelegatedTransactionWouldRevertError)
101
+ return 'would_revert';
102
+ if (error instanceof DelegatedTransactionEstimateUnavailableError)
103
+ return 'estimate_unavailable';
85
104
  if (error instanceof SealedReceiptTimeoutError)
86
105
  return 'receipt_not_sealed';
87
106
  if (isAdapterRefusal(error))
@@ -111,13 +130,22 @@ export function createCdpRig(raw) {
111
130
  // it lands only after any in-flight plan on this rig and its allowance clear have settled, and a
112
131
  // plan queued behind it runs only after the read-back — where it is refused, because the user was
113
132
  // marked revocation-requested BEFORE the raw revoke was issued.
133
+ /**
134
+ * Revoke the grant this user holds RIGHT NOW, marking that grant — and only that grant —
135
+ * revocation-requested before the raw call, so a plan already queued behind this is refused whatever
136
+ * the read-back says. A user with NO live grant is a no-op success: there is nothing to stop, and
137
+ * marking them would take away permissions they have not been given yet.
138
+ */
114
139
  revoke: (userId) => serializedOn(rig, async () => {
115
- requested.add(userId);
140
+ const before = await raw.readDelegation(userId);
141
+ if (!before)
142
+ return;
143
+ requested.add(grantKey(userId, before.expiresAt));
116
144
  try {
117
145
  await raw.revoke(userId);
118
146
  }
119
147
  catch {
120
- // A grant that never existed (or is already gone) reads back as absent below; other failures surface there.
148
+ // A grant that is already gone reads back as absent below; other failures surface there.
121
149
  }
122
150
  const after = await raw.readDelegation(userId);
123
151
  if (after)
@@ -191,7 +219,41 @@ export async function openCdpRig(credentials, rpcUrl) {
191
219
  export function revokeDelegationByUserId(rig, userId) {
192
220
  return rig.revoke(userId);
193
221
  }
194
- async function buildUnsignedTransaction(pub, from, step) {
222
+ /**
223
+ * A plan step whose gas estimate REVERTED. The node evaluates nothing of Coinbase's policy — the policy
224
+ * engine answers at submission, not at `eth_estimateGas` — so a reverting estimate is a transaction that
225
+ * would revert on chain, and broadcasting it anyway would spend the user's gas and buy nothing.
226
+ *
227
+ * 🔴 THIS IS WHAT THE STATIC 400,000 FALLBACK USED TO DO (2026-09-03, the first live delegated swap):
228
+ * the estimate reverted, the step went out at 400k, reverted on chain (234k gas burned), and only then
229
+ * did the halt path clear the allowance. 400k was also below every LI.FI swap's real need (~320–580k
230
+ * used, 1.1–1.4M quoted), so even a healthy route would have reverted under it. There is no fallback
231
+ * now: an estimate that reverts is a refusal, by name.
232
+ */
233
+ export class DelegatedTransactionWouldRevertError extends AdapterRefusal {
234
+ stepKind;
235
+ constructor(stepKind, detail) {
236
+ super('DELEGATED_TX_WOULD_REVERT', `the ${stepKind} transaction would revert on chain (gas estimate reverted${detail ? `: ${detail}` : ''}); it was not broadcast, and no allowance was changed by this plan`);
237
+ this.stepKind = stepKind;
238
+ this.name = 'DelegatedTransactionWouldRevertError';
239
+ }
240
+ }
241
+ /**
242
+ * A plan step the node could not estimate AT ALL — a timeout, a rate limit, a dropped connection. Nothing
243
+ * is proven about the transaction, so nothing is broadcast without an estimate; but it is NOT a revert:
244
+ * the caller may retry, and no route is blamed (gate r7, finding 2).
245
+ */
246
+ export class DelegatedTransactionEstimateUnavailableError extends AdapterRefusal {
247
+ stepKind;
248
+ constructor(stepKind, detail) {
249
+ super('DELEGATED_TX_ESTIMATE_UNAVAILABLE', `the ${stepKind} transaction could not be estimated by the node (${detail || 'no detail'}); nothing was broadcast — retry`);
250
+ this.stepKind = stepKind;
251
+ this.name = 'DelegatedTransactionEstimateUnavailableError';
252
+ }
253
+ }
254
+ /** The bounded limit the halt path's `approve(spender, 0)` uses when the node cannot estimate it. */
255
+ export const ALLOWANCE_CLEAR_STATIC_GAS = 80000n;
256
+ async function buildUnsignedTransaction(pub, from, step, onEstimateFailure) {
195
257
  const [nonce, fees] = await Promise.all([
196
258
  pub.getTransactionCount({ address: from, blockTag: 'pending' }),
197
259
  pub.estimateFeesPerGas(),
@@ -200,10 +262,24 @@ async function buildUnsignedTransaction(pub, from, step) {
200
262
  try {
201
263
  gas = ((await pub.estimateGas({ account: from, to: step.to, value: step.value, data: step.data })) * 12n) / 10n;
202
264
  }
203
- catch {
204
- // An out-of-policy transaction is still a valid transaction; a node-side estimate failure must not
205
- // be confused with the policy engine refusing. Keep a generous static limit.
206
- gas = 400000n;
265
+ catch (error) {
266
+ /**
267
+ * 🔴 TWO FACTS, TWO ANSWERS (gate r7, findings 1 and 2). The node either PROVED the transaction would
268
+ * revert, or it could not answer. A proven revert is never broadcast — not a plan step, and not the
269
+ * halt path's `approve(spender, 0)` either: a clear the node has already shown to revert is a spend
270
+ * with nothing bought, and the halt reports it as NOT cleared with the hand-off. A node that could
271
+ * not answer proves nothing: a plan step is refused as retriable (no route is blamed), while the
272
+ * halt's clear — the safety action, of a fixed shape — still goes out on a bounded static limit.
273
+ */
274
+ const failure = classifyEvmCallFailure(error);
275
+ const detail = evmCallFailureDetail(error);
276
+ if (failure === 'revert') {
277
+ throw new DelegatedTransactionWouldRevertError(onEstimateFailure.kind === 'refuse' ? onEstimateFailure.stepKind : 'allowance clear', detail);
278
+ }
279
+ if (onEstimateFailure.kind === 'refuse') {
280
+ throw new DelegatedTransactionEstimateUnavailableError(onEstimateFailure.stepKind, detail);
281
+ }
282
+ gas = ALLOWANCE_CLEAR_STATIC_GAS;
207
283
  }
208
284
  return serializeTransaction({
209
285
  type: 'eip1559',
@@ -224,6 +300,36 @@ export class CdpDelegatedSigner {
224
300
  #raw;
225
301
  #user;
226
302
  #sealedWait;
303
+ /**
304
+ * An ASYNC re-check the caller runs before every step (`execution-delegation.ts` passes one that
305
+ * re-reads the grant and its recorded per-swap cap). The synchronous guards below cover revocation and
306
+ * the plan's own freshness; they cannot cover a fact that lives behind a network read, and a plan
307
+ * cleared under one permission must not finish executing under the permission that replaced it.
308
+ * It THROWS to stop the plan; the caller's refusal is halted and the allowance cleared like any other.
309
+ */
310
+ #beforeEachStep;
311
+ /**
312
+ * The expiry of the grant this plan was CLEARED AGAINST. The revocation marker is per grant now, so
313
+ * the signer has to name the grant it is asking about; without it the check would be back to "has
314
+ * anything ever been revoked for this person", which is the sticky, over-broad shape that was wrong.
315
+ */
316
+ #grantExpiresAt;
317
+ /**
318
+ * 🔴 THE LAST AUTHORITATIVE CHECK, AND IT RUNS AFTER THE TRANSACTION IS BUILT (gate finding, HIGH —
319
+ * round 3): "the final asynchronous grant/cap check still happens before transaction construction …
320
+ * only a synchronous abort check precedes raw submission. A revoke/re-mint to a narrower cap during
321
+ * transaction building therefore escapes the last authoritative check."
322
+ *
323
+ * So there are two hooks, and the split is the fix:
324
+ * `#afterBuild` — ASYNC, run once the nonce/fee/gas round trips are done. It RE-READS the grant and
325
+ * the confirmed record, so a change that landed DURING construction is seen.
326
+ * `#beforeSubmit` — SYNCHRONOUS, run immediately before the raw hand-off, asserting what that
327
+ * re-read just found. It cannot await, because awaiting is what opens the window it closes.
328
+ *
329
+ * A signer given only the sync hook keeps the old behaviour; the delegated tool passes both.
330
+ */
331
+ #afterBuild;
332
+ #beforeSubmit;
227
333
  constructor(rig, user, options = {}) {
228
334
  const raw = RAW.get(rig);
229
335
  if (!raw)
@@ -232,6 +338,10 @@ export class CdpDelegatedSigner {
232
338
  this.#raw = raw;
233
339
  this.#user = Object.freeze({ userId: user.userId, address: user.address });
234
340
  this.#sealedWait = options.sealedWait;
341
+ this.#beforeEachStep = options.beforeEachStep;
342
+ this.#afterBuild = options.afterBuild;
343
+ this.#beforeSubmit = options.beforeSubmit;
344
+ this.#grantExpiresAt = options.grantExpiresAt;
235
345
  }
236
346
  get address() {
237
347
  return this.#user.address;
@@ -244,19 +354,44 @@ export class CdpDelegatedSigner {
244
354
  * binding re-check, AFTER the RPC round trips and immediately before the hand-off), then submit.
245
355
  */
246
356
  async #submit(step, beforeSubmit) {
247
- const transaction = await buildUnsignedTransaction(this.#raw.pub, this.#user.address, step);
248
- beforeSubmit?.();
357
+ // A plan step (it carries `beforeSubmit`) refuses on a reverting estimate; the halt path's clear does not.
358
+ const transaction = await buildUnsignedTransaction(this.#raw.pub, this.#user.address, step, beforeSubmit ? { kind: 'refuse', stepKind: step.kind ?? 'plan' } : { kind: 'allowance_clear' });
359
+ /**
360
+ * 🔴 THE AUTHORITATIVE RE-READ, AFTER CONSTRUCTION. A revoke or a narrower re-mint that landed while
361
+ * the nonce, fees and gas were being fetched is visible here and nowhere earlier.
362
+ *
363
+ * 🔴 AND ONLY FOR A PLAN STEP. `beforeSubmit` is passed by `sendPlan` and by nothing else, so it is
364
+ * the signal that this submission is part of the plan. The halt path's `approve(spender, 0)` goes
365
+ * out through this same method WITHOUT it, and it must never be gated: the clear is the safety
366
+ * action that runs precisely BECAUSE something went wrong, including the grant changing under it.
367
+ * Refusing to clear an allowance because the permission that opened it has gone is the one outcome
368
+ * nobody wants.
369
+ */
370
+ if (beforeSubmit) {
371
+ await this.#afterBuild?.();
372
+ beforeSubmit();
373
+ // …and the caller's own last word, synchronous, on what that re-read just found. Nothing is
374
+ // awaited between here and the hand-off, because an await is the window this exists to close.
375
+ this.#beforeSubmit?.();
376
+ }
249
377
  return this.#raw.submit(this.#user, transaction);
250
378
  }
251
- /** Fail-closed: once revocation was requested for this user on this rig, nothing is submitted for them again. */
379
+ /**
380
+ * Fail-closed: once revocation was requested for THE GRANT THIS PLAN IS BOUND TO, nothing more is
381
+ * submitted under it. A signer built without a grant expiry (the adapter's own direct use) keeps the
382
+ * old, broader behaviour of refusing nothing here — the delegated tool always passes one.
383
+ */
252
384
  #assertNotRevoked() {
253
- if (revocationRequested(this.#rig, this.#user.userId))
385
+ if (this.#grantExpiresAt !== undefined &&
386
+ revocationRequested(this.#rig, this.#user.userId, this.#grantExpiresAt)) {
254
387
  throw new RevocationRequestedError(this.#user.userId);
388
+ }
255
389
  }
256
390
  /**
257
391
  * `send_evm_transaction`, under delegation: only a plan `assertPlanSignableBy` accepts for THIS user,
258
- * re-checked before every step AND again immediately before each hand-off to CDP (after the nonce /
259
- * fee / gas round trips, so an artifact that expires during preparation is never submitted). The plan
392
+ * re-checked before every step — together with the caller's async `beforeEachStep`, which re-reads the
393
+ * GRANT and its recorded per-swap cap — AND again immediately before each hand-off to CDP (after the
394
+ * nonce / fee / gas round trips, so an artifact that expires during preparation is never submitted). The plan
260
395
  * halts at the first step that does not succeed; after ANY failure a bounded `approve(spender, 0)` is
261
396
  * attempted under the same policy and its real outcome — confirmed by an allowance read-back — is
262
397
  * reported. A failed reset may leave a pre-existing allowance, so step 0 is not exempt.
@@ -272,6 +407,10 @@ export class CdpDelegatedSigner {
272
407
  try {
273
408
  this.#assertNotRevoked();
274
409
  assertPlanSignableBy(plan, this, Date.now());
410
+ // The caller's async re-check: the grant is still the one this plan was cleared against, and its
411
+ // recorded cap still admits it. Runs before EVERY step, so a delegation replaced mid-plan stops
412
+ // the remaining ones rather than being carried by the check made before step 0.
413
+ await this.#beforeEachStep?.();
275
414
  }
276
415
  catch (error) {
277
416
  throw await this.#halt(plan, index, `plan no longer signable before step ${index}: ${error instanceof Error ? error.message : String(error)}`, haltCauseOf(error));
@@ -289,6 +428,17 @@ export class CdpDelegatedSigner {
289
428
  receipt = await waitForSealedReceipt(this.#raw.pub, hash, this.#sealedWait);
290
429
  }
291
430
  catch (error) {
431
+ /**
432
+ * 🔴 A REVERTING ESTIMATE BEFORE THE FIRST STEP IS A REFUSAL, NOT A HALT. Nothing of this plan has
433
+ * reached the chain, so there is no allowance of ours to clear and no reason to spend the user's gas
434
+ * on a clear; the caller gets the typed reason and may prepare again. From step 1 on, an earlier step
435
+ * has landed (an allowance may stand), so the halt path runs and the cause is `would_revert`.
436
+ */
437
+ if ((error instanceof DelegatedTransactionWouldRevertError ||
438
+ error instanceof DelegatedTransactionEstimateUnavailableError) &&
439
+ index === 0) {
440
+ throw error;
441
+ }
292
442
  // The CAUSE is classified from the error's structure (`haltCauseOf`); the message carries the upstream
293
443
  // text for the operator and is never what a consumer classifies on.
294
444
  const errorType = error.errorType;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * evm-call-failure.ts — ONE classifier for "the node did not execute this call", shared by the delegated
3
+ * signer's gas estimate and the constructor's route simulation.
4
+ *
5
+ * 🔴 TWO DIFFERENT FACTS HIDE BEHIND ONE THROW (gate r7, findings 2 and 3). `eth_estimateGas` and
6
+ * `eth_call` reject for a transaction that WOULD REVERT — and also for a node that timed out, rate-limited
7
+ * (429), or dropped the connection. The first is proof about the transaction: never broadcast it, never
8
+ * prepare that route. The second proves nothing about the transaction: refusing to broadcast without an
9
+ * estimate is still right, but it is not a revert, it is retriable, and it must not blacklist a healthy
10
+ * route or deny a person a working plan. Calling a timeout `would_revert` is the error this module exists
11
+ * to prevent.
12
+ *
13
+ * viem wraps errors in a `cause` chain; the revert lives on an inner node (`ExecutionRevertedError`,
14
+ * `ContractFunctionRevertedError`, or a node whose `details` carry the node's "execution reverted …"
15
+ * text). Transport lives on the transport CLASSES (`HttpRequestError`, `TimeoutError`, socket/websocket,
16
+ * `LimitExceededRpcError`, …); `RpcRequestError` is a NODE answer, not transport. Anything not PROVEN a
17
+ * revert is `unavailable` — the conservative side.
18
+ *
19
+ * Nothing here returns a URL: viem's `message` embeds the RPC request (whose path carries an API key);
20
+ * only `details`/`shortMessage` survive, with any URL that slipped through replaced.
21
+ */
22
+ export type EvmCallFailure = 'revert' | 'unavailable';
23
+ /**
24
+ * `revert` only when some node in the cause chain PROVES it AND no node in the chain is transport
25
+ * evidence; everything else is `unavailable`.
26
+ */
27
+ export declare function classifyEvmCallFailure(error: unknown): EvmCallFailure;
28
+ /** A short, URL-free reason for the caller: the node's `details`, else the class, else the message. */
29
+ export declare function evmCallFailureDetail(error: unknown): string;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * evm-call-failure.ts — ONE classifier for "the node did not execute this call", shared by the delegated
3
+ * signer's gas estimate and the constructor's route simulation.
4
+ *
5
+ * 🔴 TWO DIFFERENT FACTS HIDE BEHIND ONE THROW (gate r7, findings 2 and 3). `eth_estimateGas` and
6
+ * `eth_call` reject for a transaction that WOULD REVERT — and also for a node that timed out, rate-limited
7
+ * (429), or dropped the connection. The first is proof about the transaction: never broadcast it, never
8
+ * prepare that route. The second proves nothing about the transaction: refusing to broadcast without an
9
+ * estimate is still right, but it is not a revert, it is retriable, and it must not blacklist a healthy
10
+ * route or deny a person a working plan. Calling a timeout `would_revert` is the error this module exists
11
+ * to prevent.
12
+ *
13
+ * viem wraps errors in a `cause` chain; the revert lives on an inner node (`ExecutionRevertedError`,
14
+ * `ContractFunctionRevertedError`, or a node whose `details` carry the node's "execution reverted …"
15
+ * text). Transport lives on the transport CLASSES (`HttpRequestError`, `TimeoutError`, socket/websocket,
16
+ * `LimitExceededRpcError`, …); `RpcRequestError` is a NODE answer, not transport. Anything not PROVEN a
17
+ * revert is `unavailable` — the conservative side.
18
+ *
19
+ * Nothing here returns a URL: viem's `message` embeds the RPC request (whose path carries an API key);
20
+ * only `details`/`shortMessage` survive, with any URL that slipped through replaced.
21
+ */
22
+ const REVERT_ERROR_NAMES = new Set([
23
+ 'ExecutionRevertedError',
24
+ 'ContractFunctionRevertedError',
25
+ ]);
26
+ /** The node's own revert text, in every phrasing seen from Base RPCs. */
27
+ const REVERT_DETAIL_PATTERN = /execution reverted|\brevert(ed)?\b|out of gas|invalid opcode/i;
28
+ /**
29
+ * 🔴 AFFIRMATIVE TRANSPORT EVIDENCE DOMINATES (gate r7 round 2). viem synthesises `ExecutionRevertedError`
30
+ * from any `details` text that looks like a revert — including a body an HTTP 429 happened to carry — so
31
+ * a chain can read `CallExecutionError → ExecutionRevertedError → HttpRequestError(status 429)`. That
32
+ * proves rate limiting, not a revert. The whole chain is scanned for a transport CLASS first; only a chain
33
+ * with none may be called a revert.
34
+ *
35
+ * 🔴 BY CLASS, NEVER BY A FIELD OF A RAW OBJECT (gate r7 rounds 3 and 4). viem keeps the raw JSON-RPC
36
+ * error object as `RpcRequestError.cause`, and a node can put ANYTHING in it: a stray `status: 503`
37
+ * (round 3) or a fabricated `name: "HttpRequestError"` (round 4) beside a code-3 "execution reverted".
38
+ * Both are PROVEN reverts; calling either transport would have let the halt path's clear broadcast on its
39
+ * static limit against a proven revert. So transport evidence is accepted ONLY from an actual `Error`
40
+ * instance whose name is one of viem's transport classes — a plain object can never impersonate one —
41
+ * plus the one node-level form viem itself treats as rate limiting: an actual `RpcRequestError` whose
42
+ * JSON-RPC `code` is 429 (HTTP 200), whatever its text says. Nothing else is transport.
43
+ */
44
+ const TRANSPORT_ERROR_NAMES = new Set([
45
+ 'HttpRequestError',
46
+ 'TimeoutError',
47
+ 'WebSocketRequestError',
48
+ 'SocketClosedError',
49
+ 'LimitExceededRpcError',
50
+ 'ResourceUnavailableRpcError',
51
+ ]);
52
+ /** JSON-RPC's rate-limit code, which viem retries on and which is never a statement about execution. */
53
+ const JSON_RPC_RATE_LIMITED = 429;
54
+ function* chain(error) {
55
+ let node = error;
56
+ for (let depth = 0; depth < 12 && node && typeof node === 'object'; depth += 1) {
57
+ yield node;
58
+ node = node.cause;
59
+ }
60
+ }
61
+ function isTransport(node) {
62
+ // A raw JSON object hanging off `RpcRequestError.cause` is never an Error, so it can claim nothing here.
63
+ if (!(node instanceof Error))
64
+ return false;
65
+ if (TRANSPORT_ERROR_NAMES.has(node.name))
66
+ return true;
67
+ return node.name === 'RpcRequestError' && node.code === JSON_RPC_RATE_LIMITED;
68
+ }
69
+ /**
70
+ * `revert` only when some node in the cause chain PROVES it AND no node in the chain is transport
71
+ * evidence; everything else is `unavailable`.
72
+ */
73
+ export function classifyEvmCallFailure(error) {
74
+ const nodes = [...chain(error)];
75
+ if (nodes.some(isTransport))
76
+ return 'unavailable';
77
+ for (const node of nodes) {
78
+ // A revert CLASS counts only on an actual Error instance too; the node's own text counts wherever it is.
79
+ if (node instanceof Error && REVERT_ERROR_NAMES.has(node.name))
80
+ return 'revert';
81
+ const details = node.details;
82
+ if (typeof details === 'string' && REVERT_DETAIL_PATTERN.test(details))
83
+ return 'revert';
84
+ }
85
+ return 'unavailable';
86
+ }
87
+ /** A short, URL-free reason for the caller: the node's `details`, else the class, else the message. */
88
+ export function evmCallFailureDetail(error) {
89
+ let text = '';
90
+ for (const node of chain(error)) {
91
+ if (typeof node.details === 'string' && node.details.trim()) {
92
+ text = node.details;
93
+ break;
94
+ }
95
+ }
96
+ if (!text) {
97
+ const top = error;
98
+ text =
99
+ typeof top?.shortMessage === 'string' && top.shortMessage
100
+ ? top.shortMessage
101
+ : typeof top?.name === 'string' && top.name !== 'Error'
102
+ ? top.name
103
+ : typeof top?.message === 'string'
104
+ ? top.message
105
+ : String(error);
106
+ }
107
+ return text.replace(/https?:\/\/\S+/g, '[url]').slice(0, 200);
108
+ }
109
+ //# sourceMappingURL=evm-call-failure.js.map
@@ -8,7 +8,7 @@
8
8
  * `errorType` mirrors `code` so the CDP kit's ladder — which only grades a *structured* refusal as
9
9
  * the fence biting — recognises adapter refusals without a second grading path.
10
10
  */
11
- export declare const ADAPTER_REFUSAL_CODES: readonly ["SCHEMA_INVALID", "UNSUPPORTED_OPERATION", "DIGEST_MISMATCH", "STALE_ARTIFACT", "ACCOUNT_BINDING_MISMATCH", "CAPABILITY_MISSING", "CHAIN_MISMATCH", "PLAN_SHAPE_INVALID", "APPROVAL_INVALID", "TARGET_NOT_ALLOWED", "SWAP_CALLDATA_INVALID", "RECEIVER_MISMATCH", "INTENT_MISMATCH", "MIN_OUTPUT_TOO_LOW", "ATTRIBUTION_MISSING", "ATTRIBUTION_MISMATCH", "FEE_CAP_EXCEEDED"];
11
+ export declare const ADAPTER_REFUSAL_CODES: readonly ["SCHEMA_INVALID", "UNSUPPORTED_OPERATION", "DIGEST_MISMATCH", "STALE_ARTIFACT", "ACCOUNT_BINDING_MISMATCH", "CAPABILITY_MISSING", "CHAIN_MISMATCH", "PLAN_SHAPE_INVALID", "APPROVAL_INVALID", "TARGET_NOT_ALLOWED", "SWAP_CALLDATA_INVALID", "RECEIVER_MISMATCH", "DELEGATED_TX_WOULD_REVERT", "DELEGATED_TX_ESTIMATE_UNAVAILABLE", "INTENT_MISMATCH", "MIN_OUTPUT_TOO_LOW", "ATTRIBUTION_MISSING", "ATTRIBUTION_MISMATCH", "FEE_CAP_EXCEEDED"];
12
12
  export type AdapterRefusalCode = (typeof ADAPTER_REFUSAL_CODES)[number];
13
13
  export declare class AdapterRefusal extends Error {
14
14
  readonly code: AdapterRefusalCode;
@@ -24,6 +24,19 @@ export const ADAPTER_REFUSAL_CODES = [
24
24
  'TARGET_NOT_ALLOWED',
25
25
  'SWAP_CALLDATA_INVALID',
26
26
  'RECEIVER_MISMATCH',
27
+ /**
28
+ * The node's gas estimate REVERTED for a plan step, so the transaction would revert on chain: it is not
29
+ * broadcast (a knowingly-reverting broadcast spends the user's gas and buys nothing). Raised by the
30
+ * delegated signer; surfaced to a delegated caller as `DELEGATED_PLAN_REFUSED [DELEGATED_TX_WOULD_REVERT]`
31
+ * when nothing of the plan had reached the chain, or as the cause of `DELEGATED_PLAN_HALTED` otherwise.
32
+ */
33
+ 'DELEGATED_TX_WOULD_REVERT',
34
+ /**
35
+ * The node could not estimate a plan step at all — a timeout, a rate limit, a dropped connection —
36
+ * so nothing was proven about the transaction and nothing is broadcast without an estimate. Distinct
37
+ * from `DELEGATED_TX_WOULD_REVERT` on purpose: this one is RETRIABLE and blacklists nothing.
38
+ */
39
+ 'DELEGATED_TX_ESTIMATE_UNAVAILABLE',
27
40
  // Intent binding — the artifact must be the trade the USER asked for
28
41
  'INTENT_MISMATCH',
29
42
  'MIN_OUTPUT_TOO_LOW',