otto-execute 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -0
  3. package/dist/cdp-signer.d.ts +2 -0
  4. package/dist/cdp-signer.js +3 -0
  5. package/dist/cdp.d.ts +7 -0
  6. package/dist/cdp.js +8 -0
  7. package/dist/chain.d.ts +2 -0
  8. package/dist/chain.js +3 -0
  9. package/dist/cli.d.ts +17 -0
  10. package/dist/cli.js +400 -0
  11. package/dist/delegate.d.ts +117 -0
  12. package/dist/delegate.js +394 -0
  13. package/dist/eoa-signer.d.ts +94 -0
  14. package/dist/eoa-signer.js +289 -0
  15. package/dist/erc20.d.ts +2 -0
  16. package/dist/erc20.js +3 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.js +19 -0
  19. package/dist/lifi-decode.d.ts +2 -0
  20. package/dist/lifi-decode.js +3 -0
  21. package/dist/mutations.d.ts +55 -0
  22. package/dist/mutations.js +363 -0
  23. package/dist/refusal.d.ts +2 -0
  24. package/dist/refusal.js +3 -0
  25. package/dist/vendor/otto-intel-mcp/VENDORED.json +52 -0
  26. package/dist/vendor/otto-intel-mcp/adapter/cdp-signer.d.ts +133 -0
  27. package/dist/vendor/otto-intel-mcp/adapter/cdp-signer.js +356 -0
  28. package/dist/vendor/otto-intel-mcp/adapter/chain.d.ts +36 -0
  29. package/dist/vendor/otto-intel-mcp/adapter/chain.js +65 -0
  30. package/dist/vendor/otto-intel-mcp/adapter/erc20.d.ts +39 -0
  31. package/dist/vendor/otto-intel-mcp/adapter/erc20.js +17 -0
  32. package/dist/vendor/otto-intel-mcp/adapter/lifi-decode.d.ts +52 -0
  33. package/dist/vendor/otto-intel-mcp/adapter/lifi-decode.js +149 -0
  34. package/dist/vendor/otto-intel-mcp/adapter/refusal.d.ts +21 -0
  35. package/dist/vendor/otto-intel-mcp/adapter/refusal.js +55 -0
  36. package/dist/vendor/otto-intel-mcp/adapter/sent-step.d.ts +15 -0
  37. package/dist/vendor/otto-intel-mcp/adapter/sent-step.js +6 -0
  38. package/dist/vendor/otto-intel-mcp/adapter/verify.d.ts +149 -0
  39. package/dist/vendor/otto-intel-mcp/adapter/verify.js +432 -0
  40. package/dist/vendor/otto-intel-mcp/adapter-cdp-index.d.ts +6 -0
  41. package/dist/vendor/otto-intel-mcp/adapter-cdp-index.js +7 -0
  42. package/dist/vendor/otto-intel-mcp/adapter-index.d.ts +15 -0
  43. package/dist/vendor/otto-intel-mcp/adapter-index.js +15 -0
  44. package/dist/vendor/otto-intel-mcp/artifact-id.d.ts +16 -0
  45. package/dist/vendor/otto-intel-mcp/artifact-id.js +60 -0
  46. package/dist/vendor/otto-intel-mcp/execution-config.d.ts +232 -0
  47. package/dist/vendor/otto-intel-mcp/execution-config.js +443 -0
  48. package/dist/vendor/otto-intel-mcp/execution-delegated-definition.d.ts +165 -0
  49. package/dist/vendor/otto-intel-mcp/execution-delegated-definition.js +116 -0
  50. package/dist/vendor/otto-intel-mcp/execution-delegation-admin-definition.d.ts +208 -0
  51. package/dist/vendor/otto-intel-mcp/execution-delegation-admin-definition.js +170 -0
  52. package/dist/vendor/otto-intel-mcp/execution-delegation-policy.d.ts +257 -0
  53. package/dist/vendor/otto-intel-mcp/execution-delegation-policy.js +279 -0
  54. package/dist/vendor/otto-intel-mcp/execution-errors.d.ts +9 -0
  55. package/dist/vendor/otto-intel-mcp/execution-errors.js +134 -0
  56. package/dist/vendor/otto-intel-mcp/execution-index.d.ts +17 -0
  57. package/dist/vendor/otto-intel-mcp/execution-index.js +16 -0
  58. package/dist/vendor/otto-intel-mcp/execution-intent.d.ts +14 -0
  59. package/dist/vendor/otto-intel-mcp/execution-intent.js +36 -0
  60. package/dist/vendor/otto-intel-mcp/execution-tool-definitions.d.ts +1103 -0
  61. package/dist/vendor/otto-intel-mcp/execution-tool-definitions.js +1051 -0
  62. package/dist/vendor/otto-intel-mcp/execution-types.d.ts +274 -0
  63. package/dist/vendor/otto-intel-mcp/execution-types.js +157 -0
  64. package/dist/verify.d.ts +2 -0
  65. package/dist/verify.js +3 -0
  66. package/dist/x402-table.d.ts +99 -0
  67. package/dist/x402-table.js +221 -0
  68. package/package.json +97 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * cdp-signer.ts — the CDP-delegated capability provider, product-shaped.
3
+ *
4
+ * The end user has ALREADY signed in and minted a time-boxed delegation somewhere else (a dApp, the
5
+ * proof kit's OTP flow, …). This adapter holds Otto's developer-side CDP access and submits the
6
+ * user's OWN transactions under that delegation: user-authenticated, Otto-governed (the project policy
7
+ * is the fence), Otto-revocable. It never holds a user key, never mints a grant, and submits ONLY a
8
+ * plan that `assertPlanSignableBy` accepts for THIS user.
9
+ *
10
+ * RUNTIME privacy, not TypeScript privacy: the raw "send this unsigned transaction under the user's
11
+ * delegation" capability lives in a module-private WeakMap keyed by the rig object. A `CdpRig` value
12
+ * exposes only chain reads, delegation read-back and revocation; the signer reaches the raw send
13
+ * through the WeakMap, and its own submission path is a `#private` method. Nothing reachable from a
14
+ * rig or a signer can submit an unverified transaction.
15
+ *
16
+ * Lifecycle serialization lives on the RIG, not on the signer: `sendPlan`, its allowance clear, the
17
+ * signer's `revokeDeveloperSide` AND the rig's public `revoke` all run through one module-private lock
18
+ * per rig, so a revocation issued by anyone holding the rig can never race an in-flight plan or its
19
+ * cleanup. Revocation is a FAIL-CLOSED per-user transition: the lock is held through the raw revoke AND
20
+ * its read-back, and once revocation has been REQUESTED for a user on a rig, no plan for that user is
21
+ * ever submitted on that rig again — confirmed, unconfirmed or failed. A halted plan's clear is
22
+ * confirmed by an `allowance` read-back, never inferred from a receipt.
23
+ *
24
+ * Nonce, gas and serialization are adapter-owned (spec §6): the unsigned EIP-1559 transaction is
25
+ * assembled here from chain reads and handed to CDP to sign and broadcast.
26
+ */
27
+ import { createPublicClient, encodeFunctionData, http, serializeTransaction, } from 'viem';
28
+ import { base } from 'viem/chains';
29
+ import { SealedReceiptTimeoutError, waitForSealedReceipt } from './chain.js';
30
+ import { ERC20_APPROVE_ABI, readAllowance } from './erc20.js';
31
+ import { isAdapterRefusal } from './refusal.js';
32
+ import { assertPlanSignableBy } from './verify.js';
33
+ export const CDP_CAPABILITIES = Object.freeze(['send_evm_transaction']);
34
+ export const CHAIN_ID = 8453;
35
+ export const NETWORK = 'base';
36
+ const RAW = new WeakMap();
37
+ /** One lifecycle lock PER RIG — shared by every signer over it and by the rig's own public `revoke`. */
38
+ const LOCKS = new WeakMap();
39
+ /** Per rig: the users whose revocation has been REQUESTED (set before the raw revoke is issued; never cleared). */
40
+ 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;
44
+ }
45
+ /** Run `operation` after everything previously queued on this rig has settled; nothing else starts until it settles. */
46
+ function serializedOn(rig, operation) {
47
+ const lock = LOCKS.get(rig);
48
+ if (!lock)
49
+ throw new Error('rig was not created by openCdpRig/createCdpRig');
50
+ const run = lock.chain.then(operation, operation);
51
+ lock.chain = run.catch(() => undefined);
52
+ return run;
53
+ }
54
+ /**
55
+ * A delegated plan halted; `cause` is the structured class above, `allowanceCleared` says whether the
56
+ * router allowance is known to be zero again. The message is for the operator; a consumer that must not
57
+ * relay upstream text reads `cause` and the two fields only.
58
+ */
59
+ export class DelegatedPlanHaltedError extends Error {
60
+ failedStep;
61
+ allowanceCleared;
62
+ cause;
63
+ clearTransactionHash;
64
+ constructor(message, failedStep, allowanceCleared, cause, clearTransactionHash) {
65
+ super(message);
66
+ this.failedStep = failedStep;
67
+ this.allowanceCleared = allowanceCleared;
68
+ this.cause = cause;
69
+ this.clearTransactionHash = clearTransactionHash;
70
+ this.name = 'DelegatedPlanHaltedError';
71
+ }
72
+ }
73
+ /** Thrown by the signer itself once revocation was requested for the user on this rig (fail-closed). */
74
+ export class RevocationRequestedError extends Error {
75
+ constructor(userId) {
76
+ super(`refusing to submit: delegation revocation was requested for ${userId} on this rig`);
77
+ this.name = 'RevocationRequestedError';
78
+ }
79
+ }
80
+ const ABSENT_DELEGATION_ERROR_TYPES = ['delegation_not_found', 'delegation_revoked', 'delegation_expired'];
81
+ /** Classify a failure from its STRUCTURE (class, `errorType`) — never from its message text. */
82
+ export function haltCauseOf(error) {
83
+ if (error instanceof RevocationRequestedError)
84
+ return 'delegation_not_active';
85
+ if (error instanceof SealedReceiptTimeoutError)
86
+ return 'receipt_not_sealed';
87
+ if (isAdapterRefusal(error))
88
+ return 'artifact_no_longer_signable';
89
+ const errorType = error?.errorType;
90
+ if (errorType === 'policy_violation')
91
+ return 'policy_violation';
92
+ if (typeof errorType === 'string' && ABSENT_DELEGATION_ERROR_TYPES.includes(errorType))
93
+ return 'delegation_not_active';
94
+ return 'upstream_failure';
95
+ }
96
+ function isAbsentDelegation(error) {
97
+ const e = error;
98
+ return e.statusCode === 404 || ABSENT_DELEGATION_ERROR_TYPES.includes(e.errorType ?? '');
99
+ }
100
+ /**
101
+ * Build a rig from raw operations. The raw operations are captured into the module-private registry;
102
+ * the returned object exposes only reads and revocation. (Callers who hold a CDP client already have
103
+ * the raw capability — this function does not create it, it fences it.)
104
+ */
105
+ export function createCdpRig(raw) {
106
+ const requested = new Set();
107
+ const rig = Object.freeze({
108
+ pub: raw.pub,
109
+ readDelegation: (userId) => raw.readDelegation(userId),
110
+ // The public revocation goes through the rig's lifecycle lock and HOLDS it through the read-back:
111
+ // it lands only after any in-flight plan on this rig and its allowance clear have settled, and a
112
+ // plan queued behind it runs only after the read-back — where it is refused, because the user was
113
+ // marked revocation-requested BEFORE the raw revoke was issued.
114
+ revoke: (userId) => serializedOn(rig, async () => {
115
+ requested.add(userId);
116
+ try {
117
+ await raw.revoke(userId);
118
+ }
119
+ catch {
120
+ // A grant that never existed (or is already gone) reads back as absent below; other failures surface there.
121
+ }
122
+ const after = await raw.readDelegation(userId);
123
+ if (after)
124
+ throw new Error(`delegation for ${userId} still reads back after revoke (expires ${after.expiresAt})`);
125
+ }),
126
+ });
127
+ RAW.set(rig, raw);
128
+ LOCKS.set(rig, { chain: Promise.resolve() });
129
+ REVOKE_REQUESTED.set(rig, requested);
130
+ return rig;
131
+ }
132
+ /**
133
+ * `@coinbase/cdp-sdk` is an OPTIONAL dependency of this package, loaded here and only here: an install
134
+ * that never delegates does not need it, and a consumer that does gets a named failure instead of a
135
+ * module-resolution stack when it is missing.
136
+ */
137
+ async function loadCdpSdk() {
138
+ try {
139
+ return await import('@coinbase/cdp-sdk');
140
+ }
141
+ catch (error) {
142
+ const reason = error instanceof Error ? error.message : String(error);
143
+ throw new Error(`@coinbase/cdp-sdk is not installed: the delegated signer needs the optional dependency (${reason})`);
144
+ }
145
+ }
146
+ /** Open a rig with Otto's developer credentials against Base mainnet. */
147
+ export async function openCdpRig(credentials, rpcUrl) {
148
+ const { CdpClient } = await loadCdpSdk();
149
+ const cdp = new CdpClient({
150
+ apiKeyId: credentials.apiKeyId,
151
+ apiKeySecret: credentials.apiKeySecret,
152
+ walletSecret: credentials.walletSecret,
153
+ });
154
+ const pub = createPublicClient({ chain: base, transport: http(rpcUrl) });
155
+ let chainId;
156
+ try {
157
+ chainId = await pub.getChainId();
158
+ }
159
+ catch {
160
+ // FIXED text: viem's own message carries the RPC URL (a key may ride in its path); it is never rethrown.
161
+ throw new Error('the RPC endpoint did not answer eth_chainId; refusing to open the rig');
162
+ }
163
+ if (chainId !== CHAIN_ID)
164
+ throw new Error(`RPC reports chainId ${chainId}, expected ${CHAIN_ID} (Base mainnet)`);
165
+ return createCdpRig({
166
+ pub,
167
+ submit: async (user, transaction) => {
168
+ const result = await cdp.endUser.sendEvmTransaction({ userId: user.userId, address: user.address, transaction, network: NETWORK });
169
+ return result.transactionHash;
170
+ },
171
+ revoke: async (userId) => {
172
+ await cdp.endUser.revokeDelegationForEndUser({ userId });
173
+ },
174
+ readDelegation: async (userId) => {
175
+ try {
176
+ return await cdp.endUser.getDelegationForEndUser({ userId });
177
+ }
178
+ catch (error) {
179
+ if (isAbsentDelegation(error))
180
+ return undefined;
181
+ throw error;
182
+ }
183
+ },
184
+ });
185
+ }
186
+ /**
187
+ * Revoke by `userId` alone, with read-back — usable even when the delegation request's response was
188
+ * lost (the grant may have committed remotely). Resolves only once the grant no longer reads back.
189
+ * This IS `rig.revoke`: serialized on the rig, lock held through the read-back, fail-closed afterwards.
190
+ */
191
+ export function revokeDelegationByUserId(rig, userId) {
192
+ return rig.revoke(userId);
193
+ }
194
+ async function buildUnsignedTransaction(pub, from, step) {
195
+ const [nonce, fees] = await Promise.all([
196
+ pub.getTransactionCount({ address: from, blockTag: 'pending' }),
197
+ pub.estimateFeesPerGas(),
198
+ ]);
199
+ let gas;
200
+ try {
201
+ gas = ((await pub.estimateGas({ account: from, to: step.to, value: step.value, data: step.data })) * 12n) / 10n;
202
+ }
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;
207
+ }
208
+ return serializeTransaction({
209
+ type: 'eip1559',
210
+ chainId: CHAIN_ID,
211
+ nonce,
212
+ to: step.to,
213
+ value: step.value,
214
+ data: step.data,
215
+ gas,
216
+ maxFeePerGas: fees.maxFeePerGas,
217
+ maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
218
+ });
219
+ }
220
+ export class CdpDelegatedSigner {
221
+ capabilities = CDP_CAPABILITIES;
222
+ chainId = CHAIN_ID;
223
+ #rig;
224
+ #raw;
225
+ #user;
226
+ #sealedWait;
227
+ constructor(rig, user, options = {}) {
228
+ const raw = RAW.get(rig);
229
+ if (!raw)
230
+ throw new Error('CdpDelegatedSigner needs a rig created by openCdpRig/createCdpRig');
231
+ this.#rig = rig;
232
+ this.#raw = raw;
233
+ this.#user = Object.freeze({ userId: user.userId, address: user.address });
234
+ this.#sealedWait = options.sealedWait;
235
+ }
236
+ get address() {
237
+ return this.#user.address;
238
+ }
239
+ get user() {
240
+ return this.#user;
241
+ }
242
+ /**
243
+ * Assemble the unsigned transaction from chain reads, run `beforeSubmit` (the plan's freshness /
244
+ * binding re-check, AFTER the RPC round trips and immediately before the hand-off), then submit.
245
+ */
246
+ async #submit(step, beforeSubmit) {
247
+ const transaction = await buildUnsignedTransaction(this.#raw.pub, this.#user.address, step);
248
+ beforeSubmit?.();
249
+ return this.#raw.submit(this.#user, transaction);
250
+ }
251
+ /** Fail-closed: once revocation was requested for this user on this rig, nothing is submitted for them again. */
252
+ #assertNotRevoked() {
253
+ if (revocationRequested(this.#rig, this.#user.userId))
254
+ throw new RevocationRequestedError(this.#user.userId);
255
+ }
256
+ /**
257
+ * `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
260
+ * halts at the first step that does not succeed; after ANY failure a bounded `approve(spender, 0)` is
261
+ * attempted under the same policy and its real outcome — confirmed by an allowance read-back — is
262
+ * reported. A failed reset may leave a pre-existing allowance, so step 0 is not exempt.
263
+ */
264
+ sendPlan(plan) {
265
+ return serializedOn(this.#rig, async () => {
266
+ // Entry, inside the lock: a plan queued behind a revocation lands here AFTER the revoke and its
267
+ // read-back, and is refused before any step — nothing to clear.
268
+ this.#assertNotRevoked();
269
+ assertPlanSignableBy(plan, this, Date.now());
270
+ const out = [];
271
+ for (const [index, step] of plan.steps.entries()) {
272
+ try {
273
+ this.#assertNotRevoked();
274
+ assertPlanSignableBy(plan, this, Date.now());
275
+ }
276
+ catch (error) {
277
+ throw await this.#halt(plan, index, `plan no longer signable before step ${index}: ${error instanceof Error ? error.message : String(error)}`, haltCauseOf(error));
278
+ }
279
+ let hash;
280
+ let receipt;
281
+ try {
282
+ hash = await this.#submit(step, () => {
283
+ this.#assertNotRevoked();
284
+ assertPlanSignableBy(plan, this, Date.now());
285
+ });
286
+ await this.#raw.pub.waitForTransactionReceipt({ hash }); // first sight (may be a preconfirmation) …
287
+ // … then only the SEALED, CANONICAL receipt counts: the next step is estimated against sealed state,
288
+ // and a preconfirmation omitted from the sealed block is never reported as success.
289
+ receipt = await waitForSealedReceipt(this.#raw.pub, hash, this.#sealedWait);
290
+ }
291
+ catch (error) {
292
+ // The CAUSE is classified from the error's structure (`haltCauseOf`); the message carries the upstream
293
+ // text for the operator and is never what a consumer classifies on.
294
+ const errorType = error.errorType;
295
+ throw await this.#halt(plan, index, `delegated step ${index} (${step.kind}) failed: ${typeof errorType === 'string' && errorType ? `${errorType} | ` : ''}${error instanceof Error ? error.message : String(error)}`, haltCauseOf(error));
296
+ }
297
+ if (receipt.status !== 'success') {
298
+ throw await this.#halt(plan, index, `delegated step ${index} (${step.kind}) reverted on-chain: ${hash}`, 'reverted');
299
+ }
300
+ out.push(Object.freeze({
301
+ index,
302
+ kind: step.kind,
303
+ to: step.to,
304
+ transaction_hash: hash,
305
+ status: 'success',
306
+ block_number: receipt.blockNumber.toString(),
307
+ gas_used: receipt.gasUsed.toString(),
308
+ }));
309
+ }
310
+ return out;
311
+ });
312
+ }
313
+ /**
314
+ * After ANY failure: attempt `approve(spender, 0)` under the delegation, then READ the allowance back.
315
+ * `allowanceCleared` is true only when the read-back is zero — a mined clear whose read-back is not
316
+ * zero, a reverted clear, a failed submission or a failed read all report false with the hand-off.
317
+ */
318
+ async #halt(plan, failedStep, message, cause) {
319
+ const handOff = `clear approve(${plan.spender}, 0) on ${plan.token} BEFORE revoking the delegation`;
320
+ let hash;
321
+ try {
322
+ hash = await this.#submit({
323
+ to: plan.token,
324
+ value: 0n,
325
+ data: encodeFunctionData({ abi: ERC20_APPROVE_ABI, functionName: 'approve', args: [plan.spender, 0n] }),
326
+ });
327
+ await this.#raw.pub.waitForTransactionReceipt({ hash });
328
+ // The read-back below must see the clear: only its sealed canonical receipt counts.
329
+ const clearReceipt = await waitForSealedReceipt(this.#raw.pub, hash, this.#sealedWait);
330
+ if (clearReceipt.status !== 'success') {
331
+ return new DelegatedPlanHaltedError(`${message}; allowance clear ${hash} reverted — ${handOff}`, failedStep, false, cause, hash);
332
+ }
333
+ const remaining = await readAllowance(this.#raw.pub, plan.token, this.#user.address, plan.spender);
334
+ if (remaining !== 0n) {
335
+ return new DelegatedPlanHaltedError(`${message}; allowance clear ${hash} was mined but ${remaining} still reads back — ${handOff}`, failedStep, false, cause, hash);
336
+ }
337
+ return new DelegatedPlanHaltedError(`${message}; allowance cleared in ${hash} (read-back 0)`, failedStep, true, cause, hash);
338
+ }
339
+ catch (error) {
340
+ const reason = error instanceof Error ? error.message : String(error);
341
+ return new DelegatedPlanHaltedError(`${message}; allowance clear ${hash ? `${hash} could not be confirmed` : 'FAILED'} (${reason}) — ${handOff}`, failedStep, false, cause, hash);
342
+ }
343
+ }
344
+ /**
345
+ * The Otto-side kill switch, with read-back. Serialized on the rig (waits for any in-flight plan and
346
+ * its cleanup, holds the lock through the read-back) and fail-closed: no later plan for this user
347
+ * is submitted on this rig, whatever the read-back said.
348
+ */
349
+ revokeDeveloperSide() {
350
+ return this.#rig.revoke(this.#user.userId);
351
+ }
352
+ delegation() {
353
+ return this.#rig.readDelegation(this.#user.userId);
354
+ }
355
+ }
356
+ //# sourceMappingURL=cdp-signer.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * chain.ts — the one chain-state rule both signers follow on Base.
3
+ *
4
+ * With Flashblocks, a transaction receipt can arrive from a PRECONFIRMATION while `latest` — the last
5
+ * SEALED block, the state every default read and gas estimate runs against — still predates it, and a
6
+ * preconfirmation is not final: Base defines a Flashblock reorg as a preconfirmed transaction OMITTED
7
+ * from the sealed block. Observed live on 2026-08-28: the approve was preconfirmed, the swap call was
8
+ * estimated against `latest`, and the router's `transferFrom` reverted `TRANSFER_FROM_FAILED` on an
9
+ * allowance that was already granted. Rule: a transaction counts as mined ONLY once its receipt is
10
+ * refetched by hash and the sealed canonical block at that height carries the receipt's block hash.
11
+ * Until then the signer touches no state (no next estimate, no allowance read-back); if that never
12
+ * happens within the bound, the signer fails closed. This is the conservative mechanism — it is also
13
+ * exactly what a user copying this flow in a wallet observes.
14
+ */
15
+ import type { Hex, PublicClient, TransactionReceipt } from 'viem';
16
+ export interface SealedWaitOptions {
17
+ /** Delay between polls (default 250 ms). */
18
+ readonly pollMs?: number;
19
+ /** Give up after this many polls (default 240 → 60 s at the default cadence). */
20
+ readonly maxPolls?: number;
21
+ }
22
+ export type SealedClient = Pick<PublicClient, 'getBlockNumber' | 'getTransactionReceipt' | 'getBlock'>;
23
+ /** The wait gave up: no receipt in a sealed canonical block within the bound. A CLASS, so callers classify structurally. */
24
+ export declare class SealedReceiptTimeoutError extends Error {
25
+ readonly hash: Hex;
26
+ readonly polls: number;
27
+ constructor(hash: Hex, polls: number);
28
+ }
29
+ /**
30
+ * Resolve with the receipt of `hash` once it is SEALED and CANONICAL: refetched by hash, its block
31
+ * number ≤ the sealed head, and the sealed block at that number has the receipt's `blockHash`. A
32
+ * preconfirmed receipt that was omitted from the sealed block (Flashblock reorg) is never returned —
33
+ * the wait continues until the transaction is re-included in a sealed block, or throws after
34
+ * `maxPolls` so the caller fails closed instead of treating the transaction as mined.
35
+ */
36
+ export declare function waitForSealedReceipt(pub: SealedClient, hash: Hex, options?: SealedWaitOptions): Promise<TransactionReceipt>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * chain.ts — the one chain-state rule both signers follow on Base.
3
+ *
4
+ * With Flashblocks, a transaction receipt can arrive from a PRECONFIRMATION while `latest` — the last
5
+ * SEALED block, the state every default read and gas estimate runs against — still predates it, and a
6
+ * preconfirmation is not final: Base defines a Flashblock reorg as a preconfirmed transaction OMITTED
7
+ * from the sealed block. Observed live on 2026-08-28: the approve was preconfirmed, the swap call was
8
+ * estimated against `latest`, and the router's `transferFrom` reverted `TRANSFER_FROM_FAILED` on an
9
+ * allowance that was already granted. Rule: a transaction counts as mined ONLY once its receipt is
10
+ * refetched by hash and the sealed canonical block at that height carries the receipt's block hash.
11
+ * Until then the signer touches no state (no next estimate, no allowance read-back); if that never
12
+ * happens within the bound, the signer fails closed. This is the conservative mechanism — it is also
13
+ * exactly what a user copying this flow in a wallet observes.
14
+ */
15
+ /** The wait gave up: no receipt in a sealed canonical block within the bound. A CLASS, so callers classify structurally. */
16
+ export class SealedReceiptTimeoutError extends Error {
17
+ hash;
18
+ polls;
19
+ constructor(hash, polls) {
20
+ super(`transaction ${hash} has no receipt in a sealed canonical block after ${polls} polls; refusing to treat it as mined`);
21
+ this.hash = hash;
22
+ this.polls = polls;
23
+ this.name = 'SealedReceiptTimeoutError';
24
+ }
25
+ }
26
+ /** STRUCTURAL: viem's own class name for "no receipt yet". Message text is never consulted — it must not select a class. */
27
+ function isReceiptNotFound(error) {
28
+ return error?.name === 'TransactionReceiptNotFoundError';
29
+ }
30
+ /**
31
+ * Resolve with the receipt of `hash` once it is SEALED and CANONICAL: refetched by hash, its block
32
+ * number ≤ the sealed head, and the sealed block at that number has the receipt's `blockHash`. A
33
+ * preconfirmed receipt that was omitted from the sealed block (Flashblock reorg) is never returned —
34
+ * the wait continues until the transaction is re-included in a sealed block, or throws after
35
+ * `maxPolls` so the caller fails closed instead of treating the transaction as mined.
36
+ */
37
+ export async function waitForSealedReceipt(pub, hash, options = {}) {
38
+ const pollMs = options.pollMs ?? 250;
39
+ const maxPolls = options.maxPolls ?? 240;
40
+ for (let poll = 0;; poll++) {
41
+ let receipt;
42
+ try {
43
+ receipt = await pub.getTransactionReceipt({ hash });
44
+ }
45
+ catch (error) {
46
+ if (!isReceiptNotFound(error))
47
+ throw error;
48
+ }
49
+ if (receipt) {
50
+ // viem caches getBlockNumber for the client's cacheTime by default — a cached head would defeat the wait.
51
+ const head = await pub.getBlockNumber({ cacheTime: 0 });
52
+ if (head >= receipt.blockNumber) {
53
+ const sealed = await pub.getBlock({ blockNumber: receipt.blockNumber });
54
+ if (sealed.hash === receipt.blockHash)
55
+ return receipt;
56
+ // The block this receipt claims is not the sealed block at that height: the preconfirmation did
57
+ // not survive sealing. Keep polling — the transaction may be re-included in a later block.
58
+ }
59
+ }
60
+ if (poll + 1 >= maxPolls)
61
+ throw new SealedReceiptTimeoutError(hash, maxPolls);
62
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
63
+ }
64
+ }
65
+ //# sourceMappingURL=chain.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * erc20.ts — the two ERC-20 calls the signers make on their OWN behalf: the bounded `approve(spender, 0)`
3
+ * that clears a halted plan's allowance, and the `allowance` read-back that turns "the clear was
4
+ * mined" into "the allowance IS zero" — a receipt status is a claim about a transaction, the read-back
5
+ * is the fact about the token.
6
+ */
7
+ import { type Address, type PublicClient } from 'viem';
8
+ export declare const ERC20_APPROVE_ABI: readonly [{
9
+ readonly name: "approve";
10
+ readonly type: "function";
11
+ readonly stateMutability: "nonpayable";
12
+ readonly inputs: readonly [{
13
+ readonly type: "address";
14
+ readonly name: "spender";
15
+ }, {
16
+ readonly type: "uint256";
17
+ readonly name: "amount";
18
+ }];
19
+ readonly outputs: readonly [{
20
+ readonly type: "bool";
21
+ }];
22
+ }];
23
+ export declare const ERC20_ALLOWANCE_ABI: readonly [{
24
+ readonly name: "allowance";
25
+ readonly type: "function";
26
+ readonly stateMutability: "view";
27
+ readonly inputs: readonly [{
28
+ readonly type: "address";
29
+ readonly name: "owner";
30
+ }, {
31
+ readonly type: "address";
32
+ readonly name: "spender";
33
+ }];
34
+ readonly outputs: readonly [{
35
+ readonly type: "uint256";
36
+ }];
37
+ }];
38
+ /** The live allowance `owner` has granted `spender` on `token`. Throws if the read itself fails. */
39
+ export declare function readAllowance(pub: PublicClient, token: Address, owner: Address, spender: Address): Promise<bigint>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * erc20.ts — the two ERC-20 calls the signers make on their OWN behalf: the bounded `approve(spender, 0)`
3
+ * that clears a halted plan's allowance, and the `allowance` read-back that turns "the clear was
4
+ * mined" into "the allowance IS zero" — a receipt status is a claim about a transaction, the read-back
5
+ * is the fact about the token.
6
+ */
7
+ import { parseAbi } from 'viem';
8
+ export const ERC20_APPROVE_ABI = parseAbi(['function approve(address spender, uint256 amount) returns (bool)']);
9
+ export const ERC20_ALLOWANCE_ABI = parseAbi(['function allowance(address owner, address spender) view returns (uint256)']);
10
+ /** The live allowance `owner` has granted `spender` on `token`. Throws if the read itself fails. */
11
+ export async function readAllowance(pub, token, owner, spender) {
12
+ const value = await pub.readContract({ address: token, abi: ERC20_ALLOWANCE_ABI, functionName: 'allowance', args: [owner, spender] });
13
+ if (typeof value !== 'bigint')
14
+ throw new Error('allowance read-back did not return a uint256');
15
+ return value;
16
+ }
17
+ //# sourceMappingURL=erc20.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * lifi-decode.ts — the adapter's OWN decoder for what it is about to sign.
3
+ *
4
+ * Re-specified (not imported) from the reviewed layouts in `src/lib/otto-wallet/lifi-swap-action.ts`
5
+ * (GenericSwapV3 ERC20→ERC20: receiver@96 / minAmount@128, `LibSwap.SwapData` tuple) and the
6
+ * FeeCollector `collectTokenFees(token, (recipient, amount)[])` body. Spec §11 fences the dApp decoder
7
+ * as a live owner-op importer that must not move; spec §1 keeps the constructor's own assertions on
8
+ * the constructor's side. This file is the user-side view: if the two ever disagree, the artifact is
9
+ * refused, which is the correct outcome for a disagreement about calldata that moves money.
10
+ *
11
+ * Decoding runs on the ARG REGION with `decodeAbiParameters` after a separate selector check, so a
12
+ * selector/ABI mismatch cannot mask a correct-looking parameter decode. Every decode is re-encoded
13
+ * and compared to the input: trailing bytes or non-canonical encodings are refused, never ignored.
14
+ */
15
+ import { type Address, type Hex } from 'viem';
16
+ /** swapTokensSingleV3ERC20ToERC20 / swapTokensMultipleV3ERC20ToERC20 — the only selectors a swap plan may call. */
17
+ export declare const GENERIC_SWAP_SELECTORS: Readonly<{
18
+ single: Hex;
19
+ multiple: Hex;
20
+ }>;
21
+ /** FeeCollector.collectTokenFees — the only calldata a fee-forwarding leg may carry. */
22
+ export declare const COLLECT_TOKEN_FEES_SELECTOR: Hex;
23
+ export interface SwapLeg {
24
+ readonly callTo: Address;
25
+ readonly approveTo: Address;
26
+ readonly sendingAssetId: Address;
27
+ readonly receivingAssetId: Address;
28
+ readonly fromAmount: bigint;
29
+ readonly callData: Hex;
30
+ readonly requiresDeposit: boolean;
31
+ }
32
+ export interface DecodedGenericSwap {
33
+ readonly selector: Hex;
34
+ readonly transactionId: Hex;
35
+ readonly integrator: string;
36
+ readonly referrer: string;
37
+ readonly receiver: Address;
38
+ readonly minAmount: bigint;
39
+ readonly legs: readonly SwapLeg[];
40
+ }
41
+ export interface FeeDistribution {
42
+ readonly recipient: Address;
43
+ readonly amount: bigint;
44
+ }
45
+ export interface DecodedCollectTokenFees {
46
+ readonly token: Address;
47
+ readonly distributions: readonly FeeDistribution[];
48
+ }
49
+ export declare function decodeGenericSwap(data: Hex): DecodedGenericSwap;
50
+ export declare function decodeCollectTokenFees(callData: Hex): DecodedCollectTokenFees;
51
+ export declare function encodeGenericSwap(swap: DecodedGenericSwap): Hex;
52
+ export declare function encodeCollectTokenFees(fees: DecodedCollectTokenFees): Hex;