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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 otto AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # otto-execute
2
+
3
+ The **user's side** of Otto Model B.
4
+
5
+ > **Agent path to a delegation — `otto-execute delegate`** (`otto-execute/delegate` entry): sign in as the end user by email OTP (typed on the terminal, never echoed, never written), mint a time-boxed CDP delegation to Otto's project (default 90 days, max 180), read it back, revoke it — all from the agent's own process, end-user-side (no Otto credential is ever in it). Before minting it reads the hosted MCP's PUBLIC `otto_delegation_fence_status` and refuses when the Model-B fence is not present (and fails closed when the MCP cannot be reached). **This path is not documented or supported by Coinbase and can break on an SDK update; the browser page is the supported path.** `@coinbase/cdp-core` is an optional peer PINNED to `0.0.120` (the version proven live); a canary test drives its real web build on Node against a loopback fake on every dependency bump, and the shim breaking is a named refusal (`CDP_CORE_NODE_PATH_BROKEN`), never a silent failure. The fence source (Otto's MCP, `https://mcp.ottoai.services/mcp`) and the CDP SDK endpoint are **fixed** — no flag or environment variable redirects either, and the mint binds to the public project id the fence itself publishes (there is no `--project-id`). The command also refuses to run with any Otto credential or 64-hex value in its environment, and keeps the OTP out of argv by every spelling. **Threat model:** the residual after all of the above is a **platform universal**, not a property of this CLI — a caller who controls the process environment has pre-entry arbitrary code execution in ANY Node program (via `NODE_OPTIONS=--import=…`, `LD_PRELOAD`, or PATH-shadowing the `node` binary), so environment control is equivalent to process control. **An attacker with environment control of your shell already owns your OTP and every CLI on the machine.** Run the delegate path only in an environment you trust.
6
+
7
+ Otto's hosted MCP (`otto-intel-mcp`, `https://mcp.ottoai.services`) constructs transactions
8
+ **prepare-only**: `otto_prepare_swap` returns an unsigned, digest-committed envelope carrying a LI.FI
9
+ swap with Otto's fee attribution baked in. Nothing in it is signed. This package is what **your**
10
+ signer runs before and while signing one:
11
+
12
+ 1. **Verify** — integrity (the constructor's own schema / digest / commitment / freshness / binding
13
+ assertions), then **your intent** (input token and exact amount, the deposit legs pulling exactly
14
+ that, the output token, and your own positive minimum output), then policy (reviewed router,
15
+ `_receiver` == you, the fee leg decoded independently and matched to the ruled 10 bps to Otto's
16
+ recipient through a reviewed FeeForwarder deployment, aggregate under the cap). Twelve named checks
17
+ (+ `intent_digest` when you pass the exact request you sent). A verified plan is deep-frozen and
18
+ registered by identity; signers additionally require the plan to be bound to THEIR address and chain, to
19
+ cover their capabilities, and to be fresh at signing time — re-checked before every live step.
20
+ 2. **Sign / submit** —
21
+ - **BYO/EOA** (`EoaSigner`): your key signs **and** submits, one ordered transaction per step,
22
+ halting and clearing a live allowance on any uncertain failure (the clear is confirmed by an
23
+ `allowance` read-back, never inferred from a receipt). Freshness and binding are re-asserted
24
+ immediately before every signature, after the nonce / fee / gas round trips. `signPlanDryRun`
25
+ signs, decodes back and recovers without broadcasting.
26
+ - **CDP-delegated** (`CdpDelegatedSigner`, from the `otto-execute/cdp` entry): you have already signed
27
+ in and minted a time-boxed delegation; Otto's server submits your own transactions under it —
28
+ user-authenticated, Otto-governed (project policy), Otto-revocable. No raw-submission surface exists
29
+ on the signer; submission, allowance clearing and revocation are serialized on one lock per rig.
30
+ - **x402** (`validateX402Challenge` + `EoaSigner.signX402Payment`): a 402 is validated against an
31
+ exact-match table (resource, recipient, exact amount, exact 300 s window, pinned EIP-712 domain),
32
+ revalidated immediately before signing, and the produced authorization is checked against the
33
+ table row. The `table` parameter is public on purpose: it defaults to the built-in `X402_TABLE`
34
+ (Otto's routes), and a caller may pass a narrower or newer table — the per-payment spend cap below
35
+ bounds what any table can authorize, so a wrong table cannot sign for more than `$1`.
36
+
37
+ Otto never holds your keys or funds on either path.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ npm install otto-execute
43
+ ```
44
+
45
+ That installs this package plus `@x402/core`, `@x402/evm`, `viem` and `zod` — 18 packages. The parts of
46
+ `otto-intel-mcp` this package runs at runtime (`otto-intel-mcp/execution`, `/adapter`, `/adapter-cdp` —
47
+ the envelope schema, the verifier, the signing adapters) are **vendored** into `dist/vendor/otto-intel-mcp/`
48
+ at build time as file-for-file copies of the published `otto-intel-mcp` release, so a consumer does not
49
+ install that package (it is also an MCP server and would cost ~240 packages). The copy is byte-verified:
50
+ `dist/vendor/otto-intel-mcp/VENDORED.json` records the version, the registry integrity hash and a sha256 per
51
+ file; `npm run vendor:check` re-derives it (and runs in `prepack`). A change in the seam is therefore a
52
+ visible `otto-intel-mcp` version bump + re-vendor here, never silent drift. `@coinbase/cdp-sdk` stays an
53
+ optional peer, loaded only by the `otto-execute/cdp` entry.
54
+
55
+ Working on it inside the monorepo:
56
+
57
+ ```bash
58
+ cd services/otto-execute && npm ci
59
+ npm run verify # typecheck → build (tsc + vendor-seam) → tests; the prepublishOnly gate
60
+ npm run fixtures:check # monorepo-only: the committed envelope fixtures still match the sibling constructor
61
+ npm run smoke:install # pack + clean install outside the repo + a real verify + the consumer package count
62
+ ```
63
+
64
+ The test suite is **hermetic**: it resolves `otto-intel-mcp/*` from `node_modules` (the published package,
65
+ an exact devDependency) and runs on captured envelope fixtures under `test/fixtures/envelopes/` — genuine
66
+ `otto_prepare_swap` outputs built by the constructor's own code over its real LI.FI captures and committed by
67
+ `scripts/capture-envelopes.ts` (the one file that touches `../otto-intel-mcp`, run only in the monorepo).
68
+
69
+ **Sealed-state rule (Base Flashblocks).** A receipt can arrive from a preconfirmation while `latest` — the
70
+ state gas estimates and reads run against — still predates it, and a preconfirmation is not final (a
71
+ Flashblock reorg = a preconfirmed transaction omitted from the sealed block). Both signers therefore count a
72
+ transaction as mined ONLY once `waitForSealedReceipt` has refetched its receipt and the sealed canonical
73
+ block at that height carries the receipt's block hash — before the next estimate and before the allowance
74
+ read-back after a clear; if that never happens within the bound, the step fails closed. Observed live
75
+ 2026-08-28: an approve preconfirmed, the swap call estimated against `latest`, `TRANSFER_FROM_FAILED` on an
76
+ allowance already granted. The wait is what a user's wallet observes.
77
+
78
+ `@coinbase/cdp-sdk` is an OPTIONAL peer: only the CDP-delegated signer needs it, and that signer lives on its
79
+ own entry — `import { CdpDelegatedSigner } from 'otto-execute/cdp'`. The root entry (`otto-execute`: verifier,
80
+ BYO/EOA signer, x402 table) and the CLI never resolve the SDK; `test/root-without-cdp.test.ts` loads the root
81
+ with the peer made unresolvable to prove it. The dev-only `.npmrc` (`legacy-peer-deps`) is **gone**: it existed
82
+ only because the SDK's optional `@x402` peers want `^2.19` while this package pinned `2.16.0`, and the
83
+ reconcile to `2.23.0` satisfies that range natively — `npm install` now succeeds with no flag.
84
+
85
+ > **`@x402/*` reconciled to 2.23.0 (2026-09-01)**, matching what `otto-intel-mcp@0.1.0` ships, so one
86
+ > `@x402/core` instance serves both packages.
87
+ >
88
+ > **`spendControls` is set explicitly, and KEPT.** `@x402/core` ≥ 2.23 seeds `spendControls = {}` in the
89
+ > bare `x402Client` constructor, which the SDK reads as *default assets only, capped at `$1` per payment*.
90
+ > `signX402Payment` states that cap in our own source (`PER_PAYMENT_SPEND_CAP = '$1'`) instead of inheriting
91
+ > it, so the number is visible here and a future SDK default cannot move it silently.
92
+ >
93
+ > It is deliberately **not** disabled. The table checks more *fields*, but it is **not an independent
94
+ > authority**: `table` is a parameter, and even the default table's `amount_atomic` values are hand-edited
95
+ > constants that nothing else cross-checks. A price typo (`5000` → `5000000`) leaves the table agreeing with
96
+ > itself perfectly, and only a cap derived from the requirement amount rather than from the table stands
97
+ > between that typo and a signature for 1000× the intended amount. Field coverage is not a second opinion.
98
+ >
99
+ > The SDK's rejection is translated into this package's own vocabulary as
100
+ > **`X402_SPEND_LIMIT_EXCEEDED`** — a distinct condition from `X402_AMOUNT_EXCEEDED` (which means the
101
+ > *challenge* disagreed with the table; this one means the *table* asked for more than the budget). Only
102
+ > that rejection is translated; every other error rethrows untouched.
103
+ >
104
+ > Three tests pin it, mutation-proved in both directions: a `$5` typo row is never signed (flip the cap to
105
+ > `false` and it signs; delete the translation and a raw SDK error escapes instead of an `X402Refusal`), a
106
+ > live-priced row still signs through the same path, and a challenge that disagrees with the table is still
107
+ > `X402_AMOUNT_EXCEEDED`.
108
+
109
+ ## Library
110
+
111
+ ```ts
112
+ import { verifySwapEnvelope, defaultAdapterPolicy, EoaSigner } from 'otto-execute';
113
+
114
+ const signer = EoaSigner.fromPrivateKey(process.env.MY_KEY as `0x${string}`, RPC_URL);
115
+ const verified = verifySwapEnvelope(envelopeFromMcp, {
116
+ profile: { kind: 'eoa', address: signer.address, chainId: 8453 },
117
+ intent: { fromToken: USDC, toToken: WETH, fromAmount: 2_000_000n, minAmountOut: myOwnFloor },
118
+ capabilities: signer.capabilities,
119
+ policy: defaultAdapterPolicy(8453),
120
+ prepareInput: theExactInputISentToOttoPrepareSwap, // optional: binds intent_digest to your request
121
+ });
122
+ await signer.signPlanDryRun(verified); // proves what would be signed; broadcasts nothing
123
+ await signer.sendPlan(verified); // Base mainnet, real funds
124
+ ```
125
+
126
+ `minAmountOut` is **yours**: derive it from a price source you trust. It is also the on-chain fence
127
+ for the swap's internal DEX legs (LI.FI's router reverts unless you receive at least it), so it must
128
+ be positive.
129
+
130
+ ## CLI
131
+
132
+ ```bash
133
+ otto-execute verify --envelope e.json --intent intent.json --account 0x… # no key needed
134
+ OTTO_EXECUTE_PRIVATE_KEY=0x… otto-execute sign --envelope e.json --intent intent.json # dry: sign + decode back
135
+ OTTO_EXECUTE_PRIVATE_KEY=0x… otto-execute submit --envelope e.json --intent intent.json --i-understand-mainnet
136
+ otto-execute x402 --challenge c.json # validate a 402 against the table
137
+ OTTO_EXECUTE_PRIVATE_KEY=0x… otto-execute x402 --challenge c.json --sign # emit x_payment
138
+ ```
139
+
140
+ Keys come from the environment only; a key value found in argv aborts. Refusals exit 2 with
141
+ `{ "refused": { "code", "message" } }`.
142
+
143
+ ## What is proven, and where
144
+
145
+ The consistency matrix (`MUTATION_CASES`, 24 rows) and the tests are the ones the Model-B proof kit
146
+ (`scripts/probes/model-b-proof/`, PR #1918, codex gate GO r5/5) shipped with; the kit now consumes this
147
+ package. Live sittings run from the kit's runbook (`docs/0826_model_b_proof_runbook.md`).
148
+
149
+ Not in this package by design: an allowlist of DEX contracts (the on-chain minimum-output bind is the
150
+ fence), user-side revocation claims (exists per CDP's API; unobserved), bridge / Polymarket / Hyperliquid
151
+ adapters (their constructors are merged; consumers follow).
@@ -0,0 +1,2 @@
1
+ /** cdp-signer.ts — re-exported from `./vendor/otto-intel-mcp/adapter-cdp-index.js` (moved 2026-08-29; see that entry's header). */
2
+ export { CDP_CAPABILITIES, CHAIN_ID, CdpDelegatedSigner, DelegatedPlanHaltedError, NETWORK, createCdpRig, openCdpRig, revocationRequested, revokeDelegationByUserId, type CdpDeveloperCredentials, type CdpEndUser, type CdpRawOperations, type CdpRig, } from './vendor/otto-intel-mcp/adapter-cdp-index.js';
@@ -0,0 +1,3 @@
1
+ /** cdp-signer.ts — re-exported from `./vendor/otto-intel-mcp/adapter-cdp-index.js` (moved 2026-08-29; see that entry's header). */
2
+ export { CDP_CAPABILITIES, CHAIN_ID, CdpDelegatedSigner, DelegatedPlanHaltedError, NETWORK, createCdpRig, openCdpRig, revocationRequested, revokeDelegationByUserId, } from './vendor/otto-intel-mcp/adapter-cdp-index.js';
3
+ //# sourceMappingURL=cdp-signer.js.map
package/dist/cdp.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * otto-execute/cdp — the CDP-delegated signer on its OWN entry, re-exported from `./vendor/otto-intel-mcp/adapter-cdp-index.js`
3
+ * (moved 2026-08-29). The optional `@coinbase/cdp-sdk` peer is loaded LAZILY, inside `openCdpRig` only: this
4
+ * entry imports safely without it, and a consumer that only signs with its own key imports `otto-execute`
5
+ * and never touches the SDK at all. Kept off the root export so the boundary stays visible.
6
+ */
7
+ export { CDP_CAPABILITIES, CdpDelegatedSigner, DelegatedPlanHaltedError, createCdpRig, openCdpRig, revocationRequested, revokeDelegationByUserId, type CdpDeveloperCredentials, type CdpEndUser, type CdpRawOperations, type CdpRig, } from './cdp-signer.js';
package/dist/cdp.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * otto-execute/cdp — the CDP-delegated signer on its OWN entry, re-exported from `./vendor/otto-intel-mcp/adapter-cdp-index.js`
3
+ * (moved 2026-08-29). The optional `@coinbase/cdp-sdk` peer is loaded LAZILY, inside `openCdpRig` only: this
4
+ * entry imports safely without it, and a consumer that only signs with its own key imports `otto-execute`
5
+ * and never touches the SDK at all. Kept off the root export so the boundary stays visible.
6
+ */
7
+ export { CDP_CAPABILITIES, CdpDelegatedSigner, DelegatedPlanHaltedError, createCdpRig, openCdpRig, revocationRequested, revokeDelegationByUserId, } from './cdp-signer.js';
8
+ //# sourceMappingURL=cdp.js.map
@@ -0,0 +1,2 @@
1
+ /** chain.ts — re-exported from `./vendor/otto-intel-mcp/adapter-index.js` (moved 2026-08-29; see that entry's header). */
2
+ export { waitForSealedReceipt, type SealedClient, type SealedWaitOptions, } from './vendor/otto-intel-mcp/adapter-index.js';
package/dist/chain.js ADDED
@@ -0,0 +1,3 @@
1
+ /** chain.ts — re-exported from `./vendor/otto-intel-mcp/adapter-index.js` (moved 2026-08-29; see that entry's header). */
2
+ export { waitForSealedReceipt, } from './vendor/otto-intel-mcp/adapter-index.js';
3
+ //# sourceMappingURL=chain.js.map
package/dist/cli.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * otto-execute CLI — the user's signer, from a terminal or an agent's shell.
4
+ *
5
+ * otto-execute verify --envelope e.json --account 0x… --intent intent.json [--prepare-input p.json]
6
+ * otto-execute sign --envelope e.json --intent intent.json [--prepare-input p.json] (dry: sign, decode back, never broadcast)
7
+ * otto-execute submit --envelope e.json --intent intent.json [--prepare-input p.json] --i-understand-mainnet (live: Base mainnet)
8
+ * otto-execute x402 --challenge c.json [--sign] (validate a 402 against the exact-match table; --sign emits x_payment)
9
+ * otto-execute delegate --email you@example.com [--expires-days 90]
10
+ * (agent path: email OTP → mint a Model-B delegation, bound to the project the PUBLIC fence read publishes; see src/delegate.ts)
11
+ * otto-execute delegation status|revoke --email you@example.com (end-user-side read-back / revoke, after an OTP sign-in)
12
+ *
13
+ * The signing key is read ONLY from `OTTO_EXECUTE_PRIVATE_KEY` (never argv). `verify` needs no key.
14
+ * intent.json: { "fromToken": "0x…", "toToken": "0x…", "fromAmount": "1000000", "minAmountOut": "395000000000000" }
15
+ * Output is one JSON document on stdout; refusals exit 2 with { "refused": { code, message } }.
16
+ */
17
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * otto-execute CLI — the user's signer, from a terminal or an agent's shell.
4
+ *
5
+ * otto-execute verify --envelope e.json --account 0x… --intent intent.json [--prepare-input p.json]
6
+ * otto-execute sign --envelope e.json --intent intent.json [--prepare-input p.json] (dry: sign, decode back, never broadcast)
7
+ * otto-execute submit --envelope e.json --intent intent.json [--prepare-input p.json] --i-understand-mainnet (live: Base mainnet)
8
+ * otto-execute x402 --challenge c.json [--sign] (validate a 402 against the exact-match table; --sign emits x_payment)
9
+ * otto-execute delegate --email you@example.com [--expires-days 90]
10
+ * (agent path: email OTP → mint a Model-B delegation, bound to the project the PUBLIC fence read publishes; see src/delegate.ts)
11
+ * otto-execute delegation status|revoke --email you@example.com (end-user-side read-back / revoke, after an OTP sign-in)
12
+ *
13
+ * The signing key is read ONLY from `OTTO_EXECUTE_PRIVATE_KEY` (never argv). `verify` needs no key.
14
+ * intent.json: { "fromToken": "0x…", "toToken": "0x…", "fromAmount": "1000000", "minAmountOut": "395000000000000" }
15
+ * Output is one JSON document on stdout; refusals exit 2 with { "refused": { code, message } }.
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { createInterface } from 'node:readline';
19
+ import { getAddress } from 'viem';
20
+ import { privateKeyToAccount } from 'viem/accounts';
21
+ import { DEFAULT_OTTO_MCP_URL, DelegateRefusal, UNSUPPORTED_PATH_NOTE, assertFencePresent, initEndUserSdk, isDelegateRefusal, mintDelegation, readDelegationAsUser, readPublicFenceStatus, redactSecrets, resolveDelegateEndpoints, revokeDelegationAsUser, signInWithEmailOtp, } from './delegate.js';
22
+ import { EoaSigner } from './eoa-signer.js';
23
+ import { isAdapterRefusal } from './refusal.js';
24
+ import { defaultAdapterPolicy, verifySwapEnvelope } from './verify.js';
25
+ import { validateX402Challenge, X402Refusal } from './x402-table.js';
26
+ const CHAIN_ID = 8453;
27
+ function usage() {
28
+ process.stderr.write('usage: otto-execute verify|sign|submit --envelope <file> --intent <file> [--account 0x…] [--prepare-input <file>] [--rpc <https url>] [--i-understand-mainnet]\n' +
29
+ ' otto-execute x402 --challenge <file> [--sign]\n' +
30
+ ' otto-execute delegate --email <you@example.com> [--expires-days 90]\n' +
31
+ ' otto-execute delegation status|revoke --email <you@example.com>\n');
32
+ process.exit(1);
33
+ }
34
+ function arg(name) {
35
+ const index = process.argv.indexOf(`--${name}`);
36
+ return index >= 0 ? process.argv[index + 1] : undefined;
37
+ }
38
+ function flag(name) {
39
+ return process.argv.includes(`--${name}`);
40
+ }
41
+ function readJson(path, what) {
42
+ if (!path)
43
+ throw new Error(`--${what} is required`);
44
+ return JSON.parse(readFileSync(path, 'utf8'));
45
+ }
46
+ function out(value) {
47
+ process.stdout.write(`${JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v), 2)}\n`);
48
+ }
49
+ function refuseAndExit(error) {
50
+ if (isAdapterRefusal(error) || error instanceof X402Refusal || isDelegateRefusal(error)) {
51
+ out({ refused: { code: error.code, message: redactSecrets(error.message) } });
52
+ process.exit(2);
53
+ }
54
+ process.stderr.write(`${redactSecrets(error instanceof Error ? error.message : String(error))}\n`);
55
+ process.exit(1);
56
+ }
57
+ function loadKey() {
58
+ const raw = process.env.OTTO_EXECUTE_PRIVATE_KEY?.trim();
59
+ if (!raw)
60
+ throw new Error('OTTO_EXECUTE_PRIVATE_KEY is required (environment only; never pass a key as an argument)');
61
+ const key = raw.startsWith('0x') ? raw : `0x${raw}`;
62
+ if (!/^0x[0-9a-fA-F]{64}$/.test(key))
63
+ throw new Error('OTTO_EXECUTE_PRIVATE_KEY must be a 32-byte hex private key');
64
+ return key;
65
+ }
66
+ function loadIntent() {
67
+ const raw = readJson(arg('intent'), 'intent');
68
+ const str = (k) => {
69
+ const v = raw[k];
70
+ if (typeof v !== 'string' || v === '')
71
+ throw new Error(`intent.${k} is required`);
72
+ return v;
73
+ };
74
+ return {
75
+ fromToken: getAddress(str('fromToken')),
76
+ toToken: getAddress(str('toToken')),
77
+ fromAmount: BigInt(str('fromAmount')),
78
+ minAmountOut: BigInt(str('minAmountOut')),
79
+ };
80
+ }
81
+ /**
82
+ * Startup guard, before any command runs: a 32-byte hex value anywhere in argv (any case, with or
83
+ * without 0x, as a token OR as the value of a `--flag=value` token) or a key-looking flag is refused
84
+ * outright — argv is visible in the process table and in shell history, whichever command was invoked.
85
+ */
86
+ function refuseSecretsInArgv() {
87
+ const argv = process.argv.slice(2);
88
+ const isKeyLike = (value) => /^(0x)?[0-9a-f]{64}$/i.test(value.trim());
89
+ const parts = (token) => token.includes('=')
90
+ ? [token.slice(0, token.indexOf('=')), token.slice(token.indexOf('=') + 1)]
91
+ : [token];
92
+ if (argv.some((token) => parts(token).some(isKeyLike))) {
93
+ throw new Error('refusing: a 32-byte hex value is present in argv; keys come from OTTO_EXECUTE_PRIVATE_KEY only');
94
+ }
95
+ if (argv.some((token) => /^--?(private-?key|key|secret|mnemonic|seed)(=|$)/i.test(token))) {
96
+ throw new Error('refusing: key-like flags are not accepted; keys come from OTTO_EXECUTE_PRIVATE_KEY only');
97
+ }
98
+ }
99
+ /* ------------------------------------------------------------------ *
100
+ * The delegate path runs with NO Otto credential in its process, only the known flags, a trusted fence
101
+ * source, and every error redacted. These guards run before the fence read or any CDP request.
102
+ * ------------------------------------------------------------------ */
103
+ const DELEGATE_SENSITIVE_ENV = [
104
+ 'OTTO_EXECUTE_PRIVATE_KEY',
105
+ 'OTTO_DELEGATION_SERVER_SECRET',
106
+ 'CDP_API_KEY_ID',
107
+ 'CDP_API_KEY_SECRET',
108
+ 'CDP_WALLET_SECRET',
109
+ ];
110
+ const DELEGATE_KNOWN_FLAGS = {
111
+ delegate: new Set(['email', 'expires-days']),
112
+ delegation: new Set(['email']),
113
+ };
114
+ /** Refuse if any Otto credential, the server secret, or ANY 64-hex value is present in the environment. */
115
+ function guardDelegateEnvironment() {
116
+ const named = DELEGATE_SENSITIVE_ENV.filter((n) => (process.env[n]?.trim() ?? '') !== '');
117
+ if (named.length > 0) {
118
+ throw new DelegateRefusal('CREDENTIAL_IN_ENVIRONMENT', `refusing: ${named.join(', ')} present in this process's environment; the delegate path runs with no Otto credential in-process (clear it and re-run)`);
119
+ }
120
+ const sixtyFourHex = /(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])/;
121
+ for (const [name, value] of Object.entries(process.env)) {
122
+ if (value && sixtyFourHex.test(value)) {
123
+ throw new DelegateRefusal('CREDENTIAL_IN_ENVIRONMENT', `refusing: a 64-hex value is present in the environment (${name}); the delegate path runs with no key material in-process (clear it and re-run)`);
124
+ }
125
+ }
126
+ }
127
+ /**
128
+ * Only the known long flags (and their values) plus the command's own positionals are accepted. Short flags
129
+ * (`-o 654321`), unknown long flags (`--otp/--code/--token`), and stray positionals (a bare `654321`) are all
130
+ * refused — so an OTP can never sit in argv (the process table / shell history) by ANY spelling.
131
+ */
132
+ function assertKnownDelegateFlags(command) {
133
+ const allowed = DELEGATE_KNOWN_FLAGS[command];
134
+ const argv = process.argv.slice(2);
135
+ // argv[0] is the command; `delegation` also takes a subcommand positional (status|revoke).
136
+ const maxPositionals = command === 'delegation' ? 2 : 1;
137
+ let positionals = 0;
138
+ const seenFlags = new Set();
139
+ for (let i = 0; i < argv.length; i += 1) {
140
+ const token = argv[i];
141
+ if (token.startsWith('--')) {
142
+ const name = token.slice(2).split('=')[0];
143
+ if (!allowed.has(name)) {
144
+ throw new DelegateRefusal('UNKNOWN_OPTION', `refusing: unknown option --${name}; the OTP is entered interactively and is never accepted as a flag`);
145
+ }
146
+ // A repeated flag would leave its extra value in argv — refuse before consuming it (no token echo).
147
+ if (seenFlags.has(name)) {
148
+ throw new DelegateRefusal('UNKNOWN_OPTION', `refusing: option --${name} is repeated; the OTP is entered interactively and is never passed as an argument`);
149
+ }
150
+ seenFlags.add(name);
151
+ // Consume this flag's value (the `--flag value` form) so it is not counted as a stray positional.
152
+ if (!token.includes('=') && i + 1 < argv.length && !argv[i + 1].startsWith('-'))
153
+ i += 1;
154
+ continue;
155
+ }
156
+ // NB: never echo the token here — a short-flag or bare positional could BE the OTP.
157
+ if (token.startsWith('-')) {
158
+ throw new DelegateRefusal('UNKNOWN_OPTION', 'refusing: a short option is not accepted; the OTP is entered interactively and is never passed as an argument');
159
+ }
160
+ positionals += 1;
161
+ if (positionals > maxPositionals) {
162
+ throw new DelegateRefusal('UNKNOWN_OPTION', 'refusing: an unexpected positional argument is present; the OTP is entered interactively and is never passed as an argument');
163
+ }
164
+ }
165
+ }
166
+ function hostIsLoopback(urlStr) {
167
+ try {
168
+ const host = new URL(urlStr).hostname.toLowerCase().replace(/^\[|\]$/g, '');
169
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ /**
176
+ * A sanity check on the resolved fence source: Otto's default MCP, or a loopback host (which can only arise
177
+ * from the in-process test global, never a caller's env/argv). A non-loopback, non-default source can only
178
+ * mean a mis-set test global — refuse it.
179
+ */
180
+ function assertFenceSourceTrusted(mcpUrl) {
181
+ if (mcpUrl === DEFAULT_OTTO_MCP_URL || hostIsLoopback(mcpUrl))
182
+ return;
183
+ throw new DelegateRefusal('FENCE_SOURCE_NOT_OTTO', 'refusing: the fence source is neither Otto’s MCP nor a loopback host; it is not trusted');
184
+ }
185
+ /** The SDK-endpoint override (`OTTO_DELEGATE_CDP_BASE_PATH`) is accepted only for a loopback host (the tests). */
186
+ function assertLoopbackBasePath(basePath) {
187
+ if (!basePath || hostIsLoopback(basePath))
188
+ return;
189
+ throw new DelegateRefusal('SDK_ENDPOINT_NOT_COINBASE', 'refusing: OTTO_DELEGATE_CDP_BASE_PATH redirects the SDK endpoint to a non-loopback host');
190
+ }
191
+ /**
192
+ * The OTP, read from the terminal WITHOUT echo (raw mode on a TTY; one un-echoed line when stdin is a pipe —
193
+ * an agent feeding the code). It is consulted once by the SDK and never written anywhere by this process.
194
+ */
195
+ async function readOtpMuted(email) {
196
+ process.stderr.write(`An OTP was sent to ${email}. Type it here (not echoed, never written): `);
197
+ const input = process.stdin;
198
+ if (input.isTTY && typeof input.setRawMode === 'function') {
199
+ return new Promise((resolve) => {
200
+ let code = '';
201
+ const cleanup = () => {
202
+ input.setRawMode(false);
203
+ input.pause();
204
+ input.off('data', onData);
205
+ };
206
+ const onData = (chunk) => {
207
+ for (const ch of chunk) {
208
+ if (ch === '\u0003') {
209
+ cleanup();
210
+ process.stderr.write('\n');
211
+ process.exit(130);
212
+ }
213
+ if (ch === '\r' || ch === '\n') {
214
+ cleanup();
215
+ process.stderr.write('\n');
216
+ resolve(code);
217
+ return;
218
+ }
219
+ if (ch === '\u007f' || ch === '\b') {
220
+ code = code.slice(0, -1);
221
+ continue;
222
+ }
223
+ code += ch;
224
+ }
225
+ };
226
+ input.setRawMode(true);
227
+ input.resume();
228
+ input.setEncoding('utf8');
229
+ input.on('data', onData);
230
+ });
231
+ }
232
+ const rl = createInterface({ input, terminal: false });
233
+ try {
234
+ for await (const line of rl)
235
+ return line;
236
+ }
237
+ finally {
238
+ rl.close();
239
+ }
240
+ return '';
241
+ }
242
+ /** Write the document, let stdout drain, then exit: cdp-core schedules refresh timers that would keep the process alive. */
243
+ async function finish(value) {
244
+ out(value);
245
+ await new Promise((resolve) => process.stdout.write('', () => resolve()));
246
+ process.exit(0);
247
+ }
248
+ async function delegateCommands(command) {
249
+ // No Otto credential in-process, only the known flags, a trusted fence source and SDK endpoint.
250
+ guardDelegateEnvironment();
251
+ assertKnownDelegateFlags(command);
252
+ const email = arg('email');
253
+ if (!email)
254
+ usage();
255
+ // Production: the fence is Otto's default MCP and the SDK is Coinbase's own endpoint — FIXED. The only
256
+ // override is the in-process test global, which a production invocation never sets (see resolveDelegateEndpoints).
257
+ const { mcpUrl, apiBasePath } = resolveDelegateEndpoints();
258
+ assertLoopbackBasePath(apiBasePath);
259
+ assertFenceSourceTrusted(mcpUrl);
260
+ if (command === 'delegate') {
261
+ // FENCE BEFORE AUTHORITY, and the mint is BOUND to the project the fence publishes — there is no CLI
262
+ // project-id override: an agent cannot read Otto's real fence and mint to some other project.
263
+ const fence = await readPublicFenceStatus(mcpUrl);
264
+ assertFencePresent(fence);
265
+ const days = arg('expires-days');
266
+ await initEndUserSdk(fence.project_id, { ...(apiBasePath ? { apiBasePath } : {}) });
267
+ const user = await signInWithEmailOtp(email, () => readOtpMuted(email));
268
+ const minted = await mintDelegation(days === undefined ? undefined : Number(days));
269
+ await finish({
270
+ delegated: {
271
+ user_id: user.userId,
272
+ address: user.address,
273
+ expires_at: minted.expiresAt,
274
+ days: minted.days,
275
+ fence: {
276
+ policy_name: fence.policy_name,
277
+ rules_digest: fence.rules_digest,
278
+ per_swap_cap_usd: fence.per_swap_cap_usd,
279
+ project_id: fence.project_id,
280
+ },
281
+ },
282
+ note: UNSUPPORTED_PATH_NOTE,
283
+ });
284
+ }
285
+ const sub = process.argv[3];
286
+ if (command === 'delegation' && (sub === 'status' || sub === 'revoke')) {
287
+ // Bind to the SAME published project id (present or not — status/revoke work after the fence is removed).
288
+ const fence = await readPublicFenceStatus(mcpUrl);
289
+ await initEndUserSdk(fence.project_id, { ...(apiBasePath ? { apiBasePath } : {}) });
290
+ const user = await signInWithEmailOtp(email, () => readOtpMuted(email));
291
+ if (sub === 'status') {
292
+ const grant = await readDelegationAsUser();
293
+ await finish({
294
+ user_id: user.userId,
295
+ address: user.address,
296
+ delegation: grant ? { active: true, expires_at: grant.expiresAt } : { active: false },
297
+ });
298
+ }
299
+ const revoked = await revokeDelegationAsUser();
300
+ // A revoke that did not take is a NON-ZERO refusal, never a success (the grant still reads back).
301
+ if (revoked.readsBack) {
302
+ throw new DelegateRefusal('REVOKE_NOT_CONFIRMED', 'the revoke did not take: the grant still reads back from CDP; nothing is confirmed');
303
+ }
304
+ await finish({
305
+ user_id: user.userId,
306
+ address: user.address,
307
+ revoked: true,
308
+ reads_back: false,
309
+ });
310
+ }
311
+ usage();
312
+ }
313
+ async function main() {
314
+ refuseSecretsInArgv();
315
+ const command = process.argv[2];
316
+ if (!command)
317
+ usage();
318
+ if (command === 'delegate' || command === 'delegation') {
319
+ await delegateCommands(command);
320
+ return;
321
+ }
322
+ if (command === 'x402') {
323
+ // Accept the raw PaymentRequired document OR the hosted MCP's challenge result / a capture wrapper
324
+ // ({ error: 'payment_required', payment_required: {...} }) — the document is what gets validated.
325
+ const loaded = readJson(arg('challenge'), 'challenge');
326
+ const challenge = loaded && typeof loaded === 'object' && 'payment_required' in loaded
327
+ ? loaded.payment_required
328
+ : loaded;
329
+ const validated = validateX402Challenge(challenge);
330
+ if (!flag('sign')) {
331
+ out({
332
+ validated: {
333
+ row: validated.row,
334
+ requirement: validated.requirement,
335
+ requirement_index: validated.requirementIndex,
336
+ },
337
+ });
338
+ return;
339
+ }
340
+ const signer = EoaSigner.fromPrivateKey(loadKey(), arg('rpc'));
341
+ const signed = await signer.signX402Payment(challenge, validated);
342
+ out({ payer: signer.address, row: validated.row.resource, x_payment: signed.header });
343
+ return;
344
+ }
345
+ if (command !== 'verify' && command !== 'sign' && command !== 'submit')
346
+ usage();
347
+ const envelope = readJson(arg('envelope'), 'envelope');
348
+ const intent = loadIntent();
349
+ const prepareInput = arg('prepare-input')
350
+ ? readJson(arg('prepare-input'), 'prepare-input')
351
+ : undefined;
352
+ let account;
353
+ let signer;
354
+ if (command === 'verify') {
355
+ const given = arg('account');
356
+ if (given) {
357
+ account = getAddress(given);
358
+ }
359
+ else {
360
+ account = getAddress(privateKeyToAccount(loadKey()).address);
361
+ }
362
+ }
363
+ else {
364
+ signer = EoaSigner.fromPrivateKey(loadKey(), arg('rpc'));
365
+ account = signer.address;
366
+ }
367
+ const verified = verifySwapEnvelope(envelope, {
368
+ profile: { kind: 'eoa', address: account, chainId: CHAIN_ID },
369
+ intent,
370
+ capabilities: signer?.capabilities ?? ['send_evm_transaction', 'sign_x402_payment'],
371
+ policy: defaultAdapterPolicy(CHAIN_ID),
372
+ ...(prepareInput ? { prepareInput } : {}),
373
+ });
374
+ const summary = {
375
+ account,
376
+ checks: verified.checks.map((c) => c.id),
377
+ token: verified.token,
378
+ output_token: verified.outputToken,
379
+ gross: verified.gross,
380
+ otto_fee: verified.ottoFee,
381
+ min_amount_out: verified.minAmountOut,
382
+ fee_distributions: verified.fees.distributions,
383
+ valid_until: verified.artifact.valid_until,
384
+ artifact_id: verified.artifact.artifact_id,
385
+ };
386
+ if (command === 'verify') {
387
+ out({ verified: summary });
388
+ return;
389
+ }
390
+ if (command === 'sign') {
391
+ out({ verified: summary, signed_dry_run: await signer.signPlanDryRun(verified) });
392
+ return;
393
+ }
394
+ if (!flag('i-understand-mainnet')) {
395
+ throw new Error('submit broadcasts on Base MAINNET with real funds; pass --i-understand-mainnet to proceed');
396
+ }
397
+ out({ verified: summary, submitted: await signer.sendPlan(verified) });
398
+ }
399
+ main().catch(refuseAndExit);
400
+ //# sourceMappingURL=cli.js.map