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
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` —
|
|
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
|
-
/**
|
|
67
|
-
|
|
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'
|
|
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
|
|
118
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
259
|
-
*
|
|
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;
|
package/dist/adapter/refusal.js
CHANGED
|
@@ -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',
|