From b58be757a74640441c8d30c45dfce882b5c48759 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Wed, 22 Apr 2026 15:25:09 -0700 Subject: [PATCH 01/76] scaffold actions-cli package skeleton --- packages/cli/.prettierignore | 2 ++ packages/cli/package.json | 45 +++++++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 8 +++++++ packages/cli/tsconfig.json | 23 ++++++++++++++++++ packages/cli/vitest.config.ts | 14 +++++++++++ pnpm-lock.yaml | 37 +++++++++++++++++++++++----- 6 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 packages/cli/.prettierignore create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/vitest.config.ts diff --git a/packages/cli/.prettierignore b/packages/cli/.prettierignore new file mode 100644 index 000000000..1eae0cf67 --- /dev/null +++ b/packages/cli/.prettierignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..200b91f3e --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,45 @@ +{ + "name": "actions-cli", + "private": true, + "description": "Agent-first command-line interface for the Actions SDK.", + "repository": { + "type": "git", + "url": "https://github.com/ethereum-optimism/actions.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/ethereum-optimism/actions/tree/main/packages/cli#readme", + "bugs": { + "url": "https://github.com/ethereum-optimism/actions/issues" + }, + "version": "0.1.0", + "type": "module", + "bin": { + "actions": "./dist/index.js" + }, + "files": [ + "dist", + "SKILL.md", + "README.md" + ], + "scripts": { + "build": "pnpm clean && tsc && resolve-tspaths && chmod +x dist/index.js", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "dev": "tsx src/index.ts", + "lint": "eslint \"**/*.{ts,tsx}\" && prettier --check \"**/*.{ts,tsx}\"", + "lint:fix": "eslint \"**/*.{ts,tsx}\" --fix && prettier \"**/*.{ts,tsx}\" --write --log-level=warn", + "start": "node dist/index.js", + "test": "vitest --run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@eth-optimism/actions-sdk": "workspace:*", + "commander": "^13.1.0", + "envalid": "^8.1.0", + "viem": "^2.24.1" + }, + "devDependencies": { + "@types/node": "22.14.0", + "typescript": "^5.2.2", + "vitest": "^1.6.1" + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 000000000..04f5b9b2a --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import { Command } from 'commander' + +const program = new Command() + .name('actions') + .description('Agent-first CLI for the Actions SDK.') + +await program.parseAsync(process.argv) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..e0041943d --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "rootDir": "src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "target": "es2021", + "lib": ["esnext"], + "strict": true, + "composite": true, + "outDir": "dist", + "moduleResolution": "NodeNext", + "module": "NodeNext", + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "noEmit": false + } +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 000000000..a0e342443 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,14 @@ +import path from 'node:path' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + testTimeout: 30_000, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd2e2a5ee..4d0d8773f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,31 @@ importers: specifier: ^4.0.0 version: 4.21.0 + packages/cli: + dependencies: + '@eth-optimism/actions-sdk': + specifier: workspace:* + version: link:../sdk + commander: + specifier: ^13.1.0 + version: 13.1.0 + envalid: + specifier: ^8.1.0 + version: 8.1.1 + viem: + specifier: 2.33.0 + version: 2.33.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@types/node': + specifier: 22.14.0 + version: 22.14.0 + typescript: + specifier: ^5.2.2 + version: 5.9.3 + vitest: + specifier: ^1.6.1 + version: 1.6.1(@types/node@22.14.0)(jsdom@23.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2) + packages/demo/backend: dependencies: '@eth-optimism/actions-sdk': @@ -12643,7 +12668,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -13280,7 +13305,7 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 '@noble/curves': 1.9.7 - '@noble/hashes': 1.4.0 + '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) agentkeepalive: 4.6.0 @@ -18751,11 +18776,11 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.2(typescript@5.9.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -18766,11 +18791,11 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.9.3)(zod@4.1.13) + abitype: 1.2.2(typescript@5.9.3)(zod@4.1.13) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 From 52ba69fb506f27d63db29bd16b20ef055d555410 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Wed, 22 Apr 2026 15:28:33 -0700 Subject: [PATCH 02/76] scope no-console error to cli --- .changeset/add-actions-cli-package.md | 8 ++++++++ eslint.config.js | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-actions-cli-package.md diff --git a/.changeset/add-actions-cli-package.md b/.changeset/add-actions-cli-package.md new file mode 100644 index 000000000..a837ae9b6 --- /dev/null +++ b/.changeset/add-actions-cli-package.md @@ -0,0 +1,8 @@ +--- +'actions-cli': minor +--- + +Add actions-cli package: agent-first CLI for the Actions SDK. Ships scaffolding, +JSON output pipeline, smart-wallet bootstrap, and smoke commands (`assets`, +`chains`, `wallet address`, `wallet balance`). Lend and swap namespaces land +in subsequent PRs. diff --git a/eslint.config.js b/eslint.config.js index 6ee17d786..7dffe8a96 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,7 +19,7 @@ const importRule = fixupPluginRules(importPlugin); const typescript = fixupPluginRules(tseslintPlugin); // JavaScript and TypeScript files -module.exports = { +const baseConfig = { files: ['**/*.{js,jsx,ts,tsx}'], ignores: ['**/.storybook/**', '**/dist/**'], plugins: { @@ -104,3 +104,15 @@ module.exports = { 'lib/**', ] } + +// actions-cli: agent-consumed output must be JSON-only; any stray console +// call pollutes stdout/stderr and breaks the subprocess contract. +const cliConfig = { + files: ['packages/cli/src/**/*.{ts,tsx}'], + rules: { + 'no-console': 'error', + }, +} + +module.exports = [baseConfig, cliConfig] + From 2eb51750fb5b30c72724201617f9d6dc2014e69c Mon Sep 17 00:00:00 2001 From: its-everdred Date: Wed, 22 Apr 2026 18:03:56 -0700 Subject: [PATCH 03/76] extract serializeBigInt to sdk util --- .../demo/backend/src/controllers/assets.ts | 2 +- packages/demo/backend/src/controllers/lend.ts | 6 ++- packages/demo/backend/src/controllers/swap.ts | 2 +- packages/demo/backend/src/services/wallet.ts | 7 ++- .../demo/backend/src/utils/serializers.ts | 11 ---- packages/sdk/src/index.ts | 1 + .../src/utils/__tests__/serializers.test.ts | 53 +++++++++++++++++++ packages/sdk/src/utils/serializers.ts | 21 ++++++++ 8 files changed, 86 insertions(+), 17 deletions(-) delete mode 100644 packages/demo/backend/src/utils/serializers.ts create mode 100644 packages/sdk/src/utils/__tests__/serializers.test.ts create mode 100644 packages/sdk/src/utils/serializers.ts diff --git a/packages/demo/backend/src/controllers/assets.ts b/packages/demo/backend/src/controllers/assets.ts index 7a3bf49f9..ddbf2de1a 100644 --- a/packages/demo/backend/src/controllers/assets.ts +++ b/packages/demo/backend/src/controllers/assets.ts @@ -1,7 +1,7 @@ +import { serializeBigInt } from '@eth-optimism/actions-sdk' import type { Context } from 'hono' import { getActions } from '@/config/actions.js' -import { serializeBigInt } from '@/utils/serializers.js' /** * GET - Retrieve configured supported assets diff --git a/packages/demo/backend/src/controllers/lend.ts b/packages/demo/backend/src/controllers/lend.ts index 7895c25f5..02722adcd 100644 --- a/packages/demo/backend/src/controllers/lend.ts +++ b/packages/demo/backend/src/controllers/lend.ts @@ -1,4 +1,7 @@ -import type { SupportedChainId } from '@eth-optimism/actions-sdk' +import { + serializeBigInt, + type SupportedChainId, +} from '@eth-optimism/actions-sdk' import type { Context } from 'hono' import type { Address } from 'viem' import { z } from 'zod' @@ -6,7 +9,6 @@ import { z } from 'zod' import { errorResponse, requireAuth } from '@/helpers/errors.js' import { validateRequest } from '@/helpers/validation.js' import * as lendService from '@/services/lend.js' -import { serializeBigInt } from '@/utils/serializers.js' const tokenAddressSchema = z .string() diff --git a/packages/demo/backend/src/controllers/swap.ts b/packages/demo/backend/src/controllers/swap.ts index 6afe6930b..e8f2929d5 100644 --- a/packages/demo/backend/src/controllers/swap.ts +++ b/packages/demo/backend/src/controllers/swap.ts @@ -1,5 +1,6 @@ import { ACTIONS_SUPPORTED_CHAIN_IDS, + serializeBigInt, type SupportedChainId, } from '@eth-optimism/actions-sdk' import type { Context } from 'hono' @@ -9,7 +10,6 @@ import { z } from 'zod' import { errorResponse, requireAuth } from '@/helpers/errors.js' import { validateRequest } from '@/helpers/validation.js' import * as swapService from '@/services/swap.js' -import { serializeBigInt } from '@/utils/serializers.js' const supportedChainIds = ACTIONS_SUPPORTED_CHAIN_IDS as readonly number[] const providerEnum = z.enum(['uniswap', 'velodrome']).optional() diff --git a/packages/demo/backend/src/services/wallet.ts b/packages/demo/backend/src/services/wallet.ts index 46df028ab..78512e90f 100644 --- a/packages/demo/backend/src/services/wallet.ts +++ b/packages/demo/backend/src/services/wallet.ts @@ -6,7 +6,11 @@ import type { UserOperationTransactionReceipt, Wallet, } from '@eth-optimism/actions-sdk' -import { getAssetAddress, USDC_DEMO } from '@eth-optimism/actions-sdk' +import { + getAssetAddress, + serializeBigInt, + USDC_DEMO, +} from '@eth-optimism/actions-sdk' import type { User } from '@privy-io/node' import type { Address } from 'viem' import { encodeFunctionData, formatUnits, getAddress } from 'viem' @@ -15,7 +19,6 @@ import { baseSepolia } from 'viem/chains' import { mintableErc20Abi } from '@/abis/mintableErc20Abi.js' import { getActions, getPrivyClient } from '@/config/actions.js' import { getBlockExplorerUrls } from '@/utils/explorers.js' -import { serializeBigInt } from '@/utils/serializers.js' /** * Options for getting all wallets diff --git a/packages/demo/backend/src/utils/serializers.ts b/packages/demo/backend/src/utils/serializers.ts deleted file mode 100644 index bbdd248a1..000000000 --- a/packages/demo/backend/src/utils/serializers.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Serialize object with BigInt values to plain object with string values - * Useful for Hono's c.json() which calls JSON.stringify internally - */ -export function serializeBigInt(obj: T): T { - return JSON.parse( - JSON.stringify(obj, (key, value) => - typeof value === 'bigint' ? value.toString() : value, - ), - ) -} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a6d9c5aa0..dffbf6022 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -112,6 +112,7 @@ export type { WalletSwapParams, } from '@/types/index.js' export { getAssetAddress, isAssetSupportedOnChain } from '@/utils/assets.js' +export { serializeBigInt } from '@/utils/serializers.js' export * from '@/wallet/core/error/errors.js' export { Wallet } from '@/wallet/core/wallets/abstract/Wallet.js' export { SmartWallet } from '@/wallet/core/wallets/smart/abstract/SmartWallet.js' diff --git a/packages/sdk/src/utils/__tests__/serializers.test.ts b/packages/sdk/src/utils/__tests__/serializers.test.ts new file mode 100644 index 000000000..03bd8ccb0 --- /dev/null +++ b/packages/sdk/src/utils/__tests__/serializers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { serializeBigInt } from '@/utils/serializers.js' + +describe('serializeBigInt', () => { + it('coerces bigints to decimal strings', () => { + expect(serializeBigInt({ amount: 1n })).toEqual({ amount: '1' }) + }) + + it('preserves precision for large bigints', () => { + const huge = 1234567890123456789n + expect(serializeBigInt({ n: huge })).toEqual({ n: '1234567890123456789' }) + }) + + it('never emits scientific notation', () => { + const big = 10n ** 30n + expect(serializeBigInt({ n: big }).n).toBe( + '1000000000000000000000000000000', + ) + }) + + it('recurses through arrays and nested objects', () => { + expect( + serializeBigInt({ + balances: [ + { chainId: 84532, amount: 100n }, + { chainId: 10, amount: 200n }, + ], + }), + ).toEqual({ + balances: [ + { chainId: 84532, amount: '100' }, + { chainId: 10, amount: '200' }, + ], + }) + }) + + it('passes through non-bigint primitives untouched', () => { + expect(serializeBigInt({ s: 'hello', n: 42, b: true, z: null })).toEqual({ + s: 'hello', + n: 42, + b: true, + z: null, + }) + }) + + it('returns a fresh object (no shared references)', () => { + const input = { a: { b: 1n } } + const output = serializeBigInt(input) + expect(output).not.toBe(input) + expect(output.a).not.toBe(input.a) + }) +}) diff --git a/packages/sdk/src/utils/serializers.ts b/packages/sdk/src/utils/serializers.ts new file mode 100644 index 000000000..44784d8d5 --- /dev/null +++ b/packages/sdk/src/utils/serializers.ts @@ -0,0 +1,21 @@ +/** + * @description Deep-clones an object, replacing every `bigint` with its + * decimal string form. Needed because `JSON.stringify` throws on `bigint` + * but Actions SDK return types carry `bigint` amounts, balances, and ids. + * + * The returned object preserves the input type signature for ergonomics; + * `bigint` fields are strings at runtime and callers must treat them as + * such. Use only at serialization boundaries (HTTP responses, CLI stdout). + * @param obj - Value to clone. Objects, arrays, and primitives are + * supported; cycles, `Map`, `Set`, `Date`, and `undefined` follow standard + * `JSON.stringify` semantics. + * @returns A structurally identical clone with every `bigint` coerced to + * its base-10 string representation. + */ +export function serializeBigInt(obj: T): T { + return JSON.parse( + JSON.stringify(obj, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), + ) +} From 394bfef89609f040953f488d9ba6f36cb7b5d9d5 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Wed, 22 Apr 2026 18:07:58 -0700 Subject: [PATCH 04/76] add pr 408 handoff doc --- .../2026-04-22-actions-cli-pr408-handoff.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md diff --git a/docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md b/docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md new file mode 100644 index 000000000..3f5e3725a --- /dev/null +++ b/docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md @@ -0,0 +1,287 @@ +--- +title: Actions CLI PR #408 scaffolding — handoff +type: handoff +status: active +date: 2026-04-22 +branch: feat/cli-scaffolding +github_issue: https://github.com/ethereum-optimism/actions/issues/408 +parent_issue: https://github.com/ethereum-optimism/actions/issues/407 +origin_brainstorm: docs/brainstorms/2026-04-21-actions-cli-brainstorm.md +origin_plan: docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md +--- + +# Actions CLI PR #408 — Handoff + +Pick up a scaffolding PR for a new `packages/cli/` workspace package. The CLI +is an **agent-first subprocess**: JSON on stdout, JSON error envelope on +stderr, five-value exit-code taxonomy, consumed by the `opie` Slack bot +(`git@github.com:ethereum-optimism/opie.git`). + +## Where everything lives + +| Artifact | Location | +|---|---| +| Working directory (worktree) | `/Users/kevin/github/optimism/actions-cli-scaffolding` | +| Branch | `feat/cli-scaffolding` (pushed, tracking `origin/feat/cli-scaffolding`) | +| GitHub issue | | +| Parent issue | | +| Brainstorm | `docs/brainstorms/2026-04-21-actions-cli-brainstorm.md` (on branch `kevin/actions-cli`, PR #420) | +| **Plan (source of truth)** | `docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md` (on branch `kevin/actions-cli`, 734 lines, ce-deepened) | +| Engineering principles | (treat as binding) | +| Base commit | cut from `origin/main` at `58fc354d` (post-#356, so `hostedWalletConfig` is optional) | + +## Commits already on the branch + +``` +c5a557b5 extract serializeBigInt to sdk util +ff235127 scope no-console error to cli +5969bca0 scaffold actions-cli package skeleton +``` + +## Task tracker state (24 total) + +Use the existing TaskList. Completed: **#1 worktree, #2 skeleton, #3 eslint+changeset, +#4 serializeBigInt extraction**. Next up: **#5 writeJson**. Then #6 … #24 in order. +Dependencies are roughly linear — don't parallelize unless you re-read the plan's +interaction graph. + +| # | Task | +|---|---| +| 5 | writeJson + tests | +| 6 | CliError + safeDetails + tests | +| 7 | writeError + EPIPE handling | +| 8 | Lazy requireEnv + contract test | +| 9 | Demo chains constants | +| 10 | Demo markets constants | +| 11 | Demo config + loadConfig | +| 12 | Asset resolver + tests | +| 13 | Chain resolver + tests | +| 14 | baseContext + tests | +| 15 | walletContext + tests | +| 16 | `assets` command + tests | +| 17 | `chains` command + tests | +| 18 | `wallet address` command + tests | +| 19 | `wallet balance` command + tests | +| 20 | Wire top-level index.ts | +| 21 | picocolors for --help + stderr `Error:` label only | +| 22 | SKILL.md + README.md | +| 23 | System tests per command | +| 24 | Final gates + open PR | + +## Standing directives (from the user — do not deviate) + +- **Commits:** 3–7 words, no AI/Claude mention, one `git add + git commit + git push` + command per logical unit. +- **Verify before every commit:** `pnpm typecheck && pnpm lint` at repo root. + Run `pnpm -C packages/sdk test` when SDK changes, `pnpm -C packages/cli test` + when CLI changes. +- **Zero new lint warnings** — the backend has 75 pre-existing warnings and the + SDK has 72. Do not let those counts increase. The CLI package itself must + stay at 0. +- **picocolors scope:** `--help` output + stderr `Error:` label only. stdout + JSON payload stays ANSI-free — **asserted by integration test** (task 23). + +## Engineering principles to apply (from issue #380) + +- **Reuse before invention** — grep canonical locations before writing new utils, + mocks, fixtures. Extraction trigger = **second concrete usage**, not speculative. + That's why `serializeBigInt` now lives in the SDK (CLI was the second usage; + backend was the first). +- **Viem patterns:** named concrete error classes at throw sites only where callers + need `instanceof`. For `CliError`, keep a single class with a `code` discriminator + — the agent contract is the `err.code` string, not `instanceof`. +- **Type narrowness:** `SupportedChainId` not `number`, `Hex` not `string`, `Asset` + not loose object shapes. No `any`. No `as Foo` casts — narrow at the source. + Use `import type` for type-only symbols. +- **Structure:** ≤20 lines of logic per function, ≤200 lines per file, max 2 + nesting levels, prefer early returns / guard clauses. +- **JSDoc on every public function/class:** `@description` (what + why, not how), + `@param`, `@returns`, `@throws`. +- **No module-level singletons.** CLI constructs `Actions` fresh per command via + `baseContext()` / `walletContext()`. The backend's `let actionsInstance` is an + anti-pattern for a short-lived subprocess. + +## Key SDK references (verified on current HEAD) + +- `packages/sdk/src/types/actions.ts` — `ActionsConfig`, `WalletConfig` + (`hostedWalletConfig?:`), `NodeActionsConfig`, `SwapConfig` is + `RequireAtLeastOne<{uniswap?, velodrome?}>` — **do not write `swap: {}`**, + omit the key entirely. +- `packages/sdk/src/wallet/core/namespace/WalletNamespace.ts` — `getSmartWallet`, + `toActionsWallet`, `ToActionsWalletParam`. +- `packages/sdk/src/wallet/core/providers/smart/default/DefaultSmartWalletProvider.ts` + — `getWalletAddress` performs **one `eth_call`** to the factory. `wallet address` + is RPC-bound, not pure. +- `packages/sdk/src/wallet/core/wallets/abstract/Wallet.ts` — `getBalance` uses + nested `Promise.all` over (asset × chain). One failing RPC fails all 9 reads. + Document in SKILL.md (plan already specifies this). +- `packages/sdk/src/nodeActionsFactory.ts` — `createActions` + entry point. +- `packages/sdk/src/constants/assets.ts` — `USDC_DEMO`, `OP_DEMO`, `ETH`, `WETH` + canonical exports. **Do not create `src/demo/assets.ts`.** Import from the SDK. +- `packages/sdk/src/utils/serializers.ts` — **just extracted in commit `c5a557b5`; + import via `@eth-optimism/actions-sdk`**. +- `packages/sdk/src/utils/test.ts` — `ANVIL_ACCOUNTS` fixtures for + deterministic-address unit tests. + +## CLI package layout + +From the plan's architecture section, Decision 13. + +``` +packages/cli/src/ + index.ts # bin entrypoint — commander + EPIPE + uncaughtException + commands/ + assets.ts + chains.ts + wallet/ + index.ts # registers wallet subcommand + children + address.ts + balance.ts + config/ + loadConfig.ts # returns resolved NodeActionsConfig + env.ts # lazy envalid (NO module-top-level cleanEnv) + context/ + baseContext.ts # { config, actions } — read-only commands + walletContext.ts # { config, actions, signer, smartWallet } — wallet commands + output/ + json.ts # writeJson → stdout + errors.ts # CliError, ErrorCode, safeDetails, writeError → stderr + exit + resolvers/ + assets.ts # symbol → Asset (case-insensitive) from config.assets.allow + chains.ts # shortname ↔ SupportedChainId; round-trip property tested + utils/ + (serializeBigInt NOT here — imports from SDK now) + services/ # empty in PR 1; exists for PR 2/3 to grow into + demo/ # everything demo-specific + config.ts # baked NodeActionsConfig + chains.ts # BASE_SEPOLIA, OPTIMISM_SEPOLIA, UNICHAIN (NO bundler in PR 1) + markets.ts # GauntletUSDCDemo, AaveETH (used by PR 2; referenced via demo config's lend allowlist now) + # NO assets.ts — import USDC_DEMO, OP_DEMO, ETH from SDK +SKILL.md # Agent Skills spec frontmatter +README.md +``` + +**Directory names are deliberate:** `core/` is forbidden (SDK reserves it at +four levels). Nested `commands/wallet/` is deliberate for subcommand composition +— PR 2/3 add `commands/wallet/lend/` and `commands/wallet/swap/` under it. + +## Smoke commands this PR ships + +- `actions assets` — `actions.getSupportedAssets()` (no wallet needed) +- `actions chains` — enumerate `config.chains` via the chain resolver's inverse + (no SDK call) +- `actions wallet address` — `smartWallet.address` (1 RPC to factory) +- `actions wallet balance` — `smartWallet.getBalance()` (N×M RPCs) + +Lend/swap branches are **deliberately not registered** in PR 1. Commander's +default "unknown command" error (exit 1, plain text on stderr) is acceptable — +**don't route unknown commands through `writeError`**. Lock this distinction +in task 23's integration tests. + +## Error taxonomy (exit codes + retryable defaults) + +``` +unknown=1 retryable=false (fallback for uncaught errors) +validation=2 retryable=false +config=3 retryable=false (missing env, malformed PRIVATE_KEY, malformed config) +network=4 retryable=true (RPC failure — includes wallet-address factory read) +onchain=5 retryable=false (PR 2/3 may flip for nonce conflicts etc.) +``` + +Error body shape: + +```json +{ "error": "message", "code": "network", "retryable": true, "retry_after_ms": 1000, "details": { } } +``` + +**`details` must be redacted** via `safeDetails()` before serialization. viem +errors pack bundler URLs (containing Pimlico/Alchemy API keys), raw request +payloads, and signer metadata into `.details` / `.metaMessages`. Unit tests in +task 6 must assert: + +- URL API-key path segments are stripped (pattern: `/v[0-9]+/[^/]+/rpc(\?.*)?`) +- viem `Error` objects are reduced to `{ errorName, shortMessage }` +- Signer `publicKey` / `address` metadata never passes through + +## Lazy envalid contract (task 8) + +`actions --help` must work with **no env vars set**. envalid's `cleanEnv` cannot +be called at module top-level. Structurally enforce with a test: + +```ts +import * as envalid from 'envalid' +const spy = vi.spyOn(envalid, 'cleanEnv') +await import('../config/env.js') // Must NOT call cleanEnv +expect(spy).not.toHaveBeenCalled() +requireEnv('PRIVATE_KEY') // Must call it now +expect(spy).toHaveBeenCalledOnce() +``` + +## System tests (task 23) — use `execFile` against built `dist/index.js` + +Minimum coverage per user directive ("e2e for each granular actions function call"): + +- `actions assets` → stdout parses as JSON array, exit 0, no ANSI on stdout +- `actions chains` → stdout parses as JSON array, exit 0 +- `actions wallet address` → happy path (fixed PRIVATE_KEY via `ANVIL_ACCOUNTS.ACCOUNT_0`, + deterministic address match). Requires either anvil or a mock at the RPC + layer. Decide during implementation. +- `actions wallet balance` → happy path (mocked or anvil) +- `actions wallet address` with no `PRIVATE_KEY` → stderr JSON `code: "config"`, exit 3 +- `actions wallet balance` with `BASE_SEPOLIA_RPC_URL=http://127.0.0.1:1` (blackhole) + → stderr JSON `code: "network"`, `retryable: true`, exit 4 +- `actions ` → commander default plain-text error on stderr, exit 1 + (**not** `writeError` JSON — lock this distinction) +- `actions --help` → exit 0 with no env set + +Tests must `beforeAll(() => pnpm -C packages/cli build)` or rely on CI having +built first. Document the choice in the test file. + +## Final PR (task 24) + +- Body must link #408 and the plan file path on the `kevin/actions-cli` branch. +- Include the **Post-Deploy Monitoring & Validation** section required by + `/workflows:work` — for a dev-tool CLI with no production runtime, a one-liner + `No additional operational monitoring required: agent-facing dev tool with no server component` + is acceptable. +- Mark checkboxes in the plan file (`[ ]` → `[x]`) before committing the final + changes. The plan lives on `kevin/actions-cli`; you can either cherry-pick / + update those checkboxes in a separate small PR, or include a diff against + that branch in this one. Discuss with Kevin — he may want to defer. + +## Things that bit me; don't repeat + +1. **`mkdir` / `git` / `pnpm` are not on the login shell PATH** when Bash + commands run without a shell init — use absolute paths + (`/opt/homebrew/bin/git`, `/Users/kevin/Library/pnpm/pnpm`). +2. **Prettier lints `dist/`** unless you add a local `packages/cli/.prettierignore`. + Already created. +3. **SDK must be rebuilt** (`pnpm -C packages/sdk build`) after touching + `packages/sdk/src/**` — the backend typecheck resolves + `@eth-optimism/actions-sdk` through `packages/sdk/dist/`. +4. **`@/` path alias** needs `resolve-tspaths` in the CLI build script (already + wired). Test for it by running the built binary from a fresh clone — + `node dist/index.js --help` must resolve all `@/` imports. +5. **Shebang preservation** — TSC keeps it only if it's the very first token in + `src/index.ts`. Don't add leading comments. Already working. + +## Open product question flagged by the plan (do not resolve — surface to Kevin) + +**#412 (1-of-2 signer onboarding)** — the plan notes this is *weaker* than 1-of-1 +(more keys that can independently drain). If the intent is user-recovery / +fallback authority, 1-of-2 is correct but should be reframed as recovery. If +the intent is "user co-signs high-value actions," it needs to be k-of-n with +k>1, which needs a kernel that supports it (ZeroDev). Raise before #412 is +implemented. + +## Blocker for PR #409 (not this PR, but don't forget) + +PR #409 (lend) is **blocked on an offchain spending cap** +(`ACTIONS_SPEND_CAP_USD` / `_WEI`) enforced in the handler before UserOp +construction. ~20 LOC, no ZeroDev dependency. Full #414 onchain Call Policies +is a later follow-up. Call this out in the #409 kickoff. + +--- + +Branch is clean at `c5a557b5`. Start with task 5 (`writeJson`). Good luck. From 81d8f8d4a37781d9bb8197f3823463264ca1005e Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 10:51:21 -0700 Subject: [PATCH 05/76] add writeJson stdout helper --- .../cli/src/output/__tests__/json.test.ts | 44 +++++++++++++++++++ packages/cli/src/output/json.ts | 16 +++++++ 2 files changed, 60 insertions(+) create mode 100644 packages/cli/src/output/__tests__/json.test.ts create mode 100644 packages/cli/src/output/json.ts diff --git a/packages/cli/src/output/__tests__/json.test.ts b/packages/cli/src/output/__tests__/json.test.ts new file mode 100644 index 000000000..05f442a96 --- /dev/null +++ b/packages/cli/src/output/__tests__/json.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { writeJson } from '@/output/json.js' + +describe('writeJson', () => { + const writeSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + afterEach(() => { + writeSpy.mockClear() + }) + + const captured = (): string => { + const call = writeSpy.mock.calls[0]?.[0] + return typeof call === 'string' ? call : call!.toString() + } + + it('emits a trailing newline', () => { + writeJson({ ok: true }) + expect(captured().endsWith('\n')).toBe(true) + }) + + it('coerces bigints to decimal strings', () => { + writeJson({ amount: 1234567890123456789n }) + const parsed = JSON.parse(captured()) + expect(parsed).toEqual({ amount: '1234567890123456789' }) + }) + + it('pretty-prints with two-space indentation', () => { + writeJson({ a: 1 }) + expect(captured()).toBe('{\n "a": 1\n}\n') + }) + + it('serialises arrays and nested objects', () => { + writeJson([{ chainId: 84532, balance: 100n }]) + expect(JSON.parse(captured())).toEqual([{ chainId: 84532, balance: '100' }]) + }) + + it('passes primitives through unchanged', () => { + writeJson(null) + expect(captured()).toBe('null\n') + }) +}) diff --git a/packages/cli/src/output/json.ts b/packages/cli/src/output/json.ts new file mode 100644 index 000000000..b8892d495 --- /dev/null +++ b/packages/cli/src/output/json.ts @@ -0,0 +1,16 @@ +import { serializeBigInt } from '@eth-optimism/actions-sdk' + +/** + * @description Writes a JSON-serialised document to stdout, terminated by a + * newline. Any `bigint` values are coerced to decimal strings via + * `serializeBigInt` so the output is parseable by any JSON consumer. + * + * The CLI's agent contract is "stdout is a bare JSON document per invocation" — + * use this helper as the single stdout sink for successful command output. + * Error output goes to stderr via `writeError`, never here. + * @param doc - Any JSON-coercible value. Objects, arrays, and primitives are + * supported; `bigint` fields are stringified. + */ +export function writeJson(doc: unknown): void { + process.stdout.write(JSON.stringify(serializeBigInt(doc), null, 2) + '\n') +} From 9a753f9fed5a12f17add8c7b074cc35060114c0c Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:00:58 -0700 Subject: [PATCH 06/76] add CliError taxonomy and safeDetails --- .../cli/src/output/__tests__/errors.test.ts | 131 +++++++++++ .../src/output/__tests__/writeError.test.ts | 109 +++++++++ packages/cli/src/output/errors.ts | 222 ++++++++++++++++++ 3 files changed, 462 insertions(+) create mode 100644 packages/cli/src/output/__tests__/errors.test.ts create mode 100644 packages/cli/src/output/__tests__/writeError.test.ts create mode 100644 packages/cli/src/output/errors.ts diff --git a/packages/cli/src/output/__tests__/errors.test.ts b/packages/cli/src/output/__tests__/errors.test.ts new file mode 100644 index 000000000..87c20aa13 --- /dev/null +++ b/packages/cli/src/output/__tests__/errors.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' + +import { + CliError, + exitCodeFor, + retryableDefaultFor, + safeDetails, +} from '@/output/errors.js' + +describe('CliError', () => { + it('defaults retryability by code', () => { + expect(new CliError('network', 'rpc').retryable).toBe(true) + expect(new CliError('config', 'missing').retryable).toBe(false) + expect(new CliError('onchain', 'revert').retryable).toBe(false) + }) + + it('honours retryableOverride', () => { + expect(new CliError('onchain', 'nonce', undefined, true).retryable).toBe( + true, + ) + expect(new CliError('network', 'x', undefined, false).retryable).toBe(false) + }) + + it('preserves retryAfterMs', () => { + expect( + new CliError('network', 'x', undefined, undefined, 1500).retryAfterMs, + ).toBe(1500) + }) +}) + +describe('exitCodeFor', () => { + it('maps every code to a distinct exit value', () => { + const codes = [ + 'unknown', + 'validation', + 'config', + 'network', + 'onchain', + ] as const + const values = codes.map(exitCodeFor) + expect(new Set(values).size).toBe(codes.length) + expect(exitCodeFor('validation')).toBe(2) + expect(exitCodeFor('network')).toBe(4) + }) +}) + +describe('retryableDefaultFor', () => { + it('only flags network as retryable by default', () => { + expect(retryableDefaultFor('network')).toBe(true) + expect(retryableDefaultFor('unknown')).toBe(false) + expect(retryableDefaultFor('validation')).toBe(false) + expect(retryableDefaultFor('config')).toBe(false) + expect(retryableDefaultFor('onchain')).toBe(false) + }) +}) + +describe('safeDetails', () => { + it('returns undefined for undefined input', () => { + expect(safeDetails(undefined)).toBeUndefined() + }) + + it('strips API-key path segments from bundler URLs', () => { + const url = 'https://api.pimlico.io/v2/8453/rpc?apikey=SECRET' + const out = safeDetails({ bundlerUrl: url }) as { bundlerUrl: string } + expect(out.bundlerUrl).not.toContain('SECRET') + expect(out.bundlerUrl).not.toContain('8453') + expect(out.bundlerUrl).toContain('api.pimlico.io') + expect(out.bundlerUrl).toMatch(/\/v\*\/\*\*\*\/rpc/) + }) + + it('strips API-key segments from strings nested in arrays', () => { + const out = safeDetails({ + urls: ['https://api.pimlico.io/v2/8453/rpc?apikey=SECRET', 'plain'], + }) as { urls: string[] } + expect(out.urls[0]).not.toContain('SECRET') + expect(out.urls[1]).toBe('plain') + }) + + it('reduces viem-shaped errors to errorName + shortMessage', () => { + const viemErr = { + name: 'HttpRequestError', + shortMessage: 'HTTP request failed.', + details: 'verbose dump with headers and bodies', + metaMessages: ['URL: https://api.pimlico.io/v2/8453/rpc?apikey=SECRET'], + request: { method: 'POST', headers: { auth: 'Bearer SECRET' } }, + } + const out = safeDetails({ cause: viemErr }) as { + cause: { errorName: string; shortMessage: string } + } + expect(out.cause).toEqual({ + errorName: 'HttpRequestError', + shortMessage: 'HTTP request failed.', + }) + expect(JSON.stringify(out)).not.toContain('SECRET') + }) + + it('drops signer publicKey and address metadata', () => { + const out = safeDetails({ + signer: { + address: '0xdeadbeef', + publicKey: '0x0400aabbcc', + source: 'privateKey', + }, + address: '0xcafebabe', + chainId: 84532, + }) as Record + expect(out.signer).toBeUndefined() + expect(out.address).toBeUndefined() + expect(out.chainId).toBe(84532) + }) + + it('preserves allowlisted scalars and primitive shapes', () => { + const out = safeDetails({ + errorName: 'UserOperationRevertedError', + shortMessage: 'revert', + chainId: 84532, + status: 'reverted', + unknownScalar: 'drop-me', + nestedOk: { errorName: 'Inner', shortMessage: 'inner' }, + }) as Record + expect(out.errorName).toBe('UserOperationRevertedError') + expect(out.chainId).toBe(84532) + expect(out.unknownScalar).toBe('drop-me') // plain strings pass (stripped) + expect(out.nestedOk).toEqual({ errorName: 'Inner', shortMessage: 'inner' }) + }) + + it('preserves bigint values for later coercion', () => { + const out = safeDetails({ amount: 1n }) as { amount: bigint } + expect(out.amount).toBe(1n) + }) +}) diff --git a/packages/cli/src/output/__tests__/writeError.test.ts b/packages/cli/src/output/__tests__/writeError.test.ts new file mode 100644 index 000000000..fa49f05b8 --- /dev/null +++ b/packages/cli/src/output/__tests__/writeError.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { CliError, writeError } from '@/output/errors.js' + +const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) +const stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + +afterEach(() => { + exitSpy.mockClear() + stderrSpy.mockClear() +}) + +const capturedBody = (): Record => { + return JSON.parse(String(stderrSpy.mock.calls[0]?.[0])) +} + +describe('writeError', () => { + it('exits with the mapped exit code per CliError.code', () => { + writeError(new CliError('validation', 'bad flag')) + expect(exitSpy).toHaveBeenCalledWith(2) + exitSpy.mockClear() + stderrSpy.mockClear() + + writeError(new CliError('config', 'no env')) + expect(exitSpy).toHaveBeenCalledWith(3) + exitSpy.mockClear() + stderrSpy.mockClear() + + writeError(new CliError('network', 'rpc')) + expect(exitSpy).toHaveBeenCalledWith(4) + exitSpy.mockClear() + stderrSpy.mockClear() + + writeError(new CliError('onchain', 'revert')) + expect(exitSpy).toHaveBeenCalledWith(5) + }) + + it('emits {error, code, retryable} for a CliError', () => { + writeError(new CliError('network', 'rpc down')) + const body = capturedBody() + expect(body.error).toBe('rpc down') + expect(body.code).toBe('network') + expect(body.retryable).toBe(true) + }) + + it('includes retry_after_ms when set', () => { + writeError(new CliError('network', 'rate limited', undefined, true, 1000)) + expect(capturedBody().retry_after_ms).toBe(1000) + }) + + it('coerces bigints in details to strings', () => { + writeError(new CliError('onchain', 'revert', { amount: 1n })) + expect(capturedBody()).toEqual( + expect.objectContaining({ + details: { amount: '1' }, + }), + ) + }) + + it('redacts bundler URLs and signer metadata from details', () => { + writeError( + new CliError('network', 'failed', { + bundlerUrl: 'https://api.pimlico.io/v2/8453/rpc?apikey=SECRET', + signer: { address: '0xdead', publicKey: '0xcafe' }, + }), + ) + const raw = JSON.stringify(capturedBody()) + expect(raw).not.toContain('SECRET') + expect(raw).not.toContain('0xdead') + expect(raw).not.toContain('0xcafe') + }) + + it('reports unknown code for non-CliError throws', () => { + writeError(new Error('boom')) + expect(exitSpy).toHaveBeenCalledWith(1) + const body = capturedBody() + expect(body.code).toBe('unknown') + expect(body.retryable).toBe(false) + expect(body.details).toBeUndefined() + }) + + it('terminates the body with a newline', () => { + writeError(new CliError('validation', 'x')) + const raw = stderrSpy.mock.calls[0]?.[0] + const text = String(raw) + expect(text.endsWith('\n')).toBe(true) + }) + + it('swallows EPIPE from the stderr write', () => { + stderrSpy.mockImplementationOnce(() => { + const e: NodeJS.ErrnoException = new Error('epipe') + e.code = 'EPIPE' + throw e + }) + expect(() => writeError(new CliError('unknown', 'x'))).not.toThrow() + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it('rethrows non-EPIPE write failures', () => { + stderrSpy.mockImplementationOnce(() => { + throw new Error('disk full') + }) + expect(() => writeError(new CliError('unknown', 'x'))).toThrow('disk full') + }) +}) diff --git a/packages/cli/src/output/errors.ts b/packages/cli/src/output/errors.ts new file mode 100644 index 000000000..97d49cc03 --- /dev/null +++ b/packages/cli/src/output/errors.ts @@ -0,0 +1,222 @@ +import { serializeBigInt } from '@eth-optimism/actions-sdk' + +/** + * @description Agent-consumable error categories. The code determines the + * process exit value and the default retryability — callers may override + * the latter through `CliError.retryableOverride`. + */ +export type ErrorCode = + | 'unknown' + | 'validation' + | 'config' + | 'network' + | 'onchain' + +const EXIT: Record = { + unknown: 1, + validation: 2, + config: 3, + network: 4, + onchain: 5, +} + +const RETRYABLE_DEFAULT: Record = { + unknown: false, + validation: false, + config: false, + network: true, + onchain: false, +} + +/** + * @description Structured error raised from command handlers. Carries a + * discriminator `code`, an optional `details` payload, and optional + * retry hints the agent can use without parsing free-form messages. + */ +export class CliError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + public readonly details?: unknown, + public readonly retryableOverride?: boolean, + public readonly retryAfterMs?: number, + ) { + super(message) + this.name = 'CliError' + } + + get retryable(): boolean { + return this.retryableOverride ?? RETRYABLE_DEFAULT[this.code] + } +} + +/** + * @description Process exit code associated with an `ErrorCode`. + * @param code - Error category. + * @returns Non-zero exit value consumed by the parent process. + */ +export function exitCodeFor(code: ErrorCode): number { + return EXIT[code] +} + +/** + * @description Default retryability hint for an `ErrorCode`. Callers may + * override per-instance via `CliError.retryableOverride`. + * @param code - Error category. + * @returns `true` when the agent may retry without user intervention. + */ +export function retryableDefaultFor(code: ErrorCode): boolean { + return RETRYABLE_DEFAULT[code] +} + +const RPC_KEY_PATH = /\/v\d+\/[^/]+\/rpc(\?[^\s#]*)?/g + +const SCALAR_ALLOWLIST = new Set([ + 'chainId', + 'code', + 'errorName', + 'functionName', + 'market', + 'method', + 'operation', + 'reason', + 'shortMessage', + 'status', + 'symbol', +]) + +const SENSITIVE_KEYS = new Set([ + 'account', + 'address', + 'from', + 'headers', + 'privateKey', + 'publicKey', + 'request', + 'signer', + 'signature', +]) + +function stripRpcKey(url: string): string { + return url.replace(RPC_KEY_PATH, '/v*/***/rpc') +} + +function redactValue(value: unknown): unknown { + if (value === null || value === undefined) return value + if (typeof value === 'string') return stripRpcKey(value) + if (typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'bigint') return value + if (Array.isArray(value)) return value.map(redactValue) + if (isViemError(value)) return reduceViemError(value) + if (typeof value === 'object') + return redactRecord(value as Record) + return undefined +} + +function isViemError( + value: unknown, +): value is { name: string; shortMessage: string } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { shortMessage?: unknown }).shortMessage === 'string' && + typeof (value as { name?: unknown }).name === 'string' + ) +} + +function reduceViemError(err: { name: string; shortMessage: string }): { + errorName: string + shortMessage: string +} { + return { + errorName: err.name, + shortMessage: stripRpcKey(err.shortMessage), + } +} + +function redactRecord( + record: Record, +): Record { + const out: Record = {} + for (const [key, raw] of Object.entries(record)) { + if (SENSITIVE_KEYS.has(key)) continue + if (raw && typeof raw === 'object') { + if (isViemError(raw)) { + out[key] = reduceViemError(raw) + continue + } + const redacted = redactValue(raw) + if (redacted !== undefined) out[key] = redacted + continue + } + if (typeof raw === 'string') { + out[key] = stripRpcKey(raw) + continue + } + if (SCALAR_ALLOWLIST.has(key)) { + out[key] = raw + continue + } + if ( + typeof raw === 'number' || + typeof raw === 'boolean' || + typeof raw === 'bigint' || + raw === null + ) { + out[key] = raw + } + } + return out +} + +/** + * @description Redacts a `CliError.details` payload before it is serialised + * to stderr. Drops known-sensitive keys (signer metadata, request bodies), + * reduces viem error instances to `{ errorName, shortMessage }`, and strips + * API-key segments from any RPC/bundler URLs it encounters. The allowlist is + * intentionally conservative — unknown scalars are preserved only when their + * key is in `SCALAR_ALLOWLIST`. + * @param details - Arbitrary data attached to a `CliError`. + * @returns A safe-to-emit clone of `details`. + */ +export function safeDetails(details: unknown): unknown { + if (details === undefined) return undefined + return redactValue(details) +} + +function isEpipe(err: unknown): boolean { + return ( + err !== null && + typeof err === 'object' && + 'code' in err && + (err as { code?: unknown }).code === 'EPIPE' + ) +} + +/** + * @description Writes an error envelope to stderr and exits with the + * taxonomy's mapped exit code. The body matches the agent contract + * `{ error, code, retryable, retry_after_ms?, details? }`. `details` is + * always redacted; `bigint` values in any field are coerced to strings. + * EPIPE on the stderr write is swallowed (the parent has closed the pipe). + * @param err - Any thrown value. `CliError` receives full fidelity; other + * values are reported under `code: "unknown"`. + */ +export function writeError(err: unknown): never { + const cliErr = err instanceof CliError ? err : undefined + const code: ErrorCode = cliErr?.code ?? 'unknown' + const message = err instanceof Error ? err.message : String(err) + const body = serializeBigInt({ + error: message, + code, + retryable: cliErr?.retryable ?? RETRYABLE_DEFAULT[code], + retry_after_ms: cliErr?.retryAfterMs, + details: cliErr ? safeDetails(cliErr.details) : undefined, + }) + try { + process.stderr.write(JSON.stringify(body, null, 2) + '\n') + } catch (writeErr) { + if (!isEpipe(writeErr)) throw writeErr + } + process.exit(EXIT[code]) +} From 61df45ac88e909478f2bafe12a6cef4adce4f0b2 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:29:11 -0700 Subject: [PATCH 07/76] add lazy requireEnv accessor --- packages/cli/src/config/__tests__/env.test.ts | 93 +++++++++++++++++++ packages/cli/src/config/env.ts | 62 +++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 packages/cli/src/config/__tests__/env.test.ts create mode 100644 packages/cli/src/config/env.ts diff --git a/packages/cli/src/config/__tests__/env.test.ts b/packages/cli/src/config/__tests__/env.test.ts new file mode 100644 index 000000000..c2c26117b --- /dev/null +++ b/packages/cli/src/config/__tests__/env.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + __resetEnvCacheForTests, + optionalEnv, + requireEnv, +} from '@/config/env.js' +import { CliError } from '@/output/errors.js' + +describe('requireEnv / optionalEnv', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.PRIVATE_KEY + delete process.env.BASE_SEPOLIA_RPC_URL + delete process.env.OP_SEPOLIA_RPC_URL + delete process.env.UNICHAIN_RPC_URL + __resetEnvCacheForTests() + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + }) + + it('returns the env var value when present', () => { + process.env.PRIVATE_KEY = '0xdeadbeef' + expect(requireEnv('PRIVATE_KEY')).toBe('0xdeadbeef') + }) + + it('throws CliError(config) when the var is missing', () => { + try { + requireEnv('PRIVATE_KEY') + throw new Error('requireEnv did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + expect((err as CliError).message).toMatch(/PRIVATE_KEY/) + } + }) + + it('optionalEnv returns undefined for unset vars', () => { + expect(optionalEnv('BASE_SEPOLIA_RPC_URL')).toBeUndefined() + }) + + it('optionalEnv returns the value when set', () => { + process.env.BASE_SEPOLIA_RPC_URL = 'https://example.test' + expect(optionalEnv('BASE_SEPOLIA_RPC_URL')).toBe('https://example.test') + }) +}) + +const cleanEnvSpy = vi.hoisted(() => vi.fn()) + +vi.mock('envalid', async () => { + const actual = await vi.importActual>('envalid') + cleanEnvSpy.mockImplementation(actual.cleanEnv as typeof cleanEnvSpy) + return { ...actual, cleanEnv: cleanEnvSpy } +}) + +describe('requireEnv (lazy contract)', () => { + const originalEnv = process.env + + beforeEach(() => { + vi.resetModules() + cleanEnvSpy.mockClear() + process.env = { ...originalEnv } + delete process.env.PRIVATE_KEY + delete process.env.BASE_SEPOLIA_RPC_URL + delete process.env.OP_SEPOLIA_RPC_URL + delete process.env.UNICHAIN_RPC_URL + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('does not call cleanEnv at import time', async () => { + await import('@/config/env.js') + expect(cleanEnvSpy).not.toHaveBeenCalled() + }) + + it('calls cleanEnv exactly once across repeated requireEnv invocations', async () => { + process.env.PRIVATE_KEY = '0xabc' + const mod = await import('@/config/env.js') + expect(cleanEnvSpy).not.toHaveBeenCalled() + mod.requireEnv('PRIVATE_KEY') + expect(cleanEnvSpy).toHaveBeenCalledTimes(1) + mod.requireEnv('PRIVATE_KEY') + mod.optionalEnv('BASE_SEPOLIA_RPC_URL') + expect(cleanEnvSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/cli/src/config/env.ts b/packages/cli/src/config/env.ts new file mode 100644 index 000000000..c13d9e2d1 --- /dev/null +++ b/packages/cli/src/config/env.ts @@ -0,0 +1,62 @@ +import { cleanEnv, str } from 'envalid' + +import { CliError } from '@/output/errors.js' + +type CliEnv = { + PRIVATE_KEY: string | undefined + BASE_SEPOLIA_RPC_URL: string | undefined + OP_SEPOLIA_RPC_URL: string | undefined + UNICHAIN_RPC_URL: string | undefined +} + +export type CliEnvKey = keyof CliEnv + +let cache: CliEnv | undefined + +function load(): CliEnv { + cache ??= cleanEnv(process.env, { + PRIVATE_KEY: str({ default: undefined }), + BASE_SEPOLIA_RPC_URL: str({ default: undefined }), + OP_SEPOLIA_RPC_URL: str({ default: undefined }), + UNICHAIN_RPC_URL: str({ default: undefined }), + }) as CliEnv + return cache +} + +/** + * @description Test-only: resets the lazy env cache so repeated `cleanEnv` + * invocations can be observed. Production code must never call this — the + * subprocess model means the cache lives as long as the process. + */ +export function __resetEnvCacheForTests(): void { + cache = undefined +} + +/** + * @description Lazily reads a required env var through envalid. The first + * call parses `process.env`; subsequent calls reuse the cached result for + * the life of the subprocess. Throws `CliError('config')` if the var is not + * set — `cleanEnv` is never called at module top level, so `actions --help` + * works with no env configured. + * @param name - Env var name. + * @returns The env var value. + * @throws `CliError` with code `config` when the var is unset or empty. + */ +export function requireEnv(name: CliEnvKey): string { + const value = load()[name] + if (!value) { + throw new CliError('config', `Missing env var: ${name}`) + } + return value +} + +/** + * @description Reads an optional env var through envalid. Returns `undefined` + * if the var is unset; useful for RPC URL overrides that fall back to viem + * defaults. + * @param name - Env var name. + * @returns The env var value, or `undefined`. + */ +export function optionalEnv(name: CliEnvKey): string | undefined { + return load()[name] +} From 58939989d551408aa157994278c5f9d9cc685a3b Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:38:40 -0700 Subject: [PATCH 08/76] add demo chain configs without bundler --- packages/cli/src/demo/chains.ts | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/cli/src/demo/chains.ts diff --git a/packages/cli/src/demo/chains.ts b/packages/cli/src/demo/chains.ts new file mode 100644 index 000000000..df6798b17 --- /dev/null +++ b/packages/cli/src/demo/chains.ts @@ -0,0 +1,40 @@ +import type { SupportedChainId } from '@eth-optimism/actions-sdk' +import { baseSepolia, optimismSepolia, unichain } from 'viem/chains' + +import { type CliEnvKey, optionalEnv } from '@/config/env.js' + +interface DemoChainConfig { + chainId: SupportedChainId + rpcUrls?: string[] +} + +function rpcUrls(key: CliEnvKey): string[] | undefined { + const url = optionalEnv(key) + return url ? [url] : undefined +} + +/** + * @description Returns the CLI's baked demo chain set: Base Sepolia, + * Optimism Sepolia, Unichain — mirroring the demo backend's market + * footprint. RPC URLs come from the matching `*_RPC_URL` env vars when + * set, otherwise viem's chain defaults apply. Bundler configuration is + * omitted intentionally: the CLI signs transactions from an EOA and the + * signer pays gas directly (no ERC-4337 gas abstraction for now). + * @returns Array of chain configs suitable for `NodeActionsConfig.chains`. + */ +export function getDemoChains(): DemoChainConfig[] { + return [ + { + chainId: baseSepolia.id, + rpcUrls: rpcUrls('BASE_SEPOLIA_RPC_URL'), + }, + { + chainId: optimismSepolia.id, + rpcUrls: rpcUrls('OP_SEPOLIA_RPC_URL'), + }, + { + chainId: unichain.id, + rpcUrls: rpcUrls('UNICHAIN_RPC_URL'), + }, + ] +} From c0b245a75c76abe6557e6f77be526838d9db283b Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:44:27 -0700 Subject: [PATCH 09/76] add demo lend market constants --- packages/cli/src/demo/markets.ts | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/cli/src/demo/markets.ts diff --git a/packages/cli/src/demo/markets.ts b/packages/cli/src/demo/markets.ts new file mode 100644 index 000000000..69292b68e --- /dev/null +++ b/packages/cli/src/demo/markets.ts @@ -0,0 +1,34 @@ +import { + ETH, + type LendMarketConfig, + USDC_DEMO, + WETH, +} from '@eth-optimism/actions-sdk' +import type { Address } from 'viem' +import { baseSepolia, optimismSepolia } from 'viem/chains' + +/** + * @description Morpho vault on Base Sepolia used for USDC_DEMO lend demos. + * Mirrored from `packages/demo/backend/src/config/markets.ts` so the CLI + * operates against the same demo markets the backend does. + */ +export const GauntletUSDCDemo: LendMarketConfig = { + address: '0x018e22BBC6eB3daCfd151d1Cc4Dc72f6337B3eA1' as const, + chainId: baseSepolia.id, + name: 'Gauntlet USDC', + asset: USDC_DEMO, + lendProvider: 'morpho', +} + +/** + * @description Aave v3 ETH market on Optimism Sepolia. The market address is + * the WETH reserve token — Aave exposes ETH deposits through its WETH + * gateway. Mirrored from the demo backend's config. + */ +export const AaveETH: LendMarketConfig = { + address: WETH.address[optimismSepolia.id] as Address, + chainId: optimismSepolia.id, + name: 'Aave ETH', + asset: ETH, + lendProvider: 'aave', +} From 113ede006e5a17e8f498a475c4fbc857ae5b9f58 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:55:33 -0700 Subject: [PATCH 10/76] add demo config and loadConfig --- packages/cli/src/config/loadConfig.ts | 15 ++++++++++ packages/cli/src/demo/config.ts | 42 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/cli/src/config/loadConfig.ts create mode 100644 packages/cli/src/demo/config.ts diff --git a/packages/cli/src/config/loadConfig.ts b/packages/cli/src/config/loadConfig.ts new file mode 100644 index 000000000..d186ed14e --- /dev/null +++ b/packages/cli/src/config/loadConfig.ts @@ -0,0 +1,15 @@ +import type { NodeActionsConfig } from '@eth-optimism/actions-sdk' + +import { getDemoConfig } from '@/demo/config.js' + +/** + * @description Resolves the CLI's `NodeActionsConfig`. PR 1 returns the + * baked demo config unconditionally; the interactive agent-onboarding flow + * (#411) will swap this for a per-user source without touching callers. + * Keep every `Actions` construction site behind `loadConfig` so the + * follow-up remains a drop-in replacement. + * @returns The resolved Actions config for this process. + */ +export function loadConfig(): NodeActionsConfig { + return getDemoConfig() +} diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts new file mode 100644 index 000000000..870664d65 --- /dev/null +++ b/packages/cli/src/demo/config.ts @@ -0,0 +1,42 @@ +import { + ETH, + type NodeActionsConfig, + OP_DEMO, + USDC_DEMO, +} from '@eth-optimism/actions-sdk' + +import { getDemoChains } from '@/demo/chains.js' +import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' + +/** + * @description Returns the baked demo `NodeActionsConfig` the CLI boots + * against. Mirrors `packages/demo/backend/src/config/actions.ts` in asset + * and market set, so CLI behaviour stays aligned with the demo backend + * end-to-end. Divergences are intentional and narrow: + * + * - `hostedWalletConfig` is omitted. The CLI derives a viem `LocalAccount` + * from `PRIVATE_KEY` and passes it to `actions.wallet.toActionsWallet()`, + * producing an EOA-backed wallet. No Privy, no hosted signer. + * - `swap` is omitted entirely. `SwapConfig` requires at least one provider + * key, so `swap: {}` is a type error; leaving the field off makes + * `actions.swap` surface a "not configured" message on access. PR 3 wires + * real swap providers. + * - `chains` carry no bundler configuration. Transactions go out as + * standard EOA sends — no ERC-4337 gas abstraction for now. + * @returns `NodeActionsConfig` with no hosted wallet provider configured. + */ +export function getDemoConfig(): NodeActionsConfig { + return { + wallet: { + smartWalletConfig: { + provider: { type: 'default', attributionSuffix: 'actions' }, + }, + }, + lend: { + morpho: { marketAllowlist: [GauntletUSDCDemo] }, + aave: { marketAllowlist: [AaveETH] }, + }, + assets: { allow: [USDC_DEMO, OP_DEMO, ETH] }, + chains: getDemoChains(), + } +} From 6c64a9e11f2df714aadcdb5af6c531dff968b2dd Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:56:18 -0700 Subject: [PATCH 11/76] flatten demo config jsdoc --- packages/cli/src/demo/config.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts index 870664d65..99ffd8590 100644 --- a/packages/cli/src/demo/config.ts +++ b/packages/cli/src/demo/config.ts @@ -11,18 +11,11 @@ import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' /** * @description Returns the baked demo `NodeActionsConfig` the CLI boots * against. Mirrors `packages/demo/backend/src/config/actions.ts` in asset - * and market set, so CLI behaviour stays aligned with the demo backend - * end-to-end. Divergences are intentional and narrow: - * - * - `hostedWalletConfig` is omitted. The CLI derives a viem `LocalAccount` - * from `PRIVATE_KEY` and passes it to `actions.wallet.toActionsWallet()`, - * producing an EOA-backed wallet. No Privy, no hosted signer. - * - `swap` is omitted entirely. `SwapConfig` requires at least one provider - * key, so `swap: {}` is a type error; leaving the field off makes - * `actions.swap` surface a "not configured" message on access. PR 3 wires - * real swap providers. - * - `chains` carry no bundler configuration. Transactions go out as - * standard EOA sends — no ERC-4337 gas abstraction for now. + * and market set so CLI behaviour stays aligned with the demo backend. + * Divergences: `hostedWalletConfig` is omitted (the CLI uses an EOA-backed + * wallet via `actions.wallet.toActionsWallet(localAccount)`); `swap` is + * omitted entirely (PR 3 adds it); chain bundlers are omitted (no ERC-4337 + * gas abstraction — the signer pays gas directly). * @returns `NodeActionsConfig` with no hosted wallet provider configured. */ export function getDemoConfig(): NodeActionsConfig { From 854d4407dccc3d242fb61554bf9aabf1070b56df Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:57:21 -0700 Subject: [PATCH 12/76] add asset resolver by symbol --- .../src/resolvers/__tests__/assets.test.ts | 47 +++++++++++++++++++ packages/cli/src/resolvers/assets.ts | 28 +++++++++++ 2 files changed, 75 insertions(+) create mode 100644 packages/cli/src/resolvers/__tests__/assets.test.ts create mode 100644 packages/cli/src/resolvers/assets.ts diff --git a/packages/cli/src/resolvers/__tests__/assets.test.ts b/packages/cli/src/resolvers/__tests__/assets.test.ts new file mode 100644 index 000000000..d80370bce --- /dev/null +++ b/packages/cli/src/resolvers/__tests__/assets.test.ts @@ -0,0 +1,47 @@ +import type { Asset } from '@eth-optimism/actions-sdk' +import { baseSepolia } from 'viem/chains' +import { describe, expect, it } from 'vitest' + +import { CliError } from '@/output/errors.js' +import { resolveAsset } from '@/resolvers/assets.js' + +const USDC: Asset = { + address: { [baseSepolia.id]: '0x0000000000000000000000000000000000000001' }, + metadata: { decimals: 6, name: 'USDC', symbol: 'USDC_DEMO' }, + type: 'erc20', +} + +const ETH: Asset = { + address: { [baseSepolia.id]: 'native' }, + metadata: { decimals: 18, name: 'Ether', symbol: 'ETH' }, + type: 'native', +} + +describe('resolveAsset', () => { + it('matches exact symbols', () => { + expect(resolveAsset('USDC_DEMO', [USDC, ETH])).toBe(USDC) + }) + + it('is case-insensitive', () => { + expect(resolveAsset('eth', [USDC, ETH])).toBe(ETH) + expect(resolveAsset('Usdc_Demo', [USDC, ETH])).toBe(USDC) + }) + + it('throws CliError(validation) for unknown symbols', () => { + try { + resolveAsset('WBTC', [USDC, ETH]) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + expect((err as CliError).details).toEqual({ + symbol: 'WBTC', + allowed: ['USDC_DEMO', 'ETH'], + }) + } + }) + + it('throws on an empty allowlist', () => { + expect(() => resolveAsset('ETH', [])).toThrow(CliError) + }) +}) diff --git a/packages/cli/src/resolvers/assets.ts b/packages/cli/src/resolvers/assets.ts new file mode 100644 index 000000000..fec51c647 --- /dev/null +++ b/packages/cli/src/resolvers/assets.ts @@ -0,0 +1,28 @@ +import type { Asset } from '@eth-optimism/actions-sdk' + +import { CliError } from '@/output/errors.js' + +/** + * @description Resolves an asset symbol (e.g. `USDC_DEMO`, `eth`) to the + * matching `Asset` entry from an allowlist. Matching is case-insensitive on + * `metadata.symbol`. The resolver is config-agnostic — callers pass the + * allowlist explicitly so the same function works for demo config, user + * config (#411), and tests. + * @param symbol - User-provided asset symbol from CLI argv. + * @param allow - Asset allowlist (typically `config.assets.allow`). + * @returns The first `Asset` whose `metadata.symbol` matches, case-insensitive. + * @throws `CliError` with code `validation` when no asset matches. + */ +export function resolveAsset(symbol: string, allow: readonly Asset[]): Asset { + const needle = symbol.toLowerCase() + const match = allow.find( + (asset) => asset.metadata.symbol.toLowerCase() === needle, + ) + if (!match) { + throw new CliError('validation', `Unknown asset: ${symbol}`, { + symbol, + allowed: allow.map((a) => a.metadata.symbol), + }) + } + return match +} From d9e6f31d936eb77f7797e316804418930911d543 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 11:59:35 -0700 Subject: [PATCH 13/76] add chain resolver and inverse --- .../src/resolvers/__tests__/chains.test.ts | 80 +++++++++++++++++++ packages/cli/src/resolvers/chains.ts | 71 ++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 packages/cli/src/resolvers/__tests__/chains.test.ts create mode 100644 packages/cli/src/resolvers/chains.ts diff --git a/packages/cli/src/resolvers/__tests__/chains.test.ts b/packages/cli/src/resolvers/__tests__/chains.test.ts new file mode 100644 index 000000000..d6819ae61 --- /dev/null +++ b/packages/cli/src/resolvers/__tests__/chains.test.ts @@ -0,0 +1,80 @@ +import type { SupportedChainId } from '@eth-optimism/actions-sdk' +import { + base, + baseSepolia, + optimism, + optimismSepolia, + unichain, + unichainSepolia, +} from 'viem/chains' +import { describe, expect, it } from 'vitest' + +import { CliError } from '@/output/errors.js' +import { resolveChain, shortnameFor } from '@/resolvers/chains.js' + +const ALL: SupportedChainId[] = [ + base.id, + baseSepolia.id, + optimism.id, + optimismSepolia.id, + unichain.id, + unichainSepolia.id, +] + +const SHORTNAMES = [ + 'base', + 'base-sepolia', + 'optimism', + 'op-sepolia', + 'unichain', + 'unichain-sepolia', +] as const + +describe('resolveChain', () => { + it('resolves each canonical shortname to its chain id', () => { + expect(resolveChain('base-sepolia', ALL)).toBe(baseSepolia.id) + expect(resolveChain('op-sepolia', ALL)).toBe(optimismSepolia.id) + expect(resolveChain('unichain', ALL)).toBe(unichain.id) + }) + + it('is case-insensitive', () => { + expect(resolveChain('Base-Sepolia', ALL)).toBe(baseSepolia.id) + expect(resolveChain('OP-SEPOLIA', ALL)).toBe(optimismSepolia.id) + }) + + it('throws CliError(validation) for unknown shortnames', () => { + try { + resolveChain('mars', ALL) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects shortnames not in the configured chain set', () => { + try { + resolveChain('base', [baseSepolia.id]) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) +}) + +describe('shortnameFor', () => { + it('returns the canonical shortname for each supported chain id', () => { + expect(shortnameFor(baseSepolia.id)).toBe('base-sepolia') + expect(shortnameFor(optimismSepolia.id)).toBe('op-sepolia') + expect(shortnameFor(unichainSepolia.id)).toBe('unichain-sepolia') + }) +}) + +describe('resolver round-trip', () => { + it('shortnameFor(resolveChain(name)) === name for every entry', () => { + for (const name of SHORTNAMES) { + expect(shortnameFor(resolveChain(name, ALL))).toBe(name) + } + }) +}) diff --git a/packages/cli/src/resolvers/chains.ts b/packages/cli/src/resolvers/chains.ts new file mode 100644 index 000000000..54764181e --- /dev/null +++ b/packages/cli/src/resolvers/chains.ts @@ -0,0 +1,71 @@ +import type { SupportedChainId } from '@eth-optimism/actions-sdk' +import { + base, + baseSepolia, + optimism, + optimismSepolia, + unichain, + unichainSepolia, +} from 'viem/chains' + +import { CliError } from '@/output/errors.js' + +const SHORTNAMES: Record = { + base: base.id, + 'base-sepolia': baseSepolia.id, + optimism: optimism.id, + 'op-sepolia': optimismSepolia.id, + unichain: unichain.id, + 'unichain-sepolia': unichainSepolia.id, +} + +const CHAIN_IDS: Record = Object.fromEntries( + Object.entries(SHORTNAMES).map(([name, id]) => [id, name]), +) + +/** + * @description Resolves a chain shortname (e.g. `base-sepolia`) to a + * `SupportedChainId`. Restricted to the configured chain set so unknown + * shortnames or chains not in the active config surface as validation + * errors before the SDK sees them. Match is case-insensitive. + * @param shortname - User-provided chain shortname from CLI argv. + * @param configuredChainIds - Chain IDs present in the resolved config. + * @returns The matching `SupportedChainId`. + * @throws `CliError` with code `validation` when the shortname is unknown + * or maps to a chain not present in `configuredChainIds`. + */ +export function resolveChain( + shortname: string, + configuredChainIds: readonly SupportedChainId[], +): SupportedChainId { + const id = SHORTNAMES[shortname.toLowerCase()] + if (id === undefined || !configuredChainIds.includes(id)) { + throw new CliError('validation', `Unknown chain: ${shortname}`, { + chain: shortname, + allowed: configuredChainIds + .map((cid) => CHAIN_IDS[cid]) + .filter((name): name is string => name !== undefined), + }) + } + return id +} + +/** + * @description Inverse of `resolveChain` — maps a `SupportedChainId` back + * to its canonical shortname. Used by the `chains` command to render the + * configured chain set. The round-trip + * `shortnameFor(resolveChain(name)) === name` holds for every name in the + * resolver's map. + * @param chainId - A `SupportedChainId` present in the resolver map. + * @returns The chain's canonical shortname. + * @throws `CliError` with code `validation` when the chain has no shortname. + */ +export function shortnameFor(chainId: SupportedChainId): string { + const name = CHAIN_IDS[chainId] + if (!name) { + throw new CliError('validation', `No shortname for chainId: ${chainId}`, { + chainId, + }) + } + return name +} From 24139afc0df7b9e24df3c9a458b4887800ec9ffe Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:07:43 -0700 Subject: [PATCH 14/76] add baseContext for read-only commands --- .../src/context/__tests__/baseContext.test.ts | 29 +++++++++++++++++++ packages/cli/src/context/baseContext.ts | 28 ++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/cli/src/context/__tests__/baseContext.test.ts create mode 100644 packages/cli/src/context/baseContext.ts diff --git a/packages/cli/src/context/__tests__/baseContext.test.ts b/packages/cli/src/context/__tests__/baseContext.test.ts new file mode 100644 index 000000000..50fccb5d4 --- /dev/null +++ b/packages/cli/src/context/__tests__/baseContext.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { baseContext } from '@/context/baseContext.js' + +describe('baseContext', () => { + it('returns an Actions instance and the resolved config', () => { + const { config, actions } = baseContext() + expect(config.chains.length).toBeGreaterThan(0) + expect(actions).toBeDefined() + expect(typeof actions.getSupportedAssets).toBe('function') + }) + + it('returns a fresh Actions instance per call', () => { + const a = baseContext() + const b = baseContext() + expect(a.actions).not.toBe(b.actions) + }) + + it('does not require PRIVATE_KEY', () => { + const originalEnv = process.env + process.env = { ...originalEnv } + delete process.env.PRIVATE_KEY + try { + expect(() => baseContext()).not.toThrow() + } finally { + process.env = originalEnv + } + }) +}) diff --git a/packages/cli/src/context/baseContext.ts b/packages/cli/src/context/baseContext.ts new file mode 100644 index 000000000..762e15ac9 --- /dev/null +++ b/packages/cli/src/context/baseContext.ts @@ -0,0 +1,28 @@ +import { + createActions, + type NodeActionsConfig, +} from '@eth-optimism/actions-sdk' + +import { loadConfig } from '@/config/loadConfig.js' + +export type CliActions = ReturnType> + +export interface BaseContext { + config: NodeActionsConfig + actions: CliActions +} + +/** + * @description Builds the tier-0 context for read-only CLI commands + * (`assets`, `chains`). Loads the resolved config and constructs a fresh + * `Actions` instance per invocation — the CLI runs as a short-lived + * subprocess, so module-level singletons would only add startup surprise + * without saving allocation cost. Does not read `PRIVATE_KEY`, so + * `actions --help` and the no-wallet commands work with no env vars set. + * @returns Base context bundle. + */ +export function baseContext(): BaseContext { + const config = loadConfig() + const actions = createActions(config) + return { config, actions } +} From 1119b3fe2b67a42b48d23d5d3a21a4b4a433aa0e Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:11:45 -0700 Subject: [PATCH 15/76] add walletContext with EOA wallet --- .../context/__tests__/walletContext.test.ts | 61 +++++++++++++++++++ packages/cli/src/context/walletContext.ts | 45 ++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/cli/src/context/__tests__/walletContext.test.ts create mode 100644 packages/cli/src/context/walletContext.ts diff --git a/packages/cli/src/context/__tests__/walletContext.test.ts b/packages/cli/src/context/__tests__/walletContext.test.ts new file mode 100644 index 000000000..9441daf01 --- /dev/null +++ b/packages/cli/src/context/__tests__/walletContext.test.ts @@ -0,0 +1,61 @@ +import { privateKeyToAccount } from 'viem/accounts' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { __resetEnvCacheForTests } from '@/config/env.js' +import { walletContext } from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +const EXPECTED_ADDRESS = privateKeyToAccount(ANVIL_ACCOUNT_0).address + +describe('walletContext', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.PRIVATE_KEY + __resetEnvCacheForTests() + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + }) + + it('derives an EOA-backed wallet at the signer address', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + const ctx = await walletContext() + expect(ctx.signer.address).toBe(EXPECTED_ADDRESS) + expect(ctx.wallet.address).toBe(EXPECTED_ADDRESS) + }) + + it('produces the same address on repeated calls (pure EOA derivation)', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + const a = await walletContext() + const b = await walletContext() + expect(a.wallet.address).toBe(b.wallet.address) + }) + + it('throws CliError(config) when PRIVATE_KEY is missing', async () => { + try { + await walletContext() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) + + it('throws CliError(config) when PRIVATE_KEY is malformed', async () => { + process.env.PRIVATE_KEY = 'not-a-hex-key' + try { + await walletContext() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + expect((err as CliError).message).toMatch(/PRIVATE_KEY/) + } + }) +}) diff --git a/packages/cli/src/context/walletContext.ts b/packages/cli/src/context/walletContext.ts new file mode 100644 index 000000000..9bde8e174 --- /dev/null +++ b/packages/cli/src/context/walletContext.ts @@ -0,0 +1,45 @@ +import type { LocalAccount } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' + +import { requireEnv } from '@/config/env.js' +import { type BaseContext, baseContext } from '@/context/baseContext.js' +import { CliError } from '@/output/errors.js' + +type Hex = `0x${string}` + +export interface WalletContext extends BaseContext { + signer: LocalAccount + wallet: Awaited< + ReturnType + > +} + +function parseSigner(privateKey: string): LocalAccount { + try { + return privateKeyToAccount(privateKey as Hex) + } catch (cause) { + throw new CliError( + 'config', + 'Malformed PRIVATE_KEY: expected a 0x-prefixed 32-byte hex string', + { reason: cause instanceof Error ? cause.message : String(cause) }, + ) + } +} + +/** + * @description Builds the tier-1 context for wallet-scoped commands + * (`wallet address`, `wallet balance`, and PR 2/3 lend/swap handlers). + * Derives a viem `LocalAccount` from `PRIVATE_KEY` and wraps it in an + * EOA-backed Actions wallet via `actions.wallet.toActionsWallet(localAccount)`. + * No smart-wallet factory call, no bundler dependency — the signer pays + * gas directly from its own balance. + * @returns Context with config, actions, signer, and the EOA-backed wallet. + * @throws `CliError` with code `config` when `PRIVATE_KEY` is missing or + * malformed. + */ +export async function walletContext(): Promise { + const base = baseContext() + const signer = parseSigner(requireEnv('PRIVATE_KEY')) + const wallet = await base.actions.wallet.toActionsWallet(signer) + return { ...base, signer, wallet } +} From ddb9b4c2a234e24928b26a775aa3cf0ab442e0c1 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:12:42 -0700 Subject: [PATCH 16/76] add assets command handler --- .../cli/src/commands/__tests__/assets.test.ts | 45 +++++++++++++++++++ packages/cli/src/commands/assets.ts | 13 ++++++ 2 files changed, 58 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/assets.test.ts create mode 100644 packages/cli/src/commands/assets.ts diff --git a/packages/cli/src/commands/__tests__/assets.test.ts b/packages/cli/src/commands/__tests__/assets.test.ts new file mode 100644 index 000000000..f553e00d7 --- /dev/null +++ b/packages/cli/src/commands/__tests__/assets.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { runAssets } from '@/commands/assets.js' +import * as baseCtx from '@/context/baseContext.js' + +describe('runAssets', () => { + const writeSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + afterEach(() => { + writeSpy.mockClear() + vi.restoreAllMocks() + }) + + it('emits the configured allowlist as JSON', async () => { + const allow = [ + { + address: {}, + metadata: { decimals: 6, name: 'USDC', symbol: 'USDC_DEMO' }, + type: 'erc20' as const, + }, + ] + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: { chains: [] } as never, + actions: { getSupportedAssets: () => allow } as never, + }) + writeSpy.mockImplementation(() => true) + await runAssets() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toEqual(allow) + }) + + it('propagates SDK errors for writeError to handle', async () => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: { chains: [] } as never, + actions: { + getSupportedAssets: () => { + throw new Error('boom') + }, + } as never, + }) + await expect(runAssets()).rejects.toThrow('boom') + }) +}) diff --git a/packages/cli/src/commands/assets.ts b/packages/cli/src/commands/assets.ts new file mode 100644 index 000000000..540c41cd1 --- /dev/null +++ b/packages/cli/src/commands/assets.ts @@ -0,0 +1,13 @@ +import { baseContext } from '@/context/baseContext.js' +import { writeJson } from '@/output/json.js' + +/** + * @description Handler for `actions assets`. Returns the configured + * allowlist of assets as a JSON array on stdout. Read-only — no signer + * needed. + * @returns Promise that resolves once stdout has been written. + */ +export async function runAssets(): Promise { + const { actions } = baseContext() + writeJson(actions.getSupportedAssets()) +} From 2d449ee9ee8dbaf6a4fc343041fbfd34d82e9077 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:14:10 -0700 Subject: [PATCH 17/76] add chains command handler --- .../cli/src/commands/__tests__/chains.test.ts | 50 +++++++++++++++++++ packages/cli/src/commands/chains.ts | 21 ++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/chains.test.ts create mode 100644 packages/cli/src/commands/chains.ts diff --git a/packages/cli/src/commands/__tests__/chains.test.ts b/packages/cli/src/commands/__tests__/chains.test.ts new file mode 100644 index 000000000..025f0a040 --- /dev/null +++ b/packages/cli/src/commands/__tests__/chains.test.ts @@ -0,0 +1,50 @@ +import { baseSepolia, optimismSepolia } from 'viem/chains' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runChains } from '@/commands/chains.js' +import * as baseCtx from '@/context/baseContext.js' + +describe('runChains', () => { + let writeSpy: ReturnType> + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockConfig = (chains: unknown) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: { chains } as never, + actions: {} as never, + }) + } + + it('emits chainId + shortname + rpcUrls per configured chain', async () => { + mockConfig([ + { chainId: baseSepolia.id, rpcUrls: ['https://rpc.example'] }, + { chainId: optimismSepolia.id, rpcUrls: undefined }, + ]) + await runChains() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toEqual([ + { + chainId: baseSepolia.id, + shortname: 'base-sepolia', + rpcUrls: ['https://rpc.example'], + }, + { + chainId: optimismSepolia.id, + shortname: 'op-sepolia', + }, + ]) + }) + + it('emits an empty array when no chains are configured', async () => { + mockConfig([]) + await runChains() + expect(JSON.parse(String(writeSpy.mock.calls[0]?.[0]))).toEqual([]) + }) +}) diff --git a/packages/cli/src/commands/chains.ts b/packages/cli/src/commands/chains.ts new file mode 100644 index 000000000..bccbcc99a --- /dev/null +++ b/packages/cli/src/commands/chains.ts @@ -0,0 +1,21 @@ +import { baseContext } from '@/context/baseContext.js' +import { writeJson } from '@/output/json.js' +import { shortnameFor } from '@/resolvers/chains.js' + +/** + * @description Handler for `actions chains`. Emits the configured chain + * set as JSON — each entry carries `chainId`, canonical `shortname`, and + * any explicit `rpcUrls`. No SDK call; the data comes from the resolved + * config and the chain resolver's inverse map. + * @returns Promise that resolves once stdout has been written. + */ +export async function runChains(): Promise { + const { config } = baseContext() + writeJson( + config.chains.map((chain) => ({ + chainId: chain.chainId, + shortname: shortnameFor(chain.chainId), + rpcUrls: chain.rpcUrls, + })), + ) +} From b1e8ad9b57871d7bea08a868ebf5ff3c998789c9 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:14:57 -0700 Subject: [PATCH 18/76] add wallet address command --- .../commands/__tests__/walletAddress.test.ts | 56 +++++++++++++++++++ packages/cli/src/commands/wallet/address.ts | 13 +++++ 2 files changed, 69 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/walletAddress.test.ts create mode 100644 packages/cli/src/commands/wallet/address.ts diff --git a/packages/cli/src/commands/__tests__/walletAddress.test.ts b/packages/cli/src/commands/__tests__/walletAddress.test.ts new file mode 100644 index 000000000..888eb08f7 --- /dev/null +++ b/packages/cli/src/commands/__tests__/walletAddress.test.ts @@ -0,0 +1,56 @@ +import { privateKeyToAccount } from 'viem/accounts' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runWalletAddress } from '@/commands/wallet/address.js' +import { __resetEnvCacheForTests } from '@/config/env.js' +import { CliError } from '@/output/errors.js' + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +const EXPECTED_ADDRESS = privateKeyToAccount(ANVIL_ACCOUNT_0).address + +describe('runWalletAddress', () => { + const originalEnv = process.env + let writeSpy: ReturnType> + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.PRIVATE_KEY + __resetEnvCacheForTests() + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + vi.restoreAllMocks() + }) + + it('emits the deterministic signer address', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + await runWalletAddress() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toEqual({ address: EXPECTED_ADDRESS }) + }) + + it('rejects with CliError(config) when PRIVATE_KEY is missing', async () => { + try { + await runWalletAddress() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) + + it('rejects with CliError(config) when PRIVATE_KEY is malformed', async () => { + process.env.PRIVATE_KEY = 'not-hex' + try { + await runWalletAddress() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) +}) diff --git a/packages/cli/src/commands/wallet/address.ts b/packages/cli/src/commands/wallet/address.ts new file mode 100644 index 000000000..aba5f49f4 --- /dev/null +++ b/packages/cli/src/commands/wallet/address.ts @@ -0,0 +1,13 @@ +import { walletContext } from '@/context/walletContext.js' +import { writeJson } from '@/output/json.js' + +/** + * @description Handler for `actions wallet address`. Returns the EOA + * address derived from `PRIVATE_KEY`. Pure — no RPC call, no factory + * lookup. + * @returns Promise that resolves once stdout has been written. + */ +export async function runWalletAddress(): Promise { + const { wallet } = await walletContext() + writeJson({ address: wallet.address }) +} From 492846ecd7d4d733887bfb7bbc9715588fe8a5c3 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:16:05 -0700 Subject: [PATCH 19/76] add wallet balance command --- .../commands/__tests__/walletBalance.test.ts | 95 +++++++++++++++++++ packages/cli/src/commands/wallet/balance.ts | 26 +++++ 2 files changed, 121 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/walletBalance.test.ts create mode 100644 packages/cli/src/commands/wallet/balance.ts diff --git a/packages/cli/src/commands/__tests__/walletBalance.test.ts b/packages/cli/src/commands/__tests__/walletBalance.test.ts new file mode 100644 index 000000000..15c66aa93 --- /dev/null +++ b/packages/cli/src/commands/__tests__/walletBalance.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runWalletBalance } from '@/commands/wallet/balance.js' +import { __resetEnvCacheForTests } from '@/config/env.js' +import * as walletCtx from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +describe('runWalletBalance', () => { + const originalEnv = process.env + let writeSpy: ReturnType> + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.PRIVATE_KEY + __resetEnvCacheForTests() + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + vi.restoreAllMocks() + }) + + const mockWallet = (getBalance: () => Promise) => { + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: { chains: [] } as never, + actions: {} as never, + signer: {} as never, + wallet: { + address: '0x0', + getBalance, + } as never, + }) + } + + it('emits the balance array with bigints serialised as strings', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + mockWallet(async () => [ + { + asset: { metadata: { symbol: 'ETH' } }, + totalBalance: 0.0001, + totalBalanceRaw: 100000000000000n, + chains: { 84532: { balance: 0.0001, balanceRaw: 100000000000000n } }, + }, + ]) + await runWalletBalance() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body[0].totalBalanceRaw).toBe('100000000000000') + expect(body[0].chains['84532'].balanceRaw).toBe('100000000000000') + }) + + it('preserves precision for large bigint balances', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + mockWallet(async () => [ + { + asset: { metadata: { symbol: 'USDC' } }, + totalBalance: 0, + totalBalanceRaw: 1234567890123456789n, + chains: {}, + }, + ]) + await runWalletBalance() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body[0].totalBalanceRaw).toBe('1234567890123456789') + }) + + it('classifies RPC failures as retryable network errors', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + mockWallet(async () => { + throw new Error('HTTP request failed. Status: ECONNREFUSED') + }) + try { + await runWalletBalance() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + expect((err as CliError).retryable).toBe(true) + } + }) + + it('rejects with CliError(config) when PRIVATE_KEY is missing', async () => { + try { + await runWalletBalance() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) +}) diff --git a/packages/cli/src/commands/wallet/balance.ts b/packages/cli/src/commands/wallet/balance.ts new file mode 100644 index 000000000..77f811949 --- /dev/null +++ b/packages/cli/src/commands/wallet/balance.ts @@ -0,0 +1,26 @@ +import { walletContext } from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' +import { writeJson } from '@/output/json.js' + +/** + * @description Handler for `actions wallet balance`. Fetches ETH and + * allowlisted ERC-20 balances across every configured chain. The SDK + * implements `getBalance` as `Promise.all` over (asset × chain), so any + * single RPC failure rejects the whole batch — this handler classifies + * that rejection as a retryable `network` error so the agent can retry. + * @returns Promise that resolves once stdout has been written. + */ +export async function runWalletBalance(): Promise { + const { wallet } = await walletContext() + try { + const balances = await wallet.getBalance() + writeJson(balances) + } catch (err) { + if (err instanceof CliError) throw err + throw new CliError( + 'network', + err instanceof Error ? err.message : String(err), + { cause: err }, + ) + } +} From 323c4d58b36b158e6727e36a6f7fb31cae79642a Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:36:20 -0700 Subject: [PATCH 20/76] wire command tree and EPIPE guards --- .../cli/src/commands/__tests__/chains.test.ts | 3 +- .../commands/__tests__/walletAddress.test.ts | 3 +- .../commands/__tests__/walletBalance.test.ts | 3 +- packages/cli/src/commands/wallet/index.ts | 25 ++++++++++++ packages/cli/src/index.ts | 40 ++++++++++++++++++- 5 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/wallet/index.ts diff --git a/packages/cli/src/commands/__tests__/chains.test.ts b/packages/cli/src/commands/__tests__/chains.test.ts index 025f0a040..c355edb14 100644 --- a/packages/cli/src/commands/__tests__/chains.test.ts +++ b/packages/cli/src/commands/__tests__/chains.test.ts @@ -1,11 +1,12 @@ import { baseSepolia, optimismSepolia } from 'viem/chains' +import type { MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runChains } from '@/commands/chains.js' import * as baseCtx from '@/context/baseContext.js' describe('runChains', () => { - let writeSpy: ReturnType> + let writeSpy: MockInstance beforeEach(() => { writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) diff --git a/packages/cli/src/commands/__tests__/walletAddress.test.ts b/packages/cli/src/commands/__tests__/walletAddress.test.ts index 888eb08f7..f67568f93 100644 --- a/packages/cli/src/commands/__tests__/walletAddress.test.ts +++ b/packages/cli/src/commands/__tests__/walletAddress.test.ts @@ -1,4 +1,5 @@ import { privateKeyToAccount } from 'viem/accounts' +import type { MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runWalletAddress } from '@/commands/wallet/address.js' @@ -11,7 +12,7 @@ const EXPECTED_ADDRESS = privateKeyToAccount(ANVIL_ACCOUNT_0).address describe('runWalletAddress', () => { const originalEnv = process.env - let writeSpy: ReturnType> + let writeSpy: MockInstance beforeEach(() => { process.env = { ...originalEnv } diff --git a/packages/cli/src/commands/__tests__/walletBalance.test.ts b/packages/cli/src/commands/__tests__/walletBalance.test.ts index 15c66aa93..9f9379fee 100644 --- a/packages/cli/src/commands/__tests__/walletBalance.test.ts +++ b/packages/cli/src/commands/__tests__/walletBalance.test.ts @@ -1,3 +1,4 @@ +import type { MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runWalletBalance } from '@/commands/wallet/balance.js' @@ -10,7 +11,7 @@ const ANVIL_ACCOUNT_0 = describe('runWalletBalance', () => { const originalEnv = process.env - let writeSpy: ReturnType> + let writeSpy: MockInstance beforeEach(() => { process.env = { ...originalEnv } diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts new file mode 100644 index 000000000..b7ae519cb --- /dev/null +++ b/packages/cli/src/commands/wallet/index.ts @@ -0,0 +1,25 @@ +import { Command } from 'commander' + +import { runWalletAddress } from '@/commands/wallet/address.js' +import { runWalletBalance } from '@/commands/wallet/balance.js' + +/** + * @description Builds the `wallet` subcommand tree. Registered children + * are the wallet-scoped commands that require `PRIVATE_KEY`. PR 2/3 add + * `wallet lend …` and `wallet swap …` under this command. + * @returns Commander `Command` configured with its subcommands. + */ +export function walletCommand(): Command { + const command = new Command('wallet').description( + 'Wallet-scoped commands (require PRIVATE_KEY).', + ) + command + .command('address') + .description('Print the EOA address derived from PRIVATE_KEY.') + .action(runWalletAddress) + command + .command('balance') + .description('Print ETH and ERC-20 balances across every configured chain.') + .action(runWalletBalance) + return command +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 04f5b9b2a..3011f9c52 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,8 +1,46 @@ #!/usr/bin/env node import { Command } from 'commander' +import { runAssets } from '@/commands/assets.js' +import { runChains } from '@/commands/chains.js' +import { walletCommand } from '@/commands/wallet/index.js' +import { writeError } from '@/output/errors.js' + +function isEpipe(err: unknown): boolean { + return ( + err !== null && + typeof err === 'object' && + 'code' in err && + (err as { code?: unknown }).code === 'EPIPE' + ) +} + +process.stdout.on('error', (err) => { + if (isEpipe(err)) process.exit(0) +}) +process.stderr.on('error', (err) => { + if (isEpipe(err)) process.exit(0) +}) +process.on('uncaughtException', (err) => { + if (isEpipe(err)) process.exit(0) + writeError(err) +}) +process.on('unhandledRejection', (err) => writeError(err)) + const program = new Command() .name('actions') .description('Agent-first CLI for the Actions SDK.') -await program.parseAsync(process.argv) +program + .command('assets') + .description('List the configured asset allowlist.') + .action(runAssets) + +program + .command('chains') + .description('List the configured chains with their shortnames.') + .action(runChains) + +program.addCommand(walletCommand()) + +program.parseAsync(process.argv).catch(writeError) From 5eb981287af4d784ec5213c6649f2dad30e725e0 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:38:36 -0700 Subject: [PATCH 21/76] add picocolors for help output --- packages/cli/package.json | 1 + packages/cli/src/index.ts | 14 +++++++++++++- pnpm-lock.yaml | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 200b91f3e..b4690169a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,6 +35,7 @@ "@eth-optimism/actions-sdk": "workspace:*", "commander": "^13.1.0", "envalid": "^8.1.0", + "picocolors": "^1.1.1", "viem": "^2.24.1" }, "devDependencies": { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3011f9c52..a55e02828 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { Command } from 'commander' +import { Command, Help } from 'commander' +import pico from 'picocolors' import { runAssets } from '@/commands/assets.js' import { runChains } from '@/commands/chains.js' @@ -27,9 +28,20 @@ process.on('uncaughtException', (err) => { }) process.on('unhandledRejection', (err) => writeError(err)) +const colorizeHelp = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR + const program = new Command() .name('actions') .description('Agent-first CLI for the Actions SDK.') + .configureHelp({ + ...new Help(), + subcommandTerm: (cmd) => + colorizeHelp ? pico.cyan(cmd.name()) : cmd.name(), + commandUsage: (cmd) => { + const usage = new Help().commandUsage(cmd) + return colorizeHelp ? pico.bold(usage) : usage + }, + }) program .command('assets') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d0d8773f..de868cc68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: envalid: specifier: ^8.1.0 version: 8.1.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 viem: specifier: 2.33.0 version: 2.33.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) From acb0b565835ca5b0e8f81216bf408e902250e2ac Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:39:23 -0700 Subject: [PATCH 22/76] add SKILL.md agent contract and README --- packages/cli/README.md | 71 +++++++++++++++++++++++++++++++++++ packages/cli/SKILL.md | 84 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 packages/cli/README.md create mode 100644 packages/cli/SKILL.md diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 000000000..384582aec --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,71 @@ +# actions-cli + +Agent-first command-line interface for the Actions SDK. Emits JSON on +stdout, JSON error envelopes on stderr, distinct exit codes per failure +category. Consumed as a subprocess by agent runtimes (e.g. the `opie` +Slack bot). + +## Audience + +`actions-cli` is designed for programmatic callers (LLM agents, +automations, CI jobs) that need to invoke SDK operations without +embedding TypeScript. For the full agent contract see +[`SKILL.md`](./SKILL.md). + +## Environment + +| Var | Required | Description | +| --------------------------- | ------------ | -------------------------------------------------------- | +| `PRIVATE_KEY` | wallet cmds | 0x-prefixed 32-byte hex. Signer for all wallet commands. | +| `BASE_SEPOLIA_RPC_URL` | optional | RPC override for Base Sepolia (falls back to viem). | +| `OP_SEPOLIA_RPC_URL` | optional | RPC override for Optimism Sepolia. | +| `UNICHAIN_RPC_URL` | optional | RPC override for Unichain. | + +`actions --help` and the read-only commands (`assets`, `chains`) work +with no env set — `PRIVATE_KEY` is read lazily inside wallet-scoped +commands. + +### Env hygiene + +Prefer [`direnv`](https://direnv.net/) or a `.env` file over prefixing +commands with `PRIVATE_KEY=0x... actions ...` — the latter lands in +`~/.bash_history`. + +## Local development + +```sh +pnpm install +pnpm -C packages/cli build +pnpm -C packages/cli dev assets # tsx-based, no build step +``` + +Smoke-test the built binary: + +```sh +./packages/cli/dist/index.js --help +./packages/cli/dist/index.js chains +``` + +## Demo configuration + +PR 1 ships a baked demo `NodeActionsConfig` under `src/demo/`. The +allowlisted assets and markets mirror +`packages/demo/backend/src/config/` so the CLI and backend operate +against the same demo set. Chains: Base Sepolia, Optimism Sepolia, +Unichain. Bundlers are intentionally omitted — the EOA signer pays gas +directly. + +The interactive agent-onboarding flow (#411) will swap `loadConfig()`'s +source for per-user state without touching callers. Keep every `Actions` +construction site behind `loadConfig()` so the follow-up remains a +drop-in replacement. + +## References + +- Agent skill: [`SKILL.md`](./SKILL.md) +- Brainstorm (on `kevin/actions-cli`): + [`docs/brainstorms/2026-04-21-actions-cli-brainstorm.md`](https://github.com/ethereum-optimism/actions/blob/kevin/actions-cli/docs/brainstorms/2026-04-21-actions-cli-brainstorm.md) +- Plan (on `kevin/actions-cli`): + [`docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md`](https://github.com/ethereum-optimism/actions/blob/kevin/actions-cli/docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md) +- Parent issue: [#407](https://github.com/ethereum-optimism/actions/issues/407) +- This PR: [#408](https://github.com/ethereum-optimism/actions/issues/408) diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md new file mode 100644 index 000000000..719e12d17 --- /dev/null +++ b/packages/cli/SKILL.md @@ -0,0 +1,84 @@ +--- +name: actions-cli +description: Invoke the Actions SDK from the shell — query assets/chains, derive an EOA address from a PRIVATE_KEY env var, read balances. Use when an agent needs to interact with the Actions SDK without embedding TypeScript. Lend and swap commands land in PR 2/3. +compatibility: Requires Node.js >=18 and the PRIVATE_KEY env var for wallet-scoped commands. +--- + +# Actions CLI — Agent Skill + +## Invocation + +Spawn the `actions` binary as a subprocess. Pass subcommands + flags on argv. +Read stdout as JSON. On nonzero exit, read stderr as JSON for error info. + +## Command tree (current — PR 1) + +- `actions assets` — configured asset allowlist. +- `actions chains` — configured chain shortnames + IDs. +- `actions wallet address` — EOA address derived from `PRIVATE_KEY`. +- `actions wallet balance` — balances per chain + asset. +- `actions wallet lend …` — [PR 2 — not yet available] +- `actions wallet swap …` — [PR 3 — not yet available] + +## Wallet model + +The CLI derives a viem `LocalAccount` from `PRIVATE_KEY` and wraps it in an +EOA-backed Actions wallet via `actions.wallet.toActionsWallet(localAccount)`. +No smart wallet, no bundler, no ERC-4337 UserOps — the signer pays gas +directly. For the demo, fund the EOA with testnet ETH on Base Sepolia. + +## Resolution rules + +- **Assets** — pass the `metadata.symbol` value from the allowlist (e.g. + `USDC_DEMO`, `OP_DEMO`, `ETH`). Case-insensitive. Run `actions assets` + for the current list. +- **Chains** — pass a shortname (`base-sepolia`, `op-sepolia`, `unichain`). + Run `actions chains` for the current list. + +## Output + +- Success: JSON document on stdout, exit 0. No envelope (matches `gh` and + AWS CLI conventions). +- Error: JSON `{error, code, retryable, retry_after_ms?, details?}` on + stderr, non-zero exit. `retryable: true` means the agent may retry + (typically network failures). `retry_after_ms` is present when a + specific back-off is recommended. `details` is redacted — bundler URLs + with API keys, signer metadata, and raw viem request bodies are + scrubbed. + +## Balance semantics + +`actions wallet balance` is all-or-nothing: internally the SDK uses +nested `Promise.all` over (asset × chain), so any single failing RPC +rejects the whole call with a `network` error. Retries may succeed on a +different call — do not assume per-chain isolation. + +## RPC trust + +`*_RPC_URL` env vars must point to operator-trusted endpoints. A malicious +RPC can return fake balance (and, once PR 2/3 land, fake quote/market data). +PR 1 is low-severity (fake zero balances confuse the agent); PR 2/3 +escalates to high-severity (the agent authorises mutations against fake +state). + +## Exit codes + +| Code | Meaning | Retryable | +| ---- | -------------------------------------- | --------- | +| 0 | Success | — | +| 1 | Unknown error | false | +| 2 | Validation (bad input) | false | +| 3 | Config error (missing env, malformed) | false | +| 4 | Network error (RPC, timeout) | true | +| 5 | Onchain error (revert, UserOp failure) | false (†) | + +(†) Specific onchain sub-classes (nonce conflicts, gas underpricing) may +set `retryable: true` via the `retryableOverride` mechanism. Treat +`retryable` as the source of truth; the table row shows the default. + +## Unknown commands + +Typos (`actions lend nonsense`) exit 1 with commander's default plain-text +error on stderr — **not** the JSON error envelope. This distinction is +deliberate: the JSON envelope is only emitted for errors thrown from +within a registered handler. From 305112e3b2e85f26d15e68aaa1c16f2d82a527da Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:41:23 -0700 Subject: [PATCH 23/76] replace em dashes with hyphens --- packages/cli/README.md | 6 ++-- packages/cli/SKILL.md | 32 ++++++++++----------- packages/cli/src/commands/assets.ts | 2 +- packages/cli/src/commands/chains.ts | 2 +- packages/cli/src/commands/wallet/address.ts | 2 +- packages/cli/src/commands/wallet/balance.ts | 2 +- packages/cli/src/config/env.ts | 4 +-- packages/cli/src/context/baseContext.ts | 2 +- packages/cli/src/context/walletContext.ts | 2 +- packages/cli/src/demo/chains.ts | 2 +- packages/cli/src/demo/config.ts | 2 +- packages/cli/src/demo/markets.ts | 2 +- packages/cli/src/output/errors.ts | 4 +-- packages/cli/src/output/json.ts | 2 +- packages/cli/src/resolvers/assets.ts | 2 +- packages/cli/src/resolvers/chains.ts | 2 +- 16 files changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 384582aec..207f1f9b7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -22,13 +22,13 @@ embedding TypeScript. For the full agent contract see | `UNICHAIN_RPC_URL` | optional | RPC override for Unichain. | `actions --help` and the read-only commands (`assets`, `chains`) work -with no env set — `PRIVATE_KEY` is read lazily inside wallet-scoped +with no env set - `PRIVATE_KEY` is read lazily inside wallet-scoped commands. ### Env hygiene Prefer [`direnv`](https://direnv.net/) or a `.env` file over prefixing -commands with `PRIVATE_KEY=0x... actions ...` — the latter lands in +commands with `PRIVATE_KEY=0x... actions ...` - the latter lands in `~/.bash_history`. ## Local development @@ -52,7 +52,7 @@ PR 1 ships a baked demo `NodeActionsConfig` under `src/demo/`. The allowlisted assets and markets mirror `packages/demo/backend/src/config/` so the CLI and backend operate against the same demo set. Chains: Base Sepolia, Optimism Sepolia, -Unichain. Bundlers are intentionally omitted — the EOA signer pays gas +Unichain. Bundlers are intentionally omitted - the EOA signer pays gas directly. The interactive agent-onboarding flow (#411) will swap `loadConfig()`'s diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index 719e12d17..d8c23f655 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -1,38 +1,38 @@ --- name: actions-cli -description: Invoke the Actions SDK from the shell — query assets/chains, derive an EOA address from a PRIVATE_KEY env var, read balances. Use when an agent needs to interact with the Actions SDK without embedding TypeScript. Lend and swap commands land in PR 2/3. +description: Invoke the Actions SDK from the shell - query assets/chains, derive an EOA address from a PRIVATE_KEY env var, read balances. Use when an agent needs to interact with the Actions SDK without embedding TypeScript. Lend and swap commands land in PR 2/3. compatibility: Requires Node.js >=18 and the PRIVATE_KEY env var for wallet-scoped commands. --- -# Actions CLI — Agent Skill +# Actions CLI - Agent Skill ## Invocation Spawn the `actions` binary as a subprocess. Pass subcommands + flags on argv. Read stdout as JSON. On nonzero exit, read stderr as JSON for error info. -## Command tree (current — PR 1) +## Command tree (current - PR 1) -- `actions assets` — configured asset allowlist. -- `actions chains` — configured chain shortnames + IDs. -- `actions wallet address` — EOA address derived from `PRIVATE_KEY`. -- `actions wallet balance` — balances per chain + asset. -- `actions wallet lend …` — [PR 2 — not yet available] -- `actions wallet swap …` — [PR 3 — not yet available] +- `actions assets` - configured asset allowlist. +- `actions chains` - configured chain shortnames + IDs. +- `actions wallet address` - EOA address derived from `PRIVATE_KEY`. +- `actions wallet balance` - balances per chain + asset. +- `actions wallet lend …` - [PR 2 - not yet available] +- `actions wallet swap …` - [PR 3 - not yet available] ## Wallet model The CLI derives a viem `LocalAccount` from `PRIVATE_KEY` and wraps it in an EOA-backed Actions wallet via `actions.wallet.toActionsWallet(localAccount)`. -No smart wallet, no bundler, no ERC-4337 UserOps — the signer pays gas +No smart wallet, no bundler, no ERC-4337 UserOps - the signer pays gas directly. For the demo, fund the EOA with testnet ETH on Base Sepolia. ## Resolution rules -- **Assets** — pass the `metadata.symbol` value from the allowlist (e.g. +- **Assets** - pass the `metadata.symbol` value from the allowlist (e.g. `USDC_DEMO`, `OP_DEMO`, `ETH`). Case-insensitive. Run `actions assets` for the current list. -- **Chains** — pass a shortname (`base-sepolia`, `op-sepolia`, `unichain`). +- **Chains** - pass a shortname (`base-sepolia`, `op-sepolia`, `unichain`). Run `actions chains` for the current list. ## Output @@ -42,7 +42,7 @@ directly. For the demo, fund the EOA with testnet ETH on Base Sepolia. - Error: JSON `{error, code, retryable, retry_after_ms?, details?}` on stderr, non-zero exit. `retryable: true` means the agent may retry (typically network failures). `retry_after_ms` is present when a - specific back-off is recommended. `details` is redacted — bundler URLs + specific back-off is recommended. `details` is redacted - bundler URLs with API keys, signer metadata, and raw viem request bodies are scrubbed. @@ -51,7 +51,7 @@ directly. For the demo, fund the EOA with testnet ETH on Base Sepolia. `actions wallet balance` is all-or-nothing: internally the SDK uses nested `Promise.all` over (asset × chain), so any single failing RPC rejects the whole call with a `network` error. Retries may succeed on a -different call — do not assume per-chain isolation. +different call - do not assume per-chain isolation. ## RPC trust @@ -65,7 +65,7 @@ state). | Code | Meaning | Retryable | | ---- | -------------------------------------- | --------- | -| 0 | Success | — | +| 0 | Success | - | | 1 | Unknown error | false | | 2 | Validation (bad input) | false | | 3 | Config error (missing env, malformed) | false | @@ -79,6 +79,6 @@ set `retryable: true` via the `retryableOverride` mechanism. Treat ## Unknown commands Typos (`actions lend nonsense`) exit 1 with commander's default plain-text -error on stderr — **not** the JSON error envelope. This distinction is +error on stderr - **not** the JSON error envelope. This distinction is deliberate: the JSON envelope is only emitted for errors thrown from within a registered handler. diff --git a/packages/cli/src/commands/assets.ts b/packages/cli/src/commands/assets.ts index 540c41cd1..544ab3e1e 100644 --- a/packages/cli/src/commands/assets.ts +++ b/packages/cli/src/commands/assets.ts @@ -3,7 +3,7 @@ import { writeJson } from '@/output/json.js' /** * @description Handler for `actions assets`. Returns the configured - * allowlist of assets as a JSON array on stdout. Read-only — no signer + * allowlist of assets as a JSON array on stdout. Read-only - no signer * needed. * @returns Promise that resolves once stdout has been written. */ diff --git a/packages/cli/src/commands/chains.ts b/packages/cli/src/commands/chains.ts index bccbcc99a..d03d90e13 100644 --- a/packages/cli/src/commands/chains.ts +++ b/packages/cli/src/commands/chains.ts @@ -4,7 +4,7 @@ import { shortnameFor } from '@/resolvers/chains.js' /** * @description Handler for `actions chains`. Emits the configured chain - * set as JSON — each entry carries `chainId`, canonical `shortname`, and + * set as JSON - each entry carries `chainId`, canonical `shortname`, and * any explicit `rpcUrls`. No SDK call; the data comes from the resolved * config and the chain resolver's inverse map. * @returns Promise that resolves once stdout has been written. diff --git a/packages/cli/src/commands/wallet/address.ts b/packages/cli/src/commands/wallet/address.ts index aba5f49f4..3b38d6590 100644 --- a/packages/cli/src/commands/wallet/address.ts +++ b/packages/cli/src/commands/wallet/address.ts @@ -3,7 +3,7 @@ import { writeJson } from '@/output/json.js' /** * @description Handler for `actions wallet address`. Returns the EOA - * address derived from `PRIVATE_KEY`. Pure — no RPC call, no factory + * address derived from `PRIVATE_KEY`. Pure - no RPC call, no factory * lookup. * @returns Promise that resolves once stdout has been written. */ diff --git a/packages/cli/src/commands/wallet/balance.ts b/packages/cli/src/commands/wallet/balance.ts index 77f811949..33b3248af 100644 --- a/packages/cli/src/commands/wallet/balance.ts +++ b/packages/cli/src/commands/wallet/balance.ts @@ -6,7 +6,7 @@ import { writeJson } from '@/output/json.js' * @description Handler for `actions wallet balance`. Fetches ETH and * allowlisted ERC-20 balances across every configured chain. The SDK * implements `getBalance` as `Promise.all` over (asset × chain), so any - * single RPC failure rejects the whole batch — this handler classifies + * single RPC failure rejects the whole batch - this handler classifies * that rejection as a retryable `network` error so the agent can retry. * @returns Promise that resolves once stdout has been written. */ diff --git a/packages/cli/src/config/env.ts b/packages/cli/src/config/env.ts index c13d9e2d1..f4f389dd0 100644 --- a/packages/cli/src/config/env.ts +++ b/packages/cli/src/config/env.ts @@ -25,7 +25,7 @@ function load(): CliEnv { /** * @description Test-only: resets the lazy env cache so repeated `cleanEnv` - * invocations can be observed. Production code must never call this — the + * invocations can be observed. Production code must never call this - the * subprocess model means the cache lives as long as the process. */ export function __resetEnvCacheForTests(): void { @@ -36,7 +36,7 @@ export function __resetEnvCacheForTests(): void { * @description Lazily reads a required env var through envalid. The first * call parses `process.env`; subsequent calls reuse the cached result for * the life of the subprocess. Throws `CliError('config')` if the var is not - * set — `cleanEnv` is never called at module top level, so `actions --help` + * set - `cleanEnv` is never called at module top level, so `actions --help` * works with no env configured. * @param name - Env var name. * @returns The env var value. diff --git a/packages/cli/src/context/baseContext.ts b/packages/cli/src/context/baseContext.ts index 762e15ac9..396ec76a1 100644 --- a/packages/cli/src/context/baseContext.ts +++ b/packages/cli/src/context/baseContext.ts @@ -15,7 +15,7 @@ export interface BaseContext { /** * @description Builds the tier-0 context for read-only CLI commands * (`assets`, `chains`). Loads the resolved config and constructs a fresh - * `Actions` instance per invocation — the CLI runs as a short-lived + * `Actions` instance per invocation - the CLI runs as a short-lived * subprocess, so module-level singletons would only add startup surprise * without saving allocation cost. Does not read `PRIVATE_KEY`, so * `actions --help` and the no-wallet commands work with no env vars set. diff --git a/packages/cli/src/context/walletContext.ts b/packages/cli/src/context/walletContext.ts index 9bde8e174..1a9386245 100644 --- a/packages/cli/src/context/walletContext.ts +++ b/packages/cli/src/context/walletContext.ts @@ -31,7 +31,7 @@ function parseSigner(privateKey: string): LocalAccount { * (`wallet address`, `wallet balance`, and PR 2/3 lend/swap handlers). * Derives a viem `LocalAccount` from `PRIVATE_KEY` and wraps it in an * EOA-backed Actions wallet via `actions.wallet.toActionsWallet(localAccount)`. - * No smart-wallet factory call, no bundler dependency — the signer pays + * No smart-wallet factory call, no bundler dependency - the signer pays * gas directly from its own balance. * @returns Context with config, actions, signer, and the EOA-backed wallet. * @throws `CliError` with code `config` when `PRIVATE_KEY` is missing or diff --git a/packages/cli/src/demo/chains.ts b/packages/cli/src/demo/chains.ts index df6798b17..8d514ad88 100644 --- a/packages/cli/src/demo/chains.ts +++ b/packages/cli/src/demo/chains.ts @@ -15,7 +15,7 @@ function rpcUrls(key: CliEnvKey): string[] | undefined { /** * @description Returns the CLI's baked demo chain set: Base Sepolia, - * Optimism Sepolia, Unichain — mirroring the demo backend's market + * Optimism Sepolia, Unichain - mirroring the demo backend's market * footprint. RPC URLs come from the matching `*_RPC_URL` env vars when * set, otherwise viem's chain defaults apply. Bundler configuration is * omitted intentionally: the CLI signs transactions from an EOA and the diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts index 99ffd8590..c9f9e8484 100644 --- a/packages/cli/src/demo/config.ts +++ b/packages/cli/src/demo/config.ts @@ -15,7 +15,7 @@ import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' * Divergences: `hostedWalletConfig` is omitted (the CLI uses an EOA-backed * wallet via `actions.wallet.toActionsWallet(localAccount)`); `swap` is * omitted entirely (PR 3 adds it); chain bundlers are omitted (no ERC-4337 - * gas abstraction — the signer pays gas directly). + * gas abstraction - the signer pays gas directly). * @returns `NodeActionsConfig` with no hosted wallet provider configured. */ export function getDemoConfig(): NodeActionsConfig { diff --git a/packages/cli/src/demo/markets.ts b/packages/cli/src/demo/markets.ts index 69292b68e..9fac537f3 100644 --- a/packages/cli/src/demo/markets.ts +++ b/packages/cli/src/demo/markets.ts @@ -22,7 +22,7 @@ export const GauntletUSDCDemo: LendMarketConfig = { /** * @description Aave v3 ETH market on Optimism Sepolia. The market address is - * the WETH reserve token — Aave exposes ETH deposits through its WETH + * the WETH reserve token - Aave exposes ETH deposits through its WETH * gateway. Mirrored from the demo backend's config. */ export const AaveETH: LendMarketConfig = { diff --git a/packages/cli/src/output/errors.ts b/packages/cli/src/output/errors.ts index 97d49cc03..0af64f5b4 100644 --- a/packages/cli/src/output/errors.ts +++ b/packages/cli/src/output/errors.ts @@ -2,7 +2,7 @@ import { serializeBigInt } from '@eth-optimism/actions-sdk' /** * @description Agent-consumable error categories. The code determines the - * process exit value and the default retryability — callers may override + * process exit value and the default retryability - callers may override * the latter through `CliError.retryableOverride`. */ export type ErrorCode = @@ -174,7 +174,7 @@ function redactRecord( * to stderr. Drops known-sensitive keys (signer metadata, request bodies), * reduces viem error instances to `{ errorName, shortMessage }`, and strips * API-key segments from any RPC/bundler URLs it encounters. The allowlist is - * intentionally conservative — unknown scalars are preserved only when their + * intentionally conservative - unknown scalars are preserved only when their * key is in `SCALAR_ALLOWLIST`. * @param details - Arbitrary data attached to a `CliError`. * @returns A safe-to-emit clone of `details`. diff --git a/packages/cli/src/output/json.ts b/packages/cli/src/output/json.ts index b8892d495..b0b53b010 100644 --- a/packages/cli/src/output/json.ts +++ b/packages/cli/src/output/json.ts @@ -5,7 +5,7 @@ import { serializeBigInt } from '@eth-optimism/actions-sdk' * newline. Any `bigint` values are coerced to decimal strings via * `serializeBigInt` so the output is parseable by any JSON consumer. * - * The CLI's agent contract is "stdout is a bare JSON document per invocation" — + * The CLI's agent contract is "stdout is a bare JSON document per invocation" - * use this helper as the single stdout sink for successful command output. * Error output goes to stderr via `writeError`, never here. * @param doc - Any JSON-coercible value. Objects, arrays, and primitives are diff --git a/packages/cli/src/resolvers/assets.ts b/packages/cli/src/resolvers/assets.ts index fec51c647..8cd6e8f68 100644 --- a/packages/cli/src/resolvers/assets.ts +++ b/packages/cli/src/resolvers/assets.ts @@ -5,7 +5,7 @@ import { CliError } from '@/output/errors.js' /** * @description Resolves an asset symbol (e.g. `USDC_DEMO`, `eth`) to the * matching `Asset` entry from an allowlist. Matching is case-insensitive on - * `metadata.symbol`. The resolver is config-agnostic — callers pass the + * `metadata.symbol`. The resolver is config-agnostic - callers pass the * allowlist explicitly so the same function works for demo config, user * config (#411), and tests. * @param symbol - User-provided asset symbol from CLI argv. diff --git a/packages/cli/src/resolvers/chains.ts b/packages/cli/src/resolvers/chains.ts index 54764181e..9f5123592 100644 --- a/packages/cli/src/resolvers/chains.ts +++ b/packages/cli/src/resolvers/chains.ts @@ -51,7 +51,7 @@ export function resolveChain( } /** - * @description Inverse of `resolveChain` — maps a `SupportedChainId` back + * @description Inverse of `resolveChain` - maps a `SupportedChainId` back * to its canonical shortname. Used by the `chains` command to render the * configured chain set. The round-trip * `shortnameFor(resolveChain(name)) === name` holds for every name in the From c14db41b2a90f16bedc42379dc69681c066c57b2 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:42:58 -0700 Subject: [PATCH 24/76] add spawn-based system tests --- packages/cli/src/__tests__/system.test.ts | 140 ++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/cli/src/__tests__/system.test.ts diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts new file mode 100644 index 000000000..8ca45deab --- /dev/null +++ b/packages/cli/src/__tests__/system.test.ts @@ -0,0 +1,140 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +import { beforeAll, describe, expect, it } from 'vitest' + +const execFileP = promisify(execFile) + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const BIN = resolve(HERE, '../../dist/index.js') + +async function run( + args: string[], + env: NodeJS.ProcessEnv = {}, +): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const { stdout, stderr } = await execFileP('node', [BIN, ...args], { + env: { ...process.env, ...env }, + }) + return { stdout, stderr, code: 0 } + } catch (err) { + const e = err as { + stdout?: string + stderr?: string + code?: number + } + return { + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + code: typeof e.code === 'number' ? e.code : 1, + } + } +} + +const ANSI_PATTERN = /\x1b\[[0-9;]*m/ + +describe('actions CLI (built binary)', () => { + beforeAll(() => { + if (!existsSync(BIN)) { + throw new Error( + `dist/index.js not found at ${BIN}. Run pnpm -C packages/cli build first.`, + ) + } + }) + + describe('actions --help', () => { + it('exits 0 with no env vars set', async () => { + const { stdout, stderr, code } = await run(['--help'], { + PRIVATE_KEY: '', + }) + expect(code).toBe(0) + expect(stderr).toBe('') + expect(stdout).toContain('actions') + expect(stdout).toContain('wallet') + }) + }) + + describe('actions assets', () => { + it('emits JSON, exits 0, no ANSI on stdout', async () => { + const { stdout, stderr, code } = await run(['assets']) + expect(code).toBe(0) + expect(stderr).toBe('') + expect(stdout).not.toMatch(ANSI_PATTERN) + const body = JSON.parse(stdout) + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBeGreaterThan(0) + }) + }) + + describe('actions chains', () => { + it('emits JSON array with chainId + shortname per chain', async () => { + const { stdout, code } = await run(['chains']) + expect(code).toBe(0) + const body = JSON.parse(stdout) as Array<{ + chainId: number + shortname: string + }> + expect(body.length).toBeGreaterThan(0) + for (const entry of body) { + expect(typeof entry.chainId).toBe('number') + expect(typeof entry.shortname).toBe('string') + } + }) + }) + + describe('actions wallet address', () => { + it('missing PRIVATE_KEY -> stderr JSON code:config exit 3', async () => { + const { stdout, stderr, code } = await run(['wallet', 'address'], { + PRIVATE_KEY: '', + }) + expect(code).toBe(3) + expect(stdout).toBe('') + const body = JSON.parse(stderr) + expect(body.code).toBe('config') + expect(body.retryable).toBe(false) + }) + + it('happy path with ANVIL_ACCOUNT_0 returns deterministic address', async () => { + const { stdout, code } = await run(['wallet', 'address'], { + PRIVATE_KEY: ANVIL_ACCOUNT_0, + }) + expect(code).toBe(0) + expect(stdout).not.toMatch(ANSI_PATTERN) + const body = JSON.parse(stdout) as { address: string } + expect(body.address.toLowerCase()).toBe( + '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'.toLowerCase(), + ) + }) + }) + + describe('actions wallet balance', () => { + it('blackhole RPC -> stderr JSON code:network retryable:true exit 4', async () => { + const { stderr, code } = await run(['wallet', 'balance'], { + PRIVATE_KEY: ANVIL_ACCOUNT_0, + BASE_SEPOLIA_RPC_URL: 'http://127.0.0.1:1', + OP_SEPOLIA_RPC_URL: 'http://127.0.0.1:1', + UNICHAIN_RPC_URL: 'http://127.0.0.1:1', + }) + expect(code).toBe(4) + const body = JSON.parse(stderr) + expect(body.code).toBe('network') + expect(body.retryable).toBe(true) + }, 30_000) + }) + + describe('unknown command', () => { + it('exits 1 with commander plain-text stderr (not writeError JSON)', async () => { + const { stdout, stderr, code } = await run(['nonsense-command']) + expect(code).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('unknown command') + expect(() => JSON.parse(stderr)).toThrow() + }) + }) +}) From ad6811b0025854b1c570d8dda3ba40e9ae187ea7 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 12:58:08 -0700 Subject: [PATCH 25/76] drop handoff doc --- .../2026-04-22-actions-cli-pr408-handoff.md | 287 ------------------ 1 file changed, 287 deletions(-) delete mode 100644 docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md diff --git a/docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md b/docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md deleted file mode 100644 index 3f5e3725a..000000000 --- a/docs/handoffs/2026-04-22-actions-cli-pr408-handoff.md +++ /dev/null @@ -1,287 +0,0 @@ ---- -title: Actions CLI PR #408 scaffolding — handoff -type: handoff -status: active -date: 2026-04-22 -branch: feat/cli-scaffolding -github_issue: https://github.com/ethereum-optimism/actions/issues/408 -parent_issue: https://github.com/ethereum-optimism/actions/issues/407 -origin_brainstorm: docs/brainstorms/2026-04-21-actions-cli-brainstorm.md -origin_plan: docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md ---- - -# Actions CLI PR #408 — Handoff - -Pick up a scaffolding PR for a new `packages/cli/` workspace package. The CLI -is an **agent-first subprocess**: JSON on stdout, JSON error envelope on -stderr, five-value exit-code taxonomy, consumed by the `opie` Slack bot -(`git@github.com:ethereum-optimism/opie.git`). - -## Where everything lives - -| Artifact | Location | -|---|---| -| Working directory (worktree) | `/Users/kevin/github/optimism/actions-cli-scaffolding` | -| Branch | `feat/cli-scaffolding` (pushed, tracking `origin/feat/cli-scaffolding`) | -| GitHub issue | | -| Parent issue | | -| Brainstorm | `docs/brainstorms/2026-04-21-actions-cli-brainstorm.md` (on branch `kevin/actions-cli`, PR #420) | -| **Plan (source of truth)** | `docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md` (on branch `kevin/actions-cli`, 734 lines, ce-deepened) | -| Engineering principles | (treat as binding) | -| Base commit | cut from `origin/main` at `58fc354d` (post-#356, so `hostedWalletConfig` is optional) | - -## Commits already on the branch - -``` -c5a557b5 extract serializeBigInt to sdk util -ff235127 scope no-console error to cli -5969bca0 scaffold actions-cli package skeleton -``` - -## Task tracker state (24 total) - -Use the existing TaskList. Completed: **#1 worktree, #2 skeleton, #3 eslint+changeset, -#4 serializeBigInt extraction**. Next up: **#5 writeJson**. Then #6 … #24 in order. -Dependencies are roughly linear — don't parallelize unless you re-read the plan's -interaction graph. - -| # | Task | -|---|---| -| 5 | writeJson + tests | -| 6 | CliError + safeDetails + tests | -| 7 | writeError + EPIPE handling | -| 8 | Lazy requireEnv + contract test | -| 9 | Demo chains constants | -| 10 | Demo markets constants | -| 11 | Demo config + loadConfig | -| 12 | Asset resolver + tests | -| 13 | Chain resolver + tests | -| 14 | baseContext + tests | -| 15 | walletContext + tests | -| 16 | `assets` command + tests | -| 17 | `chains` command + tests | -| 18 | `wallet address` command + tests | -| 19 | `wallet balance` command + tests | -| 20 | Wire top-level index.ts | -| 21 | picocolors for --help + stderr `Error:` label only | -| 22 | SKILL.md + README.md | -| 23 | System tests per command | -| 24 | Final gates + open PR | - -## Standing directives (from the user — do not deviate) - -- **Commits:** 3–7 words, no AI/Claude mention, one `git add + git commit + git push` - command per logical unit. -- **Verify before every commit:** `pnpm typecheck && pnpm lint` at repo root. - Run `pnpm -C packages/sdk test` when SDK changes, `pnpm -C packages/cli test` - when CLI changes. -- **Zero new lint warnings** — the backend has 75 pre-existing warnings and the - SDK has 72. Do not let those counts increase. The CLI package itself must - stay at 0. -- **picocolors scope:** `--help` output + stderr `Error:` label only. stdout - JSON payload stays ANSI-free — **asserted by integration test** (task 23). - -## Engineering principles to apply (from issue #380) - -- **Reuse before invention** — grep canonical locations before writing new utils, - mocks, fixtures. Extraction trigger = **second concrete usage**, not speculative. - That's why `serializeBigInt` now lives in the SDK (CLI was the second usage; - backend was the first). -- **Viem patterns:** named concrete error classes at throw sites only where callers - need `instanceof`. For `CliError`, keep a single class with a `code` discriminator - — the agent contract is the `err.code` string, not `instanceof`. -- **Type narrowness:** `SupportedChainId` not `number`, `Hex` not `string`, `Asset` - not loose object shapes. No `any`. No `as Foo` casts — narrow at the source. - Use `import type` for type-only symbols. -- **Structure:** ≤20 lines of logic per function, ≤200 lines per file, max 2 - nesting levels, prefer early returns / guard clauses. -- **JSDoc on every public function/class:** `@description` (what + why, not how), - `@param`, `@returns`, `@throws`. -- **No module-level singletons.** CLI constructs `Actions` fresh per command via - `baseContext()` / `walletContext()`. The backend's `let actionsInstance` is an - anti-pattern for a short-lived subprocess. - -## Key SDK references (verified on current HEAD) - -- `packages/sdk/src/types/actions.ts` — `ActionsConfig`, `WalletConfig` - (`hostedWalletConfig?:`), `NodeActionsConfig`, `SwapConfig` is - `RequireAtLeastOne<{uniswap?, velodrome?}>` — **do not write `swap: {}`**, - omit the key entirely. -- `packages/sdk/src/wallet/core/namespace/WalletNamespace.ts` — `getSmartWallet`, - `toActionsWallet`, `ToActionsWalletParam`. -- `packages/sdk/src/wallet/core/providers/smart/default/DefaultSmartWalletProvider.ts` - — `getWalletAddress` performs **one `eth_call`** to the factory. `wallet address` - is RPC-bound, not pure. -- `packages/sdk/src/wallet/core/wallets/abstract/Wallet.ts` — `getBalance` uses - nested `Promise.all` over (asset × chain). One failing RPC fails all 9 reads. - Document in SKILL.md (plan already specifies this). -- `packages/sdk/src/nodeActionsFactory.ts` — `createActions` - entry point. -- `packages/sdk/src/constants/assets.ts` — `USDC_DEMO`, `OP_DEMO`, `ETH`, `WETH` - canonical exports. **Do not create `src/demo/assets.ts`.** Import from the SDK. -- `packages/sdk/src/utils/serializers.ts` — **just extracted in commit `c5a557b5`; - import via `@eth-optimism/actions-sdk`**. -- `packages/sdk/src/utils/test.ts` — `ANVIL_ACCOUNTS` fixtures for - deterministic-address unit tests. - -## CLI package layout - -From the plan's architecture section, Decision 13. - -``` -packages/cli/src/ - index.ts # bin entrypoint — commander + EPIPE + uncaughtException - commands/ - assets.ts - chains.ts - wallet/ - index.ts # registers wallet subcommand + children - address.ts - balance.ts - config/ - loadConfig.ts # returns resolved NodeActionsConfig - env.ts # lazy envalid (NO module-top-level cleanEnv) - context/ - baseContext.ts # { config, actions } — read-only commands - walletContext.ts # { config, actions, signer, smartWallet } — wallet commands - output/ - json.ts # writeJson → stdout - errors.ts # CliError, ErrorCode, safeDetails, writeError → stderr + exit - resolvers/ - assets.ts # symbol → Asset (case-insensitive) from config.assets.allow - chains.ts # shortname ↔ SupportedChainId; round-trip property tested - utils/ - (serializeBigInt NOT here — imports from SDK now) - services/ # empty in PR 1; exists for PR 2/3 to grow into - demo/ # everything demo-specific - config.ts # baked NodeActionsConfig - chains.ts # BASE_SEPOLIA, OPTIMISM_SEPOLIA, UNICHAIN (NO bundler in PR 1) - markets.ts # GauntletUSDCDemo, AaveETH (used by PR 2; referenced via demo config's lend allowlist now) - # NO assets.ts — import USDC_DEMO, OP_DEMO, ETH from SDK -SKILL.md # Agent Skills spec frontmatter -README.md -``` - -**Directory names are deliberate:** `core/` is forbidden (SDK reserves it at -four levels). Nested `commands/wallet/` is deliberate for subcommand composition -— PR 2/3 add `commands/wallet/lend/` and `commands/wallet/swap/` under it. - -## Smoke commands this PR ships - -- `actions assets` — `actions.getSupportedAssets()` (no wallet needed) -- `actions chains` — enumerate `config.chains` via the chain resolver's inverse - (no SDK call) -- `actions wallet address` — `smartWallet.address` (1 RPC to factory) -- `actions wallet balance` — `smartWallet.getBalance()` (N×M RPCs) - -Lend/swap branches are **deliberately not registered** in PR 1. Commander's -default "unknown command" error (exit 1, plain text on stderr) is acceptable — -**don't route unknown commands through `writeError`**. Lock this distinction -in task 23's integration tests. - -## Error taxonomy (exit codes + retryable defaults) - -``` -unknown=1 retryable=false (fallback for uncaught errors) -validation=2 retryable=false -config=3 retryable=false (missing env, malformed PRIVATE_KEY, malformed config) -network=4 retryable=true (RPC failure — includes wallet-address factory read) -onchain=5 retryable=false (PR 2/3 may flip for nonce conflicts etc.) -``` - -Error body shape: - -```json -{ "error": "message", "code": "network", "retryable": true, "retry_after_ms": 1000, "details": { } } -``` - -**`details` must be redacted** via `safeDetails()` before serialization. viem -errors pack bundler URLs (containing Pimlico/Alchemy API keys), raw request -payloads, and signer metadata into `.details` / `.metaMessages`. Unit tests in -task 6 must assert: - -- URL API-key path segments are stripped (pattern: `/v[0-9]+/[^/]+/rpc(\?.*)?`) -- viem `Error` objects are reduced to `{ errorName, shortMessage }` -- Signer `publicKey` / `address` metadata never passes through - -## Lazy envalid contract (task 8) - -`actions --help` must work with **no env vars set**. envalid's `cleanEnv` cannot -be called at module top-level. Structurally enforce with a test: - -```ts -import * as envalid from 'envalid' -const spy = vi.spyOn(envalid, 'cleanEnv') -await import('../config/env.js') // Must NOT call cleanEnv -expect(spy).not.toHaveBeenCalled() -requireEnv('PRIVATE_KEY') // Must call it now -expect(spy).toHaveBeenCalledOnce() -``` - -## System tests (task 23) — use `execFile` against built `dist/index.js` - -Minimum coverage per user directive ("e2e for each granular actions function call"): - -- `actions assets` → stdout parses as JSON array, exit 0, no ANSI on stdout -- `actions chains` → stdout parses as JSON array, exit 0 -- `actions wallet address` → happy path (fixed PRIVATE_KEY via `ANVIL_ACCOUNTS.ACCOUNT_0`, - deterministic address match). Requires either anvil or a mock at the RPC - layer. Decide during implementation. -- `actions wallet balance` → happy path (mocked or anvil) -- `actions wallet address` with no `PRIVATE_KEY` → stderr JSON `code: "config"`, exit 3 -- `actions wallet balance` with `BASE_SEPOLIA_RPC_URL=http://127.0.0.1:1` (blackhole) - → stderr JSON `code: "network"`, `retryable: true`, exit 4 -- `actions ` → commander default plain-text error on stderr, exit 1 - (**not** `writeError` JSON — lock this distinction) -- `actions --help` → exit 0 with no env set - -Tests must `beforeAll(() => pnpm -C packages/cli build)` or rely on CI having -built first. Document the choice in the test file. - -## Final PR (task 24) - -- Body must link #408 and the plan file path on the `kevin/actions-cli` branch. -- Include the **Post-Deploy Monitoring & Validation** section required by - `/workflows:work` — for a dev-tool CLI with no production runtime, a one-liner - `No additional operational monitoring required: agent-facing dev tool with no server component` - is acceptable. -- Mark checkboxes in the plan file (`[ ]` → `[x]`) before committing the final - changes. The plan lives on `kevin/actions-cli`; you can either cherry-pick / - update those checkboxes in a separate small PR, or include a diff against - that branch in this one. Discuss with Kevin — he may want to defer. - -## Things that bit me; don't repeat - -1. **`mkdir` / `git` / `pnpm` are not on the login shell PATH** when Bash - commands run without a shell init — use absolute paths - (`/opt/homebrew/bin/git`, `/Users/kevin/Library/pnpm/pnpm`). -2. **Prettier lints `dist/`** unless you add a local `packages/cli/.prettierignore`. - Already created. -3. **SDK must be rebuilt** (`pnpm -C packages/sdk build`) after touching - `packages/sdk/src/**` — the backend typecheck resolves - `@eth-optimism/actions-sdk` through `packages/sdk/dist/`. -4. **`@/` path alias** needs `resolve-tspaths` in the CLI build script (already - wired). Test for it by running the built binary from a fresh clone — - `node dist/index.js --help` must resolve all `@/` imports. -5. **Shebang preservation** — TSC keeps it only if it's the very first token in - `src/index.ts`. Don't add leading comments. Already working. - -## Open product question flagged by the plan (do not resolve — surface to Kevin) - -**#412 (1-of-2 signer onboarding)** — the plan notes this is *weaker* than 1-of-1 -(more keys that can independently drain). If the intent is user-recovery / -fallback authority, 1-of-2 is correct but should be reframed as recovery. If -the intent is "user co-signs high-value actions," it needs to be k-of-n with -k>1, which needs a kernel that supports it (ZeroDev). Raise before #412 is -implemented. - -## Blocker for PR #409 (not this PR, but don't forget) - -PR #409 (lend) is **blocked on an offchain spending cap** -(`ACTIONS_SPEND_CAP_USD` / `_WEI`) enforced in the handler before UserOp -construction. ~20 LOC, no ZeroDev dependency. Full #414 onchain Call Policies -is a later follow-up. Call this out in the #409 kickoff. - ---- - -Branch is clean at `c5a557b5`. Start with task 5 (`writeJson`). Good luck. From 0fc3d9e86f0f1a5aa8fc008d67decb6199b048de Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 13:12:12 -0700 Subject: [PATCH 26/76] add --chain and --chain-id flags --- packages/cli/src/__tests__/system.test.ts | 24 +++++++ .../commands/__tests__/walletBalance.test.ts | 44 +++++++++++++ packages/cli/src/commands/wallet/balance.ts | 38 ++++++++--- packages/cli/src/commands/wallet/index.ts | 8 +++ .../src/resolvers/__tests__/chains.test.ts | 53 ++++++++++++++++ packages/cli/src/resolvers/chains.ts | 63 +++++++++++++++++++ 6 files changed, 223 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts index 8ca45deab..c1b70953c 100644 --- a/packages/cli/src/__tests__/system.test.ts +++ b/packages/cli/src/__tests__/system.test.ts @@ -128,6 +128,30 @@ describe('actions CLI (built binary)', () => { }, 30_000) }) + describe('wallet balance --chain flags', () => { + it('rejects both --chain and --chain-id with code:validation exit 2', async () => { + const { stdout, stderr, code } = await run( + ['wallet', 'balance', '--chain', 'base-sepolia', '--chain-id', '84532'], + { PRIVATE_KEY: ANVIL_ACCOUNT_0 }, + ) + expect(code).toBe(2) + expect(stdout).toBe('') + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/not both/) + }) + + it('rejects unknown --chain-id with code:validation exit 2', async () => { + const { stderr, code } = await run( + ['wallet', 'balance', '--chain-id', '999999999'], + { PRIVATE_KEY: ANVIL_ACCOUNT_0 }, + ) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + }) + }) + describe('unknown command', () => { it('exits 1 with commander plain-text stderr (not writeError JSON)', async () => { const { stdout, stderr, code } = await run(['nonsense-command']) diff --git a/packages/cli/src/commands/__tests__/walletBalance.test.ts b/packages/cli/src/commands/__tests__/walletBalance.test.ts index 9f9379fee..0c9e2c0c1 100644 --- a/packages/cli/src/commands/__tests__/walletBalance.test.ts +++ b/packages/cli/src/commands/__tests__/walletBalance.test.ts @@ -93,4 +93,48 @@ describe('runWalletBalance', () => { expect((err as CliError).code).toBe('config') } }) + + it('filters balances to a single chain via --chain', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: { chains: [{ chainId: 84532 }, { chainId: 11155420 }] } as never, + actions: {} as never, + signer: {} as never, + wallet: { + address: '0x0', + getBalance: async () => [ + { + asset: { metadata: { symbol: 'ETH' } }, + totalBalance: 3, + totalBalanceRaw: 3n, + chains: { + 84532: { balance: 1, balanceRaw: 1n }, + 11155420: { balance: 2, balanceRaw: 2n }, + }, + }, + ], + } as never, + }) + await runWalletBalance({ chain: 'base-sepolia' }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(Object.keys(body[0].chains)).toEqual(['84532']) + expect(body[0].totalBalanceRaw).toBe('1') + }) + + it('rejects when both --chain and --chain-id are set', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: { chains: [{ chainId: 84532 }] } as never, + actions: {} as never, + signer: {} as never, + wallet: { address: '0x0', getBalance: async () => [] } as never, + }) + try { + await runWalletBalance({ chain: 'base-sepolia', chainId: '84532' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) }) diff --git a/packages/cli/src/commands/wallet/balance.ts b/packages/cli/src/commands/wallet/balance.ts index 33b3248af..7912e0b8f 100644 --- a/packages/cli/src/commands/wallet/balance.ts +++ b/packages/cli/src/commands/wallet/balance.ts @@ -1,20 +1,44 @@ +import type { SupportedChainId, TokenBalance } from '@eth-optimism/actions-sdk' + import { walletContext } from '@/context/walletContext.js' import { CliError } from '@/output/errors.js' import { writeJson } from '@/output/json.js' +import { type ChainFlags, resolveChainFlags } from '@/resolvers/chains.js' + +function filterToChain( + balances: TokenBalance[], + chainId: SupportedChainId, +): TokenBalance[] { + return balances.map((tb) => { + const entry = tb.chains[chainId] + return { + ...tb, + totalBalance: entry?.balance ?? 0, + totalBalanceRaw: entry?.balanceRaw ?? 0n, + chains: entry ? { [chainId]: entry } : {}, + } + }) +} /** * @description Handler for `actions wallet balance`. Fetches ETH and - * allowlisted ERC-20 balances across every configured chain. The SDK - * implements `getBalance` as `Promise.all` over (asset × chain), so any - * single RPC failure rejects the whole batch - this handler classifies - * that rejection as a retryable `network` error so the agent can retry. + * allowlisted ERC-20 balances across every configured chain. Pass + * `--chain ` or `--chain-id ` to scope the output to a + * single chain (mutually exclusive). The SDK implements `getBalance` + * as `Promise.all` over (asset x chain), so any single RPC failure + * rejects the whole batch; classify that as a retryable `network` error. + * @param flags - Commander-parsed options; chain selection is optional. * @returns Promise that resolves once stdout has been written. */ -export async function runWalletBalance(): Promise { - const { wallet } = await walletContext() +export async function runWalletBalance(flags: ChainFlags = {}): Promise { + const { wallet, config } = await walletContext() + const chainId = resolveChainFlags( + flags, + config.chains.map((c) => c.chainId), + ) try { const balances = await wallet.getBalance() - writeJson(balances) + writeJson(chainId ? filterToChain(balances, chainId) : balances) } catch (err) { if (err instanceof CliError) throw err throw new CliError( diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts index b7ae519cb..d21eab3eb 100644 --- a/packages/cli/src/commands/wallet/index.ts +++ b/packages/cli/src/commands/wallet/index.ts @@ -20,6 +20,14 @@ export function walletCommand(): Command { command .command('balance') .description('Print ETH and ERC-20 balances across every configured chain.') + .option( + '--chain ', + 'filter to one chain by shortname (e.g. base-sepolia); mutually exclusive with --chain-id', + ) + .option( + '--chain-id ', + 'filter to one chain by numeric id (e.g. 84532); mutually exclusive with --chain', + ) .action(runWalletBalance) return command } diff --git a/packages/cli/src/resolvers/__tests__/chains.test.ts b/packages/cli/src/resolvers/__tests__/chains.test.ts index d6819ae61..dfef28bcb 100644 --- a/packages/cli/src/resolvers/__tests__/chains.test.ts +++ b/packages/cli/src/resolvers/__tests__/chains.test.ts @@ -78,3 +78,56 @@ describe('resolver round-trip', () => { } }) }) + +describe('resolveChainId', () => { + it('accepts a configured numeric chain id', async () => { + const { resolveChainId } = await import('@/resolvers/chains.js') + expect(resolveChainId(String(baseSepolia.id), ALL)).toBe(baseSepolia.id) + }) + + it('rejects non-integers', async () => { + const { resolveChainId } = await import('@/resolvers/chains.js') + expect(() => resolveChainId('abc', ALL)).toThrow(CliError) + }) + + it('rejects ids not in the configured set', async () => { + const { resolveChainId } = await import('@/resolvers/chains.js') + expect(() => resolveChainId('1', [baseSepolia.id])).toThrow(CliError) + }) +}) + +describe('resolveChainFlags', () => { + it('returns undefined when no flag is set', async () => { + const { resolveChainFlags } = await import('@/resolvers/chains.js') + expect(resolveChainFlags({}, ALL)).toBeUndefined() + }) + + it('resolves --chain shortname', async () => { + const { resolveChainFlags } = await import('@/resolvers/chains.js') + expect(resolveChainFlags({ chain: 'base-sepolia' }, ALL)).toBe( + baseSepolia.id, + ) + }) + + it('resolves --chain-id numeric', async () => { + const { resolveChainFlags } = await import('@/resolvers/chains.js') + expect( + resolveChainFlags({ chainId: String(optimismSepolia.id) }, ALL), + ).toBe(optimismSepolia.id) + }) + + it('throws validation when both flags are set', async () => { + const { resolveChainFlags } = await import('@/resolvers/chains.js') + try { + resolveChainFlags( + { chain: 'base-sepolia', chainId: String(baseSepolia.id) }, + ALL, + ) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + expect((err as CliError).message).toMatch(/not both/) + } + }) +}) diff --git a/packages/cli/src/resolvers/chains.ts b/packages/cli/src/resolvers/chains.ts index 9f5123592..33d3f070c 100644 --- a/packages/cli/src/resolvers/chains.ts +++ b/packages/cli/src/resolvers/chains.ts @@ -69,3 +69,66 @@ export function shortnameFor(chainId: SupportedChainId): string { } return name } + +/** + * @description Parses a raw `--chain-id` flag value and validates it is + * present in the configured chain set. + * @param raw - The flag value as passed on argv. + * @param configuredChainIds - Chain IDs in the resolved config. + * @returns The validated `SupportedChainId`. + * @throws `CliError` with code `validation` when the value is not a + * positive integer or is not present in `configuredChainIds`. + */ +export function resolveChainId( + raw: string, + configuredChainIds: readonly SupportedChainId[], +): SupportedChainId { + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CliError( + 'validation', + `Invalid --chain-id: ${raw} (expected a positive integer)`, + { chainId: raw }, + ) + } + if (!configuredChainIds.includes(parsed as SupportedChainId)) { + throw new CliError('validation', `Chain ${parsed} is not configured`, { + chainId: parsed, + allowed: configuredChainIds, + }) + } + return parsed as SupportedChainId +} + +export interface ChainFlags { + chain?: string + chainId?: string +} + +/** + * @description Resolves the mutually-exclusive `--chain` / `--chain-id` + * option pair into a single `SupportedChainId`, or `undefined` when + * neither is provided. Callers apply the "undefined = no filter" + * convention as they see fit. + * @param flags - Parsed commander options; either flag may be set. + * @param configuredChainIds - Chain IDs in the resolved config. + * @returns The selected chain id, or `undefined` if neither flag was used. + * @throws `CliError` with code `validation` when both flags are set or + * when the provided value is unknown. + */ +export function resolveChainFlags( + flags: ChainFlags, + configuredChainIds: readonly SupportedChainId[], +): SupportedChainId | undefined { + const { chain, chainId } = flags + if (chain && chainId) { + throw new CliError( + 'validation', + 'Pass either --chain or --chain-id, not both', + { chain, chainId }, + ) + } + if (chain) return resolveChain(chain, configuredChainIds) + if (chainId) return resolveChainId(chainId, configuredChainIds) + return undefined +} From c03af9d79afb7f958662a2bd90a3e2ea18a5ad05 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 14:10:53 -0700 Subject: [PATCH 27/76] scrub agent mentions and follow-up refs --- packages/cli/README.md | 38 ++++++----------------- packages/cli/src/commands/wallet/index.ts | 3 +- packages/cli/src/config/loadConfig.ts | 8 ++--- packages/cli/src/context/walletContext.ts | 11 +++---- packages/cli/src/demo/config.ts | 4 +-- packages/cli/src/index.ts | 2 +- packages/cli/src/output/errors.ts | 10 +++--- packages/cli/src/output/json.ts | 2 +- packages/cli/src/resolvers/assets.ts | 4 +-- 9 files changed, 30 insertions(+), 52 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 207f1f9b7..4bde5d22d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,16 +1,14 @@ # actions-cli -Agent-first command-line interface for the Actions SDK. Emits JSON on -stdout, JSON error envelopes on stderr, distinct exit codes per failure -category. Consumed as a subprocess by agent runtimes (e.g. the `opie` -Slack bot). +Command-line interface for the Actions SDK. Emits JSON on stdout, JSON +error envelopes on stderr, distinct exit codes per failure category. +Designed to be consumed as a subprocess: spawn, read stdout, parse. ## Audience -`actions-cli` is designed for programmatic callers (LLM agents, -automations, CI jobs) that need to invoke SDK operations without -embedding TypeScript. For the full agent contract see -[`SKILL.md`](./SKILL.md). +`actions-cli` is for programmatic callers (automations, CI jobs, shell +scripts) that need to invoke SDK operations without embedding TypeScript. +For the skill contract see [`SKILL.md`](./SKILL.md). ## Environment @@ -22,7 +20,7 @@ embedding TypeScript. For the full agent contract see | `UNICHAIN_RPC_URL` | optional | RPC override for Unichain. | `actions --help` and the read-only commands (`assets`, `chains`) work -with no env set - `PRIVATE_KEY` is read lazily inside wallet-scoped +with no env set. `PRIVATE_KEY` is read lazily inside wallet-scoped commands. ### Env hygiene @@ -48,24 +46,8 @@ Smoke-test the built binary: ## Demo configuration -PR 1 ships a baked demo `NodeActionsConfig` under `src/demo/`. The -allowlisted assets and markets mirror +The package ships a baked demo `NodeActionsConfig` under `src/demo/`. +The allowlisted assets and markets mirror `packages/demo/backend/src/config/` so the CLI and backend operate against the same demo set. Chains: Base Sepolia, Optimism Sepolia, -Unichain. Bundlers are intentionally omitted - the EOA signer pays gas -directly. - -The interactive agent-onboarding flow (#411) will swap `loadConfig()`'s -source for per-user state without touching callers. Keep every `Actions` -construction site behind `loadConfig()` so the follow-up remains a -drop-in replacement. - -## References - -- Agent skill: [`SKILL.md`](./SKILL.md) -- Brainstorm (on `kevin/actions-cli`): - [`docs/brainstorms/2026-04-21-actions-cli-brainstorm.md`](https://github.com/ethereum-optimism/actions/blob/kevin/actions-cli/docs/brainstorms/2026-04-21-actions-cli-brainstorm.md) -- Plan (on `kevin/actions-cli`): - [`docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md`](https://github.com/ethereum-optimism/actions/blob/kevin/actions-cli/docs/plans/2026-04-21-feat-actions-cli-scaffolding-plan.md) -- Parent issue: [#407](https://github.com/ethereum-optimism/actions/issues/407) -- This PR: [#408](https://github.com/ethereum-optimism/actions/issues/408) +Unichain. Bundlers are omitted - the EOA signer pays gas directly. diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts index d21eab3eb..310a5a968 100644 --- a/packages/cli/src/commands/wallet/index.ts +++ b/packages/cli/src/commands/wallet/index.ts @@ -5,8 +5,7 @@ import { runWalletBalance } from '@/commands/wallet/balance.js' /** * @description Builds the `wallet` subcommand tree. Registered children - * are the wallet-scoped commands that require `PRIVATE_KEY`. PR 2/3 add - * `wallet lend …` and `wallet swap …` under this command. + * are the wallet-scoped commands that require `PRIVATE_KEY`. * @returns Commander `Command` configured with its subcommands. */ export function walletCommand(): Command { diff --git a/packages/cli/src/config/loadConfig.ts b/packages/cli/src/config/loadConfig.ts index d186ed14e..5105ad575 100644 --- a/packages/cli/src/config/loadConfig.ts +++ b/packages/cli/src/config/loadConfig.ts @@ -3,11 +3,9 @@ import type { NodeActionsConfig } from '@eth-optimism/actions-sdk' import { getDemoConfig } from '@/demo/config.js' /** - * @description Resolves the CLI's `NodeActionsConfig`. PR 1 returns the - * baked demo config unconditionally; the interactive agent-onboarding flow - * (#411) will swap this for a per-user source without touching callers. - * Keep every `Actions` construction site behind `loadConfig` so the - * follow-up remains a drop-in replacement. + * @description Resolves the CLI's `NodeActionsConfig`. Returns the baked + * demo config. Keep every `Actions` construction site behind `loadConfig` + * so the source can be swapped without touching callers. * @returns The resolved Actions config for this process. */ export function loadConfig(): NodeActionsConfig { diff --git a/packages/cli/src/context/walletContext.ts b/packages/cli/src/context/walletContext.ts index 1a9386245..65f156d28 100644 --- a/packages/cli/src/context/walletContext.ts +++ b/packages/cli/src/context/walletContext.ts @@ -27,12 +27,11 @@ function parseSigner(privateKey: string): LocalAccount { } /** - * @description Builds the tier-1 context for wallet-scoped commands - * (`wallet address`, `wallet balance`, and PR 2/3 lend/swap handlers). - * Derives a viem `LocalAccount` from `PRIVATE_KEY` and wraps it in an - * EOA-backed Actions wallet via `actions.wallet.toActionsWallet(localAccount)`. - * No smart-wallet factory call, no bundler dependency - the signer pays - * gas directly from its own balance. + * @description Builds the context for wallet-scoped commands. Derives a + * viem `LocalAccount` from `PRIVATE_KEY` and wraps it in an EOA-backed + * Actions wallet via `actions.wallet.toActionsWallet(localAccount)`. No + * smart-wallet factory call, no bundler dependency - the signer pays gas + * directly from its own balance. * @returns Context with config, actions, signer, and the EOA-backed wallet. * @throws `CliError` with code `config` when `PRIVATE_KEY` is missing or * malformed. diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts index c9f9e8484..f7ebc46cb 100644 --- a/packages/cli/src/demo/config.ts +++ b/packages/cli/src/demo/config.ts @@ -14,8 +14,8 @@ import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' * and market set so CLI behaviour stays aligned with the demo backend. * Divergences: `hostedWalletConfig` is omitted (the CLI uses an EOA-backed * wallet via `actions.wallet.toActionsWallet(localAccount)`); `swap` is - * omitted entirely (PR 3 adds it); chain bundlers are omitted (no ERC-4337 - * gas abstraction - the signer pays gas directly). + * omitted; chain bundlers are omitted (no ERC-4337 gas abstraction - the + * signer pays gas directly). * @returns `NodeActionsConfig` with no hosted wallet provider configured. */ export function getDemoConfig(): NodeActionsConfig { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a55e02828..11fe82d34 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -32,7 +32,7 @@ const colorizeHelp = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR const program = new Command() .name('actions') - .description('Agent-first CLI for the Actions SDK.') + .description('Command-line interface for the Actions SDK.') .configureHelp({ ...new Help(), subcommandTerm: (cmd) => diff --git a/packages/cli/src/output/errors.ts b/packages/cli/src/output/errors.ts index 0af64f5b4..29449fb7c 100644 --- a/packages/cli/src/output/errors.ts +++ b/packages/cli/src/output/errors.ts @@ -1,8 +1,8 @@ import { serializeBigInt } from '@eth-optimism/actions-sdk' /** - * @description Agent-consumable error categories. The code determines the - * process exit value and the default retryability - callers may override + * @description Error categories consumed by the caller. The code determines + * the process exit value and the default retryability - callers may override * the latter through `CliError.retryableOverride`. */ export type ErrorCode = @@ -31,7 +31,7 @@ const RETRYABLE_DEFAULT: Record = { /** * @description Structured error raised from command handlers. Carries a * discriminator `code`, an optional `details` payload, and optional - * retry hints the agent can use without parsing free-form messages. + * retry hints the caller can use without parsing free-form messages. */ export class CliError extends Error { constructor( @@ -63,7 +63,7 @@ export function exitCodeFor(code: ErrorCode): number { * @description Default retryability hint for an `ErrorCode`. Callers may * override per-instance via `CliError.retryableOverride`. * @param code - Error category. - * @returns `true` when the agent may retry without user intervention. + * @returns `true` when the caller may retry without user intervention. */ export function retryableDefaultFor(code: ErrorCode): boolean { return RETRYABLE_DEFAULT[code] @@ -195,7 +195,7 @@ function isEpipe(err: unknown): boolean { /** * @description Writes an error envelope to stderr and exits with the - * taxonomy's mapped exit code. The body matches the agent contract + * taxonomy's mapped exit code. The body matches the contract * `{ error, code, retryable, retry_after_ms?, details? }`. `details` is * always redacted; `bigint` values in any field are coerced to strings. * EPIPE on the stderr write is swallowed (the parent has closed the pipe). diff --git a/packages/cli/src/output/json.ts b/packages/cli/src/output/json.ts index b0b53b010..479657693 100644 --- a/packages/cli/src/output/json.ts +++ b/packages/cli/src/output/json.ts @@ -5,7 +5,7 @@ import { serializeBigInt } from '@eth-optimism/actions-sdk' * newline. Any `bigint` values are coerced to decimal strings via * `serializeBigInt` so the output is parseable by any JSON consumer. * - * The CLI's agent contract is "stdout is a bare JSON document per invocation" - + * The CLI's stdout contract is "one bare JSON document per invocation" - * use this helper as the single stdout sink for successful command output. * Error output goes to stderr via `writeError`, never here. * @param doc - Any JSON-coercible value. Objects, arrays, and primitives are diff --git a/packages/cli/src/resolvers/assets.ts b/packages/cli/src/resolvers/assets.ts index 8cd6e8f68..2ff8f6dd0 100644 --- a/packages/cli/src/resolvers/assets.ts +++ b/packages/cli/src/resolvers/assets.ts @@ -6,8 +6,8 @@ import { CliError } from '@/output/errors.js' * @description Resolves an asset symbol (e.g. `USDC_DEMO`, `eth`) to the * matching `Asset` entry from an allowlist. Matching is case-insensitive on * `metadata.symbol`. The resolver is config-agnostic - callers pass the - * allowlist explicitly so the same function works for demo config, user - * config (#411), and tests. + * allowlist explicitly so the same function works across config sources + * and tests. * @param symbol - User-provided asset symbol from CLI argv. * @param allow - Asset allowlist (typically `config.assets.allow`). * @returns The first `Asset` whose `metadata.symbol` matches, case-insensitive. From ca45598ed51594e60fdfe5081d20669d0a0d3bcf Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 14:28:57 -0700 Subject: [PATCH 28/76] add --json flag, consolidate output formatters --- packages/cli/SKILL.md | 76 ++++++++----- packages/cli/src/__tests__/system.test.ts | 85 ++++++++------ .../cli/src/commands/__tests__/assets.test.ts | 6 +- .../cli/src/commands/__tests__/chains.test.ts | 4 + .../commands/__tests__/walletAddress.test.ts | 4 + .../commands/__tests__/walletBalance.test.ts | 4 + packages/cli/src/commands/assets.ts | 10 +- packages/cli/src/commands/chains.ts | 20 ++-- packages/cli/src/commands/wallet/address.ts | 9 +- packages/cli/src/commands/wallet/balance.ts | 15 ++- packages/cli/src/index.ts | 5 + .../src/output/__tests__/writeError.test.ts | 14 ++- packages/cli/src/output/errors.ts | 25 +++-- packages/cli/src/output/mode.ts | 20 ++++ packages/cli/src/output/printOutput.ts | 106 ++++++++++++++++++ 15 files changed, 304 insertions(+), 99 deletions(-) create mode 100644 packages/cli/src/output/mode.ts create mode 100644 packages/cli/src/output/printOutput.ts diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index d8c23f655..5a8c8ff81 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -1,6 +1,6 @@ --- name: actions-cli -description: Invoke the Actions SDK from the shell - query assets/chains, derive an EOA address from a PRIVATE_KEY env var, read balances. Use when an agent needs to interact with the Actions SDK without embedding TypeScript. Lend and swap commands land in PR 2/3. +description: Invoke the Actions SDK from the shell - query assets/chains, derive an EOA address from a PRIVATE_KEY env var, read balances. Use when an agent needs to interact with the Actions SDK without embedding TypeScript. compatibility: Requires Node.js >=18 and the PRIVATE_KEY env var for wallet-scoped commands. --- @@ -8,58 +8,74 @@ compatibility: Requires Node.js >=18 and the PRIVATE_KEY env var for wallet-scop ## Invocation -Spawn the `actions` binary as a subprocess. Pass subcommands + flags on argv. -Read stdout as JSON. On nonzero exit, read stderr as JSON for error info. +Spawn the `actions` binary as a subprocess. **Always pass `--json`** (as +the first flag) - the default output is human-readable and not intended +for parsing. With `--json`, stdout is a bare JSON document on success +and stderr is the error envelope on failure. -## Command tree (current - PR 1) +```sh +actions --json assets +actions --json wallet balance --chain base-sepolia +``` + +## Command tree - `actions assets` - configured asset allowlist. - `actions chains` - configured chain shortnames + IDs. - `actions wallet address` - EOA address derived from `PRIVATE_KEY`. -- `actions wallet balance` - balances per chain + asset. -- `actions wallet lend …` - [PR 2 - not yet available] -- `actions wallet swap …` - [PR 3 - not yet available] +- `actions wallet balance [--chain | --chain-id ]` - balances + per chain + asset; the chain flags are mutually exclusive. ## Wallet model -The CLI derives a viem `LocalAccount` from `PRIVATE_KEY` and wraps it in an -EOA-backed Actions wallet via `actions.wallet.toActionsWallet(localAccount)`. -No smart wallet, no bundler, no ERC-4337 UserOps - the signer pays gas -directly. For the demo, fund the EOA with testnet ETH on Base Sepolia. +The CLI derives a viem `LocalAccount` from `PRIVATE_KEY` and wraps it in +an EOA-backed Actions wallet via +`actions.wallet.toActionsWallet(localAccount)`. No smart wallet, no +bundler, no ERC-4337 UserOps - the signer pays gas directly. For the +demo, fund the EOA with testnet ETH on Base Sepolia. ## Resolution rules -- **Assets** - pass the `metadata.symbol` value from the allowlist (e.g. - `USDC_DEMO`, `OP_DEMO`, `ETH`). Case-insensitive. Run `actions assets` - for the current list. -- **Chains** - pass a shortname (`base-sepolia`, `op-sepolia`, `unichain`). - Run `actions chains` for the current list. +- **Assets** - pass the `metadata.symbol` value from the allowlist + (e.g. `USDC_DEMO`, `OP_DEMO`, `ETH`). Case-insensitive. Run + `actions --json assets` for the current list. +- **Chains** - pass a shortname (`base-sepolia`, `op-sepolia`, + `unichain`) via `--chain`, or a numeric id via `--chain-id` + (mutually exclusive). Run `actions --json chains` for the current + list. ## Output -- Success: JSON document on stdout, exit 0. No envelope (matches `gh` and - AWS CLI conventions). +With `--json`: + +- Success: bare JSON document on stdout, exit 0. No envelope (matches + `gh` and AWS CLI conventions). - Error: JSON `{error, code, retryable, retry_after_ms?, details?}` on - stderr, non-zero exit. `retryable: true` means the agent may retry + stderr, non-zero exit. `retryable: true` means the caller may retry (typically network failures). `retry_after_ms` is present when a - specific back-off is recommended. `details` is redacted - bundler URLs - with API keys, signer metadata, and raw viem request bodies are + specific back-off is recommended. `details` is redacted - bundler + URLs with API keys, signer metadata, and raw viem request bodies are scrubbed. +Without `--json` (default): + +- Success: plain text on stdout intended for human reading. Not stable + across versions. +- Error: `Error (): ` on stderr, exit code per the table + below. + ## Balance semantics `actions wallet balance` is all-or-nothing: internally the SDK uses -nested `Promise.all` over (asset × chain), so any single failing RPC +nested `Promise.all` over (asset x chain), so any single failing RPC rejects the whole call with a `network` error. Retries may succeed on a different call - do not assume per-chain isolation. ## RPC trust -`*_RPC_URL` env vars must point to operator-trusted endpoints. A malicious -RPC can return fake balance (and, once PR 2/3 land, fake quote/market data). -PR 1 is low-severity (fake zero balances confuse the agent); PR 2/3 -escalates to high-severity (the agent authorises mutations against fake -state). +`*_RPC_URL` env vars must point to operator-trusted endpoints. A +malicious RPC can return fake balance data, which will confuse the +caller. ## Exit codes @@ -72,13 +88,13 @@ state). | 4 | Network error (RPC, timeout) | true | | 5 | Onchain error (revert, UserOp failure) | false (†) | -(†) Specific onchain sub-classes (nonce conflicts, gas underpricing) may -set `retryable: true` via the `retryableOverride` mechanism. Treat +(†) Specific onchain sub-classes (nonce conflicts, gas underpricing) +may set `retryable: true` via the `retryableOverride` mechanism. Treat `retryable` as the source of truth; the table row shows the default. ## Unknown commands -Typos (`actions lend nonsense`) exit 1 with commander's default plain-text +Typos (`actions nonsense`) exit 1 with commander's default plain-text error on stderr - **not** the JSON error envelope. This distinction is deliberate: the JSON envelope is only emitted for errors thrown from within a registered handler. diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts index c1b70953c..c17e595af 100644 --- a/packages/cli/src/__tests__/system.test.ts +++ b/packages/cli/src/__tests__/system.test.ts @@ -60,9 +60,9 @@ describe('actions CLI (built binary)', () => { }) }) - describe('actions assets', () => { - it('emits JSON, exits 0, no ANSI on stdout', async () => { - const { stdout, stderr, code } = await run(['assets']) + describe('--json mode', () => { + it('actions --json assets -> JSON array, no ANSI', async () => { + const { stdout, stderr, code } = await run(['--json', 'assets']) expect(code).toBe(0) expect(stderr).toBe('') expect(stdout).not.toMatch(ANSI_PATTERN) @@ -70,11 +70,9 @@ describe('actions CLI (built binary)', () => { expect(Array.isArray(body)).toBe(true) expect(body.length).toBeGreaterThan(0) }) - }) - describe('actions chains', () => { - it('emits JSON array with chainId + shortname per chain', async () => { - const { stdout, code } = await run(['chains']) + it('actions --json chains -> JSON array with chainId + shortname', async () => { + const { stdout, code } = await run(['--json', 'chains']) expect(code).toBe(0) const body = JSON.parse(stdout) as Array<{ chainId: number @@ -86,22 +84,9 @@ describe('actions CLI (built binary)', () => { expect(typeof entry.shortname).toBe('string') } }) - }) - describe('actions wallet address', () => { - it('missing PRIVATE_KEY -> stderr JSON code:config exit 3', async () => { - const { stdout, stderr, code } = await run(['wallet', 'address'], { - PRIVATE_KEY: '', - }) - expect(code).toBe(3) - expect(stdout).toBe('') - const body = JSON.parse(stderr) - expect(body.code).toBe('config') - expect(body.retryable).toBe(false) - }) - - it('happy path with ANVIL_ACCOUNT_0 returns deterministic address', async () => { - const { stdout, code } = await run(['wallet', 'address'], { + it('actions --json wallet address -> JSON doc with deterministic address', async () => { + const { stdout, code } = await run(['--json', 'wallet', 'address'], { PRIVATE_KEY: ANVIL_ACCOUNT_0, }) expect(code).toBe(0) @@ -111,11 +96,21 @@ describe('actions CLI (built binary)', () => { '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'.toLowerCase(), ) }) - }) - describe('actions wallet balance', () => { - it('blackhole RPC -> stderr JSON code:network retryable:true exit 4', async () => { - const { stderr, code } = await run(['wallet', 'balance'], { + it('missing PRIVATE_KEY with --json -> stderr JSON code:config exit 3', async () => { + const { stdout, stderr, code } = await run( + ['--json', 'wallet', 'address'], + { PRIVATE_KEY: '' }, + ) + expect(code).toBe(3) + expect(stdout).toBe('') + const body = JSON.parse(stderr) + expect(body.code).toBe('config') + expect(body.retryable).toBe(false) + }) + + it('blackhole RPC with --json -> stderr JSON code:network retryable:true exit 4', async () => { + const { stderr, code } = await run(['--json', 'wallet', 'balance'], { PRIVATE_KEY: ANVIL_ACCOUNT_0, BASE_SEPOLIA_RPC_URL: 'http://127.0.0.1:1', OP_SEPOLIA_RPC_URL: 'http://127.0.0.1:1', @@ -126,12 +121,18 @@ describe('actions CLI (built binary)', () => { expect(body.code).toBe('network') expect(body.retryable).toBe(true) }, 30_000) - }) - describe('wallet balance --chain flags', () => { - it('rejects both --chain and --chain-id with code:validation exit 2', async () => { + it('both --chain and --chain-id with --json -> stderr JSON code:validation exit 2', async () => { const { stdout, stderr, code } = await run( - ['wallet', 'balance', '--chain', 'base-sepolia', '--chain-id', '84532'], + [ + '--json', + 'wallet', + 'balance', + '--chain', + 'base-sepolia', + '--chain-id', + '84532', + ], { PRIVATE_KEY: ANVIL_ACCOUNT_0 }, ) expect(code).toBe(2) @@ -141,9 +142,9 @@ describe('actions CLI (built binary)', () => { expect(body.error).toMatch(/not both/) }) - it('rejects unknown --chain-id with code:validation exit 2', async () => { + it('unknown --chain-id with --json -> stderr JSON code:validation exit 2', async () => { const { stderr, code } = await run( - ['wallet', 'balance', '--chain-id', '999999999'], + ['--json', 'wallet', 'balance', '--chain-id', '999999999'], { PRIVATE_KEY: ANVIL_ACCOUNT_0 }, ) expect(code).toBe(2) @@ -152,6 +153,26 @@ describe('actions CLI (built binary)', () => { }) }) + describe('default (human) mode', () => { + it('actions assets -> plain text, not JSON', async () => { + const { stdout, stderr, code } = await run(['assets']) + expect(code).toBe(0) + expect(stderr).toBe('') + expect(() => JSON.parse(stdout)).toThrow() + expect(stdout.length).toBeGreaterThan(0) + }) + + it('missing PRIVATE_KEY -> stderr "Error (config): ..." exit 3', async () => { + const { stdout, stderr, code } = await run(['wallet', 'address'], { + PRIVATE_KEY: '', + }) + expect(code).toBe(3) + expect(stdout).toBe('') + expect(stderr).toMatch(/^Error \(config\):/) + expect(() => JSON.parse(stderr)).toThrow() + }) + }) + describe('unknown command', () => { it('exits 1 with commander plain-text stderr (not writeError JSON)', async () => { const { stdout, stderr, code } = await run(['nonsense-command']) diff --git a/packages/cli/src/commands/__tests__/assets.test.ts b/packages/cli/src/commands/__tests__/assets.test.ts index f553e00d7..ab8f9990f 100644 --- a/packages/cli/src/commands/__tests__/assets.test.ts +++ b/packages/cli/src/commands/__tests__/assets.test.ts @@ -1,7 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runAssets } from '@/commands/assets.js' import * as baseCtx from '@/context/baseContext.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) describe('runAssets', () => { const writeSpy = vi diff --git a/packages/cli/src/commands/__tests__/chains.test.ts b/packages/cli/src/commands/__tests__/chains.test.ts index c355edb14..2fcc34bc1 100644 --- a/packages/cli/src/commands/__tests__/chains.test.ts +++ b/packages/cli/src/commands/__tests__/chains.test.ts @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runChains } from '@/commands/chains.js' import * as baseCtx from '@/context/baseContext.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) describe('runChains', () => { let writeSpy: MockInstance diff --git a/packages/cli/src/commands/__tests__/walletAddress.test.ts b/packages/cli/src/commands/__tests__/walletAddress.test.ts index f67568f93..7cde3d0dc 100644 --- a/packages/cli/src/commands/__tests__/walletAddress.test.ts +++ b/packages/cli/src/commands/__tests__/walletAddress.test.ts @@ -5,6 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runWalletAddress } from '@/commands/wallet/address.js' import { __resetEnvCacheForTests } from '@/config/env.js' import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) const ANVIL_ACCOUNT_0 = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' diff --git a/packages/cli/src/commands/__tests__/walletBalance.test.ts b/packages/cli/src/commands/__tests__/walletBalance.test.ts index 0c9e2c0c1..498fe6fac 100644 --- a/packages/cli/src/commands/__tests__/walletBalance.test.ts +++ b/packages/cli/src/commands/__tests__/walletBalance.test.ts @@ -5,6 +5,10 @@ import { runWalletBalance } from '@/commands/wallet/balance.js' import { __resetEnvCacheForTests } from '@/config/env.js' import * as walletCtx from '@/context/walletContext.js' import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) const ANVIL_ACCOUNT_0 = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' diff --git a/packages/cli/src/commands/assets.ts b/packages/cli/src/commands/assets.ts index 544ab3e1e..5bed79c75 100644 --- a/packages/cli/src/commands/assets.ts +++ b/packages/cli/src/commands/assets.ts @@ -1,13 +1,13 @@ import { baseContext } from '@/context/baseContext.js' -import { writeJson } from '@/output/json.js' +import { printOutput } from '@/output/printOutput.js' /** - * @description Handler for `actions assets`. Returns the configured - * allowlist of assets as a JSON array on stdout. Read-only - no signer - * needed. + * @description Handler for `actions assets`. Emits the configured + * allowlist of assets (JSON or human-readable, per `--json`). + * Read-only, no signer needed. * @returns Promise that resolves once stdout has been written. */ export async function runAssets(): Promise { const { actions } = baseContext() - writeJson(actions.getSupportedAssets()) + printOutput('assets', actions.getSupportedAssets()) } diff --git a/packages/cli/src/commands/chains.ts b/packages/cli/src/commands/chains.ts index d03d90e13..5fbbc1a25 100644 --- a/packages/cli/src/commands/chains.ts +++ b/packages/cli/src/commands/chains.ts @@ -1,21 +1,19 @@ import { baseContext } from '@/context/baseContext.js' -import { writeJson } from '@/output/json.js' +import { type ChainRow, printOutput } from '@/output/printOutput.js' import { shortnameFor } from '@/resolvers/chains.js' /** * @description Handler for `actions chains`. Emits the configured chain - * set as JSON - each entry carries `chainId`, canonical `shortname`, and - * any explicit `rpcUrls`. No SDK call; the data comes from the resolved - * config and the chain resolver's inverse map. + * set: `chainId`, canonical `shortname`, and any explicit `rpcUrls`. + * No SDK call; data comes from the resolved config. * @returns Promise that resolves once stdout has been written. */ export async function runChains(): Promise { const { config } = baseContext() - writeJson( - config.chains.map((chain) => ({ - chainId: chain.chainId, - shortname: shortnameFor(chain.chainId), - rpcUrls: chain.rpcUrls, - })), - ) + const rows: ChainRow[] = config.chains.map((chain) => ({ + chainId: chain.chainId, + shortname: shortnameFor(chain.chainId), + rpcUrls: chain.rpcUrls, + })) + printOutput('chains', rows) } diff --git a/packages/cli/src/commands/wallet/address.ts b/packages/cli/src/commands/wallet/address.ts index 3b38d6590..7d0e3af77 100644 --- a/packages/cli/src/commands/wallet/address.ts +++ b/packages/cli/src/commands/wallet/address.ts @@ -1,13 +1,12 @@ import { walletContext } from '@/context/walletContext.js' -import { writeJson } from '@/output/json.js' +import { printOutput } from '@/output/printOutput.js' /** - * @description Handler for `actions wallet address`. Returns the EOA - * address derived from `PRIVATE_KEY`. Pure - no RPC call, no factory - * lookup. + * @description Handler for `actions wallet address`. Emits the EOA + * address derived from `PRIVATE_KEY`. Pure, no RPC call. * @returns Promise that resolves once stdout has been written. */ export async function runWalletAddress(): Promise { const { wallet } = await walletContext() - writeJson({ address: wallet.address }) + printOutput('address', { address: wallet.address }) } diff --git a/packages/cli/src/commands/wallet/balance.ts b/packages/cli/src/commands/wallet/balance.ts index 7912e0b8f..2549baf3c 100644 --- a/packages/cli/src/commands/wallet/balance.ts +++ b/packages/cli/src/commands/wallet/balance.ts @@ -2,7 +2,7 @@ import type { SupportedChainId, TokenBalance } from '@eth-optimism/actions-sdk' import { walletContext } from '@/context/walletContext.js' import { CliError } from '@/output/errors.js' -import { writeJson } from '@/output/json.js' +import { printOutput } from '@/output/printOutput.js' import { type ChainFlags, resolveChainFlags } from '@/resolvers/chains.js' function filterToChain( @@ -23,10 +23,10 @@ function filterToChain( /** * @description Handler for `actions wallet balance`. Fetches ETH and * allowlisted ERC-20 balances across every configured chain. Pass - * `--chain ` or `--chain-id ` to scope the output to a - * single chain (mutually exclusive). The SDK implements `getBalance` - * as `Promise.all` over (asset x chain), so any single RPC failure - * rejects the whole batch; classify that as a retryable `network` error. + * `--chain ` or `--chain-id ` (mutually exclusive) to + * scope the output to one chain. The SDK implements `getBalance` as + * `Promise.all` over (asset x chain), so any single RPC failure rejects + * the whole batch; surface that as a retryable `network` error. * @param flags - Commander-parsed options; chain selection is optional. * @returns Promise that resolves once stdout has been written. */ @@ -38,7 +38,10 @@ export async function runWalletBalance(flags: ChainFlags = {}): Promise { ) try { const balances = await wallet.getBalance() - writeJson(chainId ? filterToChain(balances, chainId) : balances) + printOutput( + 'balance', + chainId ? filterToChain(balances, chainId) : balances, + ) } catch (err) { if (err instanceof CliError) throw err throw new CliError( diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 11fe82d34..c9d7ca65e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import { runAssets } from '@/commands/assets.js' import { runChains } from '@/commands/chains.js' import { walletCommand } from '@/commands/wallet/index.js' import { writeError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' function isEpipe(err: unknown): boolean { return ( @@ -33,6 +34,10 @@ const colorizeHelp = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR const program = new Command() .name('actions') .description('Command-line interface for the Actions SDK.') + .option('--json', 'emit machine-readable JSON on stdout and stderr') + .hook('preAction', (thisCommand) => { + setJsonMode(Boolean(thisCommand.opts().json)) + }) .configureHelp({ ...new Help(), subcommandTerm: (cmd) => diff --git a/packages/cli/src/output/__tests__/writeError.test.ts b/packages/cli/src/output/__tests__/writeError.test.ts index fa49f05b8..8ba8e4791 100644 --- a/packages/cli/src/output/__tests__/writeError.test.ts +++ b/packages/cli/src/output/__tests__/writeError.test.ts @@ -1,6 +1,10 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CliError, writeError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) const exitSpy = vi .spyOn(process, 'exit') @@ -90,6 +94,14 @@ describe('writeError', () => { expect(text.endsWith('\n')).toBe(true) }) + it('emits human-readable text when json mode is off', () => { + setJsonMode(false) + writeError(new CliError('config', 'no key')) + const text = String(stderrSpy.mock.calls[0]?.[0]) + expect(text).toBe('Error (config): no key\n') + expect(() => JSON.parse(text)).toThrow() + }) + it('swallows EPIPE from the stderr write', () => { stderrSpy.mockImplementationOnce(() => { const e: NodeJS.ErrnoException = new Error('epipe') diff --git a/packages/cli/src/output/errors.ts b/packages/cli/src/output/errors.ts index 29449fb7c..e01b65b1b 100644 --- a/packages/cli/src/output/errors.ts +++ b/packages/cli/src/output/errors.ts @@ -1,5 +1,7 @@ import { serializeBigInt } from '@eth-optimism/actions-sdk' +import { isJsonMode } from '@/output/mode.js' + /** * @description Error categories consumed by the caller. The code determines * the process exit value and the default retryability - callers may override @@ -206,15 +208,22 @@ export function writeError(err: unknown): never { const cliErr = err instanceof CliError ? err : undefined const code: ErrorCode = cliErr?.code ?? 'unknown' const message = err instanceof Error ? err.message : String(err) - const body = serializeBigInt({ - error: message, - code, - retryable: cliErr?.retryable ?? RETRYABLE_DEFAULT[code], - retry_after_ms: cliErr?.retryAfterMs, - details: cliErr ? safeDetails(cliErr.details) : undefined, - }) + const retryable = cliErr?.retryable ?? RETRYABLE_DEFAULT[code] + const body = isJsonMode() + ? JSON.stringify( + serializeBigInt({ + error: message, + code, + retryable, + retry_after_ms: cliErr?.retryAfterMs, + details: cliErr ? safeDetails(cliErr.details) : undefined, + }), + null, + 2, + ) + '\n' + : `Error (${code}): ${message}\n` try { - process.stderr.write(JSON.stringify(body, null, 2) + '\n') + process.stderr.write(body) } catch (writeErr) { if (!isEpipe(writeErr)) throw writeErr } diff --git a/packages/cli/src/output/mode.ts b/packages/cli/src/output/mode.ts new file mode 100644 index 000000000..9254dde93 --- /dev/null +++ b/packages/cli/src/output/mode.ts @@ -0,0 +1,20 @@ +let jsonMode = false + +/** + * @description Sets the process-wide output mode. Called once from the + * commander `preAction` hook after parsing the root `--json` flag. + * @param value - `true` to emit JSON on stdout and stderr; `false` for + * human-readable text. + */ +export function setJsonMode(value: boolean): void { + jsonMode = value +} + +/** + * @description Reports whether the CLI should emit JSON. Handlers and + * error sinks gate their formatter choice on this. + * @returns `true` when `--json` was set. + */ +export function isJsonMode(): boolean { + return jsonMode +} diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts new file mode 100644 index 000000000..0ca9822ee --- /dev/null +++ b/packages/cli/src/output/printOutput.ts @@ -0,0 +1,106 @@ +import type { + Asset, + SupportedChainId, + TokenBalance, +} from '@eth-optimism/actions-sdk' + +import { writeJson } from '@/output/json.js' +import { isJsonMode } from '@/output/mode.js' + +function writeLine(line = ''): void { + process.stdout.write(line + '\n') +} + +export interface ChainRow { + chainId: SupportedChainId + shortname: string + rpcUrls?: string[] +} + +export interface AddressDoc { + address: string +} + +interface Printers { + assets: readonly Asset[] + chains: readonly ChainRow[] + address: AddressDoc + balance: readonly TokenBalance[] +} + +function formatAssets(assets: Printers['assets']): void { + if (assets.length === 0) { + writeLine('(no assets configured)') + return + } + for (const asset of assets) { + const { symbol, name, decimals } = asset.metadata + writeLine(`${symbol.padEnd(12)} ${name} (${decimals}d, ${asset.type})`) + } +} + +function formatChains(rows: Printers['chains']): void { + if (rows.length === 0) { + writeLine('(no chains configured)') + return + } + for (const row of rows) { + const rpc = row.rpcUrls?.length ? ` rpc=${row.rpcUrls.join(',')}` : '' + writeLine(`${row.shortname.padEnd(18)} ${row.chainId}${rpc}`) + } +} + +function formatAddress(doc: Printers['address']): void { + writeLine(doc.address) +} + +function formatBalance(balances: Printers['balance']): void { + if (balances.length === 0) { + writeLine('(no balances)') + return + } + for (const tb of balances) { + const { symbol } = tb.asset.metadata + writeLine(`${symbol} total=${tb.totalBalance}`) + const chainIds = Object.keys(tb.chains) + if (chainIds.length === 0) { + writeLine(` (no chain breakdown)`) + continue + } + for (const cid of chainIds) { + const entry = tb.chains[cid as unknown as SupportedChainId] + if (!entry) continue + writeLine( + ` chain=${cid} balance=${entry.balance} raw=${entry.balanceRaw}`, + ) + } + } +} + +const TEXT_FORMATTERS: { + [K in keyof Printers]: (data: Printers[K]) => void +} = { + assets: formatAssets, + chains: formatChains, + address: formatAddress, + balance: formatBalance, +} + +/** + * @description Single stdout sink for command output. In JSON mode emits + * the raw document via `writeJson` (bigint-aware, pretty-printed). In + * text mode dispatches to the per-kind human formatter. Command handlers + * should call this and never format or write to stdout themselves. + * @param kind - Command output discriminator. + * @param data - The typed payload for that kind. + */ +export function printOutput( + kind: K, + data: Printers[K], +): void { + if (isJsonMode()) { + writeJson(data) + return + } + TEXT_FORMATTERS[kind](data) +} From 24b549e6ae6edbd37129f2160f93b520a767bdbc Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 14:32:14 -0700 Subject: [PATCH 29/76] lock full-sdk shape for no-chain balance --- .../commands/__tests__/walletBalance.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/cli/src/commands/__tests__/walletBalance.test.ts b/packages/cli/src/commands/__tests__/walletBalance.test.ts index 498fe6fac..8c1518301 100644 --- a/packages/cli/src/commands/__tests__/walletBalance.test.ts +++ b/packages/cli/src/commands/__tests__/walletBalance.test.ts @@ -98,6 +98,66 @@ describe('runWalletBalance', () => { } }) + it('returns full SDK shape across all chains when no chain flag is set', async () => { + process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 + const sdkResponse = [ + { + asset: { metadata: { symbol: 'ETH' } }, + totalBalance: 3, + totalBalanceRaw: 3000000000000000000n, + chains: { + 84532: { balance: 1, balanceRaw: 1000000000000000000n }, + 11155420: { balance: 2, balanceRaw: 2000000000000000000n }, + 130: { balance: 0, balanceRaw: 0n }, + }, + }, + { + asset: { metadata: { symbol: 'USDC_DEMO' } }, + totalBalance: 5, + totalBalanceRaw: 5000000n, + chains: { + 84532: { balance: 5, balanceRaw: 5000000n }, + }, + }, + ] + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: { + chains: [{ chainId: 84532 }, { chainId: 11155420 }, { chainId: 130 }], + } as never, + actions: {} as never, + signer: {} as never, + wallet: { address: '0x0', getBalance: async () => sdkResponse } as never, + }) + + await runWalletBalance() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + + expect(body).toHaveLength(2) + + const eth = body[0] + expect(eth.asset.metadata.symbol).toBe('ETH') + expect(eth.totalBalance).toBe(3) + expect(eth.totalBalanceRaw).toBe('3000000000000000000') + expect(Object.keys(eth.chains).sort()).toEqual( + ['11155420', '130', '84532'].sort(), + ) + expect(eth.chains['84532']).toEqual({ + balance: 1, + balanceRaw: '1000000000000000000', + }) + expect(eth.chains['11155420']).toEqual({ + balance: 2, + balanceRaw: '2000000000000000000', + }) + expect(eth.chains['130']).toEqual({ balance: 0, balanceRaw: '0' }) + + const usdc = body[1] + expect(usdc.asset.metadata.symbol).toBe('USDC_DEMO') + expect(usdc.totalBalance).toBe(5) + expect(usdc.totalBalanceRaw).toBe('5000000') + expect(Object.keys(usdc.chains)).toEqual(['84532']) + }) + it('filters balances to a single chain via --chain', async () => { process.env.PRIVATE_KEY = ANVIL_ACCOUNT_0 vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ From d1c16d17a53ee28b8dea9420e81e31973b17c15c Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 18:16:17 -0700 Subject: [PATCH 30/76] add wallet lend open and close --- packages/cli/SKILL.md | 41 ++++ packages/cli/src/__tests__/system.test.ts | 40 ++++ .../__tests__/walletLendClose.test.ts | 136 ++++++++++++++ .../commands/__tests__/walletLendOpen.test.ts | 177 ++++++++++++++++++ packages/cli/src/commands/wallet/index.ts | 2 + .../cli/src/commands/wallet/lend/close.ts | 61 ++++++ .../cli/src/commands/wallet/lend/index.ts | 42 +++++ packages/cli/src/commands/wallet/lend/open.ts | 62 ++++++ packages/cli/src/commands/wallet/lend/util.ts | 118 ++++++++++++ packages/cli/src/output/printOutput.ts | 38 ++++ .../src/resolvers/__tests__/markets.test.ts | 47 +++++ packages/cli/src/resolvers/markets.ts | 49 +++++ 12 files changed, 813 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/walletLendClose.test.ts create mode 100644 packages/cli/src/commands/__tests__/walletLendOpen.test.ts create mode 100644 packages/cli/src/commands/wallet/lend/close.ts create mode 100644 packages/cli/src/commands/wallet/lend/index.ts create mode 100644 packages/cli/src/commands/wallet/lend/open.ts create mode 100644 packages/cli/src/commands/wallet/lend/util.ts create mode 100644 packages/cli/src/resolvers/__tests__/markets.test.ts create mode 100644 packages/cli/src/resolvers/markets.ts diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index 5a8c8ff81..df706e5fa 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -25,6 +25,10 @@ actions --json wallet balance --chain base-sepolia - `actions wallet address` - EOA address derived from `PRIVATE_KEY`. - `actions wallet balance [--chain | --chain-id ]` - balances per chain + asset; the chain flags are mutually exclusive. +- `actions wallet lend open --market --amount ` - supply + assets to a market in the config allowlist. +- `actions wallet lend close --market --amount ` - withdraw + assets from a lending position. ## Wallet model @@ -43,6 +47,13 @@ demo, fund the EOA with testnet ETH on Base Sepolia. `unichain`) via `--chain`, or a numeric id via `--chain-id` (mutually exclusive). Run `actions --json chains` for the current list. +- **Markets** - pass the market `name` from the config allowlist + (e.g. `Gauntlet USDC`, `Aave ETH`). Case-insensitive; whitespace + and hyphens are ignored, so `gauntlet-usdc` and `gauntletusdc` + resolve to the same entry. The market entry carries its own chain + and asset, so no `--chain` is needed. +- **Amounts** - human-readable decimal numbers (e.g. `10`, `0.5`). + The SDK converts to wei using the asset's decimals. ## Output @@ -71,6 +82,36 @@ nested `Promise.all` over (asset x chain), so any single failing RPC rejects the whole call with a `network` error. Retries may succeed on a different call - do not assume per-chain isolation. +## Lend semantics + +`wallet lend open` and `wallet lend close` emit a structured envelope +on stdout: + +```json +{ + "action": "open" | "close", + "market": { "name": "...", "address": "0x...", "chainId": ..., "provider": "..." }, + "asset": { "symbol": "..." }, + "amount": , + "transactions": [ { "transactionHash": "0x...", "status": "success", ... } ] +} +``` + +`transactions` is always an array. On EOA the SDK sends approval + +position as two sequential transactions when an approval is required, +so `open` returns 1-2 receipts and `close` returns 1. Bigint receipt +fields (`blockNumber`, `gasUsed`) are stringified. + +A receipt with `status: "reverted"` is normalised to a `code: "onchain"` +error envelope on stderr (exit 5), so callers do not need to inspect +receipt status to detect failure. + +NL -> command examples: + +- "supply 10 USDC to Gauntlet" -> `actions --json wallet lend open --market gauntlet-usdc --amount 10` +- "deposit 0.5 ETH into Aave on op-sepolia" -> `actions --json wallet lend open --market aave-eth --amount 0.5` +- "withdraw 5 USDC from Gauntlet" -> `actions --json wallet lend close --market gauntlet-usdc --amount 5` + ## RPC trust `*_RPC_URL` env vars must point to operator-trusted endpoints. A diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts index c17e595af..0b8be71ca 100644 --- a/packages/cli/src/__tests__/system.test.ts +++ b/packages/cli/src/__tests__/system.test.ts @@ -151,6 +151,46 @@ describe('actions CLI (built binary)', () => { const body = JSON.parse(stderr) expect(body.code).toBe('validation') }) + + it('unknown --market on lend open -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run( + [ + '--json', + 'wallet', + 'lend', + 'open', + '--market', + 'no-such-market', + '--amount', + '1', + ], + { PRIVATE_KEY: ANVIL_ACCOUNT_0 }, + ) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/Unknown market/) + }) + + it('non-positive --amount on lend close -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run( + [ + '--json', + 'wallet', + 'lend', + 'close', + '--market', + 'aave-eth', + '--amount', + '0', + ], + { PRIVATE_KEY: ANVIL_ACCOUNT_0 }, + ) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/Invalid --amount/) + }) }) describe('default (human) mode', () => { diff --git a/packages/cli/src/commands/__tests__/walletLendClose.test.ts b/packages/cli/src/commands/__tests__/walletLendClose.test.ts new file mode 100644 index 000000000..d49d67256 --- /dev/null +++ b/packages/cli/src/commands/__tests__/walletLendClose.test.ts @@ -0,0 +1,136 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runWalletLendClose } from '@/commands/wallet/lend/close.js' +import { __resetEnvCacheForTests } from '@/config/env.js' +import * as walletCtx from '@/context/walletContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +const successReceipt = (hash: string) => ({ + transactionHash: hash, + status: 'success' as const, + blockNumber: 7n, + gasUsed: 50000n, +}) + +describe('runWalletLendClose', () => { + const originalEnv = process.env + let writeSpy: MockInstance + + beforeEach(() => { + process.env = { ...originalEnv, PRIVATE_KEY: ANVIL_ACCOUNT_0 } + __resetEnvCacheForTests() + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + vi.restoreAllMocks() + }) + + const mockWallet = (closePosition: (params: unknown) => Promise) => { + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: getDemoConfig(), + actions: {} as never, + signer: {} as never, + wallet: { + address: '0xabc', + lend: { closePosition, openPosition: async () => null }, + } as never, + }) + } + + it('emits a structured envelope with action=close and a one-tx array', async () => { + const captured: unknown[] = [] + mockWallet(async (params) => { + captured.push(params) + return successReceipt('0xclose') + }) + await runWalletLendClose({ market: 'aave-eth', amount: '0.25' }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.action).toBe('close') + expect(body.market.name).toBe('Aave ETH') + expect(body.market.provider).toBe('aave') + expect(body.asset.symbol).toBe('ETH') + expect(body.amount).toBe(0.25) + expect(body.transactions).toHaveLength(1) + expect(body.transactions[0].transactionHash).toBe('0xclose') + const call = captured[0] as { + amount: number + marketId: { chainId: number } + } + expect(call.amount).toBe(0.25) + expect(call.marketId.chainId).toBe(11155420) + }) + + it('rejects unknown markets with CliError(validation)', async () => { + mockWallet(async () => successReceipt('0x')) + try { + await runWalletLendClose({ market: 'no-such-market', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects non-positive amounts with CliError(validation)', async () => { + mockWallet(async () => successReceipt('0x')) + try { + await runWalletLendClose({ market: 'aave-eth', amount: '0' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('maps reverted receipts to CliError(onchain)', async () => { + mockWallet(async () => ({ + ...successReceipt('0xrevert'), + status: 'reverted' as const, + })) + try { + await runWalletLendClose({ market: 'aave-eth', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('onchain') + } + }) + + it('maps RPC failures to CliError(network) and marks them retryable', async () => { + mockWallet(async () => { + throw new Error('HTTP request failed. Status: ECONNREFUSED') + }) + try { + await runWalletLendClose({ market: 'aave-eth', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + expect((err as CliError).retryable).toBe(true) + } + }) + + it('rejects with CliError(config) when PRIVATE_KEY is missing', async () => { + delete process.env.PRIVATE_KEY + __resetEnvCacheForTests() + try { + await runWalletLendClose({ market: 'aave-eth', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) +}) diff --git a/packages/cli/src/commands/__tests__/walletLendOpen.test.ts b/packages/cli/src/commands/__tests__/walletLendOpen.test.ts new file mode 100644 index 000000000..3efe5577d --- /dev/null +++ b/packages/cli/src/commands/__tests__/walletLendOpen.test.ts @@ -0,0 +1,177 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runWalletLendOpen } from '@/commands/wallet/lend/open.js' +import { __resetEnvCacheForTests } from '@/config/env.js' +import * as walletCtx from '@/context/walletContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +const successReceipt = (hash: string) => ({ + transactionHash: hash, + status: 'success' as const, + blockNumber: 1n, + gasUsed: 21000n, +}) + +describe('runWalletLendOpen', () => { + const originalEnv = process.env + let writeSpy: MockInstance + + beforeEach(() => { + process.env = { ...originalEnv, PRIVATE_KEY: ANVIL_ACCOUNT_0 } + __resetEnvCacheForTests() + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + vi.restoreAllMocks() + }) + + const mockWallet = ( + openPosition: (params: unknown) => Promise, + lendProviders: 'morpho' | 'aave' | 'both' = 'both', + ) => { + const lend = + lendProviders === 'morpho' || + lendProviders === 'both' || + lendProviders === 'aave' + ? { openPosition, closePosition: async () => null } + : undefined + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: getDemoConfig(), + actions: {} as never, + signer: {} as never, + wallet: { + address: '0xabc', + lend, + } as never, + }) + } + + it('emits a structured envelope with normalised array of receipts', async () => { + const captured: unknown[] = [] + mockWallet(async (params) => { + captured.push(params) + return [successReceipt('0xapprove'), successReceipt('0xposition')] + }) + await runWalletLendOpen({ market: 'gauntlet-usdc', amount: '10' }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.action).toBe('open') + expect(body.market.name).toBe('Gauntlet USDC') + expect(body.market.provider).toBe('morpho') + expect(body.asset.symbol).toBe('USDC_DEMO') + expect(body.amount).toBe(10) + expect(body.transactions).toHaveLength(2) + expect(body.transactions[0].transactionHash).toBe('0xapprove') + expect(body.transactions[1].transactionHash).toBe('0xposition') + expect(body.transactions[0].blockNumber).toBe('1') + expect(captured).toHaveLength(1) + const call = captured[0] as { + amount: number + marketId: { chainId: number } + } + expect(call.amount).toBe(10) + expect(call.marketId.chainId).toBe(84532) + }) + + it('wraps a single receipt into a one-element array', async () => { + mockWallet(async () => successReceipt('0xonly')) + await runWalletLendOpen({ market: 'aave-eth', amount: '0.5' }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.transactions).toHaveLength(1) + expect(body.transactions[0].transactionHash).toBe('0xonly') + }) + + it('rejects unknown markets with CliError(validation)', async () => { + mockWallet(async () => successReceipt('0x')) + try { + await runWalletLendOpen({ market: 'no-such-market', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects non-positive amounts with CliError(validation)', async () => { + mockWallet(async () => successReceipt('0x')) + for (const bad of ['0', '-1', 'foo', 'NaN']) { + try { + await runWalletLendOpen({ market: 'gauntlet-usdc', amount: bad }) + throw new Error(`did not throw for ${bad}`) + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + } + }) + + it('maps reverted receipts to CliError(onchain)', async () => { + mockWallet(async () => [ + successReceipt('0xapprove'), + { ...successReceipt('0xrevert'), status: 'reverted' as const }, + ]) + try { + await runWalletLendOpen({ market: 'gauntlet-usdc', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('onchain') + } + }) + + it('maps RPC failures to CliError(network) and marks them retryable', async () => { + mockWallet(async () => { + throw new Error('HTTP request failed. Status: ECONNREFUSED') + }) + try { + await runWalletLendOpen({ market: 'gauntlet-usdc', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + expect((err as CliError).retryable).toBe(true) + } + }) + + it('maps simulation reverts to CliError(onchain)', async () => { + mockWallet(async () => { + const e = new Error('execution reverted: ERC20: insufficient allowance') + e.name = 'ContractFunctionRevertedError' + throw e + }) + try { + await runWalletLendOpen({ market: 'gauntlet-usdc', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('onchain') + } + }) + + it('rejects with CliError(config) when wallet.lend is undefined', async () => { + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: getDemoConfig(), + actions: {} as never, + signer: {} as never, + wallet: { address: '0xabc' } as never, + }) + try { + await runWalletLendOpen({ market: 'gauntlet-usdc', amount: '1' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) +}) diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts index 310a5a968..8e150e456 100644 --- a/packages/cli/src/commands/wallet/index.ts +++ b/packages/cli/src/commands/wallet/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander' import { runWalletAddress } from '@/commands/wallet/address.js' import { runWalletBalance } from '@/commands/wallet/balance.js' +import { lendCommand } from '@/commands/wallet/lend/index.js' /** * @description Builds the `wallet` subcommand tree. Registered children @@ -28,5 +29,6 @@ export function walletCommand(): Command { 'filter to one chain by numeric id (e.g. 84532); mutually exclusive with --chain', ) .action(runWalletBalance) + command.addCommand(lendCommand()) return command } diff --git a/packages/cli/src/commands/wallet/lend/close.ts b/packages/cli/src/commands/wallet/lend/close.ts new file mode 100644 index 000000000..2756ad62d --- /dev/null +++ b/packages/cli/src/commands/wallet/lend/close.ts @@ -0,0 +1,61 @@ +import { walletContext } from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveMarket } from '@/resolvers/markets.js' + +import { + ensureOnchainSuccess, + parseAmount, + rethrowAsCliError, + toReceiptArray, +} from './util.js' + +export interface LendCloseFlags { + market: string + amount: string +} + +/** + * @description Handler for `actions wallet lend close --market + * --amount `. Resolves the market through the config allowlist, + * delegates to `wallet.lend.closePosition`, and emits a structured + * receipt envelope. The amount is the human-readable quantity to + * withdraw - the SDK converts to wei. Reverts surface as `onchain`; + * RPC failures as retryable `network`. + * @param flags - Commander-parsed required options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runWalletLendClose(flags: LendCloseFlags): Promise { + const { wallet, config } = await walletContext() + if (!wallet.lend) { + throw new CliError( + 'config', + 'Lending is not configured (no providers in config.lend)', + ) + } + const market = resolveMarket(flags.market, config) + const amount = parseAmount(flags.amount) + try { + const receipt = await wallet.lend.closePosition({ + asset: market.asset, + marketId: { address: market.address, chainId: market.chainId }, + amount, + }) + const receipts = toReceiptArray(receipt) + ensureOnchainSuccess(receipts) + printOutput('lendClose', { + action: 'close', + market: { + name: market.name, + address: market.address, + chainId: market.chainId, + provider: market.lendProvider, + }, + asset: { symbol: market.asset.metadata.symbol }, + amount, + transactions: receipts, + }) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/wallet/lend/index.ts b/packages/cli/src/commands/wallet/lend/index.ts new file mode 100644 index 000000000..844b2548e --- /dev/null +++ b/packages/cli/src/commands/wallet/lend/index.ts @@ -0,0 +1,42 @@ +import { Command } from 'commander' + +import { runWalletLendClose } from '@/commands/wallet/lend/close.js' +import { runWalletLendOpen } from '@/commands/wallet/lend/open.js' + +/** + * @description Builds the `wallet lend` subcommand tree. Each child + * resolves its market through the config allowlist and dispatches to + * `wallet.lend.{openPosition,closePosition}`. Read-only siblings + * (`markets`, `market`, `position`) are deferred to a follow-up. + * @returns Commander `Command` configured with `open` and `close`. + */ +export function lendCommand(): Command { + const command = new Command('lend').description( + 'Open and close lending positions on configured markets.', + ) + command + .command('open') + .description('Supply assets to a lending market.') + .requiredOption( + '--market ', + 'market name from the config allowlist (e.g. "Gauntlet USDC", "gauntlet-usdc")', + ) + .requiredOption( + '--amount ', + 'amount to supply in human-readable units (e.g. 10 for 10 USDC)', + ) + .action(runWalletLendOpen) + command + .command('close') + .description('Withdraw assets from a lending position.') + .requiredOption( + '--market ', + 'market name from the config allowlist (e.g. "Gauntlet USDC", "gauntlet-usdc")', + ) + .requiredOption( + '--amount ', + 'amount to withdraw in human-readable units (e.g. 10 for 10 USDC)', + ) + .action(runWalletLendClose) + return command +} diff --git a/packages/cli/src/commands/wallet/lend/open.ts b/packages/cli/src/commands/wallet/lend/open.ts new file mode 100644 index 000000000..b6a9ca293 --- /dev/null +++ b/packages/cli/src/commands/wallet/lend/open.ts @@ -0,0 +1,62 @@ +import { walletContext } from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveMarket } from '@/resolvers/markets.js' + +import { + ensureOnchainSuccess, + parseAmount, + rethrowAsCliError, + toReceiptArray, +} from './util.js' + +export interface LendOpenFlags { + market: string + amount: string +} + +/** + * @description Handler for `actions wallet lend open --market + * --amount `. Resolves the market through the config allowlist, + * delegates to `wallet.lend.openPosition` (which dispatches an optional + * ERC-20 approval + the position call as a single sendBatch on EOA), and + * emits a structured receipt envelope. Reverts surface as `onchain`; + * RPC failures as retryable `network`. Both flags are enforced as + * required by commander, so the handler trusts they are present. + * @param flags - Commander-parsed required options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runWalletLendOpen(flags: LendOpenFlags): Promise { + const { wallet, config } = await walletContext() + if (!wallet.lend) { + throw new CliError( + 'config', + 'Lending is not configured (no providers in config.lend)', + ) + } + const market = resolveMarket(flags.market, config) + const amount = parseAmount(flags.amount) + try { + const receipt = await wallet.lend.openPosition({ + asset: market.asset, + marketId: { address: market.address, chainId: market.chainId }, + amount, + }) + const receipts = toReceiptArray(receipt) + ensureOnchainSuccess(receipts) + printOutput('lendOpen', { + action: 'open', + market: { + name: market.name, + address: market.address, + chainId: market.chainId, + provider: market.lendProvider, + }, + asset: { symbol: market.asset.metadata.symbol }, + amount, + transactions: receipts, + }) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/wallet/lend/util.ts b/packages/cli/src/commands/wallet/lend/util.ts new file mode 100644 index 000000000..fd131b3a5 --- /dev/null +++ b/packages/cli/src/commands/wallet/lend/util.ts @@ -0,0 +1,118 @@ +import type { + EOATransactionReceipt, + LendTransactionReceipt, + UserOperationTransactionReceipt, +} from '@eth-optimism/actions-sdk' + +import { CliError } from '@/output/errors.js' + +/** + * Single normalised receipt the CLI emits. The SDK's union type collapses + * `EOATransactionReceipt | UserOperationTransactionReceipt | EOATransactionReceipt[]`, + * which is awkward to consume from the agent side. + */ +export type SingleReceipt = + | EOATransactionReceipt + | UserOperationTransactionReceipt + +/** + * @description Parses a CLI-provided amount string. Accepts any positive + * finite number, including decimals (the SDK converts to wei using the + * asset's decimals). Bigint-style strings (`100n`) are rejected; use a + * decimal literal instead. + * @param raw - Flag value as passed on argv. + * @returns The validated amount as a number. + * @throws `CliError` with code `validation` when the value is not a + * positive finite number. + */ +export function parseAmount(raw: string): number { + const value = Number(raw) + if (!Number.isFinite(value) || value <= 0) { + throw new CliError( + 'validation', + `Invalid --amount: ${raw} (expected a positive number)`, + { amount: raw }, + ) + } + return value +} + +function isEOAReceipt(value: SingleReceipt): value is EOATransactionReceipt { + return ( + typeof value === 'object' && + value !== null && + 'transactionHash' in value && + 'status' in value && + typeof (value as { status?: unknown }).status === 'string' + ) +} + +/** + * @description Normalises the SDK's union receipt type to a flat array. + * EOA `send` returns a single receipt; `sendBatch` returns an array; + * smart wallets return a single UserOperation receipt regardless. The + * CLI always emits an array so the agent can iterate without branching. + * @param receipt - Raw return value from the SDK. + * @returns Array of one or more receipts. + */ +export function toReceiptArray( + receipt: LendTransactionReceipt, +): readonly SingleReceipt[] { + return Array.isArray(receipt) ? receipt : [receipt] +} + +/** + * @description Inspects receipts for failure markers and raises + * `CliError('onchain')` when any leg failed. EOA receipts use + * `status: 'reverted'`; UserOp receipts use `success: false`. Called + * after the SDK call resolves. + * @param receipts - Receipts returned by the SDK. + * @throws `CliError` with code `onchain` when any receipt failed. + */ +export function ensureOnchainSuccess(receipts: readonly SingleReceipt[]): void { + for (const r of receipts) { + if (isEOAReceipt(r) && r.status === 'reverted') { + throw new CliError('onchain', 'Transaction reverted', { + transactionHash: r.transactionHash, + blockNumber: r.blockNumber, + }) + } + if ( + !isEOAReceipt(r) && + 'success' in r && + (r as { success?: unknown }).success === false + ) { + throw new CliError('onchain', 'UserOperation failed', { + userOpHash: (r as { userOpHash?: unknown }).userOpHash, + }) + } + } +} + +const ONCHAIN_HINTS = [ + 'execution reverted', + 'revert', + 'ContractFunctionRevertedError', + 'ContractFunctionExecutionError', +] + +/** + * @description Re-throws SDK exceptions as the right `CliError` code. + * Pre-flight reverts (gas estimation, eth_call simulation) and viem + * `ContractFunctionRevertedError` map to `onchain`; everything else + * (RPC down, timeout, fetch failure) defaults to retryable `network`. + * Existing `CliError` instances pass through unchanged. + * @param err - Caught exception. + * @returns Never; always throws. + */ +export function rethrowAsCliError(err: unknown): never { + if (err instanceof CliError) throw err + const message = err instanceof Error ? err.message : String(err) + const name = err instanceof Error ? err.name : '' + const looksOnchain = ONCHAIN_HINTS.some( + (h) => message.includes(h) || name.includes(h), + ) + throw new CliError(looksOnchain ? 'onchain' : 'network', message, { + cause: err, + }) +} diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts index 0ca9822ee..fefe95cf6 100644 --- a/packages/cli/src/output/printOutput.ts +++ b/packages/cli/src/output/printOutput.ts @@ -1,8 +1,11 @@ import type { Asset, + EOATransactionReceipt, SupportedChainId, TokenBalance, + UserOperationTransactionReceipt, } from '@eth-optimism/actions-sdk' +import type { Address } from 'viem' import { writeJson } from '@/output/json.js' import { isJsonMode } from '@/output/mode.js' @@ -21,11 +24,28 @@ export interface AddressDoc { address: string } +export interface LendActionDoc { + action: 'open' | 'close' + market: { + name: string + address: Address + chainId: SupportedChainId + provider: string + } + asset: { symbol: string } + amount: number + transactions: ReadonlyArray< + EOATransactionReceipt | UserOperationTransactionReceipt + > +} + interface Printers { assets: readonly Asset[] chains: readonly ChainRow[] address: AddressDoc balance: readonly TokenBalance[] + lendOpen: LendActionDoc + lendClose: LendActionDoc } function formatAssets(assets: Printers['assets']): void { @@ -77,6 +97,22 @@ function formatBalance(balances: Printers['balance']): void { } } +function formatLendAction(doc: LendActionDoc): void { + const verb = doc.action === 'open' ? 'opened' : 'closed' + writeLine( + `${verb} position: ${doc.amount} ${doc.asset.symbol} on ${doc.market.name} (${doc.market.provider}, chain ${doc.market.chainId})`, + ) + for (const tx of doc.transactions) { + if ('transactionHash' in tx) { + writeLine(` tx=${tx.transactionHash} status=${tx.status}`) + } else { + const userOpHash = (tx as { userOpHash?: string }).userOpHash ?? '?' + const success = (tx as { success?: boolean }).success + writeLine(` userOp=${userOpHash} success=${success}`) + } + } +} + const TEXT_FORMATTERS: { [K in keyof Printers]: (data: Printers[K]) => void } = { @@ -84,6 +120,8 @@ const TEXT_FORMATTERS: { chains: formatChains, address: formatAddress, balance: formatBalance, + lendOpen: formatLendAction, + lendClose: formatLendAction, } /** diff --git a/packages/cli/src/resolvers/__tests__/markets.test.ts b/packages/cli/src/resolvers/__tests__/markets.test.ts new file mode 100644 index 000000000..1c40b35b1 --- /dev/null +++ b/packages/cli/src/resolvers/__tests__/markets.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { resolveMarket } from '@/resolvers/markets.js' + +const config = getDemoConfig() + +describe('resolveMarket', () => { + it('matches by exact .name', () => { + const market = resolveMarket('Gauntlet USDC', config) + expect(market.name).toBe('Gauntlet USDC') + expect(market.lendProvider).toBe('morpho') + }) + + it('matches case-insensitively and ignores hyphens / spaces', () => { + expect(resolveMarket('gauntlet-usdc', config).name).toBe('Gauntlet USDC') + expect(resolveMarket('GAUNTLETUSDC', config).name).toBe('Gauntlet USDC') + expect(resolveMarket('aave eth', config).name).toBe('Aave ETH') + expect(resolveMarket('aave-eth', config).name).toBe('Aave ETH') + }) + + it('walks every provider allowlist', () => { + expect(resolveMarket('Aave ETH', config).lendProvider).toBe('aave') + expect(resolveMarket('Gauntlet USDC', config).lendProvider).toBe('morpho') + }) + + it('throws CliError(validation) with allowed list on miss', () => { + try { + resolveMarket('does-not-exist', config) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + expect( + ((err as CliError).details as { allowed: string[] }).allowed, + ).toEqual(['Gauntlet USDC', 'Aave ETH']) + } + }) + + it('returns a market entry carrying address, chainId, asset, provider', () => { + const m = resolveMarket('Gauntlet USDC', config) + expect(m.address).toMatch(/^0x[a-fA-F0-9]{40}$/) + expect(typeof m.chainId).toBe('number') + expect(m.asset.metadata.symbol).toBe('USDC_DEMO') + }) +}) diff --git a/packages/cli/src/resolvers/markets.ts b/packages/cli/src/resolvers/markets.ts new file mode 100644 index 000000000..77c7a7c5e --- /dev/null +++ b/packages/cli/src/resolvers/markets.ts @@ -0,0 +1,49 @@ +import type { + LendMarketConfig, + NodeActionsConfig, +} from '@eth-optimism/actions-sdk' + +import { CliError } from '@/output/errors.js' + +function normalize(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, '') +} + +function collectMarkets( + config: NodeActionsConfig, +): readonly LendMarketConfig[] { + const out: LendMarketConfig[] = [] + for (const provider of Object.values(config.lend ?? {})) { + if (provider?.marketAllowlist) out.push(...provider.marketAllowlist) + } + return out +} + +/** + * @description Resolves a `--market ` flag value to the matching + * `LendMarketConfig` entry from any provider's allowlist. Match is + * case-insensitive and ignores whitespace / hyphens, so all of + * `Gauntlet USDC`, `gauntlet-usdc`, `GauntletUSDC`, and `gauntletusdc` + * resolve to the same market. Throws `CliError('validation')` on miss + * with an `allowed` list cribbed from the canonical `.name` fields. + * @param name - User-provided market name from CLI argv. + * @param config - Resolved CLI config. + * @returns The matching market entry (carries `address`, `chainId`, + * `asset`, `lendProvider`). + * @throws `CliError` with code `validation` when no market matches. + */ +export function resolveMarket( + name: string, + config: NodeActionsConfig, +): LendMarketConfig { + const target = normalize(name) + const markets = collectMarkets(config) + const match = markets.find((m) => normalize(m.name) === target) + if (!match) { + throw new CliError('validation', `Unknown market: ${name}`, { + market: name, + allowed: markets.map((m) => m.name), + }) + } + return match +} From 632c5faf8b901a90a3f5f17b409a414ae5b29ada Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 18:41:00 -0700 Subject: [PATCH 31/76] add lend read commands and wallet position --- packages/cli/SKILL.md | 16 +++ .../src/commands/__tests__/lendMarket.test.ts | 81 ++++++++++++ .../commands/__tests__/lendMarkets.test.ts | 72 ++++++++++ .../__tests__/walletLendPosition.test.ts | 123 ++++++++++++++++++ packages/cli/src/commands/lend/index.ts | 30 +++++ packages/cli/src/commands/lend/market.ts | 30 +++++ packages/cli/src/commands/lend/markets.ts | 25 ++++ .../cli/src/commands/wallet/lend/index.ts | 22 +++- .../cli/src/commands/wallet/lend/position.ts | 40 ++++++ packages/cli/src/index.ts | 2 + packages/cli/src/output/printOutput.ts | 35 +++++ 11 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/lendMarket.test.ts create mode 100644 packages/cli/src/commands/__tests__/lendMarkets.test.ts create mode 100644 packages/cli/src/commands/__tests__/walletLendPosition.test.ts create mode 100644 packages/cli/src/commands/lend/index.ts create mode 100644 packages/cli/src/commands/lend/market.ts create mode 100644 packages/cli/src/commands/lend/markets.ts create mode 100644 packages/cli/src/commands/wallet/lend/position.ts diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index df706e5fa..219ff3f55 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -22,9 +22,15 @@ actions --json wallet balance --chain base-sepolia - `actions assets` - configured asset allowlist. - `actions chains` - configured chain shortnames + IDs. +- `actions lend markets` - all lending markets across configured + providers (no wallet). +- `actions lend market --market ` - inspect one market by name + (no wallet). - `actions wallet address` - EOA address derived from `PRIVATE_KEY`. - `actions wallet balance [--chain | --chain-id ]` - balances per chain + asset; the chain flags are mutually exclusive. +- `actions wallet lend position --market ` - the wallet's current + balance and shares in a market. - `actions wallet lend open --market --amount ` - supply assets to a market in the config allowlist. - `actions wallet lend close --market --amount ` - withdraw @@ -106,11 +112,21 @@ A receipt with `status: "reverted"` is normalised to a `code: "onchain"` error envelope on stderr (exit 5), so callers do not need to inspect receipt status to detect failure. +`wallet lend position` returns the SDK `LendMarketPosition` shape +verbatim: `{ balance, balanceFormatted, shares, sharesFormatted, marketId }` +with bigint fields stringified. + +`lend markets` and `lend market` return the SDK `LendMarket` shape(s) +verbatim: `{ marketId, name, asset, supply, apy, metadata }`. These do +not require `PRIVATE_KEY`. + NL -> command examples: +- "what markets can I lend in" -> `actions --json lend markets` - "supply 10 USDC to Gauntlet" -> `actions --json wallet lend open --market gauntlet-usdc --amount 10` - "deposit 0.5 ETH into Aave on op-sepolia" -> `actions --json wallet lend open --market aave-eth --amount 0.5` - "withdraw 5 USDC from Gauntlet" -> `actions --json wallet lend close --market gauntlet-usdc --amount 5` +- "how much do I have in Gauntlet" -> `actions --json wallet lend position --market gauntlet-usdc` ## RPC trust diff --git a/packages/cli/src/commands/__tests__/lendMarket.test.ts b/packages/cli/src/commands/__tests__/lendMarket.test.ts new file mode 100644 index 000000000..aa21f5fdc --- /dev/null +++ b/packages/cli/src/commands/__tests__/lendMarket.test.ts @@ -0,0 +1,81 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runLendMarket } from '@/commands/lend/market.js' +import * as baseCtx from '@/context/baseContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +describe('runLendMarket', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getMarket: (params: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { lend: { getMarket } } as never, + }) + } + + it('routes by resolved market and emits the SDK shape verbatim', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return { + marketId: params, + name: 'Gauntlet USDC', + asset: { metadata: { symbol: 'USDC_DEMO' } }, + supply: { totalAssets: 42n, totalShares: 41n }, + apy: { + total: 0.05, + native: 0.04, + totalRewards: 0.01, + performanceFee: 0.1, + }, + metadata: { owner: '0x', curator: '0x', fee: 0, lastUpdate: 0 }, + } + }) + await runLendMarket({ market: 'gauntlet-usdc' }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.name).toBe('Gauntlet USDC') + expect(body.supply.totalAssets).toBe('42') + expect(captured).toHaveLength(1) + const call = captured[0] as { address: string; chainId: number } + expect(call.chainId).toBe(84532) + }) + + it('rejects unknown markets with CliError(validation)', async () => { + mockActions(async () => ({})) + try { + await runLendMarket({ market: 'no-such-market' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('maps RPC failures to CliError(network)', async () => { + mockActions(async () => { + throw new Error('fetch failed') + }) + try { + await runLendMarket({ market: 'gauntlet-usdc' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + } + }) +}) diff --git a/packages/cli/src/commands/__tests__/lendMarkets.test.ts b/packages/cli/src/commands/__tests__/lendMarkets.test.ts new file mode 100644 index 000000000..61ca93dd5 --- /dev/null +++ b/packages/cli/src/commands/__tests__/lendMarkets.test.ts @@ -0,0 +1,72 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runLendMarkets } from '@/commands/lend/markets.js' +import * as baseCtx from '@/context/baseContext.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +describe('runLendMarkets', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getMarkets: () => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: {} as never, + actions: { lend: { getMarkets } } as never, + }) + } + + it('emits the array of markets with bigints stringified', async () => { + mockActions(async () => [ + { + marketId: { address: '0xabc', chainId: 84532 }, + name: 'Gauntlet USDC', + asset: { metadata: { symbol: 'USDC_DEMO' } }, + supply: { totalAssets: 1000000n, totalShares: 999999n }, + apy: { + total: 0.05, + native: 0.04, + totalRewards: 0.01, + performanceFee: 0.1, + }, + metadata: { + owner: '0xowner', + curator: '0xcurator', + fee: 100, + lastUpdate: 0, + }, + }, + ]) + await runLendMarkets() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toHaveLength(1) + expect(body[0].name).toBe('Gauntlet USDC') + expect(body[0].supply.totalAssets).toBe('1000000') + expect(body[0].marketId.chainId).toBe(84532) + }) + + it('maps RPC failures to CliError(network)', async () => { + mockActions(async () => { + throw new Error('HTTP request failed. Status: ECONNREFUSED') + }) + try { + await runLendMarkets() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + expect((err as CliError).retryable).toBe(true) + } + }) +}) diff --git a/packages/cli/src/commands/__tests__/walletLendPosition.test.ts b/packages/cli/src/commands/__tests__/walletLendPosition.test.ts new file mode 100644 index 000000000..40ef99d15 --- /dev/null +++ b/packages/cli/src/commands/__tests__/walletLendPosition.test.ts @@ -0,0 +1,123 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runWalletLendPosition } from '@/commands/wallet/lend/position.js' +import { __resetEnvCacheForTests } from '@/config/env.js' +import * as walletCtx from '@/context/walletContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +describe('runWalletLendPosition', () => { + const originalEnv = process.env + let writeSpy: MockInstance + + beforeEach(() => { + process.env = { ...originalEnv, PRIVATE_KEY: ANVIL_ACCOUNT_0 } + __resetEnvCacheForTests() + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + vi.restoreAllMocks() + }) + + const mockWallet = ( + getPosition: (params: unknown) => Promise, + withLend = true, + ) => { + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: getDemoConfig(), + actions: {} as never, + signer: {} as never, + wallet: { + address: '0xabc', + lend: withLend + ? { + getPosition, + openPosition: async () => null, + closePosition: async () => null, + } + : undefined, + } as never, + }) + } + + it('emits the SDK position shape verbatim with bigints stringified', async () => { + const captured: unknown[] = [] + mockWallet(async (params) => { + captured.push(params) + return { + balance: 1234567n, + balanceFormatted: '1.234567', + shares: 1000000n, + sharesFormatted: '1.0', + marketId: { address: '0xabc', chainId: 84532 }, + } + }) + await runWalletLendPosition({ market: 'gauntlet-usdc' }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.balance).toBe('1234567') + expect(body.balanceFormatted).toBe('1.234567') + expect(body.shares).toBe('1000000') + expect(body.marketId.chainId).toBe(84532) + const call = captured[0] as { marketId: { chainId: number } } + expect(call.marketId.chainId).toBe(84532) + }) + + it('rejects unknown markets with CliError(validation)', async () => { + mockWallet(async () => ({})) + try { + await runWalletLendPosition({ market: 'no-such-market' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects with CliError(config) when wallet.lend is undefined', async () => { + mockWallet(async () => ({}), false) + try { + await runWalletLendPosition({ market: 'gauntlet-usdc' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) + + it('maps RPC failures to CliError(network)', async () => { + mockWallet(async () => { + throw new Error('HTTP request failed') + }) + try { + await runWalletLendPosition({ market: 'gauntlet-usdc' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + expect((err as CliError).retryable).toBe(true) + } + }) + + it('rejects with CliError(config) when PRIVATE_KEY is missing', async () => { + delete process.env.PRIVATE_KEY + __resetEnvCacheForTests() + try { + await runWalletLendPosition({ market: 'gauntlet-usdc' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) +}) diff --git a/packages/cli/src/commands/lend/index.ts b/packages/cli/src/commands/lend/index.ts new file mode 100644 index 000000000..2988afa5e --- /dev/null +++ b/packages/cli/src/commands/lend/index.ts @@ -0,0 +1,30 @@ +import { Command } from 'commander' + +import { runLendMarket } from '@/commands/lend/market.js' +import { runLendMarkets } from '@/commands/lend/markets.js' + +/** + * @description Builds the root `lend` subcommand tree. Children read + * lending data with no signer; wallet-scoped operations live under + * `wallet lend`. Provider routing happens inside the SDK based on the + * resolved market. + * @returns Commander `Command` configured with `markets` and `market`. + */ +export function lendCommand(): Command { + const command = new Command('lend').description( + 'Read-only lending market commands (no PRIVATE_KEY required).', + ) + command + .command('markets') + .description('List all lending markets across configured providers.') + .action(runLendMarkets) + command + .command('market') + .description('Inspect one lending market by name.') + .requiredOption( + '--market ', + 'market name from the config allowlist (e.g. "Gauntlet USDC", "gauntlet-usdc")', + ) + .action(runLendMarket) + return command +} diff --git a/packages/cli/src/commands/lend/market.ts b/packages/cli/src/commands/lend/market.ts new file mode 100644 index 000000000..ccd7d7872 --- /dev/null +++ b/packages/cli/src/commands/lend/market.ts @@ -0,0 +1,30 @@ +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveMarket } from '@/resolvers/markets.js' + +export interface LendMarketFlags { + market: string +} + +/** + * @description Handler for `actions lend market --market `. + * Resolves the market name through the config allowlist, then calls + * `actions.lend.getMarket({address, chainId})` and emits the SDK shape + * verbatim. Read-only, no signer needed. + * @param flags - Commander-parsed required option. + * @returns Promise that resolves once stdout has been written. + */ +export async function runLendMarket(flags: LendMarketFlags): Promise { + const { actions, config } = baseContext() + const market = resolveMarket(flags.market, config) + try { + const result = await actions.lend.getMarket({ + address: market.address, + chainId: market.chainId, + }) + printOutput('lendMarket', result) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/lend/markets.ts b/packages/cli/src/commands/lend/markets.ts new file mode 100644 index 000000000..fd60684b2 --- /dev/null +++ b/packages/cli/src/commands/lend/markets.ts @@ -0,0 +1,25 @@ +import { baseContext } from '@/context/baseContext.js' +import { CliError } from '@/output/errors.js' +import { printOutput } from '@/output/printOutput.js' + +/** + * @description Handler for `actions lend markets`. Aggregates + * `getMarkets()` across every configured lend provider (Morpho + Aave). + * Read-only, no signer needed. RPC failures surface as a retryable + * `network` error since the SDK fans out under `Promise.all`. + * @returns Promise that resolves once stdout has been written. + */ +export async function runLendMarkets(): Promise { + const { actions } = baseContext() + try { + const markets = await actions.lend.getMarkets() + printOutput('lendMarkets', markets) + } catch (err) { + if (err instanceof CliError) throw err + throw new CliError( + 'network', + err instanceof Error ? err.message : String(err), + { cause: err }, + ) + } +} diff --git a/packages/cli/src/commands/wallet/lend/index.ts b/packages/cli/src/commands/wallet/lend/index.ts index 844b2548e..ba95a814c 100644 --- a/packages/cli/src/commands/wallet/lend/index.ts +++ b/packages/cli/src/commands/wallet/lend/index.ts @@ -2,17 +2,19 @@ import { Command } from 'commander' import { runWalletLendClose } from '@/commands/wallet/lend/close.js' import { runWalletLendOpen } from '@/commands/wallet/lend/open.js' +import { runWalletLendPosition } from '@/commands/wallet/lend/position.js' /** - * @description Builds the `wallet lend` subcommand tree. Each child - * resolves its market through the config allowlist and dispatches to - * `wallet.lend.{openPosition,closePosition}`. Read-only siblings - * (`markets`, `market`, `position`) are deferred to a follow-up. - * @returns Commander `Command` configured with `open` and `close`. + * @description Builds the `wallet lend` subcommand tree. Children + * resolve their market through the config allowlist and dispatch to + * the matching `wallet.lend.*` method. Read-only `markets` and + * `market` aliases live on the root `actions lend` tree to avoid + * forcing PRIVATE_KEY for purely public reads. + * @returns Commander `Command` configured with `open`, `close`, and `position`. */ export function lendCommand(): Command { const command = new Command('lend').description( - 'Open and close lending positions on configured markets.', + 'Open, close, and inspect lending positions on configured markets.', ) command .command('open') @@ -38,5 +40,13 @@ export function lendCommand(): Command { 'amount to withdraw in human-readable units (e.g. 10 for 10 USDC)', ) .action(runWalletLendClose) + command + .command('position') + .description('Inspect the current lending position for the wallet.') + .requiredOption( + '--market ', + 'market name from the config allowlist (e.g. "Gauntlet USDC", "gauntlet-usdc")', + ) + .action(runWalletLendPosition) return command } diff --git a/packages/cli/src/commands/wallet/lend/position.ts b/packages/cli/src/commands/wallet/lend/position.ts new file mode 100644 index 000000000..7606f1f2e --- /dev/null +++ b/packages/cli/src/commands/wallet/lend/position.ts @@ -0,0 +1,40 @@ +import { walletContext } from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveMarket } from '@/resolvers/markets.js' + +import { rethrowAsCliError } from './util.js' + +export interface LendPositionFlags { + market: string +} + +/** + * @description Handler for `actions wallet lend position --market `. + * Resolves the market through the config allowlist and calls + * `wallet.lend.getPosition({marketId})` to fetch the EOA's current + * balance and shares in that market. Emits the SDK `LendMarketPosition` + * shape verbatim (bigints stringified by the JSON sink). + * @param flags - Commander-parsed required option. + * @returns Promise that resolves once stdout has been written. + */ +export async function runWalletLendPosition( + flags: LendPositionFlags, +): Promise { + const { wallet, config } = await walletContext() + if (!wallet.lend) { + throw new CliError( + 'config', + 'Lending is not configured (no providers in config.lend)', + ) + } + const market = resolveMarket(flags.market, config) + try { + const position = await wallet.lend.getPosition({ + marketId: { address: market.address, chainId: market.chainId }, + }) + printOutput('lendPosition', position) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c9d7ca65e..bd46091da 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import pico from 'picocolors' import { runAssets } from '@/commands/assets.js' import { runChains } from '@/commands/chains.js' +import { lendCommand } from '@/commands/lend/index.js' import { walletCommand } from '@/commands/wallet/index.js' import { writeError } from '@/output/errors.js' import { setJsonMode } from '@/output/mode.js' @@ -58,6 +59,7 @@ program .description('List the configured chains with their shortnames.') .action(runChains) +program.addCommand(lendCommand()) program.addCommand(walletCommand()) program.parseAsync(process.argv).catch(writeError) diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts index fefe95cf6..b26968956 100644 --- a/packages/cli/src/output/printOutput.ts +++ b/packages/cli/src/output/printOutput.ts @@ -1,6 +1,8 @@ import type { Asset, EOATransactionReceipt, + LendMarket, + LendMarketPosition, SupportedChainId, TokenBalance, UserOperationTransactionReceipt, @@ -46,6 +48,9 @@ interface Printers { balance: readonly TokenBalance[] lendOpen: LendActionDoc lendClose: LendActionDoc + lendMarkets: readonly LendMarket[] + lendMarket: LendMarket + lendPosition: LendMarketPosition } function formatAssets(assets: Printers['assets']): void { @@ -113,6 +118,33 @@ function formatLendAction(doc: LendActionDoc): void { } } +function formatLendMarket(m: LendMarket): void { + writeLine( + `${m.name} symbol=${m.asset.metadata.symbol} chain=${m.marketId.chainId} apy=${(m.apy.total * 100).toFixed(2)}%`, + ) + writeLine(` address=${m.marketId.address}`) + writeLine( + ` totalAssets=${m.supply.totalAssets} totalShares=${m.supply.totalShares}`, + ) +} + +function formatLendMarkets(markets: readonly LendMarket[]): void { + if (markets.length === 0) { + writeLine('(no markets)') + return + } + for (const m of markets) formatLendMarket(m) +} + +function formatLendPosition(p: LendMarketPosition): void { + writeLine( + `position: balance=${p.balanceFormatted} shares=${p.sharesFormatted} chain=${p.marketId.chainId}`, + ) + writeLine( + ` market=${p.marketId.address} balanceWei=${p.balance} sharesRaw=${p.shares}`, + ) +} + const TEXT_FORMATTERS: { [K in keyof Printers]: (data: Printers[K]) => void } = { @@ -122,6 +154,9 @@ const TEXT_FORMATTERS: { balance: formatBalance, lendOpen: formatLendAction, lendClose: formatLendAction, + lendMarkets: formatLendMarkets, + lendMarket: formatLendMarket, + lendPosition: formatLendPosition, } /** From 391e4da047c1371a1ccc9a925baef20aa45c4ebb Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 19:20:51 -0700 Subject: [PATCH 32/76] add swap namespace and wallet execute --- packages/cli/SKILL.md | 62 ++++- packages/cli/src/__tests__/system.test.ts | 62 +++++ .../src/commands/__tests__/swapMarket.test.ts | 74 ++++++ .../commands/__tests__/swapMarkets.test.ts | 83 ++++++ .../src/commands/__tests__/swapQuote.test.ts | 242 ++++++++++++++++++ .../__tests__/walletSwapExecute.test.ts | 192 ++++++++++++++ packages/cli/src/commands/swap/index.ts | 72 ++++++ packages/cli/src/commands/swap/market.ts | 34 +++ packages/cli/src/commands/swap/markets.ts | 36 +++ packages/cli/src/commands/swap/quote.ts | 29 +++ packages/cli/src/commands/swap/quotes.ts | 28 ++ packages/cli/src/commands/swap/util.ts | 127 +++++++++ packages/cli/src/commands/wallet/index.ts | 2 + .../cli/src/commands/wallet/swap/execute.ts | 55 ++++ .../cli/src/commands/wallet/swap/index.ts | 43 ++++ packages/cli/src/demo/config.ts | 24 +- packages/cli/src/index.ts | 2 + packages/cli/src/output/printOutput.ts | 75 ++++++ 18 files changed, 1235 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/swapMarket.test.ts create mode 100644 packages/cli/src/commands/__tests__/swapMarkets.test.ts create mode 100644 packages/cli/src/commands/__tests__/swapQuote.test.ts create mode 100644 packages/cli/src/commands/__tests__/walletSwapExecute.test.ts create mode 100644 packages/cli/src/commands/swap/index.ts create mode 100644 packages/cli/src/commands/swap/market.ts create mode 100644 packages/cli/src/commands/swap/markets.ts create mode 100644 packages/cli/src/commands/swap/quote.ts create mode 100644 packages/cli/src/commands/swap/quotes.ts create mode 100644 packages/cli/src/commands/swap/util.ts create mode 100644 packages/cli/src/commands/wallet/swap/execute.ts create mode 100644 packages/cli/src/commands/wallet/swap/index.ts diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index 219ff3f55..811b78c88 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -26,6 +26,16 @@ actions --json wallet balance --chain base-sepolia providers (no wallet). - `actions lend market --market ` - inspect one market by name (no wallet). +- `actions swap markets [--chain ]` - all swap markets across + configured providers (no wallet). +- `actions swap market --pool --chain ` - inspect one swap + market by pool id (no wallet). +- `actions swap quote --in --out + (--amount-in | --amount-out ) --chain + [--provider uniswap|velodrome] [--slippage ]` - best quote + (no wallet). +- `actions swap quotes ...` - same flag set; returns every provider's + quote sorted best price first. - `actions wallet address` - EOA address derived from `PRIVATE_KEY`. - `actions wallet balance [--chain | --chain-id ]` - balances per chain + asset; the chain flags are mutually exclusive. @@ -35,6 +45,10 @@ actions --json wallet balance --chain base-sepolia assets to a market in the config allowlist. - `actions wallet lend close --market --amount ` - withdraw assets from a lending position. +- `actions wallet swap execute --in --out + (--amount-in | --amount-out ) --chain + [--provider uniswap|velodrome] [--slippage ]` - execute a swap + on the resolved chain. ## Wallet model @@ -53,13 +67,24 @@ demo, fund the EOA with testnet ETH on Base Sepolia. `unichain`) via `--chain`, or a numeric id via `--chain-id` (mutually exclusive). Run `actions --json chains` for the current list. -- **Markets** - pass the market `name` from the config allowlist +- **Markets (lend)** - pass the market `name` from the config allowlist (e.g. `Gauntlet USDC`, `Aave ETH`). Case-insensitive; whitespace and hyphens are ignored, so `gauntlet-usdc` and `gauntletusdc` resolve to the same entry. The market entry carries its own chain and asset, so no `--chain` is needed. +- **Markets (swap)** - addressed pair-wise via `--in/--out/--chain` for + quotes and execution. `--pool ` is only used for direct + `swap market` lookups; the `poolId` surfaces in `swap markets`. - **Amounts** - human-readable decimal numbers (e.g. `10`, `0.5`). The SDK converts to wei using the asset's decimals. +- **Slippage** - `--slippage` accepts a percent (e.g. `0.5` for 0.5%); + the CLI converts to the SDK's decimal form internally. +- **Amount direction** - exactly one of `--amount-in` (exact-in) or + `--amount-out` (exact-out) is required for `swap quote`, + `swap quotes`, and `wallet swap execute`. +- **Provider selection** - `--provider uniswap|velodrome` forces a + provider and skips routing. Omit to let the SDK pick the best + available. ## Output @@ -128,6 +153,41 @@ NL -> command examples: - "withdraw 5 USDC from Gauntlet" -> `actions --json wallet lend close --market gauntlet-usdc --amount 5` - "how much do I have in Gauntlet" -> `actions --json wallet lend position --market gauntlet-usdc` +## Swap semantics + +`swap quote` returns the SDK `SwapQuote` shape verbatim: amounts (both +display and `Raw` bigint), price + price-impact, slippage (decimal), +deadline, and pre-built `execution` calldata. `swap quotes` is the +multi-provider variant sorted by `amountOutRaw` desc. + +`wallet swap execute` emits a structured envelope on stdout: + +```json +{ + "action": "execute", + "assetIn": { "symbol": "USDC_DEMO" }, + "assetOut": { "symbol": "OP_DEMO" }, + "amountIn": 5, "amountOut": 4.9, + "amountInRaw": "5000000", + "amountOutRaw": "4900000000000000000", + "price": 0.98, "priceImpact": 0.001, + "transactions": [ { "transactionHash": "0x...", "status": "success", ... } ] +} +``` + +`transactions` is always an array. EOA execution can fan out into +token-approval + Permit2-approval + swap (up to 3 receipts); smart +wallets collapse to a single UserOp receipt. A receipt with +`status: "reverted"` is normalised to `code: "onchain"` exit 5. + +NL -> command examples: + +- "swap 5 USDC for OP on Unichain" -> `actions --json wallet swap execute --in USDC_DEMO --out OP_DEMO --amount-in 5 --chain unichain` +- "buy 1 OP with USDC" -> `actions --json wallet swap execute --in USDC_DEMO --out OP_DEMO --amount-out 1 --chain unichain` +- "what's the best price for 100 USDC -> OP" -> `actions --json swap quote --in USDC_DEMO --out OP_DEMO --amount-in 100 --chain unichain` +- "compare provider quotes" -> `actions --json swap quotes --in USDC_DEMO --out OP_DEMO --amount-in 100 --chain unichain` +- "execute on Velodrome with 1% slippage" -> `actions --json wallet swap execute --in USDC_DEMO --out OP_DEMO --amount-in 100 --chain unichain --provider velodrome --slippage 1` + ## RPC trust `*_RPC_URL` env vars must point to operator-trusted endpoints. A diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts index 0b8be71ca..b4fbcfc47 100644 --- a/packages/cli/src/__tests__/system.test.ts +++ b/packages/cli/src/__tests__/system.test.ts @@ -191,6 +191,68 @@ describe('actions CLI (built binary)', () => { expect(body.code).toBe('validation') expect(body.error).toMatch(/Invalid --amount/) }) + + it('swap quote without --amount-in or --amount-out -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run([ + '--json', + 'swap', + 'quote', + '--in', + 'USDC_DEMO', + '--out', + 'OP_DEMO', + '--chain', + 'unichain', + ]) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/--amount-in or --amount-out/) + }) + + it('swap quote with both --amount-in and --amount-out -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run([ + '--json', + 'swap', + 'quote', + '--in', + 'USDC_DEMO', + '--out', + 'OP_DEMO', + '--amount-in', + '1', + '--amount-out', + '1', + '--chain', + 'unichain', + ]) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/not both/) + }) + + it('swap quote with unknown --provider -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run([ + '--json', + 'swap', + 'quote', + '--in', + 'USDC_DEMO', + '--out', + 'OP_DEMO', + '--amount-in', + '1', + '--chain', + 'unichain', + '--provider', + 'sushiswap', + ]) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/Invalid --provider/) + }) }) describe('default (human) mode', () => { diff --git a/packages/cli/src/commands/__tests__/swapMarket.test.ts b/packages/cli/src/commands/__tests__/swapMarket.test.ts new file mode 100644 index 000000000..146497ad7 --- /dev/null +++ b/packages/cli/src/commands/__tests__/swapMarket.test.ts @@ -0,0 +1,74 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runSwapMarket } from '@/commands/swap/market.js' +import * as baseCtx from '@/context/baseContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +describe('runSwapMarket', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getMarket: (params: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getMarket } } as never, + }) + } + + it('looks up the market with the resolved chainId and pool', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return { + marketId: params, + assets: [ + { metadata: { symbol: 'USDC_DEMO' } }, + { metadata: { symbol: 'OP_DEMO' } }, + ], + fee: 100, + provider: 'uniswap', + } + }) + await runSwapMarket({ pool: '0xpool', chain: 'unichain' }) + expect(captured[0]).toEqual({ poolId: '0xpool', chainId: 130 }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.provider).toBe('uniswap') + }) + + it('rejects unknown --chain values with CliError(validation)', async () => { + mockActions(async () => ({})) + try { + await runSwapMarket({ pool: '0x', chain: 'no-such-chain' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('maps RPC failures to CliError(network)', async () => { + mockActions(async () => { + throw new Error('fetch failed') + }) + try { + await runSwapMarket({ pool: '0x', chain: 'unichain' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + } + }) +}) diff --git a/packages/cli/src/commands/__tests__/swapMarkets.test.ts b/packages/cli/src/commands/__tests__/swapMarkets.test.ts new file mode 100644 index 000000000..f299633a3 --- /dev/null +++ b/packages/cli/src/commands/__tests__/swapMarkets.test.ts @@ -0,0 +1,83 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runSwapMarkets } from '@/commands/swap/markets.js' +import * as baseCtx from '@/context/baseContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +describe('runSwapMarkets', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getMarkets: (params?: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getMarkets } } as never, + }) + } + + it('emits the array of markets', async () => { + mockActions(async () => [ + { + marketId: { poolId: '0xpool', chainId: 130 }, + assets: [ + { metadata: { symbol: 'USDC_DEMO' } }, + { metadata: { symbol: 'OP_DEMO' } }, + ], + fee: 100, + provider: 'uniswap', + }, + ]) + await runSwapMarkets() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toHaveLength(1) + expect(body[0].provider).toBe('uniswap') + expect(body[0].marketId.poolId).toBe('0xpool') + }) + + it('forwards --chain to the SDK after resolution', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return [] + }) + await runSwapMarkets({ chain: 'unichain' }) + expect(captured[0]).toEqual({ chainId: 130 }) + }) + + it('rejects unknown --chain values with CliError(validation)', async () => { + mockActions(async () => []) + try { + await runSwapMarkets({ chain: 'no-such-chain' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('maps RPC failures to CliError(network)', async () => { + mockActions(async () => { + throw new Error('HTTP request failed') + }) + try { + await runSwapMarkets() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + } + }) +}) diff --git a/packages/cli/src/commands/__tests__/swapQuote.test.ts b/packages/cli/src/commands/__tests__/swapQuote.test.ts new file mode 100644 index 000000000..230756f47 --- /dev/null +++ b/packages/cli/src/commands/__tests__/swapQuote.test.ts @@ -0,0 +1,242 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runSwapQuote } from '@/commands/swap/quote.js' +import { runSwapQuotes } from '@/commands/swap/quotes.js' +import * as baseCtx from '@/context/baseContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +const stubQuote = (provider: string, amountOutRaw: bigint) => ({ + assetIn: { metadata: { symbol: 'USDC_DEMO' } }, + assetOut: { metadata: { symbol: 'OP_DEMO' } }, + chainId: 130, + amountIn: 5, + amountInRaw: 5000000n, + amountOut: 4.9, + amountOutRaw, + amountOutMin: 4.85, + amountOutMinRaw: 4850000000000000000n, + price: 0.98, + priceInverse: 1.02, + priceImpact: 0.001, + route: { hops: [] }, + execution: { swapCalldata: '0x', routerAddress: '0xrouter', value: 0n }, + provider, + slippage: 0.005, + deadline: 1, + quotedAt: 1, + expiresAt: 2, + quotedRecipient: '0xrecipient', +}) + +describe('runSwapQuote', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getQuote: (params: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getQuote } } as never, + }) + } + + it('builds quote params from --in/--out/--amount-in/--chain and stringifies bigints', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return stubQuote('uniswap', 4900000000000000000n) + }) + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '5', + chain: 'unichain', + }) + const call = captured[0] as { + assetIn: { metadata: { symbol: string } } + assetOut: { metadata: { symbol: string } } + chainId: number + amountIn?: number + amountOut?: number + slippage?: number + provider?: string + } + expect(call.assetIn.metadata.symbol).toBe('USDC_DEMO') + expect(call.assetOut.metadata.symbol).toBe('OP_DEMO') + expect(call.chainId).toBe(130) + expect(call.amountIn).toBe(5) + expect(call.amountOut).toBeUndefined() + expect(call.slippage).toBeUndefined() + expect(call.provider).toBeUndefined() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.amountOutRaw).toBe('4900000000000000000') + expect(body.provider).toBe('uniswap') + }) + + it('converts --slippage percent to SDK decimal', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return stubQuote('uniswap', 1n) + }) + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + slippage: '0.5', + }) + const call = captured[0] as { slippage: number } + expect(call.slippage).toBeCloseTo(0.005, 12) + }) + + it('forwards --provider when supplied', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return stubQuote('velodrome', 1n) + }) + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + provider: 'velodrome', + }) + const call = captured[0] as { provider: string } + expect(call.provider).toBe('velodrome') + }) + + it('rejects when both --amount-in and --amount-out are set', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + amountOut: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects when neither --amount-in nor --amount-out is set', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects unknown asset symbols with CliError(validation)', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'NOPE', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects unknown providers with CliError(validation)', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + provider: 'sushiswap', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects out-of-range slippage with CliError(validation)', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + for (const bad of ['-1', '101', 'foo']) { + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + slippage: bad, + }) + throw new Error(`did not throw for ${bad}`) + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + } + }) +}) + +describe('runSwapQuotes', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getQuotes: (params: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getQuotes } } as never, + }) + } + + it('emits an array of quotes verbatim', async () => { + mockActions(async () => [ + stubQuote('uniswap', 5000000000000000000n), + stubQuote('velodrome', 4800000000000000000n), + ]) + await runSwapQuotes({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '5', + chain: 'unichain', + }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toHaveLength(2) + expect(body[0].provider).toBe('uniswap') + expect(body[1].provider).toBe('velodrome') + expect(body[0].amountOutRaw).toBe('5000000000000000000') + }) +}) diff --git a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts new file mode 100644 index 000000000..3ecba7df3 --- /dev/null +++ b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts @@ -0,0 +1,192 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runWalletSwapExecute } from '@/commands/wallet/swap/execute.js' +import { __resetEnvCacheForTests } from '@/config/env.js' +import * as walletCtx from '@/context/walletContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +const successReceipt = (hash: string) => ({ + transactionHash: hash, + status: 'success' as const, + blockNumber: 9n, + gasUsed: 80000n, +}) + +const stubResult = (receipt: unknown) => ({ + receipt, + amountIn: 5, + amountOut: 4.9, + amountInRaw: 5000000n, + amountOutRaw: 4900000000000000000n, + assetIn: { metadata: { symbol: 'USDC_DEMO' } }, + assetOut: { metadata: { symbol: 'OP_DEMO' } }, + price: 0.98, + priceImpact: 0.001, +}) + +describe('runWalletSwapExecute', () => { + const originalEnv = process.env + let writeSpy: MockInstance + + beforeEach(() => { + process.env = { ...originalEnv, PRIVATE_KEY: ANVIL_ACCOUNT_0 } + __resetEnvCacheForTests() + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + vi.restoreAllMocks() + }) + + const mockWallet = ( + execute: (params: unknown) => Promise, + withSwap = true, + ) => { + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: getDemoConfig(), + actions: {} as never, + signer: {} as never, + wallet: { + address: '0xabc', + swap: withSwap ? { execute } : undefined, + } as never, + }) + } + + it('emits a structured envelope with normalised array of receipts', async () => { + const captured: unknown[] = [] + mockWallet(async (params) => { + captured.push(params) + return stubResult([successReceipt('0xapprove'), successReceipt('0xswap')]) + }) + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '5', + chain: 'unichain', + }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.action).toBe('execute') + expect(body.assetIn.symbol).toBe('USDC_DEMO') + expect(body.assetOut.symbol).toBe('OP_DEMO') + expect(body.amountInRaw).toBe('5000000') + expect(body.amountOutRaw).toBe('4900000000000000000') + expect(body.transactions).toHaveLength(2) + expect(body.transactions[0].transactionHash).toBe('0xapprove') + const call = captured[0] as { chainId: number } + expect(call.chainId).toBe(130) + }) + + it('wraps a single receipt into a one-element array', async () => { + mockWallet(async () => stubResult(successReceipt('0xonly'))) + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountOut: '5', + chain: 'unichain', + }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.transactions).toHaveLength(1) + }) + + it('maps reverted receipts to CliError(onchain)', async () => { + mockWallet(async () => + stubResult([ + successReceipt('0xapprove'), + { ...successReceipt('0xrevert'), status: 'reverted' as const }, + ]), + ) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('onchain') + } + }) + + it('maps RPC failures to CliError(network) and marks them retryable', async () => { + mockWallet(async () => { + throw new Error('HTTP request failed. Status: ECONNREFUSED') + }) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + expect((err as CliError).retryable).toBe(true) + } + }) + + it('rejects when both --amount-in and --amount-out are set', async () => { + mockWallet(async () => stubResult(successReceipt('0x'))) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + amountOut: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects with CliError(config) when wallet.swap is undefined', async () => { + mockWallet(async () => stubResult(successReceipt('0x')), false) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) + + it('rejects with CliError(config) when PRIVATE_KEY is missing', async () => { + delete process.env.PRIVATE_KEY + __resetEnvCacheForTests() + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) +}) diff --git a/packages/cli/src/commands/swap/index.ts b/packages/cli/src/commands/swap/index.ts new file mode 100644 index 000000000..314c98608 --- /dev/null +++ b/packages/cli/src/commands/swap/index.ts @@ -0,0 +1,72 @@ +import { Command } from 'commander' + +import { runSwapMarket } from '@/commands/swap/market.js' +import { runSwapMarkets } from '@/commands/swap/markets.js' +import { runSwapQuote } from '@/commands/swap/quote.js' +import { runSwapQuotes } from '@/commands/swap/quotes.js' + +const QUOTE_OPTIONS_HELP = { + in: ['--in ', 'token to sell (e.g. USDC_DEMO)'], + out: ['--out ', 'token to buy (e.g. OP_DEMO)'], + amountIn: [ + '--amount-in ', + 'exact-in amount (mutually exclusive with --amount-out)', + ], + amountOut: [ + '--amount-out ', + 'exact-out amount (mutually exclusive with --amount-in)', + ], + chain: ['--chain ', 'chain shortname (e.g. unichain, op-sepolia)'], + provider: [ + '--provider ', + 'force a provider: uniswap or velodrome (omit to let routing decide)', + ], + slippage: [ + '--slippage ', + 'slippage tolerance as a percent (e.g. 0.5 for 0.5%)', + ], +} as const + +function addQuoteOptions(cmd: Command): Command { + return cmd + .requiredOption(...QUOTE_OPTIONS_HELP.in) + .requiredOption(...QUOTE_OPTIONS_HELP.out) + .option(...QUOTE_OPTIONS_HELP.amountIn) + .option(...QUOTE_OPTIONS_HELP.amountOut) + .requiredOption(...QUOTE_OPTIONS_HELP.chain) + .option(...QUOTE_OPTIONS_HELP.provider) + .option(...QUOTE_OPTIONS_HELP.slippage) +} + +/** + * @description Builds the root `swap` subcommand tree. Children read + * markets and price quotes with no signer; wallet-scoped execution + * lives under `wallet swap`. + * @returns Commander `Command` configured with `markets`, `market`, + * `quote`, and `quotes`. + */ +export function swapCommand(): Command { + const command = new Command('swap').description( + 'Read-only swap market + quote commands (no PRIVATE_KEY required).', + ) + command + .command('markets') + .description('List swap markets across configured providers.') + .option('--chain ', 'filter to a single chain by shortname') + .action(runSwapMarkets) + command + .command('market') + .description('Inspect one swap market by pool id and chain.') + .requiredOption('--pool ', 'pool identifier (keccak256 of PoolKey)') + .requiredOption('--chain ', 'chain shortname (e.g. unichain)') + .action(runSwapMarket) + addQuoteOptions( + command.command('quote').description('Get the best swap quote.'), + ).action(runSwapQuote) + addQuoteOptions( + command + .command('quotes') + .description('Get every available provider quote, best price first.'), + ).action(runSwapQuotes) + return command +} diff --git a/packages/cli/src/commands/swap/market.ts b/packages/cli/src/commands/swap/market.ts new file mode 100644 index 000000000..f5c62e528 --- /dev/null +++ b/packages/cli/src/commands/swap/market.ts @@ -0,0 +1,34 @@ +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveChain } from '@/resolvers/chains.js' + +export interface SwapMarketFlags { + pool: string + chain: string +} + +/** + * @description Handler for `actions swap market --pool --chain `. + * Resolves the chain shortname against the config, then queries every + * provider in turn until one returns a matching market (the SDK + * iterates internally). Read-only, no signer needed. + * @param flags - Commander-parsed required options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapMarket(flags: SwapMarketFlags): Promise { + const { actions, config } = baseContext() + const chainId = resolveChain( + flags.chain, + config.chains.map((c) => c.chainId), + ) + try { + const market = await actions.swap.getMarket({ + poolId: flags.pool, + chainId, + }) + printOutput('swapMarket', market) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/swap/markets.ts b/packages/cli/src/commands/swap/markets.ts new file mode 100644 index 000000000..d8b6253ce --- /dev/null +++ b/packages/cli/src/commands/swap/markets.ts @@ -0,0 +1,36 @@ +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveChain } from '@/resolvers/chains.js' + +export interface SwapMarketsFlags { + chain?: string +} + +/** + * @description Handler for `actions swap markets [--chain ]`. + * Aggregates markets across every configured swap provider. The + * optional `--chain` filter is forwarded to the SDK so it can prune + * before iterating provider markets. + * @param flags - Commander-parsed options; `--chain` optional. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapMarkets( + flags: SwapMarketsFlags = {}, +): Promise { + const { actions, config } = baseContext() + const chainId = flags.chain + ? resolveChain( + flags.chain, + config.chains.map((c) => c.chainId), + ) + : undefined + try { + const markets = await actions.swap.getMarkets( + chainId ? { chainId } : undefined, + ) + printOutput('swapMarkets', markets) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/swap/quote.ts b/packages/cli/src/commands/swap/quote.ts new file mode 100644 index 000000000..e91273585 --- /dev/null +++ b/packages/cli/src/commands/swap/quote.ts @@ -0,0 +1,29 @@ +import { buildQuoteParams, type QuoteFlags } from '@/commands/swap/util.js' +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' + +/** + * @description Handler for + * `actions swap quote --in --out + * (--amount-in | --amount-out ) --chain + * [--provider uniswap|velodrome] [--slippage ]`. + * Returns one `SwapQuote` (best price by default; explicit `--provider` + * skips routing). Read-only. + * @param flags - Commander-parsed required + optional options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapQuote(flags: QuoteFlags): Promise { + const { actions, config } = baseContext() + const params = buildQuoteParams( + flags, + config.assets?.allow ?? [], + config.chains.map((c) => c.chainId), + ) + try { + const quote = await actions.swap.getQuote(params) + printOutput('swapQuote', quote) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/swap/quotes.ts b/packages/cli/src/commands/swap/quotes.ts new file mode 100644 index 000000000..9546dfc3e --- /dev/null +++ b/packages/cli/src/commands/swap/quotes.ts @@ -0,0 +1,28 @@ +import { buildQuoteParams, type QuoteFlags } from '@/commands/swap/util.js' +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' + +/** + * @description Handler for `actions swap quotes ...`. Same flag set as + * `swap quote` but returns every successful provider quote sorted by + * `amountOutRaw` desc (best price first). When `--provider` is set the + * SDK still returns a one-element array so the caller can branch + * uniformly. + * @param flags - Commander-parsed required + optional options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapQuotes(flags: QuoteFlags): Promise { + const { actions, config } = baseContext() + const params = buildQuoteParams( + flags, + config.assets?.allow ?? [], + config.chains.map((c) => c.chainId), + ) + try { + const quotes = await actions.swap.getQuotes(params) + printOutput('swapQuotes', quotes) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/swap/util.ts b/packages/cli/src/commands/swap/util.ts new file mode 100644 index 000000000..a8c8f9d37 --- /dev/null +++ b/packages/cli/src/commands/swap/util.ts @@ -0,0 +1,127 @@ +import type { + Asset, + SupportedChainId, + SwapProviderName, + SwapQuoteParams, +} from '@eth-optimism/actions-sdk' + +import { parseAmount } from '@/commands/wallet/lend/util.js' +import { CliError } from '@/output/errors.js' +import { resolveAsset } from '@/resolvers/assets.js' +import { resolveChain } from '@/resolvers/chains.js' + +const PROVIDERS: readonly SwapProviderName[] = ['uniswap', 'velodrome'] + +/** + * @description Validates that exactly one of `--amount-in` / `--amount-out` + * is present and parses it to a positive number. Throws + * `CliError('validation')` when both are provided or neither is. + * @param amountIn - Raw `--amount-in` flag value. + * @param amountOut - Raw `--amount-out` flag value. + * @returns One-sided amount envelope with the other field undefined. + */ +export function parseAmountFlags( + amountIn: string | undefined, + amountOut: string | undefined, +): { amountIn?: number; amountOut?: number } { + if (!amountIn && !amountOut) { + throw new CliError( + 'validation', + 'One of --amount-in or --amount-out is required', + ) + } + if (amountIn && amountOut) { + throw new CliError( + 'validation', + 'Pass either --amount-in or --amount-out, not both', + ) + } + return amountIn + ? { amountIn: parseAmount(amountIn) } + : { amountOut: parseAmount(amountOut!) } +} + +/** + * @description Parses a `--slippage ` value. Accepts a percent + * literal (e.g. `0.5` = 0.5%) and converts to the decimal form the SDK + * expects (e.g. `0.005`). `100` is the upper bound. + * @param raw - Flag value as passed on argv, or undefined. + * @returns Decimal slippage in `[0, 1]` when provided, else undefined. + * @throws `CliError` with code `validation` when not a number in `[0, 100]`. + */ +export function parseSlippage(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined + const value = Number(raw) + if (!Number.isFinite(value) || value < 0 || value > 100) { + throw new CliError( + 'validation', + `Invalid --slippage: ${raw} (expected a percent in [0, 100])`, + { slippage: raw }, + ) + } + return value / 100 +} + +/** + * @description Parses a `--provider` value against the configured + * provider names. Returns `undefined` when not supplied, letting the SDK + * apply its routing config instead. + * @param raw - Flag value as passed on argv, or undefined. + * @returns `SwapProviderName` when recognised, otherwise undefined. + * @throws `CliError` with code `validation` for any other value. + */ +export function parseProvider( + raw: string | undefined, +): SwapProviderName | undefined { + if (raw === undefined) return undefined + const needle = raw.toLowerCase() as SwapProviderName + if (!PROVIDERS.includes(needle)) { + throw new CliError( + 'validation', + `Invalid --provider: ${raw} (expected one of ${PROVIDERS.join(', ')})`, + { provider: raw, allowed: PROVIDERS.slice() }, + ) + } + return needle +} + +export interface QuoteFlags { + in: string + out: string + amountIn?: string + amountOut?: string + chain: string + provider?: string + slippage?: string +} + +/** + * @description Builds a `SwapQuoteParams` object from the CLI flag set + * shared by `quote`, `quotes`, and `execute`. Validates the assets and + * chain are in the active config, enforces the amount-in/out XOR, and + * converts the percent slippage to decimal. + * @param flags - Commander-parsed flags. + * @param allow - Asset allowlist from config. + * @param chainIds - Configured chain IDs. + * @returns Resolved quote parameters ready for the SDK. + */ +export function buildQuoteParams( + flags: QuoteFlags, + allow: readonly Asset[], + chainIds: readonly SupportedChainId[], +): SwapQuoteParams { + const assetIn = resolveAsset(flags.in, allow) + const assetOut = resolveAsset(flags.out, allow) + const chainId = resolveChain(flags.chain, chainIds) + const amounts = parseAmountFlags(flags.amountIn, flags.amountOut) + const provider = parseProvider(flags.provider) + const slippage = parseSlippage(flags.slippage) + return { + assetIn, + assetOut, + chainId, + ...amounts, + ...(provider ? { provider } : {}), + ...(slippage !== undefined ? { slippage } : {}), + } +} diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts index 8e150e456..14a40cf35 100644 --- a/packages/cli/src/commands/wallet/index.ts +++ b/packages/cli/src/commands/wallet/index.ts @@ -3,6 +3,7 @@ import { Command } from 'commander' import { runWalletAddress } from '@/commands/wallet/address.js' import { runWalletBalance } from '@/commands/wallet/balance.js' import { lendCommand } from '@/commands/wallet/lend/index.js' +import { swapCommand } from '@/commands/wallet/swap/index.js' /** * @description Builds the `wallet` subcommand tree. Registered children @@ -30,5 +31,6 @@ export function walletCommand(): Command { ) .action(runWalletBalance) command.addCommand(lendCommand()) + command.addCommand(swapCommand()) return command } diff --git a/packages/cli/src/commands/wallet/swap/execute.ts b/packages/cli/src/commands/wallet/swap/execute.ts new file mode 100644 index 000000000..8b613cee2 --- /dev/null +++ b/packages/cli/src/commands/wallet/swap/execute.ts @@ -0,0 +1,55 @@ +import { buildQuoteParams, type QuoteFlags } from '@/commands/swap/util.js' +import { + ensureOnchainSuccess, + rethrowAsCliError, + toReceiptArray, +} from '@/commands/wallet/lend/util.js' +import { walletContext } from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' +import { printOutput } from '@/output/printOutput.js' + +/** + * @description Handler for `actions wallet swap execute --in + * --out (--amount-in | --amount-out ) --chain + * [--slippage ] [--provider uniswap|velodrome]`. Builds a + * `WalletSwapParams` from CLI flags and delegates to + * `wallet.swap.execute`, which re-quotes, dispatches Permit2 / token + * approval + swap as a sendBatch, and waits for receipts. The CLI + * normalises the union receipt type to an array, surfaces reverts as + * `onchain` (exit 5), and emits a structured envelope. + * @param flags - Commander-parsed required + optional options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runWalletSwapExecute(flags: QuoteFlags): Promise { + const { wallet, config } = await walletContext() + if (!wallet.swap) { + throw new CliError( + 'config', + 'Swap is not configured (no providers in config.swap)', + ) + } + const params = buildQuoteParams( + flags, + config.assets?.allow ?? [], + config.chains.map((c) => c.chainId), + ) + try { + const result = await wallet.swap.execute(params) + const receipts = toReceiptArray(result.receipt) + ensureOnchainSuccess(receipts) + printOutput('swapExecute', { + action: 'execute', + assetIn: { symbol: result.assetIn.metadata.symbol }, + assetOut: { symbol: result.assetOut.metadata.symbol }, + amountIn: result.amountIn, + amountOut: result.amountOut, + amountInRaw: result.amountInRaw, + amountOutRaw: result.amountOutRaw, + price: result.price, + priceImpact: result.priceImpact, + transactions: receipts, + }) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/wallet/swap/index.ts b/packages/cli/src/commands/wallet/swap/index.ts new file mode 100644 index 000000000..f7d2b96ca --- /dev/null +++ b/packages/cli/src/commands/wallet/swap/index.ts @@ -0,0 +1,43 @@ +import { Command } from 'commander' + +import { runWalletSwapExecute } from '@/commands/wallet/swap/execute.js' + +/** + * @description Builds the `wallet swap` subcommand tree. Read-only + * `markets`, `market`, `quote`, `quotes` aliases live on the root + * `actions swap` tree to avoid forcing PRIVATE_KEY for purely public + * reads. The wallet tree exposes only `execute`. + * @returns Commander `Command` configured with `execute`. + */ +export function swapCommand(): Command { + const command = new Command('swap').description( + 'Execute swaps from the EOA derived from PRIVATE_KEY.', + ) + command + .command('execute') + .description('Execute a swap on a configured chain.') + .requiredOption('--in ', 'token to sell (e.g. USDC_DEMO)') + .requiredOption('--out ', 'token to buy (e.g. OP_DEMO)') + .option( + '--amount-in ', + 'exact-in amount (mutually exclusive with --amount-out)', + ) + .option( + '--amount-out ', + 'exact-out amount (mutually exclusive with --amount-in)', + ) + .requiredOption( + '--chain ', + 'chain shortname (e.g. unichain, op-sepolia)', + ) + .option( + '--provider ', + 'force a provider: uniswap or velodrome (omit to let routing decide)', + ) + .option( + '--slippage ', + 'slippage tolerance as a percent (e.g. 0.5 for 0.5%)', + ) + .action(runWalletSwapExecute) + return command +} diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts index f7ebc46cb..a17880a4c 100644 --- a/packages/cli/src/demo/config.ts +++ b/packages/cli/src/demo/config.ts @@ -10,12 +10,12 @@ import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' /** * @description Returns the baked demo `NodeActionsConfig` the CLI boots - * against. Mirrors `packages/demo/backend/src/config/actions.ts` in asset - * and market set so CLI behaviour stays aligned with the demo backend. - * Divergences: `hostedWalletConfig` is omitted (the CLI uses an EOA-backed - * wallet via `actions.wallet.toActionsWallet(localAccount)`); `swap` is - * omitted; chain bundlers are omitted (no ERC-4337 gas abstraction - the - * signer pays gas directly). + * against. Mirrors `packages/demo/backend/src/config/actions.ts` in + * asset, lend, and swap allowlists so CLI behaviour stays aligned with + * the demo backend. Divergences: `hostedWalletConfig` is omitted (the + * CLI uses an EOA-backed wallet via + * `actions.wallet.toActionsWallet(localAccount)`); chain bundlers are + * omitted (no ERC-4337 gas abstraction - the signer pays gas directly). * @returns `NodeActionsConfig` with no hosted wallet provider configured. */ export function getDemoConfig(): NodeActionsConfig { @@ -29,6 +29,18 @@ export function getDemoConfig(): NodeActionsConfig { morpho: { marketAllowlist: [GauntletUSDCDemo] }, aave: { marketAllowlist: [AaveETH] }, }, + swap: { + uniswap: { + defaultSlippage: 0.005, + marketAllowlist: [ + { assets: [USDC_DEMO, OP_DEMO], fee: 100, tickSpacing: 2 }, + ], + }, + velodrome: { + defaultSlippage: 0.005, + marketAllowlist: [{ assets: [USDC_DEMO, OP_DEMO], stable: false }], + }, + }, assets: { allow: [USDC_DEMO, OP_DEMO, ETH] }, chains: getDemoChains(), } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bd46091da..476aa63e9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import pico from 'picocolors' import { runAssets } from '@/commands/assets.js' import { runChains } from '@/commands/chains.js' import { lendCommand } from '@/commands/lend/index.js' +import { swapCommand } from '@/commands/swap/index.js' import { walletCommand } from '@/commands/wallet/index.js' import { writeError } from '@/output/errors.js' import { setJsonMode } from '@/output/mode.js' @@ -60,6 +61,7 @@ program .action(runChains) program.addCommand(lendCommand()) +program.addCommand(swapCommand()) program.addCommand(walletCommand()) program.parseAsync(process.argv).catch(writeError) diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts index b26968956..408a678d5 100644 --- a/packages/cli/src/output/printOutput.ts +++ b/packages/cli/src/output/printOutput.ts @@ -4,6 +4,8 @@ import type { LendMarket, LendMarketPosition, SupportedChainId, + SwapMarket, + SwapQuote, TokenBalance, UserOperationTransactionReceipt, } from '@eth-optimism/actions-sdk' @@ -41,6 +43,21 @@ export interface LendActionDoc { > } +export interface SwapExecuteDoc { + action: 'execute' + assetIn: { symbol: string } + assetOut: { symbol: string } + amountIn: number + amountOut: number + amountInRaw: bigint + amountOutRaw: bigint + price: number + priceImpact: number + transactions: ReadonlyArray< + EOATransactionReceipt | UserOperationTransactionReceipt + > +} + interface Printers { assets: readonly Asset[] chains: readonly ChainRow[] @@ -51,6 +68,11 @@ interface Printers { lendMarkets: readonly LendMarket[] lendMarket: LendMarket lendPosition: LendMarketPosition + swapMarkets: readonly SwapMarket[] + swapMarket: SwapMarket + swapQuote: SwapQuote + swapQuotes: readonly SwapQuote[] + swapExecute: SwapExecuteDoc } function formatAssets(assets: Printers['assets']): void { @@ -145,6 +167,54 @@ function formatLendPosition(p: LendMarketPosition): void { ) } +function formatSwapMarket(m: SwapMarket): void { + const [a, b] = m.assets + writeLine( + `${a.metadata.symbol}/${b.metadata.symbol} pool=${m.marketId.poolId} chain=${m.marketId.chainId} provider=${m.provider} fee=${m.fee}`, + ) +} + +function formatSwapMarkets(markets: readonly SwapMarket[]): void { + if (markets.length === 0) { + writeLine('(no markets)') + return + } + for (const m of markets) formatSwapMarket(m) +} + +function formatSwapQuote(q: SwapQuote): void { + writeLine( + `${q.amountIn} ${q.assetIn.metadata.symbol} -> ${q.amountOut} ${q.assetOut.metadata.symbol} (provider=${q.provider}, chain=${q.chainId})`, + ) + writeLine( + ` price=${q.price} priceImpact=${(q.priceImpact * 100).toFixed(3)}% slippage=${(q.slippage * 100).toFixed(3)}%`, + ) + writeLine(` amountOutMin=${q.amountOutMin} expiresAt=${q.expiresAt}`) +} + +function formatSwapQuotes(quotes: readonly SwapQuote[]): void { + if (quotes.length === 0) { + writeLine('(no quotes)') + return + } + for (const q of quotes) formatSwapQuote(q) +} + +function formatSwapExecute(doc: SwapExecuteDoc): void { + writeLine( + `swapped ${doc.amountIn} ${doc.assetIn.symbol} for ${doc.amountOut} ${doc.assetOut.symbol} (price=${doc.price})`, + ) + for (const tx of doc.transactions) { + if ('transactionHash' in tx) { + writeLine(` tx=${tx.transactionHash} status=${tx.status}`) + } else { + const userOpHash = (tx as { userOpHash?: string }).userOpHash ?? '?' + const success = (tx as { success?: boolean }).success + writeLine(` userOp=${userOpHash} success=${success}`) + } + } +} + const TEXT_FORMATTERS: { [K in keyof Printers]: (data: Printers[K]) => void } = { @@ -157,6 +227,11 @@ const TEXT_FORMATTERS: { lendMarkets: formatLendMarkets, lendMarket: formatLendMarket, lendPosition: formatLendPosition, + swapMarkets: formatSwapMarkets, + swapMarket: formatSwapMarket, + swapQuote: formatSwapQuote, + swapQuotes: formatSwapQuotes, + swapExecute: formatSwapExecute, } /** From c0e2fae8624cb29501cfa7d38f99b1d4ec82075c Mon Sep 17 00:00:00 2001 From: everdred Date: Fri, 24 Apr 2026 06:39:00 -0700 Subject: [PATCH 33/76] drop unichain from demo chain set --- .../src/commands/__tests__/swapMarket.test.ts | 6 ++--- .../commands/__tests__/swapMarkets.test.ts | 6 ++--- .../src/commands/__tests__/swapQuote.test.ts | 22 +++++++++---------- .../commands/__tests__/walletBalance.test.ts | 2 +- .../__tests__/walletSwapExecute.test.ts | 16 +++++++------- packages/cli/src/demo/chains.ts | 18 ++++++--------- 6 files changed, 33 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/commands/__tests__/swapMarket.test.ts b/packages/cli/src/commands/__tests__/swapMarket.test.ts index 146497ad7..eb05b996b 100644 --- a/packages/cli/src/commands/__tests__/swapMarket.test.ts +++ b/packages/cli/src/commands/__tests__/swapMarket.test.ts @@ -42,8 +42,8 @@ describe('runSwapMarket', () => { provider: 'uniswap', } }) - await runSwapMarket({ pool: '0xpool', chain: 'unichain' }) - expect(captured[0]).toEqual({ poolId: '0xpool', chainId: 130 }) + await runSwapMarket({ pool: '0xpool', chain: 'base-sepolia' }) + expect(captured[0]).toEqual({ poolId: '0xpool', chainId: 84532 }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body.provider).toBe('uniswap') }) @@ -64,7 +64,7 @@ describe('runSwapMarket', () => { throw new Error('fetch failed') }) try { - await runSwapMarket({ pool: '0x', chain: 'unichain' }) + await runSwapMarket({ pool: '0x', chain: 'base-sepolia' }) throw new Error('did not throw') } catch (err) { expect(err).toBeInstanceOf(CliError) diff --git a/packages/cli/src/commands/__tests__/swapMarkets.test.ts b/packages/cli/src/commands/__tests__/swapMarkets.test.ts index f299633a3..ec7e740c5 100644 --- a/packages/cli/src/commands/__tests__/swapMarkets.test.ts +++ b/packages/cli/src/commands/__tests__/swapMarkets.test.ts @@ -31,7 +31,7 @@ describe('runSwapMarkets', () => { it('emits the array of markets', async () => { mockActions(async () => [ { - marketId: { poolId: '0xpool', chainId: 130 }, + marketId: { poolId: '0xpool', chainId: 84532 }, assets: [ { metadata: { symbol: 'USDC_DEMO' } }, { metadata: { symbol: 'OP_DEMO' } }, @@ -53,8 +53,8 @@ describe('runSwapMarkets', () => { captured.push(params) return [] }) - await runSwapMarkets({ chain: 'unichain' }) - expect(captured[0]).toEqual({ chainId: 130 }) + await runSwapMarkets({ chain: 'base-sepolia' }) + expect(captured[0]).toEqual({ chainId: 84532 }) }) it('rejects unknown --chain values with CliError(validation)', async () => { diff --git a/packages/cli/src/commands/__tests__/swapQuote.test.ts b/packages/cli/src/commands/__tests__/swapQuote.test.ts index 230756f47..f9c55c3fa 100644 --- a/packages/cli/src/commands/__tests__/swapQuote.test.ts +++ b/packages/cli/src/commands/__tests__/swapQuote.test.ts @@ -14,7 +14,7 @@ afterEach(() => setJsonMode(false)) const stubQuote = (provider: string, amountOutRaw: bigint) => ({ assetIn: { metadata: { symbol: 'USDC_DEMO' } }, assetOut: { metadata: { symbol: 'OP_DEMO' } }, - chainId: 130, + chainId: 84532, amountIn: 5, amountInRaw: 5000000n, amountOut: 4.9, @@ -62,7 +62,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const call = captured[0] as { assetIn: { metadata: { symbol: string } } @@ -75,7 +75,7 @@ describe('runSwapQuote', () => { } expect(call.assetIn.metadata.symbol).toBe('USDC_DEMO') expect(call.assetOut.metadata.symbol).toBe('OP_DEMO') - expect(call.chainId).toBe(130) + expect(call.chainId).toBe(84532) expect(call.amountIn).toBe(5) expect(call.amountOut).toBeUndefined() expect(call.slippage).toBeUndefined() @@ -95,7 +95,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', slippage: '0.5', }) const call = captured[0] as { slippage: number } @@ -112,7 +112,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', provider: 'velodrome', }) const call = captured[0] as { provider: string } @@ -127,7 +127,7 @@ describe('runSwapQuote', () => { out: 'OP_DEMO', amountIn: '1', amountOut: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -142,7 +142,7 @@ describe('runSwapQuote', () => { await runSwapQuote({ in: 'USDC_DEMO', out: 'OP_DEMO', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -158,7 +158,7 @@ describe('runSwapQuote', () => { in: 'NOPE', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -174,7 +174,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', provider: 'sushiswap', }) throw new Error('did not throw') @@ -192,7 +192,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', slippage: bad, }) throw new Error(`did not throw for ${bad}`) @@ -231,7 +231,7 @@ describe('runSwapQuotes', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body).toHaveLength(2) diff --git a/packages/cli/src/commands/__tests__/walletBalance.test.ts b/packages/cli/src/commands/__tests__/walletBalance.test.ts index 8c1518301..8a62de7c8 100644 --- a/packages/cli/src/commands/__tests__/walletBalance.test.ts +++ b/packages/cli/src/commands/__tests__/walletBalance.test.ts @@ -122,7 +122,7 @@ describe('runWalletBalance', () => { ] vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ config: { - chains: [{ chainId: 84532 }, { chainId: 11155420 }, { chainId: 130 }], + chains: [{ chainId: 84532 }, { chainId: 11155420 }], } as never, actions: {} as never, signer: {} as never, diff --git a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts index 3ecba7df3..4ed999b62 100644 --- a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts +++ b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts @@ -74,7 +74,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body.action).toBe('execute') @@ -85,7 +85,7 @@ describe('runWalletSwapExecute', () => { expect(body.transactions).toHaveLength(2) expect(body.transactions[0].transactionHash).toBe('0xapprove') const call = captured[0] as { chainId: number } - expect(call.chainId).toBe(130) + expect(call.chainId).toBe(84532) }) it('wraps a single receipt into a one-element array', async () => { @@ -94,7 +94,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountOut: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body.transactions).toHaveLength(1) @@ -112,7 +112,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -130,7 +130,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -148,7 +148,7 @@ describe('runWalletSwapExecute', () => { out: 'OP_DEMO', amountIn: '1', amountOut: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -164,7 +164,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -181,7 +181,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { diff --git a/packages/cli/src/demo/chains.ts b/packages/cli/src/demo/chains.ts index 8d514ad88..0841effcc 100644 --- a/packages/cli/src/demo/chains.ts +++ b/packages/cli/src/demo/chains.ts @@ -1,5 +1,5 @@ import type { SupportedChainId } from '@eth-optimism/actions-sdk' -import { baseSepolia, optimismSepolia, unichain } from 'viem/chains' +import { baseSepolia, optimismSepolia } from 'viem/chains' import { type CliEnvKey, optionalEnv } from '@/config/env.js' @@ -14,12 +14,12 @@ function rpcUrls(key: CliEnvKey): string[] | undefined { } /** - * @description Returns the CLI's baked demo chain set: Base Sepolia, - * Optimism Sepolia, Unichain - mirroring the demo backend's market - * footprint. RPC URLs come from the matching `*_RPC_URL` env vars when - * set, otherwise viem's chain defaults apply. Bundler configuration is - * omitted intentionally: the CLI signs transactions from an EOA and the - * signer pays gas directly (no ERC-4337 gas abstraction for now). + * @description Returns the CLI's baked demo chain set: Base Sepolia and + * Optimism Sepolia - mirroring the demo backend's market footprint. RPC + * URLs come from the matching `*_RPC_URL` env vars when set, otherwise + * viem's chain defaults apply. Bundler configuration is omitted + * intentionally: the CLI signs transactions from an EOA and the signer + * pays gas directly (no ERC-4337 gas abstraction for now). * @returns Array of chain configs suitable for `NodeActionsConfig.chains`. */ export function getDemoChains(): DemoChainConfig[] { @@ -32,9 +32,5 @@ export function getDemoChains(): DemoChainConfig[] { chainId: optimismSepolia.id, rpcUrls: rpcUrls('OP_SEPOLIA_RPC_URL'), }, - { - chainId: unichain.id, - rpcUrls: rpcUrls('UNICHAIN_RPC_URL'), - }, ] } From 3bd9db901f97ec28ab64e5ec99ae08cd83e1767b Mon Sep 17 00:00:00 2001 From: its-everdred Date: Fri, 24 Apr 2026 12:22:34 -0400 Subject: [PATCH 34/76] add presentation hints for llm callers --- packages/cli/SKILL.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index 811b78c88..9cb6ce1b6 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -86,6 +86,27 @@ demo, fund the EOA with testnet ETH on Base Sepolia. provider and skips routing. Omit to let the SDK pick the best available. +## Presentation hints (for LLM/agent callers) + +These are rules for rendering CLI output to humans, not rules for the +CLI itself. + +- **Chain labels - only when disambiguating.** When showing a list + (balances, markets, positions, pools), mention the chain only for + entries that share their name/symbol/market with another entry on a + different chain in the same response. If every row is uniquely + identifiable by its name alone, drop the chain label. Count chain + occurrences **after** skipping zero balances. Example: two chains + in the raw payload, but only one has a non-zero balance of `X` - + render as `X ` with no chain. When the user explicitly scopes + a question to one chain, still omit the label. +- **Zero rows - skip.** Don't render zero balances, empty positions, + or pools with no meaningful data, unless the user specifically asked + about that zero value ("do I have any X on op-sepolia"). +- **Raw addresses - omit by default.** Wallet/pool/market/contract + addresses in a listing add noise. Show them only when the user asks + for them explicitly, and even then truncate (`0xabc…def`). + ## Output With `--json`: From aa21c875333f78c9ba276b451fece42b48d31e25 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Mon, 27 Apr 2026 11:21:37 -0400 Subject: [PATCH 35/76] drop redundant double-wait in sendBatch --- packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts b/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts index 23d1ca8b9..606a1ea23 100644 --- a/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts +++ b/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts @@ -82,13 +82,8 @@ export abstract class EOAWallet extends Wallet { ): Promise { const receipts: EOATransactionReceipt[] = [] for (const tx of transactionData) { + // 1 confirmation is typically enough. const receipt = await this.send(tx, chainId) - const publicClient = this.chainManager.getPublicClient(chainId) - // wait an extra confirmation so give time for nonce to be updated - await publicClient.waitForTransactionReceipt({ - hash: receipt.transactionHash, - confirmations: 2, - }) receipts.push(receipt) } return receipts From 9d990b67428aa978d9e257ff63801dded293e0ba Mon Sep 17 00:00:00 2001 From: its-everdred Date: Mon, 27 Apr 2026 11:32:27 -0400 Subject: [PATCH 36/76] default fast chains to 1500ms polling --- packages/sdk/src/services/ChainManager.ts | 16 ++++++++++++++++ packages/sdk/src/types/chain.ts | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/packages/sdk/src/services/ChainManager.ts b/packages/sdk/src/services/ChainManager.ts index d054b6319..f49d36633 100644 --- a/packages/sdk/src/services/ChainManager.ts +++ b/packages/sdk/src/services/ChainManager.ts @@ -11,10 +11,23 @@ import { } from 'viem' import type { BundlerClient, SmartAccount } from 'viem/account-abstraction' import { createBundlerClient } from 'viem/account-abstraction' +import { mainnet, sepolia } from 'viem/chains' import type { SupportedChainId } from '@/constants/supportedChains.js' import type { ChainConfig } from '@/types/chain.js' +/** viem `pollingInterval` (ms) used for fast chains (L2s with ~2s blocks). */ +export const DEFAULT_POLLING_INTERVAL_MS = 1500 +/** viem `pollingInterval` (ms) used for L1-class chains with ~12s blocks. */ +export const MAINNET_POLLING_INTERVAL_MS = 4000 + +function defaultPollingInterval(chainId: number): number { + if (chainId === mainnet.id || chainId === sepolia.id) { + return MAINNET_POLLING_INTERVAL_MS + } + return DEFAULT_POLLING_INTERVAL_MS +} + /** * Chain Manager Service * @description Manages public clients and chain infrastructure for the Verbs SDK. @@ -174,9 +187,12 @@ export class ChainManager { `Public client already configured for chain ID: ${chainConfig.chainId}`, ) } + const pollingInterval = + chainConfig.pollingInterval ?? defaultPollingInterval(chainConfig.chainId) const client = createPublicClient({ chain, transport: this.getTransportForChain(chainConfig.chainId), + pollingInterval, }) clients.set(chainConfig.chainId, client) diff --git a/packages/sdk/src/types/chain.ts b/packages/sdk/src/types/chain.ts index f6f3c1268..34ff489b7 100644 --- a/packages/sdk/src/types/chain.ts +++ b/packages/sdk/src/types/chain.ts @@ -11,6 +11,12 @@ export interface ChainConfig { rpcUrls?: string[] /** Bundler configuration */ bundler?: BundlerConfig + /** + * Polling interval (ms) for the chain's PublicClient. Used by viem's + * `waitForTransactionReceipt` and friends when the transport is HTTP. + * Defaults to 4000ms (viem default). Lower this for fast L2s. + */ + pollingInterval?: number } export interface BaseBundlerConfig { From b88d6340d6628b001b75efbff1c9e217a003da42 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Mon, 27 Apr 2026 16:07:51 -0400 Subject: [PATCH 37/76] demo cli opts into max approval mode --- packages/cli/src/demo/config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts index a17880a4c..0d81c17c6 100644 --- a/packages/cli/src/demo/config.ts +++ b/packages/cli/src/demo/config.ts @@ -28,6 +28,7 @@ export function getDemoConfig(): NodeActionsConfig { lend: { morpho: { marketAllowlist: [GauntletUSDCDemo] }, aave: { marketAllowlist: [AaveETH] }, + settings: { approvalMode: 'max' }, }, swap: { uniswap: { @@ -40,6 +41,7 @@ export function getDemoConfig(): NodeActionsConfig { defaultSlippage: 0.005, marketAllowlist: [{ assets: [USDC_DEMO, OP_DEMO], stable: false }], }, + settings: { approvalMode: 'max' }, }, assets: { allow: [USDC_DEMO, OP_DEMO, ETH] }, chains: getDemoChains(), From 3e7164bea8630878cdc567780c63b96f940c77be Mon Sep 17 00:00:00 2001 From: its-everdred Date: Mon, 27 Apr 2026 17:19:53 -0400 Subject: [PATCH 38/76] narrow lend providers iterator to skip settings --- packages/cli/src/resolvers/markets.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/resolvers/markets.ts b/packages/cli/src/resolvers/markets.ts index 77c7a7c5e..d90720dda 100644 --- a/packages/cli/src/resolvers/markets.ts +++ b/packages/cli/src/resolvers/markets.ts @@ -14,7 +14,9 @@ function collectMarkets( ): readonly LendMarketConfig[] { const out: LendMarketConfig[] = [] for (const provider of Object.values(config.lend ?? {})) { - if (provider?.marketAllowlist) out.push(...provider.marketAllowlist) + if (provider && 'marketAllowlist' in provider && provider.marketAllowlist) { + out.push(...provider.marketAllowlist) + } } return out } From 3b7f1410c1e798d255784063d94a689cbf27fb93 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 28 Apr 2026 10:19:04 -0400 Subject: [PATCH 39/76] fix prettier formatting --- packages/sdk/src/services/ChainManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/services/ChainManager.ts b/packages/sdk/src/services/ChainManager.ts index f49d36633..f1eda367f 100644 --- a/packages/sdk/src/services/ChainManager.ts +++ b/packages/sdk/src/services/ChainManager.ts @@ -188,7 +188,8 @@ export class ChainManager { ) } const pollingInterval = - chainConfig.pollingInterval ?? defaultPollingInterval(chainConfig.chainId) + chainConfig.pollingInterval ?? + defaultPollingInterval(chainConfig.chainId) const client = createPublicClient({ chain, transport: this.getTransportForChain(chainConfig.chainId), From 12d5f6edc1992084afa52bb7aae56861013cb732 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 28 Apr 2026 18:43:12 -0400 Subject: [PATCH 40/76] drop unichain from cli resolver --- packages/cli/src/__tests__/system.test.ts | 6 +++--- packages/cli/src/commands/swap/index.ts | 4 ++-- packages/cli/src/commands/wallet/swap/index.ts | 2 +- .../cli/src/resolvers/__tests__/chains.test.ts | 17 +++-------------- packages/cli/src/resolvers/chains.ts | 11 +---------- 5 files changed, 10 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts index b4fbcfc47..50e59c8e9 100644 --- a/packages/cli/src/__tests__/system.test.ts +++ b/packages/cli/src/__tests__/system.test.ts @@ -202,7 +202,7 @@ describe('actions CLI (built binary)', () => { '--out', 'OP_DEMO', '--chain', - 'unichain', + 'base-sepolia', ]) expect(code).toBe(2) const body = JSON.parse(stderr) @@ -224,7 +224,7 @@ describe('actions CLI (built binary)', () => { '--amount-out', '1', '--chain', - 'unichain', + 'base-sepolia', ]) expect(code).toBe(2) const body = JSON.parse(stderr) @@ -244,7 +244,7 @@ describe('actions CLI (built binary)', () => { '--amount-in', '1', '--chain', - 'unichain', + 'base-sepolia', '--provider', 'sushiswap', ]) diff --git a/packages/cli/src/commands/swap/index.ts b/packages/cli/src/commands/swap/index.ts index 314c98608..9e6bebb88 100644 --- a/packages/cli/src/commands/swap/index.ts +++ b/packages/cli/src/commands/swap/index.ts @@ -16,7 +16,7 @@ const QUOTE_OPTIONS_HELP = { '--amount-out ', 'exact-out amount (mutually exclusive with --amount-in)', ], - chain: ['--chain ', 'chain shortname (e.g. unichain, op-sepolia)'], + chain: ['--chain ', 'chain shortname (e.g. base-sepolia, op-sepolia)'], provider: [ '--provider ', 'force a provider: uniswap or velodrome (omit to let routing decide)', @@ -58,7 +58,7 @@ export function swapCommand(): Command { .command('market') .description('Inspect one swap market by pool id and chain.') .requiredOption('--pool ', 'pool identifier (keccak256 of PoolKey)') - .requiredOption('--chain ', 'chain shortname (e.g. unichain)') + .requiredOption('--chain ', 'chain shortname (e.g. base-sepolia)') .action(runSwapMarket) addQuoteOptions( command.command('quote').description('Get the best swap quote.'), diff --git a/packages/cli/src/commands/wallet/swap/index.ts b/packages/cli/src/commands/wallet/swap/index.ts index f7d2b96ca..452231a93 100644 --- a/packages/cli/src/commands/wallet/swap/index.ts +++ b/packages/cli/src/commands/wallet/swap/index.ts @@ -28,7 +28,7 @@ export function swapCommand(): Command { ) .requiredOption( '--chain ', - 'chain shortname (e.g. unichain, op-sepolia)', + 'chain shortname (e.g. base-sepolia, op-sepolia)', ) .option( '--provider ', diff --git a/packages/cli/src/resolvers/__tests__/chains.test.ts b/packages/cli/src/resolvers/__tests__/chains.test.ts index dfef28bcb..180a7b19b 100644 --- a/packages/cli/src/resolvers/__tests__/chains.test.ts +++ b/packages/cli/src/resolvers/__tests__/chains.test.ts @@ -1,12 +1,5 @@ import type { SupportedChainId } from '@eth-optimism/actions-sdk' -import { - base, - baseSepolia, - optimism, - optimismSepolia, - unichain, - unichainSepolia, -} from 'viem/chains' +import { base, baseSepolia, optimism, optimismSepolia } from 'viem/chains' import { describe, expect, it } from 'vitest' import { CliError } from '@/output/errors.js' @@ -17,8 +10,6 @@ const ALL: SupportedChainId[] = [ baseSepolia.id, optimism.id, optimismSepolia.id, - unichain.id, - unichainSepolia.id, ] const SHORTNAMES = [ @@ -26,15 +17,13 @@ const SHORTNAMES = [ 'base-sepolia', 'optimism', 'op-sepolia', - 'unichain', - 'unichain-sepolia', ] as const describe('resolveChain', () => { it('resolves each canonical shortname to its chain id', () => { expect(resolveChain('base-sepolia', ALL)).toBe(baseSepolia.id) expect(resolveChain('op-sepolia', ALL)).toBe(optimismSepolia.id) - expect(resolveChain('unichain', ALL)).toBe(unichain.id) + expect(resolveChain('base', ALL)).toBe(base.id) }) it('is case-insensitive', () => { @@ -67,7 +56,7 @@ describe('shortnameFor', () => { it('returns the canonical shortname for each supported chain id', () => { expect(shortnameFor(baseSepolia.id)).toBe('base-sepolia') expect(shortnameFor(optimismSepolia.id)).toBe('op-sepolia') - expect(shortnameFor(unichainSepolia.id)).toBe('unichain-sepolia') + expect(shortnameFor(optimism.id)).toBe('optimism') }) }) diff --git a/packages/cli/src/resolvers/chains.ts b/packages/cli/src/resolvers/chains.ts index 33d3f070c..30bca5e8b 100644 --- a/packages/cli/src/resolvers/chains.ts +++ b/packages/cli/src/resolvers/chains.ts @@ -1,12 +1,5 @@ import type { SupportedChainId } from '@eth-optimism/actions-sdk' -import { - base, - baseSepolia, - optimism, - optimismSepolia, - unichain, - unichainSepolia, -} from 'viem/chains' +import { base, baseSepolia, optimism, optimismSepolia } from 'viem/chains' import { CliError } from '@/output/errors.js' @@ -15,8 +8,6 @@ const SHORTNAMES: Record = { 'base-sepolia': baseSepolia.id, optimism: optimism.id, 'op-sepolia': optimismSepolia.id, - unichain: unichain.id, - 'unichain-sepolia': unichainSepolia.id, } const CHAIN_IDS: Record = Object.fromEntries( From 47521bb1e29f9a8b480a0f7332dd67cbcb966422 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 28 Apr 2026 20:48:07 -0400 Subject: [PATCH 41/76] fix uniswap v4 exact-out action byte --- .../providers/uniswap/__tests__/sdk.test.ts | 64 ++++++++++++++++++- .../swap/providers/uniswap/encoding.ts | 2 +- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/actions/swap/providers/uniswap/__tests__/sdk.test.ts b/packages/sdk/src/actions/swap/providers/uniswap/__tests__/sdk.test.ts index 682cfc124..ef1a30b81 100644 --- a/packages/sdk/src/actions/swap/providers/uniswap/__tests__/sdk.test.ts +++ b/packages/sdk/src/actions/swap/providers/uniswap/__tests__/sdk.test.ts @@ -1,6 +1,13 @@ -import { type Address, type PublicClient, zeroAddress } from 'viem' +import { + type Address, + decodeAbiParameters, + decodeFunctionData, + type PublicClient, + zeroAddress, +} from 'viem' import { describe, expect, it, vi } from 'vitest' +import { UNIVERSAL_ROUTER_ABI } from '@/actions/swap/providers/uniswap/abis.js' import { calculatePriceImpact, encodeUniversalRouterSwap, @@ -302,6 +309,61 @@ describe('encodeUniversalRouterSwap', () => { expect(calldata.length).toBeGreaterThan(10) }) + it('tags V4 action bytes per Uniswap v4-periphery Actions.sol', () => { + // Regression: SWAP_EXACT_OUT_SINGLE was previously encoded as 0x07, which + // V4Router treats as multi-hop SWAP_EXACT_IN. The router decoded our + // single-hop EXACT_OUTPUT_SINGLE_PARAMS struct as a PathKey[] path and + // bare-reverted on pool lookup. Correct codes: + // 0x06 SWAP_EXACT_IN_SINGLE + // 0x08 SWAP_EXACT_OUT_SINGLE + const decodeActions = (calldata: `0x${string}`): `0x${string}` => { + const { args } = decodeFunctionData({ + abi: UNIVERSAL_ROUTER_ABI, + data: calldata, + }) + const [, inputs] = args as readonly [ + `0x${string}`, + ReadonlyArray<`0x${string}`>, + bigint, + ] + const [actions] = decodeAbiParameters( + [{ type: 'bytes' }, { type: 'bytes[]' }], + inputs[0]!, + ) + return actions as `0x${string}` + } + + const exactIn = encodeUniversalRouterSwap({ + amountInRaw: 100000000n, + assetIn: USDC, + assetOut: WETH, + slippage: 0.005, + deadline: 1700000000, + recipient: '0xrecipient' as Address, + chainId: CHAIN_ID, + quote: baseQuote, + universalRouterAddress: '0xrouter' as Address, + fee: FEE, + tickSpacing: TICK_SPACING, + }) + expect(decodeActions(exactIn)).toBe('0x060c0f') + + const exactOut = encodeUniversalRouterSwap({ + amountOutRaw: 500000000000000000n, + assetIn: USDC, + assetOut: WETH, + slippage: 0.005, + deadline: 1700000000, + recipient: '0xrecipient' as Address, + chainId: CHAIN_ID, + quote: baseQuote, + universalRouterAddress: '0xrouter' as Address, + fee: FEE, + tickSpacing: TICK_SPACING, + }) + expect(decodeActions(exactOut)).toBe('0x080c0f') + }) + it('produces different calldata for exact-in vs exact-out', () => { const exactIn = encodeUniversalRouterSwap({ amountInRaw: 100000000n, diff --git a/packages/sdk/src/actions/swap/providers/uniswap/encoding.ts b/packages/sdk/src/actions/swap/providers/uniswap/encoding.ts index 0d213383b..39934f648 100644 --- a/packages/sdk/src/actions/swap/providers/uniswap/encoding.ts +++ b/packages/sdk/src/actions/swap/providers/uniswap/encoding.ts @@ -213,7 +213,7 @@ const V4_SWAP = 0x10 // V4 action types const SWAP_EXACT_IN_SINGLE = 0x06 -const SWAP_EXACT_OUT_SINGLE = 0x07 +const SWAP_EXACT_OUT_SINGLE = 0x08 const SETTLE_ALL = 0x0c const TAKE_ALL = 0x0f From 9fb74ecdd297531831a31a03623b8c17118820ea Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 28 Apr 2026 22:27:16 -0400 Subject: [PATCH 42/76] fix velo universal router for EOAs --- .changeset/velo-universal-router-eoa.md | 5 ++ .../velodrome/VelodromeSwapProvider.ts | 1 - .../VelodromeSwapProvider.routing.test.ts | 11 +++-- .../__tests__/VelodromeSwapProvider.test.ts | 46 +++++++++++++++++-- .../velodrome/__tests__/encoding.cl.test.ts | 31 +++++++++++++ .../velodrome/__tests__/encoding.v2.test.ts | 36 ++++++++++++++- .../velodrome/encoding/routers/approval.ts | 26 ++--------- .../velodrome/encoding/routers/cl.ts | 6 ++- .../velodrome/encoding/routers/v2.ts | 7 ++- 9 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 .changeset/velo-universal-router-eoa.md diff --git a/.changeset/velo-universal-router-eoa.md b/.changeset/velo-universal-router-eoa.md new file mode 100644 index 000000000..492bbc10c --- /dev/null +++ b/.changeset/velo-universal-router-eoa.md @@ -0,0 +1,5 @@ +--- +'@eth-optimism/actions-sdk': patch +--- + +Fix Velodrome universal-router approvals for EOA wallets. The encoder previously hardcoded `payerIsUser: false` and pre-`transfer`d tokens to the router, which only works when the caller batches atomically (4337). EOAs (and any sequentially dispatched flow) reverted with `TRANSFER_FAILED`. The router has a first-class `payerIsUser: true` path that pulls tokens via standard `transferFrom`; the SDK now uses it. Behaviorally equivalent for smart wallets, correct for EOAs. diff --git a/packages/sdk/src/actions/swap/providers/velodrome/VelodromeSwapProvider.ts b/packages/sdk/src/actions/swap/providers/velodrome/VelodromeSwapProvider.ts index 8fa5202ca..475febcc2 100644 --- a/packages/sdk/src/actions/swap/providers/velodrome/VelodromeSwapProvider.ts +++ b/packages/sdk/src/actions/swap/providers/velodrome/VelodromeSwapProvider.ts @@ -195,7 +195,6 @@ export class VelodromeSwapProvider extends SwapProvider { }) describe('universal router (Base Sepolia)', () => { - it('executes swap via Universal Router', async () => { + it('executes swap via Universal Router with standard approve', async () => { const provider = createProvider(BASE_SEPOLIA_CHAIN_ID) const result = await provider.execute({ amountIn: 100, @@ -102,12 +102,13 @@ describe('VelodromeSwapProvider router type routing', () => { }) expect(result.transactionData.swap).toBeDefined() - // Universal Router: uses ERC20.transfer (not approve) + // Universal Router pulls tokens via transferFrom (payerIsUser=true), so the + // approval is a standard ERC20 approve(router, amount) — NOT a transfer. + // See encoding.v2.test.ts and the regression test in VelodromeSwapProvider.test.ts. expect(result.transactionData.tokenApproval).toBeDefined() - // tokenApproval should be a transfer() call, not approve() const approvalData = result.transactionData.tokenApproval!.data - // transfer() selector = 0xa9059cbb - expect(approvalData.startsWith('0xa9059cbb')).toBe(true) + // approve(spender, amount) selector = 0x095ea7b3 + expect(approvalData.startsWith('0x095ea7b3')).toBe(true) }) it('quotes via pool.getAmountOut for Universal Router', async () => { diff --git a/packages/sdk/src/actions/swap/providers/velodrome/__tests__/VelodromeSwapProvider.test.ts b/packages/sdk/src/actions/swap/providers/velodrome/__tests__/VelodromeSwapProvider.test.ts index 5c1ede68c..c8857647f 100644 --- a/packages/sdk/src/actions/swap/providers/velodrome/__tests__/VelodromeSwapProvider.test.ts +++ b/packages/sdk/src/actions/swap/providers/velodrome/__tests__/VelodromeSwapProvider.test.ts @@ -1,5 +1,6 @@ import type { Address, PublicClient } from 'viem' -import { mode, optimism } from 'viem/chains' +import { decodeFunctionData, erc20Abi } from 'viem' +import { baseSepolia, mode, optimism } from 'viem/chains' import { describe, expect, it, vi } from 'vitest' import { @@ -16,7 +17,9 @@ import type { Asset } from '@/types/asset.js' const CHAIN_ID = optimism.id as SupportedChainId -function createMockChainManager(): ChainManager { +function createMockChainManager( + supportedChains: SupportedChainId[] = [CHAIN_ID], +): ChainManager { const mockPublicClient = { readContract: vi .fn() @@ -35,7 +38,7 @@ function createMockChainManager(): ChainManager { return { getPublicClient: vi.fn().mockReturnValue(mockPublicClient), tryGetPublicClient: vi.fn().mockReturnValue(undefined), - getSupportedChains: vi.fn().mockReturnValue([CHAIN_ID]), + getSupportedChains: vi.fn().mockReturnValue(supportedChains), } as unknown as ChainManager } @@ -95,6 +98,43 @@ describe('VelodromeSwapProvider', () => { expect(result.transactionData.permit2Approval).toBeUndefined() }) + // Regression for #438: on the Universal Router (Base Sepolia), the approval must + // be a standard ERC20 `approve(router, amount)`, NOT a `transfer(router, amount)`. + // The old transfer-then-execute layout reverted with TRANSFER_FAILED for EOAs + // because their txs run sequentially: tokens landed at the router with no + // allowance set, then the swap call's transferFrom failed. + it('uses approve, not transfer, for the Universal Router (Base Sepolia)', async () => { + const baseSepoliaId = baseSepolia.id as SupportedChainId + const config: VelodromeSwapProviderConfig = { + defaultSlippage: 0.005, + marketAllowlist: [ + { assets: [USDC, WETH], stable: false, chainId: baseSepoliaId }, + ], + } + const provider = new VelodromeSwapProvider( + config, + createMockChainManager([baseSepoliaId]), + ) + + const result = await provider.execute({ + amountIn: 1, + assetIn: USDC, + assetOut: WETH, + chainId: baseSepoliaId, + walletAddress: MOCK_WALLET, + }) + + const tokenApproval = result.transactionData.tokenApproval + expect(tokenApproval).toBeDefined() + // Decode the calldata; must be `approve`, not `transfer`. + const decoded = decodeFunctionData({ + abi: erc20Abi, + data: tokenApproval!.data, + }) + expect(decoded.functionName).toBe('approve') + expect(decoded.functionName).not.toBe('transfer') + }) + it('throws for exact-output swaps', async () => { const provider = createProvider() diff --git a/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.cl.test.ts b/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.cl.test.ts index 7b19a6273..2cfb85dab 100644 --- a/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.cl.test.ts +++ b/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.cl.test.ts @@ -1,3 +1,5 @@ +import type { Hex } from 'viem' +import { decodeAbiParameters } from 'viem' import { describe, expect, it } from 'vitest' import { MockUSDCAsset, MockWETHAsset } from '@/__mocks__/MockAssets.js' @@ -39,6 +41,35 @@ describe('encodeCLSwap', () => { expect(deadline).toBe(BigInt(DEADLINE)) }) + // Regression for #438: payerIsUser must be true so the router pulls tokens via + // transferFrom against an ERC20 allowance. See encoding.v2.test.ts for context. + it('encodes V3_SWAP_EXACT_IN with payerIsUser = true', () => { + const data = encodeCLSwap({ + assetIn: MockUSDCAsset, + assetOut: MockWETHAsset, + amountInRaw: 1000000n, + amountOutMin: 400000000000000000n, + tickSpacing: 100, + recipient: RECIPIENT, + deadline: DEADLINE, + chainId: BASE_CHAIN_ID, + }) + + const { args } = decode<[Hex, Hex[], bigint]>(UNIVERSAL_ROUTER_ABI, data) + const [, inputs] = args + const [, , , , payerIsUser] = decodeAbiParameters( + [ + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'bytes' }, + { type: 'bool' }, + ], + inputs[0], + ) + expect(payerIsUser).toBe(true) + }) + it('produces different calldata than V2 universal router swap', () => { const clData = encodeCLSwap({ assetIn: MockUSDCAsset, diff --git a/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.v2.test.ts b/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.v2.test.ts index d7cb3b6a3..872f553d2 100644 --- a/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.v2.test.ts +++ b/packages/sdk/src/actions/swap/providers/velodrome/__tests__/encoding.v2.test.ts @@ -1,4 +1,5 @@ -import type { Address } from 'viem' +import type { Address, Hex } from 'viem' +import { decodeAbiParameters } from 'viem' import { describe, expect, it } from 'vitest' import { @@ -197,6 +198,39 @@ describe('encodeSwap', () => { expect(inputs).toHaveLength(1) expect(deadline).toBe(BigInt(DEADLINE)) }) + + // Regression for #438: payerIsUser must be true so the router pulls tokens via + // transferFrom against an ERC20 allowance. payerIsUser=false requires tokens to + // be pre-deposited to the router, which only works when caller batches atomically. + it('encodes V2_SWAP_EXACT_IN with payerIsUser = true', () => { + const data = encodeSwap({ + assetIn: MockUSDCAsset, + assetOut: MockWETHAsset, + amountInRaw: 1000000n, + amountOutMin: 400000000000000000n, + routerType: 'universal', + stable: false, + factoryAddress: FACTORY, + recipient: RECIPIENT, + deadline: DEADLINE, + chainId: BASE_CHAIN_ID, + }) + + const { args } = decode<[Hex, Hex[], bigint]>(UNIVERSAL_ROUTER_ABI, data) + const [, inputs] = args + const [, , , , payerIsUser] = decodeAbiParameters( + [ + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'bytes' }, + { type: 'bool' }, + { type: 'bool' }, + ], + inputs[0], + ) + expect(payerIsUser).toBe(true) + }) }) describe('router type comparison', () => { diff --git a/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/approval.ts b/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/approval.ts index c7daf84f4..ef49cea80 100644 --- a/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/approval.ts +++ b/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/approval.ts @@ -1,40 +1,22 @@ import type { Address, PublicClient } from 'viem' -import { encodeFunctionData, erc20Abi } from 'viem' -import type { VelodromeRouterType } from '@/actions/swap/providers/velodrome/config.js' import type { TransactionData } from '@/types/transaction.js' import { buildApprovalTxIfNeeded } from '@/utils/approve.js' /** - * Build a token approval or transfer transaction for swap input. + * Build a standard ERC20 approval transaction for swap input, approving only the deficit. * - * Universal Router uses a direct ERC20 transfer instead of approve+transferFrom. - * This works because smart wallet batching (4337) bundles the transfer and swap - * into a single atomic UserOperation — the router receives tokens before executing - * the swap in the same transaction. The caller must already hold the tokens. - * - * Legacy routers (v2, leaf) use standard approve, approving only the deficit. + * Returns `undefined` when the on-chain allowance already covers `amount`. Applies to + * every Velodrome/Aerodrome router type: v2 routers, leaf routers, and the Universal + * Router (which encodes `payerIsUser = true` and pulls tokens via `transferFrom`). */ export async function buildTokenApproval( token: Address, router: Address, - routerType: VelodromeRouterType, amount: bigint, owner: Address, publicClient: PublicClient, ): Promise { - if (routerType === 'universal') { - return { - to: token, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [router, amount], - }), - value: 0n, - } - } - return buildApprovalTxIfNeeded({ publicClient, token, diff --git a/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/cl.ts b/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/cl.ts index d5e7ccd80..e53abcc05 100644 --- a/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/cl.ts +++ b/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/cl.ts @@ -112,6 +112,10 @@ const V3_SWAP_EXACT_IN = 0x00 /** * Encode a V3_SWAP_EXACT_IN command for a CL/Slipstream pool on the Universal Router. * Path: encodePacked([tokenIn (20), tickSpacing as int24 (3), tokenOut (20)]) — 43 bytes. + * + * payerIsUser = true: the router pulls tokens from msg.sender via standard + * transferFrom against an existing ERC20 allowance. Works for both EOAs (sequential + * approve + execute) and smart wallets (atomic approve + execute in one UserOp). * @param params - CL swap encoding parameters * @returns Encoded calldata as hex string */ @@ -144,7 +148,7 @@ export function encodeCLSwap(params: EncodeCLSwapParams): Hex { amountInRaw, amountOutMin, path, - false, // payerIsUser — tokens pre-transferred to router + true, // payerIsUser — router pulls from msg.sender via transferFrom ], ) diff --git a/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/v2.ts b/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/v2.ts index 9547defe3..9bf1acaa8 100644 --- a/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/v2.ts +++ b/packages/sdk/src/actions/swap/providers/velodrome/encoding/routers/v2.ts @@ -192,7 +192,10 @@ export function encodeSwap(params: EncodeSwapParams): Hex { /** * Encode a V2_SWAP_EXACT_IN command for the Universal Router. * Route: encodePacked(tokenIn, stable, tokenOut) — 41 bytes per hop. - * payerIsUser = false: tokens are pre-transferred to the Router by the smart wallet. + * + * payerIsUser = true: the router pulls tokens from msg.sender via standard + * transferFrom against an existing ERC20 allowance. Works for both EOAs (sequential + * approve + execute) and smart wallets (atomic approve + execute in one UserOp). */ function encodeUniversalV2Swap( tokenIn: Address, @@ -218,7 +221,7 @@ function encodeUniversalV2Swap( params.amountInRaw, params.amountOutMin, routes, - false, // payerIsUser + true, // payerIsUser — router pulls from msg.sender via transferFrom false, // isUni — false for Velodrome/Aerodrome ], ) From 1b84a8e3de7d20b5c5f706ebc7cbb4622f600a19 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Wed, 29 Apr 2026 10:53:42 -0400 Subject: [PATCH 43/76] add cli check ci job --- .circleci/config.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 570094602..775af522b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -111,6 +111,24 @@ jobs: name: Test SDK command: cd packages/sdk && pnpm test + # CLI checks + check-cli: + docker: + - image: cimg/node:20.11 + resource_class: large + steps: + - attach_workspace: + at: . + - run: + name: TypeCheck CLI + command: cd packages/cli && pnpm typecheck + - run: + name: Lint CLI + command: cd packages/cli && pnpm lint + - run: + name: Test CLI + command: cd packages/cli && pnpm test + # Backend checks check-backend: docker: @@ -193,6 +211,11 @@ workflows: - install-and-build context: - circleci-repo-readonly-authenticated-github-token + - check-cli: + requires: + - install-and-build + context: + - circleci-repo-readonly-authenticated-github-token - check-backend: requires: - install-and-build From df8563254ac2c8bb27cad6baa70cd01ba236a19b Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 29 Apr 2026 11:25:01 -0700 Subject: [PATCH 44/76] guide cross-provider quote comparison --- packages/cli/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index 9cb6ce1b6..acf02fb07 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -35,7 +35,8 @@ actions --json wallet balance --chain base-sepolia [--provider uniswap|velodrome] [--slippage ]` - best quote (no wallet). - `actions swap quotes ...` - same flag set; returns every provider's - quote sorted best price first. + quote sorted best price first. Omit `--provider` to compare across + providers in a single call - don't fan out one call per provider. - `actions wallet address` - EOA address derived from `PRIVATE_KEY`. - `actions wallet balance [--chain | --chain-id ]` - balances per chain + asset; the chain flags are mutually exclusive. @@ -84,7 +85,9 @@ demo, fund the EOA with testnet ETH on Base Sepolia. `swap quotes`, and `wallet swap execute`. - **Provider selection** - `--provider uniswap|velodrome` forces a provider and skips routing. Omit to let the SDK pick the best - available. + available. For `swap quotes`, omitting `--provider` is also how you + compare across providers - the response includes one entry per + provider, sorted best price first. ## Presentation hints (for LLM/agent callers) From 847755fc8a4123619c7f0682923035290ef24fc7 Mon Sep 17 00:00:00 2001 From: everdred Date: Thu, 23 Apr 2026 19:20:51 -0700 Subject: [PATCH 45/76] add swap namespace and wallet execute --- packages/cli/SKILL.md | 62 ++++- packages/cli/src/__tests__/system.test.ts | 62 +++++ .../src/commands/__tests__/swapMarket.test.ts | 74 ++++++ .../commands/__tests__/swapMarkets.test.ts | 83 ++++++ .../src/commands/__tests__/swapQuote.test.ts | 242 ++++++++++++++++++ .../__tests__/walletSwapExecute.test.ts | 192 ++++++++++++++ .../cli/src/commands/actions/swap/index.ts | 72 ++++++ .../cli/src/commands/actions/swap/market.ts | 34 +++ .../cli/src/commands/actions/swap/markets.ts | 36 +++ .../cli/src/commands/actions/swap/quote.ts | 29 +++ .../cli/src/commands/actions/swap/quotes.ts | 28 ++ .../cli/src/commands/actions/swap/util.ts | 127 +++++++++ packages/cli/src/commands/wallet/index.ts | 2 + .../cli/src/commands/wallet/swap/execute.ts | 55 ++++ .../cli/src/commands/wallet/swap/index.ts | 43 ++++ packages/cli/src/demo/config.ts | 24 +- packages/cli/src/index.ts | 2 + packages/cli/src/output/printOutput.ts | 75 ++++++ 18 files changed, 1235 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/swapMarket.test.ts create mode 100644 packages/cli/src/commands/__tests__/swapMarkets.test.ts create mode 100644 packages/cli/src/commands/__tests__/swapQuote.test.ts create mode 100644 packages/cli/src/commands/__tests__/walletSwapExecute.test.ts create mode 100644 packages/cli/src/commands/actions/swap/index.ts create mode 100644 packages/cli/src/commands/actions/swap/market.ts create mode 100644 packages/cli/src/commands/actions/swap/markets.ts create mode 100644 packages/cli/src/commands/actions/swap/quote.ts create mode 100644 packages/cli/src/commands/actions/swap/quotes.ts create mode 100644 packages/cli/src/commands/actions/swap/util.ts create mode 100644 packages/cli/src/commands/wallet/swap/execute.ts create mode 100644 packages/cli/src/commands/wallet/swap/index.ts diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index cc28e7bca..6a4a94403 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -27,6 +27,16 @@ actions --json wallet balance --chain base-sepolia asset and/or one chain (no wallet). - `actions lend market --market ` - inspect one market by name (no wallet). +- `actions swap markets [--chain ]` - all swap markets across + configured providers (no wallet). +- `actions swap market --pool --chain ` - inspect one swap + market by pool id (no wallet). +- `actions swap quote --in --out + (--amount-in | --amount-out ) --chain + [--provider uniswap|velodrome] [--slippage ]` - best quote + (no wallet). +- `actions swap quotes ...` - same flag set; returns every provider's + quote sorted best price first. - `actions wallet address` - EOA address derived from `PRIVATE_KEY`. - `actions wallet balance [--chain | --chain-id ]` - balances per chain + asset; the chain flags are mutually exclusive. @@ -39,6 +49,10 @@ actions --json wallet balance --chain base-sepolia withdraw assets. Pass `--max` to withdraw the wallet's full balance in the market (the CLI fetches the position first; subject to inflight interest accrual). +- `actions wallet swap execute --in --out + (--amount-in | --amount-out ) --chain + [--provider uniswap|velodrome] [--slippage ]` - execute a swap + on the resolved chain. ## Wallet model @@ -58,13 +72,24 @@ demo, fund the EOA with testnet ETH on Base Sepolia. Both flags accept a comma-separated list to scope the SDK fan-out to multiple chains. Run `actions --json chains` for the current list. -- **Markets** - pass the market `name` from the config allowlist +- **Markets (lend)** - pass the market `name` from the config allowlist (e.g. `Gauntlet USDC`, `Aave ETH`). Case-insensitive; whitespace and hyphens are ignored, so `gauntlet-usdc` and `gauntletusdc` resolve to the same entry. The market entry carries its own chain and asset, so no `--chain` is needed. +- **Markets (swap)** - addressed pair-wise via `--in/--out/--chain` for + quotes and execution. `--pool ` is only used for direct + `swap market` lookups; the `poolId` surfaces in `swap markets`. - **Amounts** - human-readable decimal numbers (e.g. `10`, `0.5`). The SDK converts to wei using the asset's decimals. +- **Slippage** - `--slippage` accepts a percent (e.g. `0.5` for 0.5%); + the CLI converts to the SDK's decimal form internally. +- **Amount direction** - exactly one of `--amount-in` (exact-in) or + `--amount-out` (exact-out) is required for `swap quote`, + `swap quotes`, and `wallet swap execute`. +- **Provider selection** - `--provider uniswap|velodrome` forces a + provider and skips routing. Omit to let the SDK pick the best + available. ## Output @@ -137,6 +162,41 @@ NL -> command examples: - "withdraw 5 USDC from Gauntlet" -> `actions --json wallet lend close --market gauntlet-usdc --amount 5` - "how much do I have in Gauntlet" -> `actions --json wallet lend position --market gauntlet-usdc` +## Swap semantics + +`swap quote` returns the SDK `SwapQuote` shape verbatim: amounts (both +display and `Raw` bigint), price + price-impact, slippage (decimal), +deadline, and pre-built `execution` calldata. `swap quotes` is the +multi-provider variant sorted by `amountOutRaw` desc. + +`wallet swap execute` emits a structured envelope on stdout: + +```json +{ + "action": "execute", + "assetIn": { "symbol": "USDC_DEMO" }, + "assetOut": { "symbol": "OP_DEMO" }, + "amountIn": 5, "amountOut": 4.9, + "amountInRaw": "5000000", + "amountOutRaw": "4900000000000000000", + "price": 0.98, "priceImpact": 0.001, + "transactions": [ { "transactionHash": "0x...", "status": "success", ... } ] +} +``` + +`transactions` is always an array. EOA execution can fan out into +token-approval + Permit2-approval + swap (up to 3 receipts); smart +wallets collapse to a single UserOp receipt. A receipt with +`status: "reverted"` is normalised to `code: "onchain"` exit 5. + +NL -> command examples: + +- "swap 5 USDC for OP on Unichain" -> `actions --json wallet swap execute --in USDC_DEMO --out OP_DEMO --amount-in 5 --chain unichain` +- "buy 1 OP with USDC" -> `actions --json wallet swap execute --in USDC_DEMO --out OP_DEMO --amount-out 1 --chain unichain` +- "what's the best price for 100 USDC -> OP" -> `actions --json swap quote --in USDC_DEMO --out OP_DEMO --amount-in 100 --chain unichain` +- "compare provider quotes" -> `actions --json swap quotes --in USDC_DEMO --out OP_DEMO --amount-in 100 --chain unichain` +- "execute on Velodrome with 1% slippage" -> `actions --json wallet swap execute --in USDC_DEMO --out OP_DEMO --amount-in 100 --chain unichain --provider velodrome --slippage 1` + ## RPC trust `*_RPC_URL` env vars must point to operator-trusted endpoints. A diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts index 0e51574a0..44801baea 100644 --- a/packages/cli/src/__tests__/system.test.ts +++ b/packages/cli/src/__tests__/system.test.ts @@ -189,6 +189,68 @@ describe('actions CLI (built binary)', () => { expect(body.code).toBe('validation') expect(body.error).toMatch(/Invalid --amount/) }) + + it('swap quote without --amount-in or --amount-out -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run([ + '--json', + 'swap', + 'quote', + '--in', + 'USDC_DEMO', + '--out', + 'OP_DEMO', + '--chain', + 'unichain', + ]) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/--amount-in or --amount-out/) + }) + + it('swap quote with both --amount-in and --amount-out -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run([ + '--json', + 'swap', + 'quote', + '--in', + 'USDC_DEMO', + '--out', + 'OP_DEMO', + '--amount-in', + '1', + '--amount-out', + '1', + '--chain', + 'unichain', + ]) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/not both/) + }) + + it('swap quote with unknown --provider -> stderr JSON code:validation exit 2', async () => { + const { stderr, code } = await run([ + '--json', + 'swap', + 'quote', + '--in', + 'USDC_DEMO', + '--out', + 'OP_DEMO', + '--amount-in', + '1', + '--chain', + 'unichain', + '--provider', + 'sushiswap', + ]) + expect(code).toBe(2) + const body = JSON.parse(stderr) + expect(body.code).toBe('validation') + expect(body.error).toMatch(/Invalid --provider/) + }) }) describe('default (human) mode', () => { diff --git a/packages/cli/src/commands/__tests__/swapMarket.test.ts b/packages/cli/src/commands/__tests__/swapMarket.test.ts new file mode 100644 index 000000000..4acff9166 --- /dev/null +++ b/packages/cli/src/commands/__tests__/swapMarket.test.ts @@ -0,0 +1,74 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runSwapMarket } from '@/commands/actions/swap/market.js' +import * as baseCtx from '@/context/baseContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +describe('runSwapMarket', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getMarket: (params: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getMarket } } as never, + }) + } + + it('looks up the market with the resolved chainId and pool', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return { + marketId: params, + assets: [ + { metadata: { symbol: 'USDC_DEMO' } }, + { metadata: { symbol: 'OP_DEMO' } }, + ], + fee: 100, + provider: 'uniswap', + } + }) + await runSwapMarket({ pool: '0xpool', chain: 'unichain' }) + expect(captured[0]).toEqual({ poolId: '0xpool', chainId: 130 }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.provider).toBe('uniswap') + }) + + it('rejects unknown --chain values with CliError(validation)', async () => { + mockActions(async () => ({})) + try { + await runSwapMarket({ pool: '0x', chain: 'no-such-chain' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('maps RPC failures to CliError(network)', async () => { + mockActions(async () => { + throw new Error('fetch failed') + }) + try { + await runSwapMarket({ pool: '0x', chain: 'unichain' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + } + }) +}) diff --git a/packages/cli/src/commands/__tests__/swapMarkets.test.ts b/packages/cli/src/commands/__tests__/swapMarkets.test.ts new file mode 100644 index 000000000..b4a39c1e2 --- /dev/null +++ b/packages/cli/src/commands/__tests__/swapMarkets.test.ts @@ -0,0 +1,83 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runSwapMarkets } from '@/commands/actions/swap/markets.js' +import * as baseCtx from '@/context/baseContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +describe('runSwapMarkets', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getMarkets: (params?: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getMarkets } } as never, + }) + } + + it('emits the array of markets', async () => { + mockActions(async () => [ + { + marketId: { poolId: '0xpool', chainId: 130 }, + assets: [ + { metadata: { symbol: 'USDC_DEMO' } }, + { metadata: { symbol: 'OP_DEMO' } }, + ], + fee: 100, + provider: 'uniswap', + }, + ]) + await runSwapMarkets() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toHaveLength(1) + expect(body[0].provider).toBe('uniswap') + expect(body[0].marketId.poolId).toBe('0xpool') + }) + + it('forwards --chain to the SDK after resolution', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return [] + }) + await runSwapMarkets({ chain: 'unichain' }) + expect(captured[0]).toEqual({ chainId: 130 }) + }) + + it('rejects unknown --chain values with CliError(validation)', async () => { + mockActions(async () => []) + try { + await runSwapMarkets({ chain: 'no-such-chain' }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('maps RPC failures to CliError(network)', async () => { + mockActions(async () => { + throw new Error('HTTP request failed') + }) + try { + await runSwapMarkets() + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + } + }) +}) diff --git a/packages/cli/src/commands/__tests__/swapQuote.test.ts b/packages/cli/src/commands/__tests__/swapQuote.test.ts new file mode 100644 index 000000000..99a9041ce --- /dev/null +++ b/packages/cli/src/commands/__tests__/swapQuote.test.ts @@ -0,0 +1,242 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runSwapQuote } from '@/commands/actions/swap/quote.js' +import { runSwapQuotes } from '@/commands/actions/swap/quotes.js' +import * as baseCtx from '@/context/baseContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +const stubQuote = (provider: string, amountOutRaw: bigint) => ({ + assetIn: { metadata: { symbol: 'USDC_DEMO' } }, + assetOut: { metadata: { symbol: 'OP_DEMO' } }, + chainId: 130, + amountIn: 5, + amountInRaw: 5000000n, + amountOut: 4.9, + amountOutRaw, + amountOutMin: 4.85, + amountOutMinRaw: 4850000000000000000n, + price: 0.98, + priceInverse: 1.02, + priceImpact: 0.001, + route: { hops: [] }, + execution: { swapCalldata: '0x', routerAddress: '0xrouter', value: 0n }, + provider, + slippage: 0.005, + deadline: 1, + quotedAt: 1, + expiresAt: 2, + quotedRecipient: '0xrecipient', +}) + +describe('runSwapQuote', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getQuote: (params: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getQuote } } as never, + }) + } + + it('builds quote params from --in/--out/--amount-in/--chain and stringifies bigints', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return stubQuote('uniswap', 4900000000000000000n) + }) + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '5', + chain: 'unichain', + }) + const call = captured[0] as { + assetIn: { metadata: { symbol: string } } + assetOut: { metadata: { symbol: string } } + chainId: number + amountIn?: number + amountOut?: number + slippage?: number + provider?: string + } + expect(call.assetIn.metadata.symbol).toBe('USDC_DEMO') + expect(call.assetOut.metadata.symbol).toBe('OP_DEMO') + expect(call.chainId).toBe(130) + expect(call.amountIn).toBe(5) + expect(call.amountOut).toBeUndefined() + expect(call.slippage).toBeUndefined() + expect(call.provider).toBeUndefined() + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.amountOutRaw).toBe('4900000000000000000') + expect(body.provider).toBe('uniswap') + }) + + it('converts --slippage percent to SDK decimal', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return stubQuote('uniswap', 1n) + }) + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + slippage: '0.5', + }) + const call = captured[0] as { slippage: number } + expect(call.slippage).toBeCloseTo(0.005, 12) + }) + + it('forwards --provider when supplied', async () => { + const captured: unknown[] = [] + mockActions(async (params) => { + captured.push(params) + return stubQuote('velodrome', 1n) + }) + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + provider: 'velodrome', + }) + const call = captured[0] as { provider: string } + expect(call.provider).toBe('velodrome') + }) + + it('rejects when both --amount-in and --amount-out are set', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + amountOut: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects when neither --amount-in nor --amount-out is set', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects unknown asset symbols with CliError(validation)', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'NOPE', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects unknown providers with CliError(validation)', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + provider: 'sushiswap', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects out-of-range slippage with CliError(validation)', async () => { + mockActions(async () => stubQuote('uniswap', 1n)) + for (const bad of ['-1', '101', 'foo']) { + try { + await runSwapQuote({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + slippage: bad, + }) + throw new Error(`did not throw for ${bad}`) + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + } + }) +}) + +describe('runSwapQuotes', () => { + let writeSpy: MockInstance + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockActions = (getQuotes: (params: unknown) => Promise) => { + vi.spyOn(baseCtx, 'baseContext').mockReturnValue({ + config: getDemoConfig(), + actions: { swap: { getQuotes } } as never, + }) + } + + it('emits an array of quotes verbatim', async () => { + mockActions(async () => [ + stubQuote('uniswap', 5000000000000000000n), + stubQuote('velodrome', 4800000000000000000n), + ]) + await runSwapQuotes({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '5', + chain: 'unichain', + }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body).toHaveLength(2) + expect(body[0].provider).toBe('uniswap') + expect(body[1].provider).toBe('velodrome') + expect(body[0].amountOutRaw).toBe('5000000000000000000') + }) +}) diff --git a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts new file mode 100644 index 000000000..3ecba7df3 --- /dev/null +++ b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts @@ -0,0 +1,192 @@ +import type { MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runWalletSwapExecute } from '@/commands/wallet/swap/execute.js' +import { __resetEnvCacheForTests } from '@/config/env.js' +import * as walletCtx from '@/context/walletContext.js' +import { getDemoConfig } from '@/demo/config.js' +import { CliError } from '@/output/errors.js' +import { setJsonMode } from '@/output/mode.js' + +beforeEach(() => setJsonMode(true)) +afterEach(() => setJsonMode(false)) + +const ANVIL_ACCOUNT_0 = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +const successReceipt = (hash: string) => ({ + transactionHash: hash, + status: 'success' as const, + blockNumber: 9n, + gasUsed: 80000n, +}) + +const stubResult = (receipt: unknown) => ({ + receipt, + amountIn: 5, + amountOut: 4.9, + amountInRaw: 5000000n, + amountOutRaw: 4900000000000000000n, + assetIn: { metadata: { symbol: 'USDC_DEMO' } }, + assetOut: { metadata: { symbol: 'OP_DEMO' } }, + price: 0.98, + priceImpact: 0.001, +}) + +describe('runWalletSwapExecute', () => { + const originalEnv = process.env + let writeSpy: MockInstance + + beforeEach(() => { + process.env = { ...originalEnv, PRIVATE_KEY: ANVIL_ACCOUNT_0 } + __resetEnvCacheForTests() + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + process.env = originalEnv + __resetEnvCacheForTests() + vi.restoreAllMocks() + }) + + const mockWallet = ( + execute: (params: unknown) => Promise, + withSwap = true, + ) => { + vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ + config: getDemoConfig(), + actions: {} as never, + signer: {} as never, + wallet: { + address: '0xabc', + swap: withSwap ? { execute } : undefined, + } as never, + }) + } + + it('emits a structured envelope with normalised array of receipts', async () => { + const captured: unknown[] = [] + mockWallet(async (params) => { + captured.push(params) + return stubResult([successReceipt('0xapprove'), successReceipt('0xswap')]) + }) + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '5', + chain: 'unichain', + }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.action).toBe('execute') + expect(body.assetIn.symbol).toBe('USDC_DEMO') + expect(body.assetOut.symbol).toBe('OP_DEMO') + expect(body.amountInRaw).toBe('5000000') + expect(body.amountOutRaw).toBe('4900000000000000000') + expect(body.transactions).toHaveLength(2) + expect(body.transactions[0].transactionHash).toBe('0xapprove') + const call = captured[0] as { chainId: number } + expect(call.chainId).toBe(130) + }) + + it('wraps a single receipt into a one-element array', async () => { + mockWallet(async () => stubResult(successReceipt('0xonly'))) + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountOut: '5', + chain: 'unichain', + }) + const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) + expect(body.transactions).toHaveLength(1) + }) + + it('maps reverted receipts to CliError(onchain)', async () => { + mockWallet(async () => + stubResult([ + successReceipt('0xapprove'), + { ...successReceipt('0xrevert'), status: 'reverted' as const }, + ]), + ) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('onchain') + } + }) + + it('maps RPC failures to CliError(network) and marks them retryable', async () => { + mockWallet(async () => { + throw new Error('HTTP request failed. Status: ECONNREFUSED') + }) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('network') + expect((err as CliError).retryable).toBe(true) + } + }) + + it('rejects when both --amount-in and --amount-out are set', async () => { + mockWallet(async () => stubResult(successReceipt('0x'))) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + amountOut: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('validation') + } + }) + + it('rejects with CliError(config) when wallet.swap is undefined', async () => { + mockWallet(async () => stubResult(successReceipt('0x')), false) + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) + + it('rejects with CliError(config) when PRIVATE_KEY is missing', async () => { + delete process.env.PRIVATE_KEY + __resetEnvCacheForTests() + try { + await runWalletSwapExecute({ + in: 'USDC_DEMO', + out: 'OP_DEMO', + amountIn: '1', + chain: 'unichain', + }) + throw new Error('did not throw') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).code).toBe('config') + } + }) +}) diff --git a/packages/cli/src/commands/actions/swap/index.ts b/packages/cli/src/commands/actions/swap/index.ts new file mode 100644 index 000000000..df70ec103 --- /dev/null +++ b/packages/cli/src/commands/actions/swap/index.ts @@ -0,0 +1,72 @@ +import { Command } from 'commander' + +import { runSwapMarket } from '@/commands/actions/swap/market.js' +import { runSwapMarkets } from '@/commands/actions/swap/markets.js' +import { runSwapQuote } from '@/commands/actions/swap/quote.js' +import { runSwapQuotes } from '@/commands/actions/swap/quotes.js' + +const QUOTE_OPTIONS_HELP = { + in: ['--in ', 'token to sell (e.g. USDC_DEMO)'], + out: ['--out ', 'token to buy (e.g. OP_DEMO)'], + amountIn: [ + '--amount-in ', + 'exact-in amount (mutually exclusive with --amount-out)', + ], + amountOut: [ + '--amount-out ', + 'exact-out amount (mutually exclusive with --amount-in)', + ], + chain: ['--chain ', 'chain shortname (e.g. unichain, op-sepolia)'], + provider: [ + '--provider ', + 'force a provider: uniswap or velodrome (omit to let routing decide)', + ], + slippage: [ + '--slippage ', + 'slippage tolerance as a percent (e.g. 0.5 for 0.5%)', + ], +} as const + +function addQuoteOptions(cmd: Command): Command { + return cmd + .requiredOption(...QUOTE_OPTIONS_HELP.in) + .requiredOption(...QUOTE_OPTIONS_HELP.out) + .option(...QUOTE_OPTIONS_HELP.amountIn) + .option(...QUOTE_OPTIONS_HELP.amountOut) + .requiredOption(...QUOTE_OPTIONS_HELP.chain) + .option(...QUOTE_OPTIONS_HELP.provider) + .option(...QUOTE_OPTIONS_HELP.slippage) +} + +/** + * @description Builds the root `swap` subcommand tree. Children read + * markets and price quotes with no signer; wallet-scoped execution + * lives under `wallet swap`. + * @returns Commander `Command` configured with `markets`, `market`, + * `quote`, and `quotes`. + */ +export function swapCommand(): Command { + const command = new Command('swap').description( + 'Read-only swap market + quote commands (no PRIVATE_KEY required).', + ) + command + .command('markets') + .description('List swap markets across configured providers.') + .option('--chain ', 'filter to a single chain by shortname') + .action(runSwapMarkets) + command + .command('market') + .description('Inspect one swap market by pool id and chain.') + .requiredOption('--pool ', 'pool identifier (keccak256 of PoolKey)') + .requiredOption('--chain ', 'chain shortname (e.g. unichain)') + .action(runSwapMarket) + addQuoteOptions( + command.command('quote').description('Get the best swap quote.'), + ).action(runSwapQuote) + addQuoteOptions( + command + .command('quotes') + .description('Get every available provider quote, best price first.'), + ).action(runSwapQuotes) + return command +} diff --git a/packages/cli/src/commands/actions/swap/market.ts b/packages/cli/src/commands/actions/swap/market.ts new file mode 100644 index 000000000..f5c62e528 --- /dev/null +++ b/packages/cli/src/commands/actions/swap/market.ts @@ -0,0 +1,34 @@ +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveChain } from '@/resolvers/chains.js' + +export interface SwapMarketFlags { + pool: string + chain: string +} + +/** + * @description Handler for `actions swap market --pool --chain `. + * Resolves the chain shortname against the config, then queries every + * provider in turn until one returns a matching market (the SDK + * iterates internally). Read-only, no signer needed. + * @param flags - Commander-parsed required options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapMarket(flags: SwapMarketFlags): Promise { + const { actions, config } = baseContext() + const chainId = resolveChain( + flags.chain, + config.chains.map((c) => c.chainId), + ) + try { + const market = await actions.swap.getMarket({ + poolId: flags.pool, + chainId, + }) + printOutput('swapMarket', market) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/actions/swap/markets.ts b/packages/cli/src/commands/actions/swap/markets.ts new file mode 100644 index 000000000..d8b6253ce --- /dev/null +++ b/packages/cli/src/commands/actions/swap/markets.ts @@ -0,0 +1,36 @@ +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' +import { resolveChain } from '@/resolvers/chains.js' + +export interface SwapMarketsFlags { + chain?: string +} + +/** + * @description Handler for `actions swap markets [--chain ]`. + * Aggregates markets across every configured swap provider. The + * optional `--chain` filter is forwarded to the SDK so it can prune + * before iterating provider markets. + * @param flags - Commander-parsed options; `--chain` optional. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapMarkets( + flags: SwapMarketsFlags = {}, +): Promise { + const { actions, config } = baseContext() + const chainId = flags.chain + ? resolveChain( + flags.chain, + config.chains.map((c) => c.chainId), + ) + : undefined + try { + const markets = await actions.swap.getMarkets( + chainId ? { chainId } : undefined, + ) + printOutput('swapMarkets', markets) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/actions/swap/quote.ts b/packages/cli/src/commands/actions/swap/quote.ts new file mode 100644 index 000000000..a4136293d --- /dev/null +++ b/packages/cli/src/commands/actions/swap/quote.ts @@ -0,0 +1,29 @@ +import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' + +/** + * @description Handler for + * `actions swap quote --in --out + * (--amount-in | --amount-out ) --chain + * [--provider uniswap|velodrome] [--slippage ]`. + * Returns one `SwapQuote` (best price by default; explicit `--provider` + * skips routing). Read-only. + * @param flags - Commander-parsed required + optional options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapQuote(flags: QuoteFlags): Promise { + const { actions, config } = baseContext() + const params = buildQuoteParams( + flags, + config.assets?.allow ?? [], + config.chains.map((c) => c.chainId), + ) + try { + const quote = await actions.swap.getQuote(params) + printOutput('swapQuote', quote) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/actions/swap/quotes.ts b/packages/cli/src/commands/actions/swap/quotes.ts new file mode 100644 index 000000000..d85e181fe --- /dev/null +++ b/packages/cli/src/commands/actions/swap/quotes.ts @@ -0,0 +1,28 @@ +import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' +import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { baseContext } from '@/context/baseContext.js' +import { printOutput } from '@/output/printOutput.js' + +/** + * @description Handler for `actions swap quotes ...`. Same flag set as + * `swap quote` but returns every successful provider quote sorted by + * `amountOutRaw` desc (best price first). When `--provider` is set the + * SDK still returns a one-element array so the caller can branch + * uniformly. + * @param flags - Commander-parsed required + optional options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runSwapQuotes(flags: QuoteFlags): Promise { + const { actions, config } = baseContext() + const params = buildQuoteParams( + flags, + config.assets?.allow ?? [], + config.chains.map((c) => c.chainId), + ) + try { + const quotes = await actions.swap.getQuotes(params) + printOutput('swapQuotes', quotes) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/actions/swap/util.ts b/packages/cli/src/commands/actions/swap/util.ts new file mode 100644 index 000000000..a8c8f9d37 --- /dev/null +++ b/packages/cli/src/commands/actions/swap/util.ts @@ -0,0 +1,127 @@ +import type { + Asset, + SupportedChainId, + SwapProviderName, + SwapQuoteParams, +} from '@eth-optimism/actions-sdk' + +import { parseAmount } from '@/commands/wallet/lend/util.js' +import { CliError } from '@/output/errors.js' +import { resolveAsset } from '@/resolvers/assets.js' +import { resolveChain } from '@/resolvers/chains.js' + +const PROVIDERS: readonly SwapProviderName[] = ['uniswap', 'velodrome'] + +/** + * @description Validates that exactly one of `--amount-in` / `--amount-out` + * is present and parses it to a positive number. Throws + * `CliError('validation')` when both are provided or neither is. + * @param amountIn - Raw `--amount-in` flag value. + * @param amountOut - Raw `--amount-out` flag value. + * @returns One-sided amount envelope with the other field undefined. + */ +export function parseAmountFlags( + amountIn: string | undefined, + amountOut: string | undefined, +): { amountIn?: number; amountOut?: number } { + if (!amountIn && !amountOut) { + throw new CliError( + 'validation', + 'One of --amount-in or --amount-out is required', + ) + } + if (amountIn && amountOut) { + throw new CliError( + 'validation', + 'Pass either --amount-in or --amount-out, not both', + ) + } + return amountIn + ? { amountIn: parseAmount(amountIn) } + : { amountOut: parseAmount(amountOut!) } +} + +/** + * @description Parses a `--slippage ` value. Accepts a percent + * literal (e.g. `0.5` = 0.5%) and converts to the decimal form the SDK + * expects (e.g. `0.005`). `100` is the upper bound. + * @param raw - Flag value as passed on argv, or undefined. + * @returns Decimal slippage in `[0, 1]` when provided, else undefined. + * @throws `CliError` with code `validation` when not a number in `[0, 100]`. + */ +export function parseSlippage(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined + const value = Number(raw) + if (!Number.isFinite(value) || value < 0 || value > 100) { + throw new CliError( + 'validation', + `Invalid --slippage: ${raw} (expected a percent in [0, 100])`, + { slippage: raw }, + ) + } + return value / 100 +} + +/** + * @description Parses a `--provider` value against the configured + * provider names. Returns `undefined` when not supplied, letting the SDK + * apply its routing config instead. + * @param raw - Flag value as passed on argv, or undefined. + * @returns `SwapProviderName` when recognised, otherwise undefined. + * @throws `CliError` with code `validation` for any other value. + */ +export function parseProvider( + raw: string | undefined, +): SwapProviderName | undefined { + if (raw === undefined) return undefined + const needle = raw.toLowerCase() as SwapProviderName + if (!PROVIDERS.includes(needle)) { + throw new CliError( + 'validation', + `Invalid --provider: ${raw} (expected one of ${PROVIDERS.join(', ')})`, + { provider: raw, allowed: PROVIDERS.slice() }, + ) + } + return needle +} + +export interface QuoteFlags { + in: string + out: string + amountIn?: string + amountOut?: string + chain: string + provider?: string + slippage?: string +} + +/** + * @description Builds a `SwapQuoteParams` object from the CLI flag set + * shared by `quote`, `quotes`, and `execute`. Validates the assets and + * chain are in the active config, enforces the amount-in/out XOR, and + * converts the percent slippage to decimal. + * @param flags - Commander-parsed flags. + * @param allow - Asset allowlist from config. + * @param chainIds - Configured chain IDs. + * @returns Resolved quote parameters ready for the SDK. + */ +export function buildQuoteParams( + flags: QuoteFlags, + allow: readonly Asset[], + chainIds: readonly SupportedChainId[], +): SwapQuoteParams { + const assetIn = resolveAsset(flags.in, allow) + const assetOut = resolveAsset(flags.out, allow) + const chainId = resolveChain(flags.chain, chainIds) + const amounts = parseAmountFlags(flags.amountIn, flags.amountOut) + const provider = parseProvider(flags.provider) + const slippage = parseSlippage(flags.slippage) + return { + assetIn, + assetOut, + chainId, + ...amounts, + ...(provider ? { provider } : {}), + ...(slippage !== undefined ? { slippage } : {}), + } +} diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts index d1a90b634..72790c323 100644 --- a/packages/cli/src/commands/wallet/index.ts +++ b/packages/cli/src/commands/wallet/index.ts @@ -3,6 +3,7 @@ import { Command } from 'commander' import { runWalletAddress } from '@/commands/wallet/address.js' import { runWalletBalance } from '@/commands/wallet/balance.js' import { walletLendCommand } from '@/commands/wallet/lend/index.js' +import { walletSwapCommand } from '@/commands/wallet/swap/index.js' /** * @description Builds the `wallet` subcommand tree. Registered children @@ -30,5 +31,6 @@ export function walletCommand(): Command { ) .action(runWalletBalance) command.addCommand(walletLendCommand()) + command.addCommand(walletSwapCommand()) return command } diff --git a/packages/cli/src/commands/wallet/swap/execute.ts b/packages/cli/src/commands/wallet/swap/execute.ts new file mode 100644 index 000000000..c348469bc --- /dev/null +++ b/packages/cli/src/commands/wallet/swap/execute.ts @@ -0,0 +1,55 @@ +import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' +import { + ensureOnchainSuccess, + rethrowAsCliError, + toReceiptArray, +} from '@/commands/wallet/lend/util.js' +import { walletContext } from '@/context/walletContext.js' +import { CliError } from '@/output/errors.js' +import { printOutput } from '@/output/printOutput.js' + +/** + * @description Handler for `actions wallet swap execute --in + * --out (--amount-in | --amount-out ) --chain + * [--slippage ] [--provider uniswap|velodrome]`. Builds a + * `WalletSwapParams` from CLI flags and delegates to + * `wallet.swap.execute`, which re-quotes, dispatches Permit2 / token + * approval + swap as a sendBatch, and waits for receipts. The CLI + * normalises the union receipt type to an array, surfaces reverts as + * `onchain` (exit 5), and emits a structured envelope. + * @param flags - Commander-parsed required + optional options. + * @returns Promise that resolves once stdout has been written. + */ +export async function runWalletSwapExecute(flags: QuoteFlags): Promise { + const { wallet, config } = await walletContext() + if (!wallet.swap) { + throw new CliError( + 'config', + 'Swap is not configured (no providers in config.swap)', + ) + } + const params = buildQuoteParams( + flags, + config.assets?.allow ?? [], + config.chains.map((c) => c.chainId), + ) + try { + const result = await wallet.swap.execute(params) + const receipts = toReceiptArray(result.receipt) + ensureOnchainSuccess(receipts) + printOutput('swapExecute', { + action: 'execute', + assetIn: { symbol: result.assetIn.metadata.symbol }, + assetOut: { symbol: result.assetOut.metadata.symbol }, + amountIn: result.amountIn, + amountOut: result.amountOut, + amountInRaw: result.amountInRaw, + amountOutRaw: result.amountOutRaw, + price: result.price, + priceImpact: result.priceImpact, + transactions: receipts, + }) + } catch (err) { + rethrowAsCliError(err) + } +} diff --git a/packages/cli/src/commands/wallet/swap/index.ts b/packages/cli/src/commands/wallet/swap/index.ts new file mode 100644 index 000000000..b0d6614d5 --- /dev/null +++ b/packages/cli/src/commands/wallet/swap/index.ts @@ -0,0 +1,43 @@ +import { Command } from 'commander' + +import { runWalletSwapExecute } from '@/commands/wallet/swap/execute.js' + +/** + * @description Builds the `wallet swap` subcommand tree. Read-only + * `markets`, `market`, `quote`, `quotes` aliases live on the root + * `actions swap` tree to avoid forcing PRIVATE_KEY for purely public + * reads. The wallet tree exposes only `execute`. + * @returns Commander `Command` configured with `execute`. + */ +export function walletSwapCommand(): Command { + const command = new Command('swap').description( + 'Execute swaps from the EOA derived from PRIVATE_KEY.', + ) + command + .command('execute') + .description('Execute a swap on a configured chain.') + .requiredOption('--in ', 'token to sell (e.g. USDC_DEMO)') + .requiredOption('--out ', 'token to buy (e.g. OP_DEMO)') + .option( + '--amount-in ', + 'exact-in amount (mutually exclusive with --amount-out)', + ) + .option( + '--amount-out ', + 'exact-out amount (mutually exclusive with --amount-in)', + ) + .requiredOption( + '--chain ', + 'chain shortname (e.g. unichain, op-sepolia)', + ) + .option( + '--provider ', + 'force a provider: uniswap or velodrome (omit to let routing decide)', + ) + .option( + '--slippage ', + 'slippage tolerance as a percent (e.g. 0.5 for 0.5%)', + ) + .action(runWalletSwapExecute) + return command +} diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts index f7ebc46cb..a17880a4c 100644 --- a/packages/cli/src/demo/config.ts +++ b/packages/cli/src/demo/config.ts @@ -10,12 +10,12 @@ import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' /** * @description Returns the baked demo `NodeActionsConfig` the CLI boots - * against. Mirrors `packages/demo/backend/src/config/actions.ts` in asset - * and market set so CLI behaviour stays aligned with the demo backend. - * Divergences: `hostedWalletConfig` is omitted (the CLI uses an EOA-backed - * wallet via `actions.wallet.toActionsWallet(localAccount)`); `swap` is - * omitted; chain bundlers are omitted (no ERC-4337 gas abstraction - the - * signer pays gas directly). + * against. Mirrors `packages/demo/backend/src/config/actions.ts` in + * asset, lend, and swap allowlists so CLI behaviour stays aligned with + * the demo backend. Divergences: `hostedWalletConfig` is omitted (the + * CLI uses an EOA-backed wallet via + * `actions.wallet.toActionsWallet(localAccount)`); chain bundlers are + * omitted (no ERC-4337 gas abstraction - the signer pays gas directly). * @returns `NodeActionsConfig` with no hosted wallet provider configured. */ export function getDemoConfig(): NodeActionsConfig { @@ -29,6 +29,18 @@ export function getDemoConfig(): NodeActionsConfig { morpho: { marketAllowlist: [GauntletUSDCDemo] }, aave: { marketAllowlist: [AaveETH] }, }, + swap: { + uniswap: { + defaultSlippage: 0.005, + marketAllowlist: [ + { assets: [USDC_DEMO, OP_DEMO], fee: 100, tickSpacing: 2 }, + ], + }, + velodrome: { + defaultSlippage: 0.005, + marketAllowlist: [{ assets: [USDC_DEMO, OP_DEMO], stable: false }], + }, + }, assets: { allow: [USDC_DEMO, OP_DEMO, ETH] }, chains: getDemoChains(), } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e33ea0330..29955b3b2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import pico from 'picocolors' import { runAssets } from '@/commands/actions/assets.js' import { runChains } from '@/commands/actions/chains.js' import { lendCommand } from '@/commands/actions/lend/index.js' +import { swapCommand } from '@/commands/actions/swap/index.js' import { walletCommand } from '@/commands/wallet/index.js' import { isEpipeError, writeError } from '@/output/errors.js' import { setJsonMode } from '@/output/mode.js' @@ -51,6 +52,7 @@ program .action(runChains) program.addCommand(lendCommand()) +program.addCommand(swapCommand()) program.addCommand(walletCommand()) program.parseAsync(process.argv).catch(writeError) diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts index 85c518ccc..321231124 100644 --- a/packages/cli/src/output/printOutput.ts +++ b/packages/cli/src/output/printOutput.ts @@ -3,6 +3,8 @@ import type { LendMarket, LendMarketPosition, SupportedChainId, + SwapMarket, + SwapQuote, TokenBalance, } from '@eth-optimism/actions-sdk' import type { Address } from 'viem' @@ -38,6 +40,21 @@ export interface LendActionDoc { transactions: readonly WalletTransactionReceipt[] } +export interface SwapExecuteDoc { + action: 'execute' + assetIn: { symbol: string } + assetOut: { symbol: string } + amountIn: number + amountOut: number + amountInRaw: bigint + amountOutRaw: bigint + price: number + priceImpact: number + transactions: ReadonlyArray< + EOATransactionReceipt | UserOperationTransactionReceipt + > +} + interface Printers { assets: readonly Asset[] chains: readonly ChainRow[] @@ -47,6 +64,11 @@ interface Printers { lendMarkets: readonly LendMarket[] lendMarket: LendMarket lendPosition: LendMarketPosition + swapMarkets: readonly SwapMarket[] + swapMarket: SwapMarket + swapQuote: SwapQuote + swapQuotes: readonly SwapQuote[] + swapExecute: SwapExecuteDoc } function formatAssets(assets: Printers['assets']): void { @@ -141,6 +163,54 @@ function formatLendPosition(p: LendMarketPosition): void { ) } +function formatSwapMarket(m: SwapMarket): void { + const [a, b] = m.assets + writeLine( + `${a.metadata.symbol}/${b.metadata.symbol} pool=${m.marketId.poolId} chain=${m.marketId.chainId} provider=${m.provider} fee=${m.fee}`, + ) +} + +function formatSwapMarkets(markets: readonly SwapMarket[]): void { + if (markets.length === 0) { + writeLine('(no markets)') + return + } + for (const m of markets) formatSwapMarket(m) +} + +function formatSwapQuote(q: SwapQuote): void { + writeLine( + `${q.amountIn} ${q.assetIn.metadata.symbol} -> ${q.amountOut} ${q.assetOut.metadata.symbol} (provider=${q.provider}, chain=${q.chainId})`, + ) + writeLine( + ` price=${q.price} priceImpact=${(q.priceImpact * 100).toFixed(3)}% slippage=${(q.slippage * 100).toFixed(3)}%`, + ) + writeLine(` amountOutMin=${q.amountOutMin} expiresAt=${q.expiresAt}`) +} + +function formatSwapQuotes(quotes: readonly SwapQuote[]): void { + if (quotes.length === 0) { + writeLine('(no quotes)') + return + } + for (const q of quotes) formatSwapQuote(q) +} + +function formatSwapExecute(doc: SwapExecuteDoc): void { + writeLine( + `swapped ${doc.amountIn} ${doc.assetIn.symbol} for ${doc.amountOut} ${doc.assetOut.symbol} (price=${doc.price})`, + ) + for (const tx of doc.transactions) { + if ('transactionHash' in tx) { + writeLine(` tx=${tx.transactionHash} status=${tx.status}`) + } else { + const userOpHash = (tx as { userOpHash?: string }).userOpHash ?? '?' + const success = (tx as { success?: boolean }).success + writeLine(` userOp=${userOpHash} success=${success}`) + } + } +} + const TEXT_FORMATTERS: { [K in keyof Printers]: (data: Printers[K]) => void } = { @@ -152,6 +222,11 @@ const TEXT_FORMATTERS: { lendMarkets: formatLendMarkets, lendMarket: formatLendMarket, lendPosition: formatLendPosition, + swapMarkets: formatSwapMarkets, + swapMarket: formatSwapMarket, + swapQuote: formatSwapQuote, + swapQuotes: formatSwapQuotes, + swapExecute: formatSwapExecute, } /** From 3129ca9cc99920eeab54ee5275edcc9f1d597481 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Fri, 24 Apr 2026 12:22:34 -0400 Subject: [PATCH 46/76] add presentation hints for llm callers --- packages/cli/SKILL.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index 6a4a94403..41f6ab255 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -91,6 +91,27 @@ demo, fund the EOA with testnet ETH on Base Sepolia. provider and skips routing. Omit to let the SDK pick the best available. +## Presentation hints (for LLM/agent callers) + +These are rules for rendering CLI output to humans, not rules for the +CLI itself. + +- **Chain labels - only when disambiguating.** When showing a list + (balances, markets, positions, pools), mention the chain only for + entries that share their name/symbol/market with another entry on a + different chain in the same response. If every row is uniquely + identifiable by its name alone, drop the chain label. Count chain + occurrences **after** skipping zero balances. Example: two chains + in the raw payload, but only one has a non-zero balance of `X` - + render as `X ` with no chain. When the user explicitly scopes + a question to one chain, still omit the label. +- **Zero rows - skip.** Don't render zero balances, empty positions, + or pools with no meaningful data, unless the user specifically asked + about that zero value ("do I have any X on op-sepolia"). +- **Raw addresses - omit by default.** Wallet/pool/market/contract + addresses in a listing add noise. Show them only when the user asks + for them explicitly, and even then truncate (`0xabc…def`). + ## Output With `--json`: From 7df35175a954cfbefacf589ea1ab1828a60c8af5 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 14:56:20 -0400 Subject: [PATCH 47/76] rebase fixups: import paths and types --- packages/cli/src/commands/actions/swap/market.ts | 2 +- packages/cli/src/commands/actions/swap/markets.ts | 2 +- packages/cli/src/commands/actions/swap/quote.ts | 2 +- packages/cli/src/commands/actions/swap/quotes.ts | 2 +- packages/cli/src/commands/actions/swap/util.ts | 2 +- packages/cli/src/commands/wallet/swap/execute.ts | 7 ++----- packages/cli/src/output/printOutput.ts | 4 +--- 7 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/actions/swap/market.ts b/packages/cli/src/commands/actions/swap/market.ts index f5c62e528..75d57910b 100644 --- a/packages/cli/src/commands/actions/swap/market.ts +++ b/packages/cli/src/commands/actions/swap/market.ts @@ -1,4 +1,4 @@ -import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { rethrowAsCliError } from '@/output/errors.js' import { baseContext } from '@/context/baseContext.js' import { printOutput } from '@/output/printOutput.js' import { resolveChain } from '@/resolvers/chains.js' diff --git a/packages/cli/src/commands/actions/swap/markets.ts b/packages/cli/src/commands/actions/swap/markets.ts index d8b6253ce..b00108151 100644 --- a/packages/cli/src/commands/actions/swap/markets.ts +++ b/packages/cli/src/commands/actions/swap/markets.ts @@ -1,4 +1,4 @@ -import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { rethrowAsCliError } from '@/output/errors.js' import { baseContext } from '@/context/baseContext.js' import { printOutput } from '@/output/printOutput.js' import { resolveChain } from '@/resolvers/chains.js' diff --git a/packages/cli/src/commands/actions/swap/quote.ts b/packages/cli/src/commands/actions/swap/quote.ts index a4136293d..3e106acfd 100644 --- a/packages/cli/src/commands/actions/swap/quote.ts +++ b/packages/cli/src/commands/actions/swap/quote.ts @@ -1,5 +1,5 @@ import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' -import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { rethrowAsCliError } from '@/output/errors.js' import { baseContext } from '@/context/baseContext.js' import { printOutput } from '@/output/printOutput.js' diff --git a/packages/cli/src/commands/actions/swap/quotes.ts b/packages/cli/src/commands/actions/swap/quotes.ts index d85e181fe..0d22d0743 100644 --- a/packages/cli/src/commands/actions/swap/quotes.ts +++ b/packages/cli/src/commands/actions/swap/quotes.ts @@ -1,5 +1,5 @@ import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' -import { rethrowAsCliError } from '@/commands/wallet/lend/util.js' +import { rethrowAsCliError } from '@/output/errors.js' import { baseContext } from '@/context/baseContext.js' import { printOutput } from '@/output/printOutput.js' diff --git a/packages/cli/src/commands/actions/swap/util.ts b/packages/cli/src/commands/actions/swap/util.ts index a8c8f9d37..511903abb 100644 --- a/packages/cli/src/commands/actions/swap/util.ts +++ b/packages/cli/src/commands/actions/swap/util.ts @@ -5,7 +5,7 @@ import type { SwapQuoteParams, } from '@eth-optimism/actions-sdk' -import { parseAmount } from '@/commands/wallet/lend/util.js' +import { parseAmount } from '@/utils/parseAmount.js' import { CliError } from '@/output/errors.js' import { resolveAsset } from '@/resolvers/assets.js' import { resolveChain } from '@/resolvers/chains.js' diff --git a/packages/cli/src/commands/wallet/swap/execute.ts b/packages/cli/src/commands/wallet/swap/execute.ts index c348469bc..d295aa686 100644 --- a/packages/cli/src/commands/wallet/swap/execute.ts +++ b/packages/cli/src/commands/wallet/swap/execute.ts @@ -1,9 +1,6 @@ import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' -import { - ensureOnchainSuccess, - rethrowAsCliError, - toReceiptArray, -} from '@/commands/wallet/lend/util.js' +import { rethrowAsCliError } from "@/output/errors.js" +import { ensureOnchainSuccess, toReceiptArray } from "@/utils/receipts.js" import { walletContext } from '@/context/walletContext.js' import { CliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts index 321231124..cde476060 100644 --- a/packages/cli/src/output/printOutput.ts +++ b/packages/cli/src/output/printOutput.ts @@ -50,9 +50,7 @@ export interface SwapExecuteDoc { amountOutRaw: bigint price: number priceImpact: number - transactions: ReadonlyArray< - EOATransactionReceipt | UserOperationTransactionReceipt - > + transactions: readonly WalletTransactionReceipt[] } interface Printers { From 8c9f24ba7be4bf23e8f5f92422983d7baa57a027 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 15:12:15 -0400 Subject: [PATCH 48/76] drop unichain from swap tests --- packages/cli/src/__tests__/system.test.ts | 6 ++--- .../src/commands/__tests__/swapMarket.test.ts | 6 ++--- .../commands/__tests__/swapMarkets.test.ts | 6 ++--- .../src/commands/__tests__/swapQuote.test.ts | 22 +++++++++---------- .../commands/__tests__/walletBalance.test.ts | 2 +- .../__tests__/walletSwapExecute.test.ts | 16 +++++++------- .../cli/src/commands/actions/swap/index.ts | 4 ++-- .../cli/src/commands/actions/swap/market.ts | 2 +- .../cli/src/commands/actions/swap/markets.ts | 2 +- .../cli/src/commands/actions/swap/quote.ts | 7 ++++-- .../cli/src/commands/actions/swap/quotes.ts | 7 ++++-- .../cli/src/commands/actions/swap/util.ts | 2 +- .../cli/src/commands/wallet/swap/execute.ts | 10 +++++---- .../cli/src/commands/wallet/swap/index.ts | 2 +- 14 files changed, 51 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/__tests__/system.test.ts b/packages/cli/src/__tests__/system.test.ts index 44801baea..676c4e47e 100644 --- a/packages/cli/src/__tests__/system.test.ts +++ b/packages/cli/src/__tests__/system.test.ts @@ -200,7 +200,7 @@ describe('actions CLI (built binary)', () => { '--out', 'OP_DEMO', '--chain', - 'unichain', + 'base-sepolia', ]) expect(code).toBe(2) const body = JSON.parse(stderr) @@ -222,7 +222,7 @@ describe('actions CLI (built binary)', () => { '--amount-out', '1', '--chain', - 'unichain', + 'base-sepolia', ]) expect(code).toBe(2) const body = JSON.parse(stderr) @@ -242,7 +242,7 @@ describe('actions CLI (built binary)', () => { '--amount-in', '1', '--chain', - 'unichain', + 'base-sepolia', '--provider', 'sushiswap', ]) diff --git a/packages/cli/src/commands/__tests__/swapMarket.test.ts b/packages/cli/src/commands/__tests__/swapMarket.test.ts index 4acff9166..9742323c5 100644 --- a/packages/cli/src/commands/__tests__/swapMarket.test.ts +++ b/packages/cli/src/commands/__tests__/swapMarket.test.ts @@ -42,8 +42,8 @@ describe('runSwapMarket', () => { provider: 'uniswap', } }) - await runSwapMarket({ pool: '0xpool', chain: 'unichain' }) - expect(captured[0]).toEqual({ poolId: '0xpool', chainId: 130 }) + await runSwapMarket({ pool: '0xpool', chain: 'base-sepolia' }) + expect(captured[0]).toEqual({ poolId: '0xpool', chainId: 84532 }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body.provider).toBe('uniswap') }) @@ -64,7 +64,7 @@ describe('runSwapMarket', () => { throw new Error('fetch failed') }) try { - await runSwapMarket({ pool: '0x', chain: 'unichain' }) + await runSwapMarket({ pool: '0x', chain: 'base-sepolia' }) throw new Error('did not throw') } catch (err) { expect(err).toBeInstanceOf(CliError) diff --git a/packages/cli/src/commands/__tests__/swapMarkets.test.ts b/packages/cli/src/commands/__tests__/swapMarkets.test.ts index b4a39c1e2..7b877dc1d 100644 --- a/packages/cli/src/commands/__tests__/swapMarkets.test.ts +++ b/packages/cli/src/commands/__tests__/swapMarkets.test.ts @@ -31,7 +31,7 @@ describe('runSwapMarkets', () => { it('emits the array of markets', async () => { mockActions(async () => [ { - marketId: { poolId: '0xpool', chainId: 130 }, + marketId: { poolId: '0xpool', chainId: 84532 }, assets: [ { metadata: { symbol: 'USDC_DEMO' } }, { metadata: { symbol: 'OP_DEMO' } }, @@ -53,8 +53,8 @@ describe('runSwapMarkets', () => { captured.push(params) return [] }) - await runSwapMarkets({ chain: 'unichain' }) - expect(captured[0]).toEqual({ chainId: 130 }) + await runSwapMarkets({ chain: 'base-sepolia' }) + expect(captured[0]).toEqual({ chainId: 84532 }) }) it('rejects unknown --chain values with CliError(validation)', async () => { diff --git a/packages/cli/src/commands/__tests__/swapQuote.test.ts b/packages/cli/src/commands/__tests__/swapQuote.test.ts index 99a9041ce..bed64d4bd 100644 --- a/packages/cli/src/commands/__tests__/swapQuote.test.ts +++ b/packages/cli/src/commands/__tests__/swapQuote.test.ts @@ -14,7 +14,7 @@ afterEach(() => setJsonMode(false)) const stubQuote = (provider: string, amountOutRaw: bigint) => ({ assetIn: { metadata: { symbol: 'USDC_DEMO' } }, assetOut: { metadata: { symbol: 'OP_DEMO' } }, - chainId: 130, + chainId: 84532, amountIn: 5, amountInRaw: 5000000n, amountOut: 4.9, @@ -62,7 +62,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const call = captured[0] as { assetIn: { metadata: { symbol: string } } @@ -75,7 +75,7 @@ describe('runSwapQuote', () => { } expect(call.assetIn.metadata.symbol).toBe('USDC_DEMO') expect(call.assetOut.metadata.symbol).toBe('OP_DEMO') - expect(call.chainId).toBe(130) + expect(call.chainId).toBe(84532) expect(call.amountIn).toBe(5) expect(call.amountOut).toBeUndefined() expect(call.slippage).toBeUndefined() @@ -95,7 +95,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', slippage: '0.5', }) const call = captured[0] as { slippage: number } @@ -112,7 +112,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', provider: 'velodrome', }) const call = captured[0] as { provider: string } @@ -127,7 +127,7 @@ describe('runSwapQuote', () => { out: 'OP_DEMO', amountIn: '1', amountOut: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -142,7 +142,7 @@ describe('runSwapQuote', () => { await runSwapQuote({ in: 'USDC_DEMO', out: 'OP_DEMO', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -158,7 +158,7 @@ describe('runSwapQuote', () => { in: 'NOPE', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -174,7 +174,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', provider: 'sushiswap', }) throw new Error('did not throw') @@ -192,7 +192,7 @@ describe('runSwapQuote', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', slippage: bad, }) throw new Error(`did not throw for ${bad}`) @@ -231,7 +231,7 @@ describe('runSwapQuotes', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body).toHaveLength(2) diff --git a/packages/cli/src/commands/__tests__/walletBalance.test.ts b/packages/cli/src/commands/__tests__/walletBalance.test.ts index b7c340783..c24101988 100644 --- a/packages/cli/src/commands/__tests__/walletBalance.test.ts +++ b/packages/cli/src/commands/__tests__/walletBalance.test.ts @@ -120,7 +120,7 @@ describe('runWalletBalance', () => { ] vi.spyOn(walletCtx, 'walletContext').mockResolvedValue({ config: { - chains: [{ chainId: 84532 }, { chainId: 11155420 }, { chainId: 130 }], + chains: [{ chainId: 84532 }, { chainId: 11155420 }, { chainId: 84532 }], } as never, actions: {} as never, signer: {} as never, diff --git a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts index 3ecba7df3..4ed999b62 100644 --- a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts +++ b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts @@ -74,7 +74,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body.action).toBe('execute') @@ -85,7 +85,7 @@ describe('runWalletSwapExecute', () => { expect(body.transactions).toHaveLength(2) expect(body.transactions[0].transactionHash).toBe('0xapprove') const call = captured[0] as { chainId: number } - expect(call.chainId).toBe(130) + expect(call.chainId).toBe(84532) }) it('wraps a single receipt into a one-element array', async () => { @@ -94,7 +94,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountOut: '5', - chain: 'unichain', + chain: 'base-sepolia', }) const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) expect(body.transactions).toHaveLength(1) @@ -112,7 +112,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -130,7 +130,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -148,7 +148,7 @@ describe('runWalletSwapExecute', () => { out: 'OP_DEMO', amountIn: '1', amountOut: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -164,7 +164,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { @@ -181,7 +181,7 @@ describe('runWalletSwapExecute', () => { in: 'USDC_DEMO', out: 'OP_DEMO', amountIn: '1', - chain: 'unichain', + chain: 'base-sepolia', }) throw new Error('did not throw') } catch (err) { diff --git a/packages/cli/src/commands/actions/swap/index.ts b/packages/cli/src/commands/actions/swap/index.ts index df70ec103..caaafa58c 100644 --- a/packages/cli/src/commands/actions/swap/index.ts +++ b/packages/cli/src/commands/actions/swap/index.ts @@ -16,7 +16,7 @@ const QUOTE_OPTIONS_HELP = { '--amount-out ', 'exact-out amount (mutually exclusive with --amount-in)', ], - chain: ['--chain ', 'chain shortname (e.g. unichain, op-sepolia)'], + chain: ['--chain ', 'chain shortname (e.g. base-sepolia, op-sepolia)'], provider: [ '--provider ', 'force a provider: uniswap or velodrome (omit to let routing decide)', @@ -58,7 +58,7 @@ export function swapCommand(): Command { .command('market') .description('Inspect one swap market by pool id and chain.') .requiredOption('--pool ', 'pool identifier (keccak256 of PoolKey)') - .requiredOption('--chain ', 'chain shortname (e.g. unichain)') + .requiredOption('--chain ', 'chain shortname (e.g. base-sepolia)') .action(runSwapMarket) addQuoteOptions( command.command('quote').description('Get the best swap quote.'), diff --git a/packages/cli/src/commands/actions/swap/market.ts b/packages/cli/src/commands/actions/swap/market.ts index 75d57910b..52bdab5c7 100644 --- a/packages/cli/src/commands/actions/swap/market.ts +++ b/packages/cli/src/commands/actions/swap/market.ts @@ -1,5 +1,5 @@ -import { rethrowAsCliError } from '@/output/errors.js' import { baseContext } from '@/context/baseContext.js' +import { rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' import { resolveChain } from '@/resolvers/chains.js' diff --git a/packages/cli/src/commands/actions/swap/markets.ts b/packages/cli/src/commands/actions/swap/markets.ts index b00108151..dd426ee48 100644 --- a/packages/cli/src/commands/actions/swap/markets.ts +++ b/packages/cli/src/commands/actions/swap/markets.ts @@ -1,5 +1,5 @@ -import { rethrowAsCliError } from '@/output/errors.js' import { baseContext } from '@/context/baseContext.js' +import { rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' import { resolveChain } from '@/resolvers/chains.js' diff --git a/packages/cli/src/commands/actions/swap/quote.ts b/packages/cli/src/commands/actions/swap/quote.ts index 3e106acfd..13ea8590d 100644 --- a/packages/cli/src/commands/actions/swap/quote.ts +++ b/packages/cli/src/commands/actions/swap/quote.ts @@ -1,6 +1,9 @@ -import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' -import { rethrowAsCliError } from '@/output/errors.js' +import { + buildQuoteParams, + type QuoteFlags, +} from '@/commands/actions/swap/util.js' import { baseContext } from '@/context/baseContext.js' +import { rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' /** diff --git a/packages/cli/src/commands/actions/swap/quotes.ts b/packages/cli/src/commands/actions/swap/quotes.ts index 0d22d0743..de1665fa2 100644 --- a/packages/cli/src/commands/actions/swap/quotes.ts +++ b/packages/cli/src/commands/actions/swap/quotes.ts @@ -1,6 +1,9 @@ -import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' -import { rethrowAsCliError } from '@/output/errors.js' +import { + buildQuoteParams, + type QuoteFlags, +} from '@/commands/actions/swap/util.js' import { baseContext } from '@/context/baseContext.js' +import { rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' /** diff --git a/packages/cli/src/commands/actions/swap/util.ts b/packages/cli/src/commands/actions/swap/util.ts index 511903abb..cca1cf4fd 100644 --- a/packages/cli/src/commands/actions/swap/util.ts +++ b/packages/cli/src/commands/actions/swap/util.ts @@ -5,10 +5,10 @@ import type { SwapQuoteParams, } from '@eth-optimism/actions-sdk' -import { parseAmount } from '@/utils/parseAmount.js' import { CliError } from '@/output/errors.js' import { resolveAsset } from '@/resolvers/assets.js' import { resolveChain } from '@/resolvers/chains.js' +import { parseAmount } from '@/utils/parseAmount.js' const PROVIDERS: readonly SwapProviderName[] = ['uniswap', 'velodrome'] diff --git a/packages/cli/src/commands/wallet/swap/execute.ts b/packages/cli/src/commands/wallet/swap/execute.ts index d295aa686..476eafcfc 100644 --- a/packages/cli/src/commands/wallet/swap/execute.ts +++ b/packages/cli/src/commands/wallet/swap/execute.ts @@ -1,9 +1,11 @@ -import { buildQuoteParams, type QuoteFlags } from '@/commands/actions/swap/util.js' -import { rethrowAsCliError } from "@/output/errors.js" -import { ensureOnchainSuccess, toReceiptArray } from "@/utils/receipts.js" +import { + buildQuoteParams, + type QuoteFlags, +} from '@/commands/actions/swap/util.js' import { walletContext } from '@/context/walletContext.js' -import { CliError } from '@/output/errors.js' +import { CliError, rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' +import { ensureOnchainSuccess, toReceiptArray } from '@/utils/receipts.js' /** * @description Handler for `actions wallet swap execute --in diff --git a/packages/cli/src/commands/wallet/swap/index.ts b/packages/cli/src/commands/wallet/swap/index.ts index b0d6614d5..e7612c199 100644 --- a/packages/cli/src/commands/wallet/swap/index.ts +++ b/packages/cli/src/commands/wallet/swap/index.ts @@ -28,7 +28,7 @@ export function walletSwapCommand(): Command { ) .requiredOption( '--chain ', - 'chain shortname (e.g. unichain, op-sepolia)', + 'chain shortname (e.g. base-sepolia, op-sepolia)', ) .option( '--provider ', From 6415a57870ec1d456f34317d21b95da90dd5e8ef Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 15:15:30 -0400 Subject: [PATCH 49/76] import ANVIL_ACCOUNT_0 from shared mock --- packages/cli/src/commands/__tests__/walletSwapExecute.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts index 4ed999b62..f779544c5 100644 --- a/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts +++ b/packages/cli/src/commands/__tests__/walletSwapExecute.test.ts @@ -1,6 +1,7 @@ import type { MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ANVIL_ACCOUNT_0 } from '@/__mocks__/anvilAccounts.js' import { runWalletSwapExecute } from '@/commands/wallet/swap/execute.js' import { __resetEnvCacheForTests } from '@/config/env.js' import * as walletCtx from '@/context/walletContext.js' @@ -11,9 +12,6 @@ import { setJsonMode } from '@/output/mode.js' beforeEach(() => setJsonMode(true)) afterEach(() => setJsonMode(false)) -const ANVIL_ACCOUNT_0 = - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' - const successReceipt = (hash: string) => ({ transactionHash: hash, status: 'success' as const, From 16e5a132bdd0e2dc420ff0218f30bed86ee664f3 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 15:27:44 -0400 Subject: [PATCH 50/76] add configuredAssets helper --- packages/cli/src/commands/actions/lend/markets.ts | 4 ++-- packages/cli/src/commands/actions/swap/quote.ts | 3 ++- packages/cli/src/commands/actions/swap/quotes.ts | 3 ++- packages/cli/src/commands/wallet/swap/execute.ts | 3 ++- packages/cli/src/resolvers/assets.ts | 13 ++++++++++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/actions/lend/markets.ts b/packages/cli/src/commands/actions/lend/markets.ts index c69630136..ace81d582 100644 --- a/packages/cli/src/commands/actions/lend/markets.ts +++ b/packages/cli/src/commands/actions/lend/markets.ts @@ -1,7 +1,7 @@ import { baseContext } from '@/context/baseContext.js' import { CliError, rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' -import { resolveAsset } from '@/resolvers/assets.js' +import { configuredAssets, resolveAsset } from '@/resolvers/assets.js' import { type ChainFlags, resolveChainFlags } from '@/resolvers/chains.js' export interface LendMarketsFlags extends ChainFlags { @@ -18,7 +18,7 @@ export async function runLendMarkets( ): Promise { const { actions, config } = baseContext() const asset = flags.asset - ? resolveAsset(flags.asset, config.assets?.allow ?? []) + ? resolveAsset(flags.asset, configuredAssets(config)) : undefined const chainIds = resolveChainFlags( flags, diff --git a/packages/cli/src/commands/actions/swap/quote.ts b/packages/cli/src/commands/actions/swap/quote.ts index 13ea8590d..100a96359 100644 --- a/packages/cli/src/commands/actions/swap/quote.ts +++ b/packages/cli/src/commands/actions/swap/quote.ts @@ -5,6 +5,7 @@ import { import { baseContext } from '@/context/baseContext.js' import { rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' +import { configuredAssets } from '@/resolvers/assets.js' /** * @description Handler for @@ -20,7 +21,7 @@ export async function runSwapQuote(flags: QuoteFlags): Promise { const { actions, config } = baseContext() const params = buildQuoteParams( flags, - config.assets?.allow ?? [], + configuredAssets(config), config.chains.map((c) => c.chainId), ) try { diff --git a/packages/cli/src/commands/actions/swap/quotes.ts b/packages/cli/src/commands/actions/swap/quotes.ts index de1665fa2..47150b6be 100644 --- a/packages/cli/src/commands/actions/swap/quotes.ts +++ b/packages/cli/src/commands/actions/swap/quotes.ts @@ -5,6 +5,7 @@ import { import { baseContext } from '@/context/baseContext.js' import { rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' +import { configuredAssets } from '@/resolvers/assets.js' /** * @description Handler for `actions swap quotes ...`. Same flag set as @@ -19,7 +20,7 @@ export async function runSwapQuotes(flags: QuoteFlags): Promise { const { actions, config } = baseContext() const params = buildQuoteParams( flags, - config.assets?.allow ?? [], + configuredAssets(config), config.chains.map((c) => c.chainId), ) try { diff --git a/packages/cli/src/commands/wallet/swap/execute.ts b/packages/cli/src/commands/wallet/swap/execute.ts index 476eafcfc..be42f3b84 100644 --- a/packages/cli/src/commands/wallet/swap/execute.ts +++ b/packages/cli/src/commands/wallet/swap/execute.ts @@ -5,6 +5,7 @@ import { import { walletContext } from '@/context/walletContext.js' import { CliError, rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' +import { configuredAssets } from '@/resolvers/assets.js' import { ensureOnchainSuccess, toReceiptArray } from '@/utils/receipts.js' /** @@ -29,7 +30,7 @@ export async function runWalletSwapExecute(flags: QuoteFlags): Promise { } const params = buildQuoteParams( flags, - config.assets?.allow ?? [], + configuredAssets(config), config.chains.map((c) => c.chainId), ) try { diff --git a/packages/cli/src/resolvers/assets.ts b/packages/cli/src/resolvers/assets.ts index fc10a9152..fd937f8f7 100644 --- a/packages/cli/src/resolvers/assets.ts +++ b/packages/cli/src/resolvers/assets.ts @@ -1,7 +1,18 @@ -import type { Asset } from '@eth-optimism/actions-sdk' +import type { Asset, NodeActionsConfig } from '@eth-optimism/actions-sdk' import { CliError } from '@/output/errors.js' +/** + * @description Returns the configured asset allowlist as a flat readonly array, or an empty array when no `assets.allow` is configured. Mirrors `configuredMarkets(config)` in `resolvers/markets.ts` so callers don't repeat the `config.assets?.allow ?? []` fallback at every site. + * @param config - Resolved CLI config. + * @returns Asset allowlist (possibly empty). + */ +export function configuredAssets( + config: NodeActionsConfig, +): readonly Asset[] { + return config.assets?.allow ?? [] +} + /** * @description Resolves an asset symbol (e.g. `USDC_DEMO`, `eth`) to the * matching `Asset` entry from an allowlist. Matching is case-insensitive on From fa127edd0f2a0a98aae8d1ca11bd18deabae86d2 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:28:30 -0400 Subject: [PATCH 51/76] format chains test array --- packages/cli/src/resolvers/__tests__/chains.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/cli/src/resolvers/__tests__/chains.test.ts b/packages/cli/src/resolvers/__tests__/chains.test.ts index 180a7b19b..f37b9b2a6 100644 --- a/packages/cli/src/resolvers/__tests__/chains.test.ts +++ b/packages/cli/src/resolvers/__tests__/chains.test.ts @@ -12,12 +12,7 @@ const ALL: SupportedChainId[] = [ optimismSepolia.id, ] -const SHORTNAMES = [ - 'base', - 'base-sepolia', - 'optimism', - 'op-sepolia', -] as const +const SHORTNAMES = ['base', 'base-sepolia', 'optimism', 'op-sepolia'] as const describe('resolveChain', () => { it('resolves each canonical shortname to its chain id', () => { From e48a42f29581fa5dc53e073ce49b08d731a0971a Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:28:33 -0400 Subject: [PATCH 52/76] drop duplicate velodrome changeset --- .changeset/velo-universal-router-eoa.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/velo-universal-router-eoa.md diff --git a/.changeset/velo-universal-router-eoa.md b/.changeset/velo-universal-router-eoa.md deleted file mode 100644 index 492bbc10c..000000000 --- a/.changeset/velo-universal-router-eoa.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@eth-optimism/actions-sdk': patch ---- - -Fix Velodrome universal-router approvals for EOA wallets. The encoder previously hardcoded `payerIsUser: false` and pre-`transfer`d tokens to the router, which only works when the caller batches atomically (4337). EOAs (and any sequentially dispatched flow) reverted with `TRANSFER_FAILED`. The router has a first-class `payerIsUser: true` path that pulls tokens via standard `transferFrom`; the SDK now uses it. Behaviorally equivalent for smart wallets, correct for EOAs. From f25f556ab86ef80792c9f643b21787bff4fc4cc9 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:30:40 -0400 Subject: [PATCH 53/76] internalize polling interval split --- packages/sdk/src/services/ChainManager.ts | 26 +++++++++++------------ packages/sdk/src/types/chain.ts | 6 ------ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/sdk/src/services/ChainManager.ts b/packages/sdk/src/services/ChainManager.ts index f1eda367f..871f4500e 100644 --- a/packages/sdk/src/services/ChainManager.ts +++ b/packages/sdk/src/services/ChainManager.ts @@ -16,17 +16,15 @@ import { mainnet, sepolia } from 'viem/chains' import type { SupportedChainId } from '@/constants/supportedChains.js' import type { ChainConfig } from '@/types/chain.js' -/** viem `pollingInterval` (ms) used for fast chains (L2s with ~2s blocks). */ -export const DEFAULT_POLLING_INTERVAL_MS = 1500 -/** viem `pollingInterval` (ms) used for L1-class chains with ~12s blocks. */ -export const MAINNET_POLLING_INTERVAL_MS = 4000 - -function defaultPollingInterval(chainId: number): number { - if (chainId === mainnet.id || chainId === sepolia.id) { - return MAINNET_POLLING_INTERVAL_MS - } - return DEFAULT_POLLING_INTERVAL_MS -} +/** viem `pollingInterval` (ms) for L2-class chains with ~2s blocks. */ +const FAST_CHAIN_POLLING_INTERVAL_MS = 1500 +/** viem `pollingInterval` (ms) for L1-class chains with ~12s blocks. */ +const SLOW_CHAIN_POLLING_INTERVAL_MS = 4000 + +const SLOW_CHAIN_IDS: ReadonlySet = new Set([ + mainnet.id, + sepolia.id, +]) /** * Chain Manager Service @@ -187,9 +185,9 @@ export class ChainManager { `Public client already configured for chain ID: ${chainConfig.chainId}`, ) } - const pollingInterval = - chainConfig.pollingInterval ?? - defaultPollingInterval(chainConfig.chainId) + const pollingInterval = SLOW_CHAIN_IDS.has(chainConfig.chainId) + ? SLOW_CHAIN_POLLING_INTERVAL_MS + : FAST_CHAIN_POLLING_INTERVAL_MS const client = createPublicClient({ chain, transport: this.getTransportForChain(chainConfig.chainId), diff --git a/packages/sdk/src/types/chain.ts b/packages/sdk/src/types/chain.ts index 34ff489b7..f6f3c1268 100644 --- a/packages/sdk/src/types/chain.ts +++ b/packages/sdk/src/types/chain.ts @@ -11,12 +11,6 @@ export interface ChainConfig { rpcUrls?: string[] /** Bundler configuration */ bundler?: BundlerConfig - /** - * Polling interval (ms) for the chain's PublicClient. Used by viem's - * `waitForTransactionReceipt` and friends when the transport is HTTP. - * Defaults to 4000ms (viem default). Lower this for fast L2s. - */ - pollingInterval?: number } export interface BaseBundlerConfig { From 04f0b41cde669428883e0a77ff98a8391b08c758 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:31:09 -0400 Subject: [PATCH 54/76] tighten l2 polling to 1s --- packages/sdk/src/services/ChainManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/services/ChainManager.ts b/packages/sdk/src/services/ChainManager.ts index 871f4500e..8e94a70ee 100644 --- a/packages/sdk/src/services/ChainManager.ts +++ b/packages/sdk/src/services/ChainManager.ts @@ -16,8 +16,8 @@ import { mainnet, sepolia } from 'viem/chains' import type { SupportedChainId } from '@/constants/supportedChains.js' import type { ChainConfig } from '@/types/chain.js' -/** viem `pollingInterval` (ms) for L2-class chains with ~2s blocks. */ -const FAST_CHAIN_POLLING_INTERVAL_MS = 1500 +/** viem `pollingInterval` (ms) for L2-class chains with ~1-2s blocks. */ +const FAST_CHAIN_POLLING_INTERVAL_MS = 1000 /** viem `pollingInterval` (ms) for L1-class chains with ~12s blocks. */ const SLOW_CHAIN_POLLING_INTERVAL_MS = 4000 From 3a397b47a4119e6e3dba07dc6546f83a14a25f3a Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:32:12 -0400 Subject: [PATCH 55/76] share polling default with bundler client --- packages/sdk/src/services/ChainManager.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/services/ChainManager.ts b/packages/sdk/src/services/ChainManager.ts index 8e94a70ee..177c3b156 100644 --- a/packages/sdk/src/services/ChainManager.ts +++ b/packages/sdk/src/services/ChainManager.ts @@ -26,6 +26,12 @@ const SLOW_CHAIN_IDS: ReadonlySet = new Set([ sepolia.id, ]) +function pollingIntervalForChain(chainId: SupportedChainId): number { + return SLOW_CHAIN_IDS.has(chainId) + ? SLOW_CHAIN_POLLING_INTERVAL_MS + : FAST_CHAIN_POLLING_INTERVAL_MS +} + /** * Chain Manager Service * @description Manages public clients and chain infrastructure for the Verbs SDK. @@ -100,6 +106,7 @@ export class ChainManager { const client = createPublicClient({ chain: this.getChain(chainId), transport: http(bundlerUrl), + pollingInterval: pollingIntervalForChain(chainId), }) return createBundlerClient({ account, @@ -185,13 +192,10 @@ export class ChainManager { `Public client already configured for chain ID: ${chainConfig.chainId}`, ) } - const pollingInterval = SLOW_CHAIN_IDS.has(chainConfig.chainId) - ? SLOW_CHAIN_POLLING_INTERVAL_MS - : FAST_CHAIN_POLLING_INTERVAL_MS const client = createPublicClient({ chain, transport: this.getTransportForChain(chainConfig.chainId), - pollingInterval, + pollingInterval: pollingIntervalForChain(chainConfig.chainId), }) clients.set(chainConfig.chainId, client) From bf449356fb3c08d123158fccd3a0d91d230e5532 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:37:06 -0400 Subject: [PATCH 56/76] attach nonce manager to eoa wallet --- .../src/wallet/core/wallets/eoa/EOAWallet.ts | 24 +++++++++++++------ .../wallets/eoa/__tests__/EOAWallet.spec.ts | 5 +++- .../privy/__tests__/PrivyWallet.spec.ts | 5 +++- .../turnkey/__tests__/TurnkeyWallet.spec.ts | 3 ++- .../local/__tests__/LocalWallet.spec.ts | 3 ++- .../dynamic/__tests__/DynamicWallet.spec.ts | 3 ++- .../privy/__tests__/PrivyWallet.spec.ts | 3 ++- .../turnkey/__tests__/TurnkeyWallet.spec.ts | 3 ++- 8 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts b/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts index 606a1ea23..405dc2e7b 100644 --- a/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts +++ b/packages/sdk/src/wallet/core/wallets/eoa/EOAWallet.ts @@ -5,7 +5,7 @@ import type { LocalAccount, WalletClient, } from 'viem' -import { createWalletClient } from 'viem' +import { createWalletClient, nonceManager } from 'viem' import type { SupportedChainId } from '@/constants/supportedChains.js' import type { TransactionData } from '@/types/lend/index.js' @@ -23,8 +23,10 @@ export abstract class EOAWallet extends Wallet { /** * Create a WalletClient for this EOA wallet. * - * Creates a viem-compatible WalletClient configured with this wallet's account - * and the specified chain. Supports fallback transport for multiple RPC URLs. + * Attaches viem's default `nonceManager` to the signer so back-to-back + * `sendTransaction` calls receive sequential nonces without re-fetching + * `eth_getTransactionCount('pending')` per tx. This avoids races on + * load-balanced RPCs where pending state lags by one block. * @param chainId - The chain ID to create the wallet client for * @returns Promise resolving to a WalletClient configured for the specified chain */ @@ -39,8 +41,11 @@ export abstract class EOAWallet extends Wallet { [] > > { + const account: LocalAccount = this.signer.nonceManager + ? this.signer + : { ...this.signer, nonceManager } return createWalletClient({ - account: this.signer, + account, chain: this.chainManager.getChain(chainId), transport: this.chainManager.getTransportForChain(chainId), }) @@ -70,8 +75,14 @@ export abstract class EOAWallet extends Wallet { /** * Send multiple transactions sequentially from this EOA wallet. * - * Executes transactions one at a time in order, waiting for 2 confirmations - * between each to ensure nonce updates. Returns an array of receipts. + * Each transaction is awaited to inclusion (one confirmation) via `send()` + * before the next is signed. The `nonceManager` attached in `walletClient()` + * keeps nonces in sequence locally, so the wait does not need extra + * confirmations to guarantee nonce monotonicity. + * + * Note: this method assumes a sequencer-ordered chain (e.g. OP-stack L2s). + * On chains with deeper reorg risk, consider an additional confirmations + * pass at the call site. * @param transactionData - Array of transactions to send * @param chainId - Chain to send the transactions on * @returns Promise resolving to array of transaction receipts (one per transaction) @@ -82,7 +93,6 @@ export abstract class EOAWallet extends Wallet { ): Promise { const receipts: EOATransactionReceipt[] = [] for (const tx of transactionData) { - // 1 confirmation is typically enough. const receipt = await this.send(tx, chainId) receipts.push(receipt) } diff --git a/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts b/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts index 3532d5845..e87202c58 100644 --- a/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts +++ b/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts @@ -120,7 +120,10 @@ describe('EOAWallet', () => { expect(createWalletClient).toHaveBeenCalledOnce() const callArgs = vi.mocked(createWalletClient).mock.calls[0][0] - expect(callArgs.account).toBe(mockLocalAccount) + expect(callArgs.account).toMatchObject({ + address: mockLocalAccount.address, + }) + expect(callArgs.account).toHaveProperty('nonceManager') expect(callArgs.chain).toBe(unichain) expect(walletClient).toBe(mockWalletClient) }) diff --git a/packages/sdk/src/wallet/node/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts b/packages/sdk/src/wallet/node/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts index 5457e11db..6de3e5dcd 100644 --- a/packages/sdk/src/wallet/node/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts +++ b/packages/sdk/src/wallet/node/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts @@ -115,7 +115,10 @@ describe('PrivyWallet', () => { expect(createWalletClient).toHaveBeenCalledOnce() const callArgs = vi.mocked(createWalletClient).mock.calls[0][0] - expect(callArgs.account).toBe(mockLocalAccount) + expect(callArgs.account).toMatchObject({ + address: mockLocalAccount.address, + }) + expect(callArgs.account).toHaveProperty('nonceManager') expect(callArgs.chain).toBe(mockChainManager.getChain(unichain.id)) expect(walletClient).toBe(mockWalletClient) }) diff --git a/packages/sdk/src/wallet/node/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts b/packages/sdk/src/wallet/node/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts index 911697013..14c30af94 100644 --- a/packages/sdk/src/wallet/node/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts +++ b/packages/sdk/src/wallet/node/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts @@ -97,7 +97,8 @@ describe('TurnkeyWallet', () => { expect(createWalletClient).toHaveBeenCalledOnce() const args = vi.mocked(createWalletClient).mock.calls[0][0] - expect(args.account).toBe(mockLocalAccount) + expect(args.account).toMatchObject({ address: mockLocalAccount.address }) + expect(args.account).toHaveProperty('nonceManager') expect(args.chain).toBe(mockChainManager.getChain(unichain.id)) expect(walletClient).toBe(mockWalletClient) }) diff --git a/packages/sdk/src/wallet/node/wallets/local/__tests__/LocalWallet.spec.ts b/packages/sdk/src/wallet/node/wallets/local/__tests__/LocalWallet.spec.ts index 86abaafbd..b2a6a53ef 100644 --- a/packages/sdk/src/wallet/node/wallets/local/__tests__/LocalWallet.spec.ts +++ b/packages/sdk/src/wallet/node/wallets/local/__tests__/LocalWallet.spec.ts @@ -57,7 +57,8 @@ describe('LocalWallet', () => { expect(createWalletClient).toHaveBeenCalledOnce() const args = vi.mocked(createWalletClient).mock.calls[0][0] - expect(args.account).toBe(mockAccount) + expect(args.account).toMatchObject({ address: mockAccount.address }) + expect(args.account).toHaveProperty('nonceManager') expect(args.chain).toBe(mockChainManager.getChain(unichain.id)) expect(walletClient).toBe(mockWalletClient) }) diff --git a/packages/sdk/src/wallet/react/wallets/hosted/dynamic/__tests__/DynamicWallet.spec.ts b/packages/sdk/src/wallet/react/wallets/hosted/dynamic/__tests__/DynamicWallet.spec.ts index 5bb8957a2..e4e1c67cc 100644 --- a/packages/sdk/src/wallet/react/wallets/hosted/dynamic/__tests__/DynamicWallet.spec.ts +++ b/packages/sdk/src/wallet/react/wallets/hosted/dynamic/__tests__/DynamicWallet.spec.ts @@ -123,7 +123,8 @@ describe('DynamicWallet', () => { expect(createWalletClient).toHaveBeenCalledOnce() const args = vi.mocked(createWalletClient).mock.calls[0][0] - expect(args.account).toBe(mockLocalAccount) + expect(args.account).toMatchObject({ address: mockLocalAccount.address }) + expect(args.account).toHaveProperty('nonceManager') expect(args.chain).toBe(mockChainManager.getChain(unichain.id)) expect(walletClient).toBe(mockWalletClient) }) diff --git a/packages/sdk/src/wallet/react/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts b/packages/sdk/src/wallet/react/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts index 0d76e20ee..841339110 100644 --- a/packages/sdk/src/wallet/react/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts +++ b/packages/sdk/src/wallet/react/wallets/hosted/privy/__tests__/PrivyWallet.spec.ts @@ -102,7 +102,8 @@ describe('PrivyWallet (React)', () => { expect(createWalletClient).toHaveBeenCalledOnce() const args = vi.mocked(createWalletClient).mock.calls[0][0] - expect(args.account).toBe(mockLocalAccount) + expect(args.account).toMatchObject({ address: mockLocalAccount.address }) + expect(args.account).toHaveProperty('nonceManager') expect(args.chain).toBe(mockChainManager.getChain(unichain.id)) expect(walletClient).toBe(mockWalletClient) }) diff --git a/packages/sdk/src/wallet/react/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts b/packages/sdk/src/wallet/react/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts index 58990e249..8914c5b22 100644 --- a/packages/sdk/src/wallet/react/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts +++ b/packages/sdk/src/wallet/react/wallets/hosted/turnkey/__tests__/TurnkeyWallet.spec.ts @@ -92,7 +92,8 @@ describe('TurnkeyWallet', () => { expect(createWalletClient).toHaveBeenCalledOnce() const args = vi.mocked(createWalletClient).mock.calls[0][0] - expect(args.account).toBe(mockLocalAccount) + expect(args.account).toMatchObject({ address: mockLocalAccount.address }) + expect(args.account).toHaveProperty('nonceManager') expect(args.chain).toBe(mockChainManager.getChain(unichain.id)) expect(walletClient).toBe(mockWalletClient) }) From f7d56b792da1ab3a0d50397a442a40304ba48448 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:38:46 -0400 Subject: [PATCH 57/76] test nonce manager attachment --- .../wallets/eoa/__tests__/EOAWallet.spec.ts | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts b/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts index e87202c58..8fc0bb8f7 100644 --- a/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts +++ b/packages/sdk/src/wallet/core/wallets/eoa/__tests__/EOAWallet.spec.ts @@ -123,10 +123,41 @@ describe('EOAWallet', () => { expect(callArgs.account).toMatchObject({ address: mockLocalAccount.address, }) - expect(callArgs.account).toHaveProperty('nonceManager') expect(callArgs.chain).toBe(unichain) expect(walletClient).toBe(mockWalletClient) }) + + it('attaches a nonce manager so back-to-back sends get sequential nonces', async () => { + await wallet.walletClient(unichain.id) + + const callArgs = vi.mocked(createWalletClient).mock.calls[0][0] + const account = callArgs.account as { nonceManager?: unknown } + expect(account.nonceManager).toMatchObject({ + consume: expect.any(Function), + increment: expect.any(Function), + get: expect.any(Function), + reset: expect.any(Function), + }) + }) + + it('respects a nonce manager already set on the signer', async () => { + const customNonceManager = { + consume: vi.fn(), + increment: vi.fn(), + get: vi.fn(), + reset: vi.fn(), + } + ;(mockLocalAccount as LocalAccount).nonceManager = + customNonceManager as unknown as LocalAccount['nonceManager'] + + await wallet.walletClient(unichain.id) + + const callArgs = vi.mocked(createWalletClient).mock.calls[0][0] + const account = callArgs.account as { nonceManager?: unknown } + expect(account.nonceManager).toBe(customNonceManager) + + delete (mockLocalAccount as LocalAccount).nonceManager + }) }) describe('send', () => { @@ -194,8 +225,8 @@ describe('EOAWallet', () => { .mockResolvedValueOnce(mockReceipt2.transactionHash) .mockResolvedValueOnce(mockReceipt3.transactionHash) - // sendBatch waits for one confirmation per transaction (delegating to - // `send()`); no longer takes a second confirmations:2 pass. + // sendBatch performs exactly one inclusion wait per transaction (the + // wait inside `send()`). One mock per tx. vi.mocked(mockPublicClient.waitForTransactionReceipt) .mockResolvedValueOnce(mockReceipt) .mockResolvedValueOnce(mockReceipt2) @@ -234,11 +265,13 @@ describe('EOAWallet', () => { unichain.id, ) - // sendBatch delegates to send() once per transaction and does not make - // an additional confirmations:2 pass. One inclusion wait per tx. + // One inclusion wait per tx, via `send()`. expect(mockPublicClient.waitForTransactionReceipt).toHaveBeenCalledTimes( 2, ) + expect( + mockPublicClient.waitForTransactionReceipt, + ).not.toHaveBeenCalledWith(expect.objectContaining({ confirmations: 2 })) }) it('should get public client for each transaction', async () => { From afc2b97e7d83e1ce15c8b51a07d81874e98cd9b9 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:39:20 -0400 Subject: [PATCH 58/76] add changeset for eoa perf changes --- .changeset/sdk-eoa-perf.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/sdk-eoa-perf.md diff --git a/.changeset/sdk-eoa-perf.md b/.changeset/sdk-eoa-perf.md new file mode 100644 index 000000000..c728186d4 --- /dev/null +++ b/.changeset/sdk-eoa-perf.md @@ -0,0 +1,28 @@ +--- +'@eth-optimism/actions-sdk': minor +--- + +Perf: cut EOA swap dispatch wall-time on fast L2s. + +- `EOAWallet.sendBatch` no longer waits for `confirmations: 2` between sub-txs. + One inclusion wait per tx is enough now that `EOAWallet.walletClient` attaches + viem's default `nonceManager` to the signer, which keeps nonces sequential + locally instead of relying on `eth_getTransactionCount('pending')` on every + send (avoids races on load-balanced RPCs). +- `ChainManager` now defaults the viem `pollingInterval` per chain class: + 1000ms for L2-class chains (~1-2s blocks) and 4000ms for L1 mainnet/sepolia + (~12s blocks). Saves ~3 RPC poll cycles per receipt wait on Base/OP/Unichain. + This default applies to the public client used by `getPublicClient()` and to + the public client wrapping the simple bundler client. There is no override + knob; if a real consumer needs one we'll add it then. + +Behavioural notes for consumers: + +- `sendBatch` is sequential and assumes a sequencer-ordered chain (e.g. + OP-stack L2s). On reorg-heavy chains, callers should consider an additional + confirmations pass at the call site. +- The Velodrome swap path uses **direct ERC-20 max approval** to its universal + router when `approvalMode: 'max'` is requested — there is no Permit2 + intermediary as on Uniswap. The full allowance persists at the router until + manually revoked. Continue to scope `approvalMode: 'max'` to demo/testnet + paths. From fb77d191ffd36d14052613ff6ed5ef6288d14f54 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Tue, 5 May 2026 17:42:13 -0400 Subject: [PATCH 59/76] guard demo config against mainnet --- packages/cli/src/demo/config.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/demo/config.ts b/packages/cli/src/demo/config.ts index 0d81c17c6..a093aa1fe 100644 --- a/packages/cli/src/demo/config.ts +++ b/packages/cli/src/demo/config.ts @@ -4,10 +4,26 @@ import { OP_DEMO, USDC_DEMO, } from '@eth-optimism/actions-sdk' +import { baseSepolia, optimismSepolia } from 'viem/chains' import { getDemoChains } from '@/demo/chains.js' import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' +const DEMO_TESTNET_IDS = new Set([baseSepolia.id, optimismSepolia.id]) + +function assertTestnetOnly(chains: Array<{ chainId: number }>): void { + for (const { chainId } of chains) { + if (!DEMO_TESTNET_IDS.has(chainId)) { + throw new Error( + `getDemoConfig() refuses to configure non-testnet chain ${chainId}: ` + + `demo defaults (approvalMode: 'max') would grant infinite approvals ` + + `on a production chain. Drop settings.approvalMode or build a ` + + `production NodeActionsConfig directly.`, + ) + } + } +} + /** * @description Returns the baked demo `NodeActionsConfig` the CLI boots * against. Mirrors `packages/demo/backend/src/config/actions.ts` in @@ -19,6 +35,8 @@ import { AaveETH, GauntletUSDCDemo } from '@/demo/markets.js' * @returns `NodeActionsConfig` with no hosted wallet provider configured. */ export function getDemoConfig(): NodeActionsConfig { + const chains = getDemoChains() + assertTestnetOnly(chains) return { wallet: { smartWalletConfig: { @@ -28,6 +46,10 @@ export function getDemoConfig(): NodeActionsConfig { lend: { morpho: { marketAllowlist: [GauntletUSDCDemo] }, aave: { marketAllowlist: [AaveETH] }, + // Demo CLI opts into infinite approvals so repeat lend/swap calls + // skip Permit2 + ERC-20 approval txs. Testnet-only by construction + // (assertTestnetOnly above). For production NodeActionsConfig, leave + // approvalMode at the SDK default ('exact'). settings: { approvalMode: 'max' }, }, swap: { @@ -41,9 +63,12 @@ export function getDemoConfig(): NodeActionsConfig { defaultSlippage: 0.005, marketAllowlist: [{ assets: [USDC_DEMO, OP_DEMO], stable: false }], }, + // See lend.settings note above. Velodrome's max-approval path is + // direct ERC-20 to the universal router (no Permit2 expiration), so + // this default is especially important to leave testnet-scoped. settings: { approvalMode: 'max' }, }, assets: { allow: [USDC_DEMO, OP_DEMO, ETH] }, - chains: getDemoChains(), + chains, } } From 3fd8192d0ea2e5b8baff5637f414f95b3672cf3b Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 08:16:29 -0700 Subject: [PATCH 60/76] barrel-export ApprovalMode and APPROVAL_MODES --- packages/cli/src/commands/wallet/lend/index.ts | 3 ++- .../cli/src/commands/wallet/lend/runLendAction.ts | 12 +++--------- packages/sdk/src/index.ts | 2 ++ packages/sdk/src/types/actions.ts | 4 +++- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/wallet/lend/index.ts b/packages/cli/src/commands/wallet/lend/index.ts index e3fb4e24d..6e1b69bee 100644 --- a/packages/cli/src/commands/wallet/lend/index.ts +++ b/packages/cli/src/commands/wallet/lend/index.ts @@ -1,3 +1,4 @@ +import { APPROVAL_MODES } from '@eth-optimism/actions-sdk' import { Command } from 'commander' import { runWalletLendClose } from '@/commands/wallet/lend/close.js' @@ -24,7 +25,7 @@ export function walletLendCommand(): Command { 'amount to supply in human-readable units (e.g. 10 for 10 USDC)', ) .option( - '--approval-mode ', + `--approval-mode <${APPROVAL_MODES.join('|')}>`, 'ERC-20 approval strategy: "exact" approves only this call (default, gas-heavier on repeat); "max" approves max-uint to amortise across future supplies', ) .action(runWalletLendOpen) diff --git a/packages/cli/src/commands/wallet/lend/runLendAction.ts b/packages/cli/src/commands/wallet/lend/runLendAction.ts index 03d790802..e32ddef04 100644 --- a/packages/cli/src/commands/wallet/lend/runLendAction.ts +++ b/packages/cli/src/commands/wallet/lend/runLendAction.ts @@ -1,3 +1,5 @@ +import { APPROVAL_MODES, type ApprovalMode } from '@eth-optimism/actions-sdk' + import { walletContext } from '@/context/walletContext.js' import { CliError, rethrowAsCliError } from '@/output/errors.js' import { printOutput } from '@/output/printOutput.js' @@ -7,14 +9,6 @@ import { ensureOnchainSuccess, toReceiptArray } from '@/utils/receipts.js' import { requireLendCapability } from './requireLendCapability.js' -// Mirrors the SDK's `ApprovalMode = 'exact' | 'max'` (declared in -// `@/types/actions` but not re-exported from the SDK barrel). -type ApprovalMode = 'exact' | 'max' -const APPROVAL_MODES = [ - 'exact', - 'max', -] as const satisfies readonly ApprovalMode[] - export interface LendOpenFlags { market: string amount: string @@ -38,7 +32,7 @@ function parseApprovalMode(raw: string | undefined): ApprovalMode | undefined { } throw new CliError( 'validation', - `Invalid --approval-mode: ${raw} (expected exact or max)`, + `Invalid --approval-mode: ${raw} (expected ${APPROVAL_MODES.join(' or ')})`, { approvalMode: raw }, ) } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a5779afdb..f9476599d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -77,6 +77,7 @@ export { } from '@/services/nameservices/ens/utils.js' export type { ActionsConfig, + ApprovalMode, ApyBreakdown, Asset, EOATransactionReceipt, @@ -112,6 +113,7 @@ export type { WalletConfig, WalletSwapParams, } from '@/types/index.js' +export { APPROVAL_MODES } from '@/types/index.js' export { getAssetAddress, isAssetSupportedOnChain } from '@/utils/assets.js' export { serializeBigInt } from '@/utils/serializers.js' export * from '@/wallet/core/error/errors.js' diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index aaf24dcec..6b8cc6226 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -151,7 +151,9 @@ export interface ActionsContext { * Default is `"exact"` for safety. Demo / dogfood configs typically opt into * `"max"` to avoid an extra approval tx per swap or supply. */ -export type ApprovalMode = 'exact' | 'max' +export const APPROVAL_MODES = ['exact', 'max'] as const + +export type ApprovalMode = (typeof APPROVAL_MODES)[number] /** * Actions SDK configuration From da2acd30921454080e51a0d5f17d55f20cfc4440 Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 08:16:33 -0700 Subject: [PATCH 61/76] format DeployMorphoMarket prettier drift --- .../demo/contracts/script/DeployMorphoMarket.s.sol | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/demo/contracts/script/DeployMorphoMarket.s.sol b/packages/demo/contracts/script/DeployMorphoMarket.s.sol index 369948047..5c73548a8 100644 --- a/packages/demo/contracts/script/DeployMorphoMarket.s.sol +++ b/packages/demo/contracts/script/DeployMorphoMarket.s.sol @@ -51,11 +51,7 @@ contract DeployMorphoMarket is Script { // Create market params MarketParams memory marketParams = MarketParams({ - loanToken: address(usdc), - collateralToken: address(op), - oracle: address(oracle), - irm: IRM, - lltv: LLTV + loanToken: address(usdc), collateralToken: address(op), oracle: address(oracle), irm: IRM, lltv: LLTV }); // Create Morpho market @@ -63,9 +59,8 @@ contract DeployMorphoMarket is Script { // Create MetaMorpho vault with 0 timelock (V1.1) bytes32 salt = keccak256(abi.encodePacked("actions-demo-vault", block.timestamp)); - address vault = IMetaMorphoFactory(METAMORPHO_FACTORY_V1_1).createMetaMorpho( - msg.sender, 0, address(usdc), "Actions Demo USDC Vault", "dUSDC", salt - ); + address vault = IMetaMorphoFactory(METAMORPHO_FACTORY_V1_1) + .createMetaMorpho(msg.sender, 0, address(usdc), "Actions Demo USDC Vault", "dUSDC", salt); console.log("Vault:", vault); // Submit and immediately accept cap (0 timelock) From c7eb8a0ab4f0421f4aa694038e3926f7234cb3fb Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 08:44:10 -0700 Subject: [PATCH 62/76] barrel-export LendProviderName --- packages/cli/src/output/printOutput.ts | 3 ++- packages/sdk/src/index.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts index cde476060..f0d4acde3 100644 --- a/packages/cli/src/output/printOutput.ts +++ b/packages/cli/src/output/printOutput.ts @@ -2,6 +2,7 @@ import type { Asset, LendMarket, LendMarketPosition, + LendProviderName, SupportedChainId, SwapMarket, SwapQuote, @@ -33,7 +34,7 @@ export interface LendActionDoc { name: string address: Address chainId: SupportedChainId - provider: string + provider: LendProviderName } asset: { symbol: string } amount: number diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f9476599d..1398f77b7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -89,6 +89,7 @@ export type { LendMarketPosition, LendMarketSupply, LendProviderConfig, + LendProviderName, LendTransaction, LendTransactionReceipt, SwapConfig, From be0d15e6d691d175db1de27b49d612af43c6a0e2 Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 09:05:57 -0700 Subject: [PATCH 63/76] barrel-export LendAction literal --- packages/cli/src/commands/wallet/lend/runLendAction.ts | 8 +++++--- packages/cli/src/output/printOutput.ts | 3 ++- packages/sdk/src/index.ts | 3 ++- packages/sdk/src/types/actions.ts | 9 +++++++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/wallet/lend/runLendAction.ts b/packages/cli/src/commands/wallet/lend/runLendAction.ts index e32ddef04..aef629b7e 100644 --- a/packages/cli/src/commands/wallet/lend/runLendAction.ts +++ b/packages/cli/src/commands/wallet/lend/runLendAction.ts @@ -1,4 +1,8 @@ -import { APPROVAL_MODES, type ApprovalMode } from '@eth-optimism/actions-sdk' +import { + APPROVAL_MODES, + type ApprovalMode, + type LendAction, +} from '@eth-optimism/actions-sdk' import { walletContext } from '@/context/walletContext.js' import { CliError, rethrowAsCliError } from '@/output/errors.js' @@ -23,8 +27,6 @@ export type LendCloseFlags = | { market: string; amount: string; max?: never } | { market: string; amount?: never; max: true } -type LendAction = 'open' | 'close' - function parseApprovalMode(raw: string | undefined): ApprovalMode | undefined { if (raw === undefined) return undefined if ((APPROVAL_MODES as readonly string[]).includes(raw)) { diff --git a/packages/cli/src/output/printOutput.ts b/packages/cli/src/output/printOutput.ts index f0d4acde3..127402139 100644 --- a/packages/cli/src/output/printOutput.ts +++ b/packages/cli/src/output/printOutput.ts @@ -1,5 +1,6 @@ import type { Asset, + LendAction, LendMarket, LendMarketPosition, LendProviderName, @@ -29,7 +30,7 @@ export interface AddressDoc { } export interface LendActionDoc { - action: 'open' | 'close' + action: LendAction market: { name: string address: Address diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1398f77b7..b96d665b2 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -81,6 +81,7 @@ export type { ApyBreakdown, Asset, EOATransactionReceipt, + LendAction, LendConfig, LendMarket, LendMarketConfig, @@ -114,7 +115,7 @@ export type { WalletConfig, WalletSwapParams, } from '@/types/index.js' -export { APPROVAL_MODES } from '@/types/index.js' +export { APPROVAL_MODES, LEND_ACTIONS } from '@/types/index.js' export { getAssetAddress, isAssetSupportedOnChain } from '@/utils/assets.js' export { serializeBigInt } from '@/utils/serializers.js' export * from '@/wallet/core/error/errors.js' diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index 6b8cc6226..423d8e1e5 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -155,6 +155,15 @@ export const APPROVAL_MODES = ['exact', 'max'] as const export type ApprovalMode = (typeof APPROVAL_MODES)[number] +/** + * The lend write actions exposed by the SDK's wallet namespace + * (`openPosition` / `closePosition`). Useful for callers that emit + * action-tagged output envelopes or branch on the action being performed. + */ +export const LEND_ACTIONS = ['open', 'close'] as const + +export type LendAction = (typeof LEND_ACTIONS)[number] + /** * Actions SDK configuration * @description Configuration object for initializing the Actions SDK From b4cd5fcb656a13b28180aa8ce0e5cda58b9cb138 Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 09:42:17 -0700 Subject: [PATCH 64/76] add getLendMarketAllowlist sdk helper --- packages/cli/src/resolvers/markets.ts | 18 +++---- packages/sdk/src/index.ts | 1 + .../src/utils/__tests__/lendConfig.test.ts | 52 +++++++++++++++++++ packages/sdk/src/utils/lendConfig.ts | 24 +++++++++ 4 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 packages/sdk/src/utils/__tests__/lendConfig.test.ts create mode 100644 packages/sdk/src/utils/lendConfig.ts diff --git a/packages/cli/src/resolvers/markets.ts b/packages/cli/src/resolvers/markets.ts index ece67530f..259530f09 100644 --- a/packages/cli/src/resolvers/markets.ts +++ b/packages/cli/src/resolvers/markets.ts @@ -1,7 +1,7 @@ -import type { - LendMarketConfig, - LendProviderConfig, - NodeActionsConfig, +import { + getLendMarketAllowlist, + type LendMarketConfig, + type NodeActionsConfig, } from '@eth-optimism/actions-sdk' import { CliError } from '@/output/errors.js' @@ -11,20 +11,14 @@ function normalize(value: string): string { } /** - * @description Walks `ActionsConfig.lend` and flattens every provider's `marketAllowlist` into a single list. Used by callers as the input to `resolveMarket`. The implementation skips the `settings` sibling key — `LendConfig` mixes provider configs with a sibling `LendSettings` entry, and only provider entries carry `marketAllowlist`. + * @description Returns every market allowlisted across the configured lend providers. Thin wrapper around `getLendMarketAllowlist(config.lend)`; kept here so CLI call sites read `configuredMarkets(config)` symmetrically with `configuredAssets(config)` / `configuredChains(config)`. * @param config - Resolved CLI config. * @returns Flat array of every allowlisted market across all providers. */ export function configuredMarkets( config: NodeActionsConfig, ): readonly LendMarketConfig[] { - const out: LendMarketConfig[] = [] - for (const [key, value] of Object.entries(config.lend ?? {})) { - if (key === 'settings') continue - const provider = value as LendProviderConfig | undefined - if (provider?.marketAllowlist) out.push(...provider.marketAllowlist) - } - return out + return getLendMarketAllowlist(config.lend) } /** diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b96d665b2..fd2dcfb23 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -117,6 +117,7 @@ export type { } from '@/types/index.js' export { APPROVAL_MODES, LEND_ACTIONS } from '@/types/index.js' export { getAssetAddress, isAssetSupportedOnChain } from '@/utils/assets.js' +export { getLendMarketAllowlist } from '@/utils/lendConfig.js' export { serializeBigInt } from '@/utils/serializers.js' export * from '@/wallet/core/error/errors.js' export { Wallet } from '@/wallet/core/wallets/abstract/Wallet.js' diff --git a/packages/sdk/src/utils/__tests__/lendConfig.test.ts b/packages/sdk/src/utils/__tests__/lendConfig.test.ts new file mode 100644 index 000000000..19f456afd --- /dev/null +++ b/packages/sdk/src/utils/__tests__/lendConfig.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { MockUSDCAsset } from '@/__mocks__/MockAssets.js' +import type { LendConfig } from '@/types/actions.js' +import type { LendMarketConfig } from '@/types/lend/base.js' +import { getLendMarketAllowlist } from '@/utils/lendConfig.js' + +const morphoMarket: LendMarketConfig = { + address: '0x0000000000000000000000000000000000000001', + chainId: 130, + name: 'Morpho USDC', + asset: MockUSDCAsset, + lendProvider: 'morpho', +} + +const aaveMarket: LendMarketConfig = { + address: '0x0000000000000000000000000000000000000002', + chainId: 130, + name: 'Aave USDC', + asset: MockUSDCAsset, + lendProvider: 'aave', +} + +describe('getLendMarketAllowlist', () => { + it('returns empty list when lend config is undefined', () => { + expect(getLendMarketAllowlist(undefined)).toEqual([]) + }) + + it('flattens allowlists across all providers', () => { + const lend: LendConfig = { + morpho: { marketAllowlist: [morphoMarket] }, + aave: { marketAllowlist: [aaveMarket] }, + } + expect(getLendMarketAllowlist(lend)).toEqual([morphoMarket, aaveMarket]) + }) + + it('skips the settings sibling key', () => { + const lend: LendConfig = { + morpho: { marketAllowlist: [morphoMarket] }, + settings: { approvalMode: 'max' }, + } + expect(getLendMarketAllowlist(lend)).toEqual([morphoMarket]) + }) + + it('returns empty list when no provider declares an allowlist', () => { + const lend: LendConfig = { + morpho: {}, + settings: { approvalMode: 'exact' }, + } + expect(getLendMarketAllowlist(lend)).toEqual([]) + }) +}) diff --git a/packages/sdk/src/utils/lendConfig.ts b/packages/sdk/src/utils/lendConfig.ts new file mode 100644 index 000000000..94ad4e28e --- /dev/null +++ b/packages/sdk/src/utils/lendConfig.ts @@ -0,0 +1,24 @@ +import type { LendConfig } from '@/types/actions.js' +import type { LendMarketConfig, LendProviderConfig } from '@/types/lend/base.js' + +/** + * Flatten every provider's `marketAllowlist` from a `LendConfig` into a single + * list. Skips the `settings` sibling key — `LendConfig` mixes per-provider + * configs with a `LendSettings` entry, and only provider entries carry + * `marketAllowlist`. + * + * Returns an empty list when `lend` is undefined or no provider declares an + * allowlist. + */ +export function getLendMarketAllowlist( + lend: LendConfig | undefined, +): readonly LendMarketConfig[] { + if (!lend) return [] + const out: LendMarketConfig[] = [] + for (const [key, value] of Object.entries(lend)) { + if (key === 'settings') continue + const provider = value as LendProviderConfig | undefined + if (provider?.marketAllowlist) out.push(...provider.marketAllowlist) + } + return out +} From b316879b8a50ffa4fe563550054dc1fe96dae085 Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 09:47:09 -0700 Subject: [PATCH 65/76] use getAssetAddress in demo markets --- packages/cli/src/demo/markets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/demo/markets.ts b/packages/cli/src/demo/markets.ts index 9fac537f3..f124b4074 100644 --- a/packages/cli/src/demo/markets.ts +++ b/packages/cli/src/demo/markets.ts @@ -1,10 +1,10 @@ import { ETH, + getAssetAddress, type LendMarketConfig, USDC_DEMO, WETH, } from '@eth-optimism/actions-sdk' -import type { Address } from 'viem' import { baseSepolia, optimismSepolia } from 'viem/chains' /** @@ -26,7 +26,7 @@ export const GauntletUSDCDemo: LendMarketConfig = { * gateway. Mirrored from the demo backend's config. */ export const AaveETH: LendMarketConfig = { - address: WETH.address[optimismSepolia.id] as Address, + address: getAssetAddress(WETH, optimismSepolia.id), chainId: optimismSepolia.id, name: 'Aave ETH', asset: ETH, From 8c79cf5910cd3085832a5298f18bc9d37275a9fc Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 10:07:19 -0700 Subject: [PATCH 66/76] add CHAIN_SHORTNAMES sdk constant --- packages/cli/src/resolvers/chains.ts | 32 ++++-------- packages/sdk/src/constants/chainShortnames.ts | 52 +++++++++++++++++++ packages/sdk/src/index.ts | 1 + 3 files changed, 64 insertions(+), 21 deletions(-) create mode 100644 packages/sdk/src/constants/chainShortnames.ts diff --git a/packages/cli/src/resolvers/chains.ts b/packages/cli/src/resolvers/chains.ts index d8001e710..beed432ae 100644 --- a/packages/cli/src/resolvers/chains.ts +++ b/packages/cli/src/resolvers/chains.ts @@ -1,18 +1,16 @@ -import type { SupportedChainId } from '@eth-optimism/actions-sdk' -import { base, baseSepolia, optimism, optimismSepolia } from 'viem/chains' +import { + CHAIN_SHORTNAMES, + type SupportedChainId, +} from '@eth-optimism/actions-sdk' import { CliError } from '@/output/errors.js' import { splitCsv } from '@/utils/strings.js' -const SHORTNAMES: Record = { - base: base.id, - 'base-sepolia': baseSepolia.id, - optimism: optimism.id, - 'op-sepolia': optimismSepolia.id, -} - -const CHAIN_IDS: Record = Object.fromEntries( - Object.entries(SHORTNAMES).map(([name, id]) => [id, name]), +const SHORTNAMES: Record = Object.fromEntries( + Object.entries(CHAIN_SHORTNAMES).map(([id, name]) => [ + name, + Number(id) as SupportedChainId, + ]), ) /** @@ -34,9 +32,7 @@ export function resolveChain( if (id === undefined || !configuredChainIds.includes(id)) { throw new CliError('validation', `Unknown chain: ${shortname}`, { chain: shortname, - allowed: configuredChainIds - .map((cid) => CHAIN_IDS[cid]) - .filter((name): name is string => name !== undefined), + allowed: configuredChainIds.map((cid) => CHAIN_SHORTNAMES[cid]), }) } return id @@ -53,13 +49,7 @@ export function resolveChain( * @throws `CliError` with code `validation` when the chain has no shortname. */ export function shortnameFor(chainId: SupportedChainId): string { - const name = CHAIN_IDS[chainId] - if (!name) { - throw new CliError('validation', `No shortname for chainId: ${chainId}`, { - chainId, - }) - } - return name + return CHAIN_SHORTNAMES[chainId] } /** diff --git a/packages/sdk/src/constants/chainShortnames.ts b/packages/sdk/src/constants/chainShortnames.ts new file mode 100644 index 000000000..7938a9451 --- /dev/null +++ b/packages/sdk/src/constants/chainShortnames.ts @@ -0,0 +1,52 @@ +import { + base, + baseSepolia, + bob, + celo, + fraxtal, + ink, + lisk, + mainnet, + metalL2, + mode, + optimism, + optimismSepolia, + sepolia, + soneium, + superseed, + swellchain, + unichain, + unichainSepolia, + worldchain, +} from 'viem/chains' + +import type { SupportedChainId } from '@/constants/supportedChains.js' + +/** + * Canonical CLI / human-friendly shortname for each `SupportedChainId`. + * Use this as the source of truth for `--chain` flag parsing and any other + * surface that maps a user-typed chain string to a `SupportedChainId`. New + * `SupportedChainId` additions must add a corresponding entry here so they + * surface in CLI / tooling validation. + */ +export const CHAIN_SHORTNAMES: Record = { + [mainnet.id]: 'mainnet', + [sepolia.id]: 'sepolia', + [optimism.id]: 'optimism', + [optimismSepolia.id]: 'op-sepolia', + [base.id]: 'base', + [baseSepolia.id]: 'base-sepolia', + [unichain.id]: 'unichain', + [unichainSepolia.id]: 'unichain-sepolia', + [worldchain.id]: 'worldchain', + [bob.id]: 'bob', + [celo.id]: 'celo', + [fraxtal.id]: 'fraxtal', + [ink.id]: 'ink', + [lisk.id]: 'lisk', + [metalL2.id]: 'metal', + [mode.id]: 'mode', + [soneium.id]: 'soneium', + [superseed.id]: 'superseed', + [swellchain.id]: 'swell', +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index fd2dcfb23..71ca99062 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -57,6 +57,7 @@ export { WLD, WSTETH, } from '@/constants/assets.js' +export { CHAIN_SHORTNAMES } from '@/constants/chainShortnames.js' export { ACTIONS_SUPPORTED_CHAIN_IDS, type SupportedChainId, From 7ec9a9b63454652457a0585b952c7771fd47f0c8 Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 10:22:31 -0700 Subject: [PATCH 67/76] add Wallet.has capability check --- .../commands/__tests__/walletLendClose.test.ts | 3 +++ .../src/commands/__tests__/walletLendOpen.test.ts | 10 +++++++++- .../commands/__tests__/walletLendPosition.test.ts | 3 +++ .../commands/wallet/lend/requireLendCapability.ts | 8 +++++--- .../src/wallet/core/wallets/abstract/Wallet.ts | 12 ++++++++++++ .../wallets/abstract/__tests__/Wallet.spec.ts | 15 +++++++++++++++ 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/__tests__/walletLendClose.test.ts b/packages/cli/src/commands/__tests__/walletLendClose.test.ts index 69940ff8f..5c2fbc017 100644 --- a/packages/cli/src/commands/__tests__/walletLendClose.test.ts +++ b/packages/cli/src/commands/__tests__/walletLendClose.test.ts @@ -51,6 +51,9 @@ describe('runWalletLendClose', () => { openPosition: async () => null, getPosition: getPosition ?? (async () => null), }, + has(namespace: 'lend' | 'swap') { + return namespace === 'lend' + }, } as never, }) } diff --git a/packages/cli/src/commands/__tests__/walletLendOpen.test.ts b/packages/cli/src/commands/__tests__/walletLendOpen.test.ts index f1dd1083a..56d12c52e 100644 --- a/packages/cli/src/commands/__tests__/walletLendOpen.test.ts +++ b/packages/cli/src/commands/__tests__/walletLendOpen.test.ts @@ -44,6 +44,9 @@ describe('runWalletLendOpen', () => { wallet: { address: '0xabc', lend: { openPosition, closePosition: async () => null }, + has(namespace: 'lend' | 'swap') { + return namespace === 'lend' + }, } as never, }) } @@ -212,7 +215,12 @@ describe('runWalletLendOpen', () => { config: getDemoConfig(), actions: {} as never, signer: {} as never, - wallet: { address: '0xabc' } as never, + wallet: { + address: '0xabc', + has() { + return false + }, + } as never, }) try { await runWalletLendOpen({ market: 'gauntlet-usdc', amount: '1' }) diff --git a/packages/cli/src/commands/__tests__/walletLendPosition.test.ts b/packages/cli/src/commands/__tests__/walletLendPosition.test.ts index 378e9bf96..8d40e49f8 100644 --- a/packages/cli/src/commands/__tests__/walletLendPosition.test.ts +++ b/packages/cli/src/commands/__tests__/walletLendPosition.test.ts @@ -45,6 +45,9 @@ describe('runWalletLendPosition', () => { closePosition: async () => null, } : undefined, + has(namespace: 'lend' | 'swap') { + return namespace === 'lend' && withLend + }, } as never, }) } diff --git a/packages/cli/src/commands/wallet/lend/requireLendCapability.ts b/packages/cli/src/commands/wallet/lend/requireLendCapability.ts index 6b8bf4eff..869abdf8a 100644 --- a/packages/cli/src/commands/wallet/lend/requireLendCapability.ts +++ b/packages/cli/src/commands/wallet/lend/requireLendCapability.ts @@ -1,14 +1,16 @@ +import type { Wallet } from '@eth-optimism/actions-sdk' + import { CliError } from '@/output/errors.js' /** - * @description Asserts that a `Wallet` has the lend namespace configured. Narrows `wallet.lend` to non-null on the caller side so each handler can reach `wallet.lend.openPosition` etc. without re-checking. Throws `CliError('config')` when no lend providers are configured (`ActionsConfig.lend` was omitted or empty). + * @description Asserts that a `Wallet` has the lend namespace configured. Defers the runtime check to `Wallet.has('lend')` and narrows `wallet.lend` to non-null on the caller side so each handler can reach `wallet.lend.openPosition` etc. without re-checking. Throws `CliError('config')` when no lend providers are configured (`ActionsConfig.lend` was omitted or empty). * @param wallet - Wallet instance from `walletContext()`. * @throws `CliError` with code `config` when `wallet.lend` is undefined. */ -export function requireLendCapability( +export function requireLendCapability( wallet: W, ): asserts wallet is W & { lend: NonNullable } { - if (!wallet.lend) { + if (!wallet.has('lend')) { throw new CliError( 'config', 'Lending is not configured (no providers in config.lend)', diff --git a/packages/sdk/src/wallet/core/wallets/abstract/Wallet.ts b/packages/sdk/src/wallet/core/wallets/abstract/Wallet.ts index def4fbcec..bd7c9acb4 100644 --- a/packages/sdk/src/wallet/core/wallets/abstract/Wallet.ts +++ b/packages/sdk/src/wallet/core/wallets/abstract/Wallet.ts @@ -84,6 +84,18 @@ export abstract class Wallet { } } + /** + * Check whether a wallet namespace (`lend`, `swap`) is configured on this + * wallet. Useful for callers that branch on capability instead of catching + * a `TypeError` from `wallet.lend!.openPosition(...)` later. Returns `false` + * when the namespace is undefined (no providers were registered for it). + * @param namespace - Wallet namespace name to probe. + * @returns `true` when the namespace is configured. + */ + has(namespace: 'lend' | 'swap'): boolean { + return this[namespace] !== undefined + } + /** * Get asset balances across the requested chains (or all supported chains). * @description Fetches ETH and ERC20 token balances for this wallet. By default queries every chain returned by the SDK's `ChainManager`. Pass `options.chainIds` to restrict the query to a subset of those chains; each id is validated against the configured chains and an `InvalidParamsError` / `ChainNotSupportedError` is thrown for unusable input. Uses the configured supported assets from `ActionsConfig.assets` if provided. diff --git a/packages/sdk/src/wallet/core/wallets/abstract/__tests__/Wallet.spec.ts b/packages/sdk/src/wallet/core/wallets/abstract/__tests__/Wallet.spec.ts index 29f5e2af7..41437c09b 100644 --- a/packages/sdk/src/wallet/core/wallets/abstract/__tests__/Wallet.spec.ts +++ b/packages/sdk/src/wallet/core/wallets/abstract/__tests__/Wallet.spec.ts @@ -125,4 +125,19 @@ describe('Wallet (base)', () => { expect(wallet.lend).toBeDefined() expect(wallet.lend).toEqual({}) }) + + describe('has', () => { + it("returns false for a namespace that wasn't configured", () => { + const wallet = new TestWallet(chainManager, address, signer) + expect(wallet.has('lend')).toBe(false) + expect(wallet.has('swap')).toBe(false) + }) + + it('returns true once a namespace has been attached', () => { + const wallet = new TestWallet(chainManager, address, signer) + wallet.lend = {} as WalletLendNamespace + expect(wallet.has('lend')).toBe(true) + expect(wallet.has('swap')).toBe(false) + }) + }) }) From 5c59eb3fa05f9f45cb00508aae12efbc47d651f5 Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 10:40:18 -0700 Subject: [PATCH 68/76] derive lend help examples from config --- packages/cli/src/commands/actions/lend/index.ts | 16 +++++++++++++--- packages/cli/src/commands/wallet/index.ts | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/actions/lend/index.ts b/packages/cli/src/commands/actions/lend/index.ts index a0f209766..7e8aa3507 100644 --- a/packages/cli/src/commands/actions/lend/index.ts +++ b/packages/cli/src/commands/actions/lend/index.ts @@ -2,6 +2,9 @@ import { Command } from 'commander' import { runLendMarket } from '@/commands/actions/lend/market.js' import { runLendMarkets } from '@/commands/actions/lend/markets.js' +import { loadConfig } from '@/config/loadConfig.js' +import { configuredAssets } from '@/resolvers/assets.js' +import { shortnameFor } from '@/resolvers/chains.js' /** * @description Builds the root `lend` subcommand tree. Children read @@ -11,6 +14,13 @@ import { runLendMarkets } from '@/commands/actions/lend/markets.js' * @returns Commander `Command` configured with `markets` and `market`. */ export function lendCommand(): Command { + const config = loadConfig() + const assetExample = configuredAssets(config)[0]?.metadata.symbol ?? 'USDC' + const firstChainId = config.chains[0]?.chainId + const chainExample = firstChainId + ? shortnameFor(firstChainId) + : 'base-sepolia' + const chainIdExample = firstChainId?.toString() ?? '84532' const command = new Command('lend').description( 'Read-only lending market commands (no PRIVATE_KEY required).', ) @@ -19,15 +29,15 @@ export function lendCommand(): Command { .description('List all lending markets across configured providers.') .option( '--asset ', - 'filter to markets denominated in one asset (e.g. USDC_DEMO). Case-insensitive.', + `filter to markets denominated in one asset (e.g. ${assetExample}). Case-insensitive.`, ) .option( '--chain ', - 'filter to markets on one chain by shortname (e.g. base-sepolia); mutually exclusive with --chain-id', + `filter to markets on one chain by shortname (e.g. ${chainExample}); mutually exclusive with --chain-id`, ) .option( '--chain-id ', - 'filter to markets on one chain by numeric id (e.g. 84532); mutually exclusive with --chain', + `filter to markets on one chain by numeric id (e.g. ${chainIdExample}); mutually exclusive with --chain`, ) .action(runLendMarkets) command diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts index 72790c323..2d4d2f755 100644 --- a/packages/cli/src/commands/wallet/index.ts +++ b/packages/cli/src/commands/wallet/index.ts @@ -4,6 +4,8 @@ import { runWalletAddress } from '@/commands/wallet/address.js' import { runWalletBalance } from '@/commands/wallet/balance.js' import { walletLendCommand } from '@/commands/wallet/lend/index.js' import { walletSwapCommand } from '@/commands/wallet/swap/index.js' +import { loadConfig } from '@/config/loadConfig.js' +import { shortnameFor } from '@/resolvers/chains.js' /** * @description Builds the `wallet` subcommand tree. Registered children @@ -11,6 +13,15 @@ import { walletSwapCommand } from '@/commands/wallet/swap/index.js' * @returns Commander `Command` configured with its subcommands. */ export function walletCommand(): Command { + const config = loadConfig() + const ids = config.chains.map((c) => c.chainId).slice(0, 2) + const firstShortname = ids[0] ? shortnameFor(ids[0]) : 'base-sepolia' + const secondShortname = ids[1] ? shortnameFor(ids[1]) : 'op-sepolia' + const shortnameList = + ids.length > 1 ? `${firstShortname},${secondShortname}` : firstShortname + const firstId = ids[0]?.toString() ?? '84532' + const secondId = ids[1]?.toString() ?? '11155420' + const idList = ids.length > 1 ? `${firstId},${secondId}` : firstId const command = new Command('wallet').description( 'Wallet-scoped commands (require PRIVATE_KEY).', ) @@ -23,11 +34,11 @@ export function walletCommand(): Command { .description('Print ETH and ERC-20 balances across every configured chain.') .option( '--chain ', - 'filter to one or more chains by shortname; comma-separated (e.g. base-sepolia or base-sepolia,op-sepolia); mutually exclusive with --chain-id', + `filter to one or more chains by shortname; comma-separated (e.g. ${firstShortname} or ${shortnameList}); mutually exclusive with --chain-id`, ) .option( '--chain-id ', - 'filter to one or more chains by numeric id; comma-separated (e.g. 84532 or 84532,130); mutually exclusive with --chain', + `filter to one or more chains by numeric id; comma-separated (e.g. ${firstId} or ${idList}); mutually exclusive with --chain`, ) .action(runWalletBalance) command.addCommand(walletLendCommand()) From 8b199c713206e57115f14dd34fb03de7a194ef6e Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 10:43:23 -0700 Subject: [PATCH 69/76] pass market directly to getMarket --- packages/cli/src/commands/actions/lend/market.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/commands/actions/lend/market.ts b/packages/cli/src/commands/actions/lend/market.ts index f42cf5ed9..e43abe09e 100644 --- a/packages/cli/src/commands/actions/lend/market.ts +++ b/packages/cli/src/commands/actions/lend/market.ts @@ -10,10 +10,7 @@ export async function runLendMarket(flags: { market: string }): Promise { const { actions, config } = baseContext() const market = resolveMarket(flags.market, configuredMarkets(config)) try { - const result = await actions.lend.getMarket({ - address: market.address, - chainId: market.chainId, - }) + const result = await actions.lend.getMarket(market) printOutput('lendMarket', result) } catch (err) { rethrowAsCliError(err) From e24b93a8efd716f06843a41383d037d3316c9cbf Mon Sep 17 00:00:00 2001 From: everdred Date: Wed, 6 May 2026 10:52:38 -0700 Subject: [PATCH 70/76] add changeset for boundary cleanup --- .changeset/sdk-cli-boundary-cleanup.md | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/sdk-cli-boundary-cleanup.md diff --git a/.changeset/sdk-cli-boundary-cleanup.md b/.changeset/sdk-cli-boundary-cleanup.md new file mode 100644 index 000000000..fec6b2263 --- /dev/null +++ b/.changeset/sdk-cli-boundary-cleanup.md @@ -0,0 +1,29 @@ +--- +'@eth-optimism/actions-sdk': minor +'actions-cli': patch +--- + +SDK: barrel-export the lend / approval / capability vocabulary that downstream +tooling was reaching past the public API to consume. + +- Re-export `ApprovalMode`, `LendProviderName`, and the new `LendAction` literal + from the package root. +- Add a runtime mirror for each: `APPROVAL_MODES` and `LEND_ACTIONS`. + `ApprovalMode` and `LendAction` are now derived from these tuples, so the + type and the runtime list cannot drift. +- Add `CHAIN_SHORTNAMES`, a canonical `Record` of + human-friendly chain shortnames (`base`, `op-sepolia`, ...). Use this as + the source of truth for `--chain` parsing and any other surface that maps + user-typed chain strings to a `SupportedChainId`. Adding a new + `SupportedChainId` requires a corresponding entry here. +- Add `getLendMarketAllowlist(lend)`, which flattens every provider's + `marketAllowlist` from a `LendConfig` and skips the `settings` sibling. +- Add `Wallet.has(namespace)` capability check for the `lend` and `swap` + namespaces. Lets callers branch on whether a namespace was registered + without poking at internal fields. + +CLI: drop the local mirrors and reach for the SDK exports instead. Help-text +examples now derive from the resolved config (asset symbols, chain shortnames, +chain ids) rather than hard-coding `USDC_DEMO` / `base-sepolia` / `84532`. +`runLendMarket` passes the resolved `LendMarketConfig` straight through to +`actions.lend.getMarket` instead of rebuilding `{address, chainId}`. From d42c925807f4f709e9ea872a74742ccca84e7536 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Thu, 7 May 2026 14:08:17 -0700 Subject: [PATCH 71/76] format DeployMorphoMarket prettier drift --- .../demo/contracts/script/DeployMorphoMarket.s.sol | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/demo/contracts/script/DeployMorphoMarket.s.sol b/packages/demo/contracts/script/DeployMorphoMarket.s.sol index 5c73548a8..369948047 100644 --- a/packages/demo/contracts/script/DeployMorphoMarket.s.sol +++ b/packages/demo/contracts/script/DeployMorphoMarket.s.sol @@ -51,7 +51,11 @@ contract DeployMorphoMarket is Script { // Create market params MarketParams memory marketParams = MarketParams({ - loanToken: address(usdc), collateralToken: address(op), oracle: address(oracle), irm: IRM, lltv: LLTV + loanToken: address(usdc), + collateralToken: address(op), + oracle: address(oracle), + irm: IRM, + lltv: LLTV }); // Create Morpho market @@ -59,8 +63,9 @@ contract DeployMorphoMarket is Script { // Create MetaMorpho vault with 0 timelock (V1.1) bytes32 salt = keccak256(abi.encodePacked("actions-demo-vault", block.timestamp)); - address vault = IMetaMorphoFactory(METAMORPHO_FACTORY_V1_1) - .createMetaMorpho(msg.sender, 0, address(usdc), "Actions Demo USDC Vault", "dUSDC", salt); + address vault = IMetaMorphoFactory(METAMORPHO_FACTORY_V1_1).createMetaMorpho( + msg.sender, 0, address(usdc), "Actions Demo USDC Vault", "dUSDC", salt + ); console.log("Vault:", vault); // Submit and immediately accept cap (0 timelock) From 716975bf40b1451f5d5d1d5fd17313a36d26bf44 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Thu, 7 May 2026 15:28:26 -0700 Subject: [PATCH 72/76] hardcode chain examples for help text --- packages/cli/src/commands/actions/lend/index.ts | 15 +++++---------- packages/cli/src/commands/wallet/index.ts | 16 +++------------- packages/cli/src/resolvers/chains.ts | 11 +++++++++++ 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/actions/lend/index.ts b/packages/cli/src/commands/actions/lend/index.ts index 7e8aa3507..dec313cc0 100644 --- a/packages/cli/src/commands/actions/lend/index.ts +++ b/packages/cli/src/commands/actions/lend/index.ts @@ -4,7 +4,7 @@ import { runLendMarket } from '@/commands/actions/lend/market.js' import { runLendMarkets } from '@/commands/actions/lend/markets.js' import { loadConfig } from '@/config/loadConfig.js' import { configuredAssets } from '@/resolvers/assets.js' -import { shortnameFor } from '@/resolvers/chains.js' +import { CHAIN_EXAMPLES } from '@/resolvers/chains.js' /** * @description Builds the root `lend` subcommand tree. Children read @@ -14,13 +14,8 @@ import { shortnameFor } from '@/resolvers/chains.js' * @returns Commander `Command` configured with `markets` and `market`. */ export function lendCommand(): Command { - const config = loadConfig() - const assetExample = configuredAssets(config)[0]?.metadata.symbol ?? 'USDC' - const firstChainId = config.chains[0]?.chainId - const chainExample = firstChainId - ? shortnameFor(firstChainId) - : 'base-sepolia' - const chainIdExample = firstChainId?.toString() ?? '84532' + const assetExample = + configuredAssets(loadConfig())[0]?.metadata.symbol ?? 'USDC' const command = new Command('lend').description( 'Read-only lending market commands (no PRIVATE_KEY required).', ) @@ -33,11 +28,11 @@ export function lendCommand(): Command { ) .option( '--chain ', - `filter to markets on one chain by shortname (e.g. ${chainExample}); mutually exclusive with --chain-id`, + `filter to markets on one chain by shortname (e.g. ${CHAIN_EXAMPLES.shortname}); mutually exclusive with --chain-id`, ) .option( '--chain-id ', - `filter to markets on one chain by numeric id (e.g. ${chainIdExample}); mutually exclusive with --chain`, + `filter to markets on one chain by numeric id (e.g. ${CHAIN_EXAMPLES.chainId}); mutually exclusive with --chain`, ) .action(runLendMarkets) command diff --git a/packages/cli/src/commands/wallet/index.ts b/packages/cli/src/commands/wallet/index.ts index 2d4d2f755..c22dbcdfb 100644 --- a/packages/cli/src/commands/wallet/index.ts +++ b/packages/cli/src/commands/wallet/index.ts @@ -4,8 +4,7 @@ import { runWalletAddress } from '@/commands/wallet/address.js' import { runWalletBalance } from '@/commands/wallet/balance.js' import { walletLendCommand } from '@/commands/wallet/lend/index.js' import { walletSwapCommand } from '@/commands/wallet/swap/index.js' -import { loadConfig } from '@/config/loadConfig.js' -import { shortnameFor } from '@/resolvers/chains.js' +import { CHAIN_EXAMPLES } from '@/resolvers/chains.js' /** * @description Builds the `wallet` subcommand tree. Registered children @@ -13,15 +12,6 @@ import { shortnameFor } from '@/resolvers/chains.js' * @returns Commander `Command` configured with its subcommands. */ export function walletCommand(): Command { - const config = loadConfig() - const ids = config.chains.map((c) => c.chainId).slice(0, 2) - const firstShortname = ids[0] ? shortnameFor(ids[0]) : 'base-sepolia' - const secondShortname = ids[1] ? shortnameFor(ids[1]) : 'op-sepolia' - const shortnameList = - ids.length > 1 ? `${firstShortname},${secondShortname}` : firstShortname - const firstId = ids[0]?.toString() ?? '84532' - const secondId = ids[1]?.toString() ?? '11155420' - const idList = ids.length > 1 ? `${firstId},${secondId}` : firstId const command = new Command('wallet').description( 'Wallet-scoped commands (require PRIVATE_KEY).', ) @@ -34,11 +24,11 @@ export function walletCommand(): Command { .description('Print ETH and ERC-20 balances across every configured chain.') .option( '--chain ', - `filter to one or more chains by shortname; comma-separated (e.g. ${firstShortname} or ${shortnameList}); mutually exclusive with --chain-id`, + `filter to one or more chains by shortname; comma-separated (e.g. ${CHAIN_EXAMPLES.shortname} or ${CHAIN_EXAMPLES.shortnameList}); mutually exclusive with --chain-id`, ) .option( '--chain-id ', - `filter to one or more chains by numeric id; comma-separated (e.g. ${firstId} or ${idList}); mutually exclusive with --chain`, + `filter to one or more chains by numeric id; comma-separated (e.g. ${CHAIN_EXAMPLES.chainId} or ${CHAIN_EXAMPLES.chainIdList}); mutually exclusive with --chain`, ) .action(runWalletBalance) command.addCommand(walletLendCommand()) diff --git a/packages/cli/src/resolvers/chains.ts b/packages/cli/src/resolvers/chains.ts index beed432ae..a3a04e848 100644 --- a/packages/cli/src/resolvers/chains.ts +++ b/packages/cli/src/resolvers/chains.ts @@ -13,6 +13,17 @@ const SHORTNAMES: Record = Object.fromEntries( ]), ) +/** + * Canonical chain examples for help text. Hard-coded to base/op sepolia + * so help output stays stable regardless of the runtime config. + */ +export const CHAIN_EXAMPLES = { + shortname: 'base-sepolia', + shortnameList: 'base-sepolia,op-sepolia', + chainId: '84532', + chainIdList: '84532,11155420', +} as const + /** * @description Resolves a chain shortname (e.g. `base-sepolia`) to a * `SupportedChainId`. Restricted to the configured chain set so unknown From 7e678ea94961cbdf6d74bc08cd87805b7be9269e Mon Sep 17 00:00:00 2001 From: its-everdred Date: Thu, 7 May 2026 15:30:44 -0700 Subject: [PATCH 73/76] rename polling consts to L2 and MAINNET --- packages/sdk/src/services/ChainManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/services/ChainManager.ts b/packages/sdk/src/services/ChainManager.ts index b62a322eb..14ef4f15a 100644 --- a/packages/sdk/src/services/ChainManager.ts +++ b/packages/sdk/src/services/ChainManager.ts @@ -18,9 +18,9 @@ import { ChainNotSupportedError } from '@/core/error/errors.js' import type { ChainConfig } from '@/types/chain.js' /** viem `pollingInterval` (ms) for L2-class chains with ~1-2s blocks. */ -const FAST_CHAIN_POLLING_INTERVAL_MS = 1000 +const L2_POLLING_INTERVAL_MS = 1000 /** viem `pollingInterval` (ms) for L1-class chains with ~12s blocks. */ -const SLOW_CHAIN_POLLING_INTERVAL_MS = 4000 +const MAINNET_POLLING_INTERVAL_MS = 4000 const SLOW_CHAIN_IDS: ReadonlySet = new Set([ mainnet.id, @@ -29,8 +29,8 @@ const SLOW_CHAIN_IDS: ReadonlySet = new Set([ function pollingIntervalForChain(chainId: SupportedChainId): number { return SLOW_CHAIN_IDS.has(chainId) - ? SLOW_CHAIN_POLLING_INTERVAL_MS - : FAST_CHAIN_POLLING_INTERVAL_MS + ? MAINNET_POLLING_INTERVAL_MS + : L2_POLLING_INTERVAL_MS } /** From 8b02c52bcb15a3958172ea509274950b8eab87e2 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Thu, 7 May 2026 15:33:24 -0700 Subject: [PATCH 74/76] rename mainnet polling const to L1 --- packages/sdk/src/services/ChainManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/services/ChainManager.ts b/packages/sdk/src/services/ChainManager.ts index 14ef4f15a..e4edbe101 100644 --- a/packages/sdk/src/services/ChainManager.ts +++ b/packages/sdk/src/services/ChainManager.ts @@ -20,16 +20,16 @@ import type { ChainConfig } from '@/types/chain.js' /** viem `pollingInterval` (ms) for L2-class chains with ~1-2s blocks. */ const L2_POLLING_INTERVAL_MS = 1000 /** viem `pollingInterval` (ms) for L1-class chains with ~12s blocks. */ -const MAINNET_POLLING_INTERVAL_MS = 4000 +const L1_POLLING_INTERVAL_MS = 4000 -const SLOW_CHAIN_IDS: ReadonlySet = new Set([ +const L1_CHAIN_IDS: ReadonlySet = new Set([ mainnet.id, sepolia.id, ]) function pollingIntervalForChain(chainId: SupportedChainId): number { - return SLOW_CHAIN_IDS.has(chainId) - ? MAINNET_POLLING_INTERVAL_MS + return L1_CHAIN_IDS.has(chainId) + ? L1_POLLING_INTERVAL_MS : L2_POLLING_INTERVAL_MS } From b5a85ae8a8fadf48a10c9726d07c31b01b1ec3cf Mon Sep 17 00:00:00 2001 From: its-everdred Date: Thu, 7 May 2026 15:34:32 -0700 Subject: [PATCH 75/76] simplify lend allowlist with provider names --- packages/sdk/src/utils/lendConfig.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/sdk/src/utils/lendConfig.ts b/packages/sdk/src/utils/lendConfig.ts index 94ad4e28e..279fa2fa9 100644 --- a/packages/sdk/src/utils/lendConfig.ts +++ b/packages/sdk/src/utils/lendConfig.ts @@ -1,24 +1,17 @@ import type { LendConfig } from '@/types/actions.js' -import type { LendMarketConfig, LendProviderConfig } from '@/types/lend/base.js' +import type { LendMarketConfig } from '@/types/lend/base.js' +import { LEND_PROVIDER_NAMES } from '@/types/providers.js' /** * Flatten every provider's `marketAllowlist` from a `LendConfig` into a single - * list. Skips the `settings` sibling key — `LendConfig` mixes per-provider - * configs with a `LendSettings` entry, and only provider entries carry - * `marketAllowlist`. - * - * Returns an empty list when `lend` is undefined or no provider declares an - * allowlist. + * list. Returns an empty list when `lend` is undefined or no provider declares + * an allowlist. */ export function getLendMarketAllowlist( lend: LendConfig | undefined, ): readonly LendMarketConfig[] { if (!lend) return [] - const out: LendMarketConfig[] = [] - for (const [key, value] of Object.entries(lend)) { - if (key === 'settings') continue - const provider = value as LendProviderConfig | undefined - if (provider?.marketAllowlist) out.push(...provider.marketAllowlist) - } - return out + return LEND_PROVIDER_NAMES.flatMap( + (name) => lend[name]?.marketAllowlist ?? [], + ) } From 8c141babfc0b245c74b028c2531df0defa965636 Mon Sep 17 00:00:00 2001 From: its-everdred Date: Thu, 7 May 2026 16:01:01 -0700 Subject: [PATCH 76/76] derive chain shortnames from viem names --- .../src/resolvers/__tests__/chains.test.ts | 8 +- packages/cli/src/resolvers/chains.ts | 19 ++-- packages/demo/backend/src/controllers/swap.ts | 4 +- .../sdk/src/actions/lend/core/LendProvider.ts | 4 +- .../src/actions/shared/morpho/contracts.ts | 2 +- .../sdk/src/actions/swap/core/SwapProvider.ts | 4 +- packages/sdk/src/constants/chainShortnames.ts | 52 --------- packages/sdk/src/constants/supportedChains.ts | 102 ++++++++++++++---- packages/sdk/src/index.ts | 5 +- 9 files changed, 104 insertions(+), 96 deletions(-) delete mode 100644 packages/sdk/src/constants/chainShortnames.ts diff --git a/packages/cli/src/resolvers/__tests__/chains.test.ts b/packages/cli/src/resolvers/__tests__/chains.test.ts index 6511c94c7..53f30e154 100644 --- a/packages/cli/src/resolvers/__tests__/chains.test.ts +++ b/packages/cli/src/resolvers/__tests__/chains.test.ts @@ -12,12 +12,16 @@ const ALL: SupportedChainId[] = [ optimismSepolia.id, ] -const SHORTNAMES = ['base', 'base-sepolia', 'optimism', 'op-sepolia'] as const +const SHORTNAMES = ['base', 'base-sepolia', 'op-mainnet', 'op-sepolia'] as const describe('resolveChain', () => { it('resolves each canonical shortname to its chain id', () => { expect(resolveChain('base-sepolia', ALL)).toBe(baseSepolia.id) expect(resolveChain('op-sepolia', ALL)).toBe(optimismSepolia.id) + expect(resolveChain('op-mainnet', ALL)).toBe(optimism.id) + }) + + it('also accepts curated aliases on top of the viem name', () => { expect(resolveChain('optimism', ALL)).toBe(optimism.id) }) @@ -51,7 +55,7 @@ describe('shortnameFor', () => { it('returns the canonical shortname for each supported chain id', () => { expect(shortnameFor(baseSepolia.id)).toBe('base-sepolia') expect(shortnameFor(optimismSepolia.id)).toBe('op-sepolia') - expect(shortnameFor(optimism.id)).toBe('optimism') + expect(shortnameFor(optimism.id)).toBe('op-mainnet') }) }) diff --git a/packages/cli/src/resolvers/chains.ts b/packages/cli/src/resolvers/chains.ts index a3a04e848..5c7d78b55 100644 --- a/packages/cli/src/resolvers/chains.ts +++ b/packages/cli/src/resolvers/chains.ts @@ -1,18 +1,12 @@ import { - CHAIN_SHORTNAMES, + chainIdFromShortname, + SUPPORTED_CHAIN_SHORTNAMES, type SupportedChainId, } from '@eth-optimism/actions-sdk' import { CliError } from '@/output/errors.js' import { splitCsv } from '@/utils/strings.js' -const SHORTNAMES: Record = Object.fromEntries( - Object.entries(CHAIN_SHORTNAMES).map(([id, name]) => [ - name, - Number(id) as SupportedChainId, - ]), -) - /** * Canonical chain examples for help text. Hard-coded to base/op sepolia * so help output stays stable regardless of the runtime config. @@ -28,7 +22,8 @@ export const CHAIN_EXAMPLES = { * @description Resolves a chain shortname (e.g. `base-sepolia`) to a * `SupportedChainId`. Restricted to the configured chain set so unknown * shortnames or chains not in the active config surface as validation - * errors before the SDK sees them. Match is case-insensitive. + * errors before the SDK sees them. Match is case-insensitive and accepts + * both the canonical shortname and the viem-derived chain-name slug. * @param shortname - User-provided chain shortname from CLI argv. * @param configuredChainIds - Chain IDs present in the resolved config. * @returns The matching `SupportedChainId`. @@ -39,11 +34,11 @@ export function resolveChain( shortname: string, configuredChainIds: readonly SupportedChainId[], ): SupportedChainId { - const id = SHORTNAMES[shortname.toLowerCase()] + const id = chainIdFromShortname(shortname) if (id === undefined || !configuredChainIds.includes(id)) { throw new CliError('validation', `Unknown chain: ${shortname}`, { chain: shortname, - allowed: configuredChainIds.map((cid) => CHAIN_SHORTNAMES[cid]), + allowed: configuredChainIds.map((cid) => SUPPORTED_CHAIN_SHORTNAMES[cid]), }) } return id @@ -60,7 +55,7 @@ export function resolveChain( * @throws `CliError` with code `validation` when the chain has no shortname. */ export function shortnameFor(chainId: SupportedChainId): string { - return CHAIN_SHORTNAMES[chainId] + return SUPPORTED_CHAIN_SHORTNAMES[chainId] } /** diff --git a/packages/demo/backend/src/controllers/swap.ts b/packages/demo/backend/src/controllers/swap.ts index e8f2929d5..ed58a38eb 100644 --- a/packages/demo/backend/src/controllers/swap.ts +++ b/packages/demo/backend/src/controllers/swap.ts @@ -1,6 +1,6 @@ import { - ACTIONS_SUPPORTED_CHAIN_IDS, serializeBigInt, + SUPPORTED_CHAIN_IDS, type SupportedChainId, } from '@eth-optimism/actions-sdk' import type { Context } from 'hono' @@ -11,7 +11,7 @@ import { errorResponse, requireAuth } from '@/helpers/errors.js' import { validateRequest } from '@/helpers/validation.js' import * as swapService from '@/services/swap.js' -const supportedChainIds = ACTIONS_SUPPORTED_CHAIN_IDS as readonly number[] +const supportedChainIds = SUPPORTED_CHAIN_IDS as readonly number[] const providerEnum = z.enum(['uniswap', 'velodrome']).optional() const chainIdFromString = z diff --git a/packages/sdk/src/actions/lend/core/LendProvider.ts b/packages/sdk/src/actions/lend/core/LendProvider.ts index 38908e0c0..2b0dfe25d 100644 --- a/packages/sdk/src/actions/lend/core/LendProvider.ts +++ b/packages/sdk/src/actions/lend/core/LendProvider.ts @@ -3,7 +3,7 @@ import { parseUnits } from 'viem' import { validateMarketAsset } from '@/actions/lend/utils/markets.js' import type { SupportedChainId } from '@/constants/supportedChains.js' -import { ACTIONS_SUPPORTED_CHAIN_IDS } from '@/constants/supportedChains.js' +import { SUPPORTED_CHAIN_IDS } from '@/constants/supportedChains.js' import { AddressRequiredError, AssetMetadataRequiredError, @@ -95,7 +95,7 @@ export abstract class LendProvider< const configuredChains = this.chainManager.getSupportedChains() return this.protocolSupportedChainIds().filter( (id): id is SupportedChainId => - (ACTIONS_SUPPORTED_CHAIN_IDS as readonly number[]).includes(id) && + (SUPPORTED_CHAIN_IDS as readonly number[]).includes(id) && (configuredChains as readonly number[]).includes(id), ) } diff --git a/packages/sdk/src/actions/shared/morpho/contracts.ts b/packages/sdk/src/actions/shared/morpho/contracts.ts index f9b2e4de3..fb7c874d4 100644 --- a/packages/sdk/src/actions/shared/morpho/contracts.ts +++ b/packages/sdk/src/actions/shared/morpho/contracts.ts @@ -78,7 +78,7 @@ export function getMorphoContracts( /** * Get all chain IDs where Morpho contracts are deployed. * Returns chains present in the local contracts registry. - * Filtering against ACTIONS_SUPPORTED_CHAIN_IDS and developer-configured chains + * Filtering against SUPPORTED_CHAIN_IDS and developer-configured chains * is handled by the LendProvider base class. */ export function getSupportedChainIds(): number[] { diff --git a/packages/sdk/src/actions/swap/core/SwapProvider.ts b/packages/sdk/src/actions/swap/core/SwapProvider.ts index 787cd5cbf..13dcb015d 100644 --- a/packages/sdk/src/actions/swap/core/SwapProvider.ts +++ b/packages/sdk/src/actions/swap/core/SwapProvider.ts @@ -3,7 +3,7 @@ import { formatUnits } from 'viem' import { UNIVERSAL_ROUTER_MSG_SENDER } from '@/actions/swap/core/markets.js' import type { SupportedChainId } from '@/constants/supportedChains.js' -import { ACTIONS_SUPPORTED_CHAIN_IDS } from '@/constants/supportedChains.js' +import { SUPPORTED_CHAIN_IDS } from '@/constants/supportedChains.js' import { MarketNotAllowedError, ProviderNotConfiguredError, @@ -222,7 +222,7 @@ export abstract class SwapProvider< const configuredChains = this.chainManager.getSupportedChains() return this.protocolSupportedChainIds().filter( (id) => - (ACTIONS_SUPPORTED_CHAIN_IDS as readonly number[]).includes(id) && + (SUPPORTED_CHAIN_IDS as readonly number[]).includes(id) && configuredChains.includes(id), ) } diff --git a/packages/sdk/src/constants/chainShortnames.ts b/packages/sdk/src/constants/chainShortnames.ts deleted file mode 100644 index 7938a9451..000000000 --- a/packages/sdk/src/constants/chainShortnames.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - base, - baseSepolia, - bob, - celo, - fraxtal, - ink, - lisk, - mainnet, - metalL2, - mode, - optimism, - optimismSepolia, - sepolia, - soneium, - superseed, - swellchain, - unichain, - unichainSepolia, - worldchain, -} from 'viem/chains' - -import type { SupportedChainId } from '@/constants/supportedChains.js' - -/** - * Canonical CLI / human-friendly shortname for each `SupportedChainId`. - * Use this as the source of truth for `--chain` flag parsing and any other - * surface that maps a user-typed chain string to a `SupportedChainId`. New - * `SupportedChainId` additions must add a corresponding entry here so they - * surface in CLI / tooling validation. - */ -export const CHAIN_SHORTNAMES: Record = { - [mainnet.id]: 'mainnet', - [sepolia.id]: 'sepolia', - [optimism.id]: 'optimism', - [optimismSepolia.id]: 'op-sepolia', - [base.id]: 'base', - [baseSepolia.id]: 'base-sepolia', - [unichain.id]: 'unichain', - [unichainSepolia.id]: 'unichain-sepolia', - [worldchain.id]: 'worldchain', - [bob.id]: 'bob', - [celo.id]: 'celo', - [fraxtal.id]: 'fraxtal', - [ink.id]: 'ink', - [lisk.id]: 'lisk', - [metalL2.id]: 'metal', - [mode.id]: 'mode', - [soneium.id]: 'soneium', - [superseed.id]: 'superseed', - [swellchain.id]: 'swell', -} diff --git a/packages/sdk/src/constants/supportedChains.ts b/packages/sdk/src/constants/supportedChains.ts index b61b054e2..cab064d7c 100644 --- a/packages/sdk/src/constants/supportedChains.ts +++ b/packages/sdk/src/constants/supportedChains.ts @@ -20,26 +20,86 @@ import { worldchain, } from 'viem/chains' -export const ACTIONS_SUPPORTED_CHAIN_IDS = [ - mainnet.id, - sepolia.id, - optimism.id, - optimismSepolia.id, - base.id, - baseSepolia.id, - unichain.id, - unichainSepolia.id, - worldchain.id, - bob.id, - celo.id, - fraxtal.id, - ink.id, - lisk.id, - metalL2.id, - mode.id, - soneium.id, - superseed.id, - swellchain.id, +const slug = (name: string): string => name.toLowerCase().replace(/\s+/g, '-') + +/** + * Single source of truth for the chains the SDK supports. All other + * chain-related constants in this module are derived from this list. + */ +const SUPPORTED_CHAINS = [ + mainnet, + sepolia, + optimism, + optimismSepolia, + base, + baseSepolia, + unichain, + unichainSepolia, + worldchain, + bob, + celo, + fraxtal, + ink, + lisk, + metalL2, + mode, + soneium, + superseed, + swellchain, ] as const -export type SupportedChainId = (typeof ACTIONS_SUPPORTED_CHAIN_IDS)[number] +export type SupportedChainId = (typeof SUPPORTED_CHAINS)[number]['id'] + +export const SUPPORTED_CHAIN_IDS = SUPPORTED_CHAINS.map( + (c) => c.id, +) as readonly SupportedChainId[] + +/** + * Extra names that resolve to a chain in addition to its canonical + * `slug(chain.name)`. Add entries here only when there's a shorter or + * more familiar name worth accepting alongside the viem one. + */ +const EXTRA_CHAIN_ALIASES: Partial< + Record +> = { + [mainnet.id]: ['mainnet'], // viem: 'ethereum' + [optimism.id]: ['optimism'], // viem: 'op-mainnet' + [worldchain.id]: ['worldchain'], // viem: 'world-chain' + [metalL2.id]: ['metal'], // viem: 'metal-l2' + [mode.id]: ['mode'], // viem: 'mode-mainnet' + [soneium.id]: ['soneium'], // viem: 'soneium-mainnet' + [swellchain.id]: ['swell'], // viem: 'swellchain' +} + +/** + * Canonical CLI / human-friendly shortname for each supported chain: + * `slug(chain.name)` (e.g. `base-sepolia`, `ethereum`, `op-mainnet`). + * Use this for display. For input parsing prefer `chainIdFromShortname`, + * which also accepts entries from `EXTRA_CHAIN_ALIASES`. + */ +export const SUPPORTED_CHAIN_SHORTNAMES: Record = + Object.fromEntries( + SUPPORTED_CHAINS.map((chain) => [chain.id, slug(chain.name)]), + ) as Record + +const NAME_TO_ID: Record = (() => { + const index: Record = {} + for (const chain of SUPPORTED_CHAINS) { + const id = chain.id as SupportedChainId + index[SUPPORTED_CHAIN_SHORTNAMES[id]] = id + for (const alias of EXTRA_CHAIN_ALIASES[id] ?? []) index[alias] = id + } + return index +})() + +/** + * Resolve a user-typed chain name to a `SupportedChainId`. Accepts both + * the canonical shortname (`mainnet`, `optimism`) and the viem `chain.name` + * slug (`ethereum`, `op-mainnet`). Case-insensitive. Returns `undefined` + * for unknown names. + */ +export function chainIdFromShortname( + name: string, +): SupportedChainId | undefined { + return NAME_TO_ID[name.toLowerCase()] +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d1cbdb2fd..5ad2400b8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -57,9 +57,10 @@ export { WLD, WSTETH, } from '@/constants/assets.js' -export { CHAIN_SHORTNAMES } from '@/constants/chainShortnames.js' export { - ACTIONS_SUPPORTED_CHAIN_IDS, + chainIdFromShortname, + SUPPORTED_CHAIN_IDS, + SUPPORTED_CHAIN_SHORTNAMES, type SupportedChainId, } from '@/constants/supportedChains.js' export * from '@/core/error/errors.js'