From 5eaad7a4165df08a76fc44423527b7780fc293b4 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Tue, 24 Feb 2026 16:00:23 -0400 Subject: [PATCH 01/18] feat: add end-to-end plan and execution flows across protocols --- .github/workflows/nightly-execution-smoke.yml | 19 + AGENTS.md | 21 +- CHANGELOG.md | 24 +- README.md | 101 ++- docs/act-execution-design.md | 578 ++++++++++++++++++ go.mod | 27 +- go.sum | 151 ++++- internal/app/approvals_command.go | 241 ++++++++ internal/app/bridge_execution_commands.go | 311 ++++++++++ internal/app/lend_execution_commands.go | 291 +++++++++ internal/app/rewards_command.go | 548 +++++++++++++++++ internal/app/runner.go | 557 +++++++++++++++-- internal/app/runner_actions_test.go | 157 +++++ internal/config/config.go | 53 +- internal/config/config_test.go | 30 + internal/errors/errors.go | 5 + internal/execution/action.go | 15 + internal/execution/executor.go | 292 +++++++++ internal/execution/planner/aave.go | 545 +++++++++++++++++ internal/execution/planner/aave_test.go | 159 +++++ internal/execution/planner/approvals.go | 83 +++ internal/execution/planner/approvals_test.go | 57 ++ internal/execution/rpc.go | 45 ++ internal/execution/signer/local.go | 175 ++++++ internal/execution/signer/local_test.go | 65 ++ internal/execution/signer/signer.go | 13 + internal/execution/store.go | 169 +++++ internal/execution/store_test.go | 66 ++ internal/execution/types.go | 88 +++ internal/id/id.go | 10 + internal/id/id_test.go | 5 + internal/providers/aave/client.go | 4 + internal/providers/lifi/client.go | 231 ++++++- internal/providers/lifi/client_test.go | 202 ++++++ internal/providers/taikoswap/client.go | 307 ++++++++++ internal/providers/taikoswap/client_test.go | 172 ++++++ internal/providers/types.go | 26 + internal/registry/execution_data.go | 71 +++ internal/registry/execution_data_test.go | 48 ++ scripts/nightly_execution_smoke.sh | 69 +++ 40 files changed, 5959 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/nightly-execution-smoke.yml create mode 100644 docs/act-execution-design.md create mode 100644 internal/app/approvals_command.go create mode 100644 internal/app/bridge_execution_commands.go create mode 100644 internal/app/lend_execution_commands.go create mode 100644 internal/app/rewards_command.go create mode 100644 internal/app/runner_actions_test.go create mode 100644 internal/execution/action.go create mode 100644 internal/execution/executor.go create mode 100644 internal/execution/planner/aave.go create mode 100644 internal/execution/planner/aave_test.go create mode 100644 internal/execution/planner/approvals.go create mode 100644 internal/execution/planner/approvals_test.go create mode 100644 internal/execution/rpc.go create mode 100644 internal/execution/signer/local.go create mode 100644 internal/execution/signer/local_test.go create mode 100644 internal/execution/signer/signer.go create mode 100644 internal/execution/store.go create mode 100644 internal/execution/store_test.go create mode 100644 internal/execution/types.go create mode 100644 internal/providers/lifi/client_test.go create mode 100644 internal/providers/taikoswap/client.go create mode 100644 internal/providers/taikoswap/client_test.go create mode 100644 internal/registry/execution_data.go create mode 100644 internal/registry/execution_data_test.go create mode 100644 scripts/nightly_execution_smoke.sh diff --git a/.github/workflows/nightly-execution-smoke.yml b/.github/workflows/nightly-execution-smoke.yml new file mode 100644 index 0000000..071c797 --- /dev/null +++ b/.github/workflows/nightly-execution-smoke.yml @@ -0,0 +1,19 @@ +name: nightly-execution-smoke + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + execution-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: execution smoke checks + run: bash scripts/nightly_execution_smoke.sh diff --git a/AGENTS.md b/AGENTS.md index 8f2ac9e..c30edb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,11 @@ internal/ providers/ # external adapters aave/ morpho/ # direct GraphQL lending + yield defillama/ # market/yield normalization + fallback + bridge analytics - across/ lifi/ # bridge quotes - oneinch/ uniswap/ # swap quotes + across/ lifi/ # bridge quotes + lifi execution planning + oneinch/ uniswap/ taikoswap/ # swap quotes + taikoswap execution planning types.go # provider interfaces + execution/ # action persistence + planner helpers + signer abstraction + tx execution + registry/ # canonical execution endpoints/contracts/ABI fragments config/ # defaults + file/env/flags precedence cache/ # sqlite cache + file lock id/ # CAIP parsing + amount normalization @@ -48,6 +50,7 @@ internal/ httpx/ # shared HTTP client/retry behavior .github/workflows/ci.yml # CI (test/vet/build) +.github/workflows/nightly-execution-smoke.yml # nightly execution planning drift checks .github/workflows/release.yml # tagged release pipeline (GoReleaser) scripts/install.sh # macOS/Linux installer from GitHub Releases .goreleaser.yml # cross-platform release artifact config @@ -63,14 +66,26 @@ README.md # user-facing usage + caveats - Lending routes by `--protocol` to direct adapters when available, then may fallback to DefiLlama on selected failures. - Most commands do not require provider API keys. - Key-gated routes: `swap quote --provider 1inch` (`DEFI_1INCH_API_KEY`), `swap quote --provider uniswap` (`DEFI_UNISWAP_API_KEY`), `chains assets`, and `bridge list` / `bridge details` via DefiLlama (`DEFI_DEFILLAMA_API_KEY`). +- Multi-provider command paths require explicit provider/protocol selection (`--provider` or `--protocol`); no implicit defaults. +- TaikoSwap quote/planning does not require an API key; execution uses local signer env inputs (`DEFI_PRIVATE_KEY{,_FILE}` or keystore envs). +- Execution commands currently available: + - `swap plan|run|submit|status` + - `bridge plan|run|submit|status` (LiFi) + - `approvals plan|run|submit|status` + - `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave) + - `rewards claim|compound plan|run|submit|status` (Aave) + - `actions list|status` +- All execution `run` / `submit` commands require `--yes` and can broadcast transactions. +- Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. - Key requirements are command + provider specific; `providers list` is metadata only and should remain callable without provider keys. - Prefer env vars for provider keys in docs/examples; keep config file usage optional and focused on non-secret defaults. -- `--chain` supports CAIP-2, numeric chain IDs, and aliases; aliases include `mantle`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, and `zksync`. +- `--chain` supports CAIP-2, numeric chain IDs, and aliases; aliases include `mantle`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, `taiko hoodi`/`hoodi`, and `zksync`. - Symbol parsing depends on the local bootstrap token registry; on chains without registry entries use token address or CAIP-19. - APY values are percentage points (`2.3` means `2.3%`), not ratios. - Morpho can emit extreme APYs in tiny markets; use `--min-tvl-usd` in ranking/filters. - Fresh cache hits (`age <= ttl`) skip provider calls; once TTL expires, the CLI re-fetches providers and only serves stale data within `max_stale` on temporary provider failures. - Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. +- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|status`) bypass cache initialization. - For `lend`/`yield`, unresolved asset symbols skip DefiLlama symbol matching and fallback/provider selection where symbol-based matching would be unsafe. - Amounts used for swaps/bridges are base units; keep both base and decimal forms consistent. - Release artifacts are built on `v*` tags via `.github/workflows/release.yml` and `.goreleaser.yml`. diff --git a/CHANGELOG.md b/CHANGELOG.md index a61894b..f5fb88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,34 @@ Format: ## [Unreleased] ### Added -- None yet. +- Added TaikoSwap provider support for `swap quote` using on-chain quoter contract calls (no API key required). +- Added swap execution workflow commands: `swap plan`, `swap run`, `swap submit`, and `swap status`. +- Added bridge execution workflow commands: `bridge plan`, `bridge run`, `bridge submit`, and `bridge status` (LiFi provider). +- Added approvals workflow commands: `approvals plan`, `approvals run`, `approvals submit`, and `approvals status`. +- Added lend execution workflow commands under `lend supply|withdraw|borrow|repay ... plan|run|submit|status` (Aave). +- Added rewards execution workflow commands under `rewards claim|compound ... plan|run|submit|status` (Aave). +- Added action persistence and inspection commands: `actions list` and `actions status`. +- Added local signer support for execution with env/file/keystore key sources and strict file-permission checks. +- Added Taiko Hoodi chain alias and token registry entries (`USDC`, `USDT`, `WETH`) for deterministic asset parsing. +- Added planner unit tests for approvals, Aave lend/rewards flows, and LiFi bridge action building. +- Added centralized execution registry data in `internal/registry` for endpoint, contract, and ABI references. +- Added nightly execution-planning smoke workflow (`nightly-execution-smoke.yml`) and script. ### Changed -- None yet. +- `providers list` now includes TaikoSwap execution capabilities (`swap.plan`, `swap.execute`) alongside quote metadata. +- `providers list` now includes LiFi bridge execution capabilities (`bridge.plan`, `bridge.execute`). +- Added execution-specific exit codes (`20`-`24`) for plan/simulation/policy/timeout/signer failures. +- Added execution config/env support for action store paths and Taiko RPC overrides. +- Execution command cache/action-store policy now covers `swap|bridge|approvals|lend|rewards ... plan|run|submit|status`. +- Removed implicit defaults for multi-provider command paths; `--provider`/`--protocol` must be set explicitly where applicable. ### Fixed - None yet. ### Docs -- None yet. +- Documented bridge/lend/rewards/approvals execution flows, signer env inputs, command behavior, and exit codes in `README.md`. +- Updated `AGENTS.md` with expanded execution command coverage and caveats. +- Updated `docs/act-execution-design.md` implementation status to reflect the shipped Phase 2 surface. ### Security - None yet. diff --git a/README.md b/README.md index 5530bb0..348f6a2 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ Built for AI agents and scripts. Stable JSON output, canonical identifiers (CAIP ## Features -- **Lending** — query markets and rates from Aave, Morpho, and more (with DefiLlama fallback). +- **Lending** — query markets and rates from Aave, Morpho, and more (with DefiLlama fallback), plus execute Aave lend actions. - **Yield** — compare opportunities across protocols and chains, filter by TVL and APY. -- **Bridging** — get cross-chain quotes (Across, LiFi) and bridge analytics (volume, chain breakdown). -- **Swapping** — get on-chain swap quotes (1inch, Uniswap). +- **Bridging** — get cross-chain quotes (Across, LiFi), bridge analytics (volume, chain breakdown), and execute LiFi bridge plans. +- **Swapping** — get swap quotes (1inch, Uniswap, TaikoSwap) and execute TaikoSwap plans on-chain. +- **Approvals & rewards** — create and execute ERC-20 approvals, Aave rewards claims, and compound flows. - **Chains & protocols** — browse top chains by TVL, inspect chain TVL by asset, discover protocols, resolve asset identifiers. - **Automation-friendly** — JSON-first output, field selection (`--select`), strict mode, and a machine-readable schema export. @@ -72,7 +73,15 @@ defi yield opportunities --chain base --asset USDC --limit 20 --results-only defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit 10 --results-only defi bridge list --limit 10 --results-only # Requires DEFI_DEFILLAMA_API_KEY defi bridge details --bridge layerzero --results-only # Requires DEFI_DEFILLAMA_API_KEY -defi bridge quote --from 1 --to 8453 --asset USDC --amount 1000000 --results-only +defi bridge quote --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --results-only +defi swap quote --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --results-only +defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --from-address 0xYourEOA --results-only +defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only +defi lend supply plan --protocol aave --chain 1 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only +defi rewards claim plan --protocol aave --chain 1 --from-address 0xYourEOA --assets 0x... --reward-token 0x... --results-only +defi approvals plan --chain taiko --asset USDC --spender 0xSpender --amount 1000000 --from-address 0xYourEOA --results-only +defi swap status --action-id --results-only +defi actions list --results-only ``` `yield opportunities --providers` accepts provider names from `defi providers list` (e.g. `defillama,aave,morpho`). @@ -80,17 +89,55 @@ defi bridge quote --from 1 --to 8453 --asset USDC --amount 1000000 --results-onl Bridge quote examples: ```bash -defi bridge quote --from 1 --to 8453 --asset USDC --amount 1000000 --results-only # Defaults to Across +defi bridge quote --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --results-only defi bridge quote --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --results-only ``` -Swap quote example (`1inch` requires API key): +Swap quote examples: ```bash export DEFI_1INCH_API_KEY=... defi swap quote --provider 1inch --chain 1 --from-asset USDC --to-asset DAI --amount 1000000 --results-only +defi swap quote --provider uniswap --chain 1 --from-asset USDC --to-asset DAI --amount 1000000 --results-only +defi swap quote --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --results-only ``` +Swap execution flow (local signer): + +```bash +export DEFI_PRIVATE_KEY_FILE=~/.config/defi/key.hex # chmod 600 + +# 1) Plan only +defi swap plan \ + --provider taikoswap \ + --chain taiko \ + --from-asset USDC \ + --to-asset WETH \ + --amount 1000000 \ + --from-address 0xYourEOA \ + --results-only + +# 2) Execute in one command +defi swap run \ + --provider taikoswap \ + --chain taiko \ + --from-asset USDC \ + --to-asset WETH \ + --amount 1000000 \ + --from-address 0xYourEOA \ + --yes \ + --results-only +``` + +Execution command surface: + +- `swap plan|run|submit|status` +- `bridge plan|run|submit|status` (provider: `lifi`) +- `approvals plan|run|submit|status` +- `lend supply|withdraw|borrow|repay plan|run|submit|status` (protocol: `aave`) +- `rewards claim|compound plan|run|submit|status` (protocol: `aave`) +- `actions list|status` + ## Command API Key Requirements Most commands do not require provider API keys. @@ -102,6 +149,7 @@ When a provider requires authentication, bring your own key: - `defi chains assets` -> `DEFI_DEFILLAMA_API_KEY` - `defi bridge list` -> `DEFI_DEFILLAMA_API_KEY` - `defi bridge details` -> `DEFI_DEFILLAMA_API_KEY` +- `defi swap quote --provider taikoswap` -> no API key required `defi providers list` includes both provider-level key metadata and capability-level key metadata (`capability_auth`). @@ -123,6 +171,18 @@ For persistent shell setup, add exports to your shell profile (for example `~/.z If a keyed provider is used without a key, CLI exits with code `10`. +## Execution Signer Inputs (Run/Submit Commands) + +Execution `run`/`submit` commands currently support a local key signer. + +Key env inputs (in precedence order when `--key-source auto`): + +- `DEFI_PRIVATE_KEY` (hex string, supported but less safe) +- `DEFI_PRIVATE_KEY_FILE` (preferred; file must be `0600` or stricter) +- `DEFI_KEYSTORE_PATH` + (`DEFI_KEYSTORE_PASSWORD` or `DEFI_KEYSTORE_PASSWORD_FILE`) + +You can force source selection with `--key-source env|file|keystore`. + ## Config (Optional) Most users only need env vars for provider keys. Use config when you want persistent non-secret defaults (output mode, timeout/retries, cache behavior). @@ -150,6 +210,13 @@ retries: 2 cache: enabled: true max_stale: 5m +execution: + actions_path: ~/.cache/defi/actions.db + actions_lock_path: ~/.cache/defi/actions.lock +providers: + taikoswap: + mainnet_rpc: https://rpc.mainnet.taiko.xyz + hoodi_rpc: https://rpc.hoodi.taiko.xyz ``` ## Cache Policy @@ -160,6 +227,7 @@ cache: - `cache.max_stale` / `--max-stale` is only a temporary provider-failure fallback window (currently `unavailable` / `rate_limited`). - If fallback is disabled (`--no-stale` or `--max-stale 0s`) or stale data exceeds the budget, the CLI exits with code `14`. - Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. +- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|status`) bypass cache reads/writes. ## Caveats @@ -170,6 +238,12 @@ cache: - `--chain` normalization supports additional EVM aliases and IDs including `mantle`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, and `zksync`. - For chains without bootstrap symbol entries, pass token address or CAIP-19 via `--asset`/`--from-asset`/`--to-asset` for deterministic resolution. - For `lend`/`yield`, unresolved asset symbols skip DefiLlama-based symbol matching and may disable fallback/provider selection to avoid unsafe broad matches. +- Swap execution currently supports TaikoSwap only. +- Bridge execution currently supports LiFi only. +- Lend and rewards execution currently support Aave only. +- All `run` / `submit` execution commands require `--yes` and will broadcast signed transactions. +- Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. +- Provider/protocol selection is explicit for multi-provider flows; pass `--provider` or `--protocol` (no implicit defaults). ## Exit Codes @@ -183,6 +257,11 @@ cache: - `14`: stale data beyond SLA - `15`: partial results in strict mode - `16`: blocked by command allowlist +- `20`: action plan validation failed +- `21`: action simulation failed +- `22`: execution rejected by policy +- `23`: action timed out while waiting for confirmation +- `24`: signer unavailable or signing failed ## Development @@ -197,9 +276,11 @@ internal/ providers/ # external adapters aave/ morpho/ # direct lending + yield defillama/ # normalization + fallback + bridge analytics - across/ lifi/ # bridge quotes - oneinch/ uniswap/ # swap + across/ lifi/ # bridge quotes + lifi execution planning + oneinch/ uniswap/ taikoswap/ # swap (quote + taikoswap execution planning) types.go # provider interfaces + execution/ # action store + planner helpers + signer + executor + registry/ # canonical execution endpoints/contracts/ABI fragments config/ # file/env/flags precedence cache/ # sqlite cache + file lock id/ # CAIP + amount normalization @@ -211,10 +292,14 @@ internal/ httpx/ # shared HTTP client .github/workflows/ci.yml # CI (test/vet/build) +.github/workflows/nightly-execution-smoke.yml # nightly live execution planning smoke AGENTS.md # contributor guide for agents ``` ### Testing ```bash go test ./... +go test -race ./... +go vet ./... +bash scripts/nightly_execution_smoke.sh ``` diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md new file mode 100644 index 0000000..c4dbbd4 --- /dev/null +++ b/docs/act-execution-design.md @@ -0,0 +1,578 @@ +# Execution ("act") Design for `defi-cli` + +Status: Phase 2 Implemented (swap/bridge/lend/rewards/approvals execution) +Author: CLI architecture proposal +Last Updated: 2026-02-23 + +## Implementation Status (Current) + +Implemented in this repository: + +- `swap plan|run|submit|status` command family +- `bridge plan|run|submit|status` (LiFi execution planner) +- `approvals plan|run|submit|status` +- `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave) +- `rewards claim|compound plan|run|submit|status` (Aave) +- `actions list|status` action inspection commands +- Local signer backend (`env|file|keystore`) with signer abstraction +- Sqlite-backed action persistence with resumable step states +- TaikoSwap on-chain quote + swap action planning (approval + swap steps) +- Centralized execution registry (`internal/registry`) for endpoints, contract addresses, and ABI fragments +- Simulation, gas estimation, signing, submission, and receipt tracking in execution engine +- Nightly live execution-planning smoke workflow (`.github/workflows/nightly-execution-smoke.yml`) + +Not yet implemented from full roadmap: + +- Additional signer backends (`safe`, external wallets, hardware) +- Broader execution provider coverage beyond current defaults (TaikoSwap/LiFi/Aave) + +## 1. Problem Statement + +`defi-cli` currently focuses on read-only data retrieval (`quote`, `markets`, `yield`, `bridge details`, etc). +We want to add execution capability ("act") so the CLI can perform transactions across protocols and chains while preserving: + +- Stable machine-consumable JSON envelope +- Stable exit code behavior +- Deterministic IDs/amount normalization +- Safety and auditability + +## 2. Goals and Non-Goals + +### Goals + +- Add a safe, deterministic execution workflow for major user actions. +- Support multi-protocol and multi-chain execution with resumable state. +- Keep provider integrations modular and testable. +- Maintain a clear source of truth for API endpoints, contract addresses, and ABIs. + +### Non-Goals (v1) + +- Fully autonomous rebalancing without explicit user invocation. +- Strategy DSL / scheduling engine. +- Support for every protocol from day one. + +## 3. Core Architectural Decision + +Execution should be modeled as a two-phase workflow: + +1. `plan`: produce a deterministic action plan (steps, calldata/intents, constraints, expected outputs). +2. `execute`: execute an existing plan and track state until terminal status. + +This separates route selection from transaction submission, improves reproducibility, and enables audit/resume. + +## 4. Integration with Existing CLI + +### 4.1 Command Surface + +Execution should be integrated into existing domains instead of a separate top-level `act` namespace. + +Examples: + +- `defi swap quote ...` (existing) +- `defi swap plan ...` (new, plan only) +- `defi swap run ...` (new, plan + execute in one invocation) +- `defi swap submit --plan-id ...` (new, execute an existing saved plan) +- `defi swap status --action-id ...` (new lifecycle tracking) + +Equivalent command families should exist for: + +- `bridge` +- `lend` +- `rewards` (new command group for claim/compound) + +This keeps the API intuitive by action domain and avoids a catch-all command surface. + +### 4.2 Code Integration (proposed package layout) + +```text +internal/ + execution/ + planner.go # generic planning orchestration + executor.go # execution orchestration + tracker.go # status polling + lifecycle transitions + store.go # action persistence (sqlite) + types.go # ActionPlan, ActionStep, statuses + simulate.go # preflight simulation hooks + signer/ + signer.go # signer interface + local.go # local key signer (v1) + txbuilder.go # EIP-1559 tx assembly + registry/ + loader.go # endpoint/address/abi loader + validation + types.go + providers/ + types.go # extend interfaces for execution capabilities + taikoswap/ # quote + execution planner for taiko swap +``` + +`internal/app/runner.go` adds domain-specific subcommands (`swap plan/run/submit/status`, etc) while reusing envelope/output/error handling patterns. + +### 4.3 Provider Capability Model + +Add capability-specific interfaces (without breaking read-only interfaces): + +- `SwapExecutionPlanner` +- `BridgeExecutionPlanner` +- `LendExecutionPlanner` +- `RewardsExecutionPlanner` + +Each provider returns provider-specific plan steps in a shared normalized action format. + +## 5. Source of Truth for Endpoints, Addresses, ABIs + +### 5.1 Registry Design + +Track interaction metadata in a versioned registry under repository control: + +```text +internal/registry/data/ + providers/ + uniswap.yaml + taikoswap.yaml + across.yaml + lifi.yaml + contracts/ + taiko-mainnet.yaml + ethereum-mainnet.yaml + abis/ + uniswap/ + quoter_v2.json + swap_router_02.json + universal_router.json + erc20/ + erc20_minimal.json + permit2.json +``` + +### 5.2 What each file tracks + +#### Provider endpoint entry + +- Provider name and version +- Base URLs and path templates (e.g. quote/swap/status endpoints) +- Auth method and env var names +- Supported chains per endpoint +- Rate-limit hints and timeout defaults + +#### Contract entry + +- `chain_id` (CAIP/EVM ID) +- protocol name +- contract role (router, quoter, factory, permit2, etc) +- address +- ABI reference path +- source verification URL (block explorer / repo) +- metadata (deployed block, notes) + +#### ABI entry + +- Canonical ABI JSON (minimal ABI fragments where possible) +- Optional selector map for validation +- Version/source metadata + +### 5.3 Validation Requirements + +Add validation checks in CI/unit tests: + +- Address format and non-zero checks +- ABI JSON parse + required method presence +- Registry schema validation +- Provider endpoint presence for declared capabilities + +Optional integration validation (nightly): + +- `eth_getCode` non-empty for configured addresses +- dry-run `eth_call` smoke on critical view methods + +### 5.4 Override Mechanism + +Support local override for rapid hotfixes: + +- `DEFI_REGISTRY_PATH=/path/to/registry-overrides` + +Precedence: + +1. CLI flags (if exposed) +2. env override registry +3. bundled registry in repo + +## 6. Forge `cast` Dependency Decision + +### 6.1 Recommendation + +Do **not** make `cast` a runtime dependency. + +Reasoning: + +- Adds external binary dependency for all users. +- Makes release artifacts less self-contained. +- Process spawning is slower and harder to test deterministically. +- Native Go JSON-RPC and ABI encoding is more portable and CI-friendly. + +### 6.2 Where `cast` should be used + +Use `cast` as developer tooling only: + +- `scripts/verify/*.sh` for parity checks +- troubleshooting registry/address issues +- reproducing on-chain call results in docs/tests + +## 7. Signer Architecture (v1 local key) + +### 7.1 Scope + +v1 supports a local key signer only, while keeping the signer layer extensible for: + +- external wallet providers +- Safe/multisig +- hardware signers +- remote signers + +### 7.2 Signer Interface + +Use a signer abstraction in `internal/execution/signer/signer.go`: + +- `Address() string` +- `SignTx(chainID, tx) -> rawTx` +- `SignMessage(payload) -> signature` (future-proofing) + +Execution orchestration consumes only this interface. + +### 7.3 Local key ingestion (v1) + +Avoid requiring private key values in CLI args. Preferred sources: + +1. `DEFI_PRIVATE_KEY_FILE` (hex key in file, strict file-permission checks) +2. `DEFI_KEYSTORE_PATH` + `DEFI_KEYSTORE_PASSWORD` or `DEFI_KEYSTORE_PASSWORD_FILE` +3. `DEFI_PRIVATE_KEY` (supported, but discouraged in shell history environments) + +Optional explicit flag: + +- `--key-source env|file|keystore` (for deterministic automation) + +### 7.4 Transaction signing flow + +For each executable step: + +1. Resolve sender address from signer. +2. Fetch nonce (`eth_getTransactionCount`, pending). +3. Build tx params: + - EIP-1559 (`maxFeePerGas`, `maxPriorityFeePerGas`) by default + - `gasLimit` from simulation/estimation with safety multiplier +4. Build unsigned tx from step data (`to`, `data`, `value`, `chainId`). +5. Sign locally. +6. Broadcast (`eth_sendRawTransaction`). +7. Persist tx hash and receipt status. + +Implementation note: use native Go libraries for EVM tx construction/signing (e.g., go-ethereum transaction types and secp256k1 signing utilities), not shelling out to external binaries. + +### 7.5 Security controls + +- Never print private key material in logs or envelopes. +- Redact signer secrets in errors. +- Validate key/address match before first execution. +- Enforce minimum key file permissions for file-based keys. +- Add `--confirm-address` optional check for CI/ops workflows. + +### 7.6 Agent and automation key handling + +Expected automation pattern: + +1. Agent injects key source via environment variables before command execution. +2. CLI resolves signer source via normal precedence (`flags > env > config > defaults`). +3. CLI emits signer address metadata only (never key material). + +Recommended usage for agents/CI: + +- Use short-lived per-command environment injection. +- Prefer file/keystore based key sources over raw-key env values. +- Set `--confirm-address` for high-safety pipelines. + +## 8. Command API Design (Draft) + +### 8.1 Common Principles + +- `plan` is safe/read-only by default. +- `run` performs plan + execute in one command (with explicit confirmation flag). +- `submit` executes an already-created plan. +- Every command returns standard envelope with `action_id` and step-level metadata. +- No hidden side effects in plan phase. +- Avoid overloaded verbs. Command names should directly describe behavior. + +### 8.2 Command Sketch + +#### Plan commands + +- `defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000` +- `defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000` +- `defi lend supply plan --protocol aave --chain 1 --asset USDC --amount 1000000` +- `defi approvals plan --chain taiko --asset USDC --spender --amount 1000000` +- `defi rewards claim plan --protocol aave --chain 1 --asset AAVE` + +#### Run commands (plan + execute) + +- `defi swap run --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --yes` +- `defi bridge run --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --yes` +- `defi lend supply run --protocol aave --chain 1 --asset USDC --amount 1000000 --yes` +- `defi approvals run --chain taiko --asset USDC --spender --amount 1000000 --yes` + +#### Submit commands (execute existing plan) + +- `defi swap submit --plan-id --yes` +- `defi bridge submit --plan-id --yes` +- `defi lend supply submit --plan-id --yes` + +#### Lifecycle commands + +- `defi swap status --action-id ` +- `defi bridge status --action-id ` +- `defi lend status --action-id ` +- `defi actions list --status pending` (optional global view) +- `defi actions resume --action-id ` (optional global resume) + +### 8.3 Global Execution Flags (proposed) + +- `--wallet` (address only mode) +- `--signer` (`local|external|walletconnect|safe`) (future) +- `--simulate` (default true for `run` and `submit`) +- `--slippage-bps` +- `--deadline` +- `--max-fee-gwei`, `--max-priority-fee-gwei` +- `--nonce-policy` (`next|fixed`) +- `--yes` (required for `run`/`submit`) + +## 9. Action Plan and Tracking Model + +### 9.1 ActionPlan (normalized) + +Core fields: + +- `action_id` +- `intent_type` (`swap`, `bridge`, `lend_supply`, `approve`, `claim`, etc) +- `created_at` +- `constraints` (slippage, deadline, policy) +- `steps[]` + +Each step includes: + +- `step_id` +- `chain_id` +- `type` (`approval`, `swap`, `bridge_send`, `bridge_finalize`, `lend_call`, `claim`) +- `target` (contract / endpoint) +- `call_data` or provider instruction payload +- `value` +- `expected_outputs` +- `depends_on[]` +- `status` + +### 9.2 Persistence (sqlite) + +Add action tables: + +- `actions` +- `action_steps` +- `action_events` + +Track: + +- tx hashes +- bridge transfer IDs/message IDs +- retries +- error details (mapped to CLI error codes) + +### 9.3 Status Lifecycle + +`planned -> validated -> awaiting_signature -> submitted -> confirmed -> completed` + +Failure paths: + +`failed`, `timed_out`, `partial` (multi-step actions) + +## 10. Main Use Cases (Phase 1 Scope) + +### 10.1 Swap Execute + +User flow: + +1. Build swap plan (route + approvals + minOut constraint). +2. Simulate each transaction step. +3. Execute approval (if required). +4. Execute swap. +5. Confirm and return final out amount and tx hash. + +Initial support: + +- single-chain swap +- exact input +- taikoswap + existing aggregator providers where feasible + +### 10.2 Bridge Execute + +User flow: + +1. Plan source transaction and destination settlement expectations. +2. Execute source chain tx. +3. Track async transfer status. +4. Mark complete when destination settlement confirms. + +Initial support: + +- bridge-only transfers first +- bridge+destination swap as phase 2 extension + +### 10.3 Approve / Revoke + +User flow: + +1. Plan approval delta (exact amount by default). +2. Execute and confirm. +3. Optional revoke command sets allowance to zero. + +### 10.4 Lend Actions + +Initial verbs: + +- `supply` +- `withdraw` +- `borrow` +- `repay` + +Each action follows plan + execute with health-factor and liquidity checks in planning. + +### 10.5 Rewards Claim / Compound + +Initial verbs: + +- `claim` +- `compound` (where protocol supports single-tx or known workflow) + +## 11. Safety, Policy, and UX Guardrails + +- Policy allowlist checks (protocol, spender, chain, asset). +- Simulation-before-execution default (opt-out should be explicit and discouraged). +- Slippage and deadline required for swap-like actions. +- Exact approval default, unlimited approval only with explicit flag. +- Step-by-step decoded previews before execution. +- Stable and explicit partial-failure semantics. + +## 12. Simulation, Consistency, and Nightly Validation + +### 12.1 Simulation layers + +Use layered checks to reduce execution surprises: + +1. Static plan validation + - chain/provider support + - token/asset normalization + - spender and target allowlist checks +2. Preflight state checks + - balances + - allowances + - protocol preconditions (where available) +3. Transaction simulation + - `eth_call` with exact tx payload (`to`, `data`, `value`, `from`) + - gas estimation (`eth_estimateGas`) with margin policy +4. Optional deep trace simulation + - `debug_traceCall` when RPC supports it + - classify likely failure reasons for better errors + +### 12.2 Consistency between plan and execution + +Each plan should record: + +- simulation block number +- simulation timestamp +- RPC endpoint fingerprint +- route/quote hash +- slippage/deadline constraints + +Execution should enforce revalidation triggers: + +- plan age exceeds configured max age +- chain head drift exceeds configured block delta +- quote hash changes (provider route changed) +- simulation indicates constraints are now unsafe + +When any trigger fails, command exits with a deterministic replan-required error. + +### 12.3 Cross-chain consistency model + +For bridge flows: + +- source-chain tx is simulated and executed deterministically. +- destination outcome is tracked asynchronously via provider status APIs and/or chain events. +- action remains `pending` until destination settlement reaches terminal status. + +Bridge plans must include explicit timeout/SLA metadata per provider route. + +### 12.4 Nightly validation jobs + +Add a nightly workflow (separate from PR CI) for live-environment checks: + +1. Registry integrity + - schema validation + - ABI parsing + - non-zero address checks +2. On-chain contract liveness + - `eth_getCode` must be non-empty for configured contracts +3. Critical method smoke calls + - e.g., quoter/factory read calls on representative pairs +4. Provider endpoint liveness + - health checks and minimal quote/simulation calls +5. Drift report artifact + - list failing chains/providers/contracts + - include first-seen timestamp for regressions + +Failures should open/annotate issues but not block all contributor PRs by default. + +### 12.5 Test strategy split + +- PR CI: deterministic unit tests + mocked RPC/provider tests. +- Nightly CI: live RPC/provider validation. +- Optional weekly: broader matrix with additional chains/providers. + +## 13. Exit Code Extensions (proposed) + +Existing exit code contract remains. Add action-specific codes: + +- `20`: action plan validation failed +- `21`: simulation failed +- `22`: execution rejected by policy +- `23`: action timed out / pending too long +- `24`: signer unavailable / signing failed + +## 14. Rollout Plan + +### Phase 0: Foundations + +- Add domain command scaffolding (`swap|bridge|lend|rewards` with `plan|run|submit|status`). +- Add action storage and status plumbing. +- Add registry framework and validators. + +### Phase 1: Core Execution + +- swap run/submit (single-chain) +- approve/revoke +- status/resume/list + +### Phase 2: Cross-Chain + +- bridge run/submit with async tracking + +### Phase 3: Lending + Rewards + +- supply/withdraw/borrow/repay +- claim/compound + +### Phase 4: UX and Automation Hardening + +- richer signer integrations +- policy presets +- batched operations and optional smart account flows + +## 15. Open Questions + +- Which signer backend should be next after local key (`external wallet`, `Safe`, or remote signer)? +- How much protocol-specific simulation is required beyond `eth_call`? +- What SLA should define `timed_out` for bridges by provider/route type? +- What should default plan-expiry and block-drift thresholds be per command type? diff --git a/go.mod b/go.mod index eca6914..5f459d2 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,48 @@ module github.com/ggonzalez94/defi-cli go 1.24.0 require ( + github.com/ethereum/go-ethereum v1.14.12 github.com/gofrs/flock v0.12.1 github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.39.1 ) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect + github.com/consensys/bavard v0.1.13 // indirect + github.com/consensys/gnark-crypto v0.12.1 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c // indirect + github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ethereum/c-kzg-4844 v1.0.0 // indirect + github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/holiman/uint256 v1.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/supranational/blst v0.3.13 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + golang.org/x/crypto v0.22.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.36.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + rsc.io/tmplfunc v0.0.3 // indirect ) diff --git a/go.sum b/go.sum index 65c3dcf..d747393 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,150 @@ +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= +github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= +github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA= +github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ= +github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= +github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M= +github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c h1:uQYC5Z1mdLRPrZhHjHxufI8+2UG/i25QG92j0Er9p6I= +github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJZH3SzKCqnz5VT0q/DyIG/tvu/dZk+VIfXicupJs= +github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI= +github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ethereum/c-kzg-4844 v1.0.0 h1:0X1LBXxaEtYD9xsyj9B9ctQEZIpnvVDeoBx8aHEwTNA= +github.com/ethereum/c-kzg-4844 v1.0.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= +github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrTwcCQSe4= +github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY= +github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 h1:8NfxH2iXvJ60YRB8ChToFTUzl8awsc3cJ8CbLjGIl/A= +github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4= +github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs= +github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= +github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= +github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= +github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg= +github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y= +github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -34,20 +152,49 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKtmTjk= +github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= @@ -76,3 +223,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= +rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= diff --git a/internal/app/approvals_command.go b/internal/app/approvals_command.go new file mode 100644 index 0000000..6ef6752 --- /dev/null +++ b/internal/app/approvals_command.go @@ -0,0 +1,241 @@ +package app + +import ( + "context" + "strings" + "time" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/execution/planner" + execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/model" + "github.com/spf13/cobra" +) + +func (s *runtimeState) newApprovalsCommand() *cobra.Command { + root := &cobra.Command{Use: "approvals", Short: "Approval execution commands"} + + type approvalArgs struct { + chainArg string + assetArg string + spender string + amountBase string + amountDecimal string + fromAddress string + simulate bool + rpcURL string + } + buildAction := func(args approvalArgs) (execution.Action, error) { + chain, err := id.ParseChain(args.chainArg) + if err != nil { + return execution.Action{}, err + } + asset, err := id.ParseAsset(args.assetArg, chain) + if err != nil { + return execution.Action{}, err + } + decimals := asset.Decimals + if decimals <= 0 { + decimals = 18 + } + base, _, err := id.NormalizeAmount(args.amountBase, args.amountDecimal, decimals) + if err != nil { + return execution.Action{}, err + } + return planner.BuildApprovalAction(planner.ApprovalRequest{ + Chain: chain, + Asset: asset, + AmountBaseUnits: base, + Sender: args.fromAddress, + Spender: args.spender, + Simulate: args.simulate, + RPCURL: args.rpcURL, + }) + } + + var plan approvalArgs + planCmd := &cobra.Command{ + Use: "plan", + Short: "Create and persist an approval action plan", + RunE: func(cmd *cobra.Command, _ []string) error { + start := time.Now() + action, err := buildAction(plan) + status := []model.ProviderStatus{{Name: "native", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, status, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + s.captureCommandDiagnostics(nil, status, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), status, false) + }, + } + planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") + planCmd.Flags().StringVar(&plan.assetArg, "asset", "", "Asset symbol/address/CAIP-19") + planCmd.Flags().StringVar(&plan.spender, "spender", "", "Spender address") + planCmd.Flags().StringVar(&plan.amountBase, "amount", "", "Amount in base units") + planCmd.Flags().StringVar(&plan.amountDecimal, "amount-decimal", "", "Amount in decimal units") + planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") + planCmd.Flags().BoolVar(&plan.simulate, "simulate", true, "Include simulation checks during execution") + planCmd.Flags().StringVar(&plan.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + _ = planCmd.MarkFlagRequired("chain") + _ = planCmd.MarkFlagRequired("asset") + _ = planCmd.MarkFlagRequired("spender") + _ = planCmd.MarkFlagRequired("from-address") + + var run approvalArgs + var runYes bool + var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runGasMultiplier float64 + var runMaxFeeGwei, runMaxPriorityFeeGwei string + runCmd := &cobra.Command{ + Use: "run", + Short: "Plan and execute an approval action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !runYes { + return clierr.New(clierr.CodeUsage, "approvals run requires --yes") + } + start := time.Now() + action, err := buildAction(run) + status := []model.ProviderStatus{{Name: "native", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, status, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) + if err != nil { + return err + } + if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } + execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + if err != nil { + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + return err + } + s.captureCommandDiagnostics(nil, status, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), status, false) + }, + } + runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") + runCmd.Flags().StringVar(&run.assetArg, "asset", "", "Asset symbol/address/CAIP-19") + runCmd.Flags().StringVar(&run.spender, "spender", "", "Spender address") + runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Amount in base units") + runCmd.Flags().StringVar(&run.amountDecimal, "amount-decimal", "", "Amount in decimal units") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().BoolVar(&run.simulate, "simulate", true, "Run preflight simulation before submission") + runCmd.Flags().StringVar(&run.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") + runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") + runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") + runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") + _ = runCmd.MarkFlagRequired("chain") + _ = runCmd.MarkFlagRequired("asset") + _ = runCmd.MarkFlagRequired("spender") + _ = runCmd.MarkFlagRequired("from-address") + + var submitActionID, submitPlanID string + var submitYes, submitSimulate bool + var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitGasMultiplier float64 + var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + submitCmd := &cobra.Command{ + Use: "submit", + Short: "Execute an existing approval action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !submitYes { + return clierr.New(clierr.CodeUsage, "approvals submit requires --yes") + } + actionID, err := resolveActionID(submitActionID, submitPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != "approve" { + return clierr.New(clierr.CodeUsage, "action is not an approval intent") + } + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + if err != nil { + return err + } + if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") + } + execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + if err != nil { + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") + submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") + submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") + submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") + submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") + submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") + submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + + var statusActionID, statusPlanID string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Get approval action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID, statusPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") + statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") + + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) + return root +} diff --git a/internal/app/bridge_execution_commands.go b/internal/app/bridge_execution_commands.go new file mode 100644 index 0000000..2fef63a --- /dev/null +++ b/internal/app/bridge_execution_commands.go @@ -0,0 +1,311 @@ +package app + +import ( + "context" + "strings" + "time" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/model" + "github.com/ggonzalez94/defi-cli/internal/providers" + "github.com/spf13/cobra" +) + +func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { + buildRequest := func(fromArg, toArg, assetArg, toAssetArg, amountBase, amountDecimal string) (providers.BridgeQuoteRequest, error) { + fromChain, err := id.ParseChain(fromArg) + if err != nil { + return providers.BridgeQuoteRequest{}, err + } + toChain, err := id.ParseChain(toArg) + if err != nil { + return providers.BridgeQuoteRequest{}, err + } + fromAsset, err := id.ParseAsset(assetArg, fromChain) + if err != nil { + return providers.BridgeQuoteRequest{}, err + } + toAssetInput := strings.TrimSpace(toAssetArg) + if toAssetInput == "" { + if fromAsset.Symbol == "" { + return providers.BridgeQuoteRequest{}, clierr.New(clierr.CodeUsage, "destination asset cannot be inferred, provide --to-asset") + } + toAssetInput = fromAsset.Symbol + } + toAsset, err := id.ParseAsset(toAssetInput, toChain) + if err != nil { + return providers.BridgeQuoteRequest{}, clierr.Wrap(clierr.CodeUsage, "resolve destination asset", err) + } + decimals := fromAsset.Decimals + if decimals <= 0 { + decimals = 18 + } + base, decimal, err := id.NormalizeAmount(amountBase, amountDecimal, decimals) + if err != nil { + return providers.BridgeQuoteRequest{}, err + } + return providers.BridgeQuoteRequest{ + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: base, + AmountDecimal: decimal, + }, nil + } + + var planProviderArg, planFromArg, planToArg, planAssetArg, planToAssetArg string + var planAmountBase, planAmountDecimal, planFromAddress, planRecipient string + var planSlippageBps int64 + var planSimulate bool + var planRPCURL string + planCmd := &cobra.Command{ + Use: "plan", + Short: "Create and persist a bridge action plan", + RunE: func(cmd *cobra.Command, _ []string) error { + providerName := strings.ToLower(strings.TrimSpace(planProviderArg)) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") + } + provider, ok := s.bridgeProviders[providerName] + if !ok { + return clierr.New(clierr.CodeUnsupported, "unsupported bridge provider") + } + execProvider, ok := provider.(providers.BridgeExecutionProvider) + if !ok { + return clierr.New(clierr.CodeUnsupported, "selected bridge provider does not support execution") + } + reqStruct, err := buildRequest(planFromArg, planToArg, planAssetArg, planToAssetArg, planAmountBase, planAmountDecimal) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := execProvider.BuildBridgeAction(ctx, reqStruct, providers.BridgeExecutionOptions{ + Sender: planFromAddress, + Recipient: planRecipient, + SlippageBps: planSlippageBps, + Simulate: planSimulate, + RPCURL: planRPCURL, + }) + statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + planCmd.Flags().StringVar(&planProviderArg, "provider", "", "Bridge provider (lifi)") + planCmd.Flags().StringVar(&planFromArg, "from", "", "Source chain") + planCmd.Flags().StringVar(&planToArg, "to", "", "Destination chain") + planCmd.Flags().StringVar(&planAssetArg, "asset", "", "Asset on source chain") + planCmd.Flags().StringVar(&planToAssetArg, "to-asset", "", "Destination asset override") + planCmd.Flags().StringVar(&planAmountBase, "amount", "", "Amount in base units") + planCmd.Flags().StringVar(&planAmountDecimal, "amount-decimal", "", "Amount in decimal units") + planCmd.Flags().StringVar(&planFromAddress, "from-address", "", "Sender EOA address") + planCmd.Flags().StringVar(&planRecipient, "recipient", "", "Recipient address (defaults to --from-address)") + planCmd.Flags().Int64Var(&planSlippageBps, "slippage-bps", 50, "Max slippage in basis points") + planCmd.Flags().BoolVar(&planSimulate, "simulate", true, "Include simulation checks during execution") + planCmd.Flags().StringVar(&planRPCURL, "rpc-url", "", "RPC URL override for source chain") + _ = planCmd.MarkFlagRequired("from") + _ = planCmd.MarkFlagRequired("to") + _ = planCmd.MarkFlagRequired("asset") + _ = planCmd.MarkFlagRequired("from-address") + _ = planCmd.MarkFlagRequired("provider") + + var runProviderArg, runFromArg, runToArg, runAssetArg, runToAssetArg string + var runAmountBase, runAmountDecimal, runFromAddress, runRecipient string + var runSlippageBps int64 + var runSimulate, runYes bool + var runRPCURL string + var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runGasMultiplier float64 + var runMaxFeeGwei, runMaxPriorityFeeGwei string + runCmd := &cobra.Command{ + Use: "run", + Short: "Plan and execute a bridge action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !runYes { + return clierr.New(clierr.CodeUsage, "bridge run requires --yes") + } + providerName := strings.ToLower(strings.TrimSpace(runProviderArg)) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") + } + provider, ok := s.bridgeProviders[providerName] + if !ok { + return clierr.New(clierr.CodeUnsupported, "unsupported bridge provider") + } + execProvider, ok := provider.(providers.BridgeExecutionProvider) + if !ok { + return clierr.New(clierr.CodeUnsupported, "selected bridge provider does not support execution") + } + reqStruct, err := buildRequest(runFromArg, runToArg, runAssetArg, runToAssetArg, runAmountBase, runAmountDecimal) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := execProvider.BuildBridgeAction(ctx, reqStruct, providers.BridgeExecutionOptions{ + Sender: runFromAddress, + Recipient: runRecipient, + SlippageBps: runSlippageBps, + Simulate: runSimulate, + RPCURL: runRPCURL, + }) + statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if !strings.EqualFold(strings.TrimSpace(runFromAddress), txSigner.Address().Hex()) { + s.captureCommandDiagnostics(nil, statuses, false) + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } + execOpts, err := parseExecuteOptions(runSimulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + runCmd.Flags().StringVar(&runProviderArg, "provider", "", "Bridge provider (lifi)") + runCmd.Flags().StringVar(&runFromArg, "from", "", "Source chain") + runCmd.Flags().StringVar(&runToArg, "to", "", "Destination chain") + runCmd.Flags().StringVar(&runAssetArg, "asset", "", "Asset on source chain") + runCmd.Flags().StringVar(&runToAssetArg, "to-asset", "", "Destination asset override") + runCmd.Flags().StringVar(&runAmountBase, "amount", "", "Amount in base units") + runCmd.Flags().StringVar(&runAmountDecimal, "amount-decimal", "", "Amount in decimal units") + runCmd.Flags().StringVar(&runFromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&runRecipient, "recipient", "", "Recipient address (defaults to --from-address)") + runCmd.Flags().Int64Var(&runSlippageBps, "slippage-bps", 50, "Max slippage in basis points") + runCmd.Flags().BoolVar(&runSimulate, "simulate", true, "Run preflight simulation before submission") + runCmd.Flags().StringVar(&runRPCURL, "rpc-url", "", "RPC URL override for source chain") + runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") + runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") + runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") + runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") + _ = runCmd.MarkFlagRequired("from") + _ = runCmd.MarkFlagRequired("to") + _ = runCmd.MarkFlagRequired("asset") + _ = runCmd.MarkFlagRequired("from-address") + _ = runCmd.MarkFlagRequired("provider") + + var submitActionID, submitPlanID string + var submitYes, submitSimulate bool + var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitGasMultiplier float64 + var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + submitCmd := &cobra.Command{ + Use: "submit", + Short: "Execute an existing bridge action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !submitYes { + return clierr.New(clierr.CodeUsage, "bridge submit requires --yes") + } + actionID, err := resolveActionID(submitActionID, submitPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != "bridge" { + return clierr.New(clierr.CodeUsage, "action is not a bridge intent") + } + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + if err != nil { + return err + } + if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") + } + execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + if err != nil { + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") + submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") + submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") + submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") + submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") + submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") + submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + + var statusActionID, statusPlanID string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Get bridge action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID, statusPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") + statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") + + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) +} diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go new file mode 100644 index 0000000..b875ef5 --- /dev/null +++ b/internal/app/lend_execution_commands.go @@ -0,0 +1,291 @@ +package app + +import ( + "context" + "strings" + "time" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/execution/planner" + execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/model" + "github.com/spf13/cobra" +) + +func (s *runtimeState) addLendExecutionSubcommands(root *cobra.Command) { + root.AddCommand(s.newLendVerbExecutionCommand(planner.AaveVerbSupply, "Supply assets to a lending protocol")) + root.AddCommand(s.newLendVerbExecutionCommand(planner.AaveVerbWithdraw, "Withdraw assets from a lending protocol")) + root.AddCommand(s.newLendVerbExecutionCommand(planner.AaveVerbBorrow, "Borrow assets from a lending protocol")) + root.AddCommand(s.newLendVerbExecutionCommand(planner.AaveVerbRepay, "Repay borrowed assets on a lending protocol")) +} + +func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, short string) *cobra.Command { + root := &cobra.Command{ + Use: string(verb), + Short: short, + } + expectedIntent := "lend_" + string(verb) + + type lendArgs struct { + protocol string + chainArg string + assetArg string + amountBase string + amountDecimal string + fromAddress string + recipient string + onBehalfOf string + interestRateMode int64 + simulate bool + rpcURL string + poolAddress string + poolAddressProvider string + } + buildAction := func(ctx context.Context, args lendArgs) (execution.Action, error) { + protocol := normalizeLendingProtocol(args.protocol) + if protocol == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + } + if protocol != "aave" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports only protocol=aave") + } + + chain, asset, err := parseChainAsset(args.chainArg, args.assetArg) + if err != nil { + return execution.Action{}, err + } + decimals := asset.Decimals + if decimals <= 0 { + decimals = 18 + } + base, _, err := id.NormalizeAmount(args.amountBase, args.amountDecimal, decimals) + if err != nil { + return execution.Action{}, err + } + + return planner.BuildAaveLendAction(ctx, planner.AaveLendRequest{ + Verb: verb, + Chain: chain, + Asset: asset, + AmountBaseUnits: base, + Sender: args.fromAddress, + Recipient: args.recipient, + OnBehalfOf: args.onBehalfOf, + InterestRateMode: args.interestRateMode, + Simulate: args.simulate, + RPCURL: args.rpcURL, + PoolAddress: args.poolAddress, + PoolAddressesProvider: args.poolAddressProvider, + }) + } + + var plan lendArgs + planCmd := &cobra.Command{ + Use: "plan", + Short: "Create and persist a lend action plan", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := buildAction(ctx, plan) + statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Lending protocol (aave)") + planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") + planCmd.Flags().StringVar(&plan.assetArg, "asset", "", "Asset symbol/address/CAIP-19") + planCmd.Flags().StringVar(&plan.amountBase, "amount", "", "Amount in base units") + planCmd.Flags().StringVar(&plan.amountDecimal, "amount-decimal", "", "Amount in decimal units") + planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") + planCmd.Flags().StringVar(&plan.recipient, "recipient", "", "Recipient address (defaults to --from-address)") + planCmd.Flags().StringVar(&plan.onBehalfOf, "on-behalf-of", "", "Aave onBehalfOf address (defaults to --from-address)") + planCmd.Flags().Int64Var(&plan.interestRateMode, "interest-rate-mode", 2, "Interest rate mode for borrow/repay (1=stable,2=variable)") + planCmd.Flags().BoolVar(&plan.simulate, "simulate", true, "Include simulation checks during execution") + planCmd.Flags().StringVar(&plan.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + planCmd.Flags().StringVar(&plan.poolAddress, "pool-address", "", "Aave pool address override") + planCmd.Flags().StringVar(&plan.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") + _ = planCmd.MarkFlagRequired("chain") + _ = planCmd.MarkFlagRequired("asset") + _ = planCmd.MarkFlagRequired("from-address") + _ = planCmd.MarkFlagRequired("protocol") + + var run lendArgs + var runYes bool + var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runGasMultiplier float64 + var runMaxFeeGwei, runMaxPriorityFeeGwei string + runCmd := &cobra.Command{ + Use: "run", + Short: "Plan and execute a lend action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !runYes { + return clierr.New(clierr.CodeUsage, "lend run requires --yes") + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := buildAction(ctx, run) + statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { + s.captureCommandDiagnostics(nil, statuses, false) + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } + execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Lending protocol (aave)") + runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") + runCmd.Flags().StringVar(&run.assetArg, "asset", "", "Asset symbol/address/CAIP-19") + runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Amount in base units") + runCmd.Flags().StringVar(&run.amountDecimal, "amount-decimal", "", "Amount in decimal units") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") + runCmd.Flags().StringVar(&run.onBehalfOf, "on-behalf-of", "", "Aave onBehalfOf address (defaults to --from-address)") + runCmd.Flags().Int64Var(&run.interestRateMode, "interest-rate-mode", 2, "Interest rate mode for borrow/repay (1=stable,2=variable)") + runCmd.Flags().BoolVar(&run.simulate, "simulate", true, "Run preflight simulation before submission") + runCmd.Flags().StringVar(&run.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + runCmd.Flags().StringVar(&run.poolAddress, "pool-address", "", "Aave pool address override") + runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") + runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") + runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") + runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") + runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") + _ = runCmd.MarkFlagRequired("chain") + _ = runCmd.MarkFlagRequired("asset") + _ = runCmd.MarkFlagRequired("from-address") + _ = runCmd.MarkFlagRequired("protocol") + + var submitActionID, submitPlanID string + var submitYes, submitSimulate bool + var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitGasMultiplier float64 + var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + submitCmd := &cobra.Command{ + Use: "submit", + Short: "Execute an existing lend action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !submitYes { + return clierr.New(clierr.CodeUsage, "lend submit requires --yes") + } + actionID, err := resolveActionID(submitActionID, submitPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != expectedIntent { + return clierr.New(clierr.CodeUsage, "action intent does not match lend verb") + } + if action.Status == execution.ActionStatusCompleted { + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) + } + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + if err != nil { + return err + } + if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") + } + execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + if err != nil { + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") + submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") + submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") + submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") + submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") + submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") + submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + + var statusActionID, statusPlanID string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Get lend action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID, statusPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != expectedIntent { + return clierr.New(clierr.CodeUsage, "action intent does not match lend verb") + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") + statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") + + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) + return root +} diff --git a/internal/app/rewards_command.go b/internal/app/rewards_command.go new file mode 100644 index 0000000..6579b6e --- /dev/null +++ b/internal/app/rewards_command.go @@ -0,0 +1,548 @@ +package app + +import ( + "context" + "strings" + "time" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/execution/planner" + execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/model" + "github.com/spf13/cobra" +) + +func (s *runtimeState) newRewardsCommand() *cobra.Command { + root := &cobra.Command{Use: "rewards", Short: "Rewards claim and compound execution commands"} + root.AddCommand(s.newRewardsClaimCommand()) + root.AddCommand(s.newRewardsCompoundCommand()) + return root +} + +func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { + root := &cobra.Command{Use: "claim", Short: "Claim rewards"} + const expectedIntent = "claim_rewards" + + type claimArgs struct { + protocol string + chainArg string + fromAddress string + recipient string + assetsCSV string + rewardToken string + amountBase string + simulate bool + rpcURL string + controllerAddress string + poolAddressProvider string + } + buildAction := func(ctx context.Context, args claimArgs) (execution.Action, error) { + protocol := normalizeLendingProtocol(args.protocol) + if protocol == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + } + if protocol != "aave" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") + } + chain, err := id.ParseChain(args.chainArg) + if err != nil { + return execution.Action{}, err + } + assets := splitCSV(args.assetsCSV) + if len(assets) == 0 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--assets is required") + } + amount := strings.TrimSpace(args.amountBase) + if amount == "" { + amount = "max" + } + return planner.BuildAaveRewardsClaimAction(ctx, planner.AaveRewardsClaimRequest{ + Chain: chain, + Sender: args.fromAddress, + Recipient: args.recipient, + Assets: assets, + RewardToken: args.rewardToken, + AmountBaseUnits: amount, + Simulate: args.simulate, + RPCURL: args.rpcURL, + ControllerAddress: args.controllerAddress, + PoolAddressesProvider: args.poolAddressProvider, + }) + } + + var plan claimArgs + planCmd := &cobra.Command{ + Use: "plan", + Short: "Create and persist a rewards-claim action plan", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := buildAction(ctx, plan) + statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Rewards protocol (aave)") + planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") + planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") + planCmd.Flags().StringVar(&plan.recipient, "recipient", "", "Recipient address (defaults to --from-address)") + planCmd.Flags().StringVar(&plan.assetsCSV, "assets", "", "Comma-separated rewards source asset addresses") + planCmd.Flags().StringVar(&plan.rewardToken, "reward-token", "", "Reward token address") + planCmd.Flags().StringVar(&plan.amountBase, "amount", "", "Claim amount in base units (defaults to max)") + planCmd.Flags().BoolVar(&plan.simulate, "simulate", true, "Include simulation checks during execution") + planCmd.Flags().StringVar(&plan.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + planCmd.Flags().StringVar(&plan.controllerAddress, "controller-address", "", "Aave incentives controller address override") + planCmd.Flags().StringVar(&plan.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") + _ = planCmd.MarkFlagRequired("chain") + _ = planCmd.MarkFlagRequired("from-address") + _ = planCmd.MarkFlagRequired("assets") + _ = planCmd.MarkFlagRequired("reward-token") + _ = planCmd.MarkFlagRequired("protocol") + + var run claimArgs + var runYes bool + var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runGasMultiplier float64 + var runMaxFeeGwei, runMaxPriorityFeeGwei string + runCmd := &cobra.Command{ + Use: "run", + Short: "Plan and execute a rewards-claim action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !runYes { + return clierr.New(clierr.CodeUsage, "rewards claim run requires --yes") + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := buildAction(ctx, run) + statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { + s.captureCommandDiagnostics(nil, statuses, false) + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } + execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Rewards protocol (aave)") + runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") + runCmd.Flags().StringVar(&run.assetsCSV, "assets", "", "Comma-separated rewards source asset addresses") + runCmd.Flags().StringVar(&run.rewardToken, "reward-token", "", "Reward token address") + runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Claim amount in base units (defaults to max)") + runCmd.Flags().BoolVar(&run.simulate, "simulate", true, "Run preflight simulation before submission") + runCmd.Flags().StringVar(&run.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + runCmd.Flags().StringVar(&run.controllerAddress, "controller-address", "", "Aave incentives controller address override") + runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") + runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") + runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") + runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") + runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") + _ = runCmd.MarkFlagRequired("chain") + _ = runCmd.MarkFlagRequired("from-address") + _ = runCmd.MarkFlagRequired("assets") + _ = runCmd.MarkFlagRequired("reward-token") + _ = runCmd.MarkFlagRequired("protocol") + + var submitActionID, submitPlanID string + var submitYes, submitSimulate bool + var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitGasMultiplier float64 + var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + submitCmd := &cobra.Command{ + Use: "submit", + Short: "Execute an existing rewards-claim action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !submitYes { + return clierr.New(clierr.CodeUsage, "rewards claim submit requires --yes") + } + actionID, err := resolveActionID(submitActionID, submitPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != expectedIntent { + return clierr.New(clierr.CodeUsage, "action is not a rewards claim intent") + } + if action.Status == execution.ActionStatusCompleted { + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) + } + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + if err != nil { + return err + } + if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") + } + execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + if err != nil { + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") + submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") + submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") + submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") + submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") + submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") + submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + + var statusActionID, statusPlanID string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Get rewards-claim action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID, statusPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != expectedIntent { + return clierr.New(clierr.CodeUsage, "action is not a rewards claim intent") + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") + statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") + + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) + return root +} + +func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { + root := &cobra.Command{Use: "compound", Short: "Compound rewards by claim + resupply"} + const expectedIntent = "compound_rewards" + + type compoundArgs struct { + protocol string + chainArg string + fromAddress string + recipient string + onBehalfOf string + assetsCSV string + rewardToken string + amountBase string + simulate bool + rpcURL string + controllerAddress string + poolAddress string + poolAddressProvider string + } + buildAction := func(ctx context.Context, args compoundArgs) (execution.Action, error) { + protocol := normalizeLendingProtocol(args.protocol) + if protocol == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + } + if protocol != "aave" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") + } + chain, err := id.ParseChain(args.chainArg) + if err != nil { + return execution.Action{}, err + } + assets := splitCSV(args.assetsCSV) + if len(assets) == 0 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--assets is required") + } + amount := strings.TrimSpace(args.amountBase) + if amount == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--amount is required") + } + return planner.BuildAaveRewardsCompoundAction(ctx, planner.AaveRewardsCompoundRequest{ + Chain: chain, + Sender: args.fromAddress, + Recipient: args.recipient, + Assets: assets, + RewardToken: args.rewardToken, + AmountBaseUnits: amount, + Simulate: args.simulate, + RPCURL: args.rpcURL, + ControllerAddress: args.controllerAddress, + PoolAddress: args.poolAddress, + PoolAddressesProvider: args.poolAddressProvider, + OnBehalfOf: args.onBehalfOf, + }) + } + + var plan compoundArgs + planCmd := &cobra.Command{ + Use: "plan", + Short: "Create and persist a rewards-compound action plan", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := buildAction(ctx, plan) + statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Rewards protocol (aave)") + planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") + planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") + planCmd.Flags().StringVar(&plan.recipient, "recipient", "", "Recipient address (defaults to --from-address)") + planCmd.Flags().StringVar(&plan.onBehalfOf, "on-behalf-of", "", "Aave onBehalfOf address for compounding supply") + planCmd.Flags().StringVar(&plan.assetsCSV, "assets", "", "Comma-separated rewards source asset addresses") + planCmd.Flags().StringVar(&plan.rewardToken, "reward-token", "", "Reward token address") + planCmd.Flags().StringVar(&plan.amountBase, "amount", "", "Compound amount in base units") + planCmd.Flags().BoolVar(&plan.simulate, "simulate", true, "Include simulation checks during execution") + planCmd.Flags().StringVar(&plan.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + planCmd.Flags().StringVar(&plan.controllerAddress, "controller-address", "", "Aave incentives controller address override") + planCmd.Flags().StringVar(&plan.poolAddress, "pool-address", "", "Aave pool address override") + planCmd.Flags().StringVar(&plan.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") + _ = planCmd.MarkFlagRequired("chain") + _ = planCmd.MarkFlagRequired("from-address") + _ = planCmd.MarkFlagRequired("assets") + _ = planCmd.MarkFlagRequired("reward-token") + _ = planCmd.MarkFlagRequired("amount") + _ = planCmd.MarkFlagRequired("protocol") + + var run compoundArgs + var runYes bool + var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runGasMultiplier float64 + var runMaxFeeGwei, runMaxPriorityFeeGwei string + runCmd := &cobra.Command{ + Use: "run", + Short: "Plan and execute a rewards-compound action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !runYes { + return clierr.New(clierr.CodeUsage, "rewards compound run requires --yes") + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := buildAction(ctx, run) + statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { + s.captureCommandDiagnostics(nil, statuses, false) + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } + execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Rewards protocol (aave)") + runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") + runCmd.Flags().StringVar(&run.onBehalfOf, "on-behalf-of", "", "Aave onBehalfOf address for compounding supply") + runCmd.Flags().StringVar(&run.assetsCSV, "assets", "", "Comma-separated rewards source asset addresses") + runCmd.Flags().StringVar(&run.rewardToken, "reward-token", "", "Reward token address") + runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Compound amount in base units") + runCmd.Flags().BoolVar(&run.simulate, "simulate", true, "Run preflight simulation before submission") + runCmd.Flags().StringVar(&run.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") + runCmd.Flags().StringVar(&run.controllerAddress, "controller-address", "", "Aave incentives controller address override") + runCmd.Flags().StringVar(&run.poolAddress, "pool-address", "", "Aave pool address override") + runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") + runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") + runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") + runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") + runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") + _ = runCmd.MarkFlagRequired("chain") + _ = runCmd.MarkFlagRequired("from-address") + _ = runCmd.MarkFlagRequired("assets") + _ = runCmd.MarkFlagRequired("reward-token") + _ = runCmd.MarkFlagRequired("amount") + _ = runCmd.MarkFlagRequired("protocol") + + var submitActionID, submitPlanID string + var submitYes, submitSimulate bool + var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitGasMultiplier float64 + var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + submitCmd := &cobra.Command{ + Use: "submit", + Short: "Execute an existing rewards-compound action", + RunE: func(cmd *cobra.Command, _ []string) error { + if !submitYes { + return clierr.New(clierr.CodeUsage, "rewards compound submit requires --yes") + } + actionID, err := resolveActionID(submitActionID, submitPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != expectedIntent { + return clierr.New(clierr.CodeUsage, "action is not a rewards compound intent") + } + if action.Status == execution.ActionStatusCompleted { + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) + } + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + if err != nil { + return err + } + if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") + } + execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + if err != nil { + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") + submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") + submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") + submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") + submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") + submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") + submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + + var statusActionID, statusPlanID string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Get rewards-compound action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID, statusPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != expectedIntent { + return clierr.New(clierr.CodeUsage, "action is not a rewards compound intent") + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") + statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") + + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) + return root +} diff --git a/internal/app/runner.go b/internal/app/runner.go index 808c0fa..2f5b052 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -16,6 +16,8 @@ import ( "github.com/ggonzalez94/defi-cli/internal/cache" "github.com/ggonzalez94/defi-cli/internal/config" clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" "github.com/ggonzalez94/defi-cli/internal/httpx" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" @@ -28,6 +30,7 @@ import ( "github.com/ggonzalez94/defi-cli/internal/providers/lifi" "github.com/ggonzalez94/defi-cli/internal/providers/morpho" "github.com/ggonzalez94/defi-cli/internal/providers/oneinch" + "github.com/ggonzalez94/defi-cli/internal/providers/taikoswap" "github.com/ggonzalez94/defi-cli/internal/providers/uniswap" "github.com/ggonzalez94/defi-cli/internal/schema" "github.com/ggonzalez94/defi-cli/internal/version" @@ -57,6 +60,7 @@ type runtimeState struct { flags config.GlobalFlags settings config.Settings cache *cache.Store + actionStore *execution.Store root *cobra.Command lastCommand string lastWarnings []string @@ -90,6 +94,9 @@ func (r *Runner) Run(args []string) int { if state.cache != nil { _ = state.cache.Close() } + if state.actionStore != nil { + _ = state.actionStore.Close() + } return 0 } @@ -97,6 +104,9 @@ func (r *Runner) Run(args []string) int { if state.cache != nil { _ = state.cache.Close() } + if state.actionStore != nil { + _ = state.actionStore.Close() + } return clierr.ExitCode(err) } @@ -125,6 +135,7 @@ func (s *runtimeState) newRootCommand() *cobra.Command { llama := defillama.New(httpClient, settings.DefiLlamaAPIKey) aaveProvider := aave.New(httpClient) morphoProvider := morpho.New(httpClient) + taikoSwapProvider := taikoswap.New(httpClient, settings.TaikoMainnetRPC, settings.TaikoHoodiRPC) s.marketProvider = llama s.defaultLendingProvider = llama s.lendingProviders = map[string]providers.LendingProvider{ @@ -146,8 +157,9 @@ func (s *runtimeState) newRootCommand() *cobra.Command { "defillama": llama, } s.swapProviders = map[string]providers.SwapProvider{ - "1inch": oneinch.New(httpClient, settings.OneInchAPIKey), - "uniswap": uniswap.New(httpClient, settings.UniswapAPIKey), + "1inch": oneinch.New(httpClient, settings.OneInchAPIKey), + "uniswap": uniswap.New(httpClient, settings.UniswapAPIKey), + "taikoswap": taikoSwapProvider, } s.providerInfos = []model.ProviderInfo{ llama.Info(), @@ -157,6 +169,7 @@ func (s *runtimeState) newRootCommand() *cobra.Command { s.bridgeProviders["lifi"].Info(), s.swapProviders["1inch"].Info(), s.swapProviders["uniswap"].Info(), + s.swapProviders["taikoswap"].Info(), } } @@ -167,6 +180,13 @@ func (s *runtimeState) newRootCommand() *cobra.Command { } s.cache = cacheStore } + if shouldOpenActionStore(path) && s.actionStore == nil { + actionStore, err := execution.OpenStore(settings.ActionStorePath, settings.ActionLockPath) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "open action store", err) + } + s.actionStore = actionStore + } return nil }, } @@ -193,8 +213,11 @@ func (s *runtimeState) newRootCommand() *cobra.Command { cmd.AddCommand(s.newProtocolsCommand()) cmd.AddCommand(s.newAssetsCommand()) cmd.AddCommand(s.newLendCommand()) + cmd.AddCommand(s.newRewardsCommand()) cmd.AddCommand(s.newBridgeCommand()) cmd.AddCommand(s.newSwapCommand()) + cmd.AddCommand(s.newApprovalsCommand()) + cmd.AddCommand(s.newActionsCommand()) cmd.AddCommand(s.newYieldCommand()) cmd.AddCommand(newVersionCommand()) @@ -517,6 +540,7 @@ func (s *runtimeState) newLendCommand() *cobra.Command { root.AddCommand(marketsCmd) root.AddCommand(ratesCmd) + s.addLendExecutionSubcommands(root) return root } @@ -531,7 +555,7 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { providerName := strings.ToLower(strings.TrimSpace(quoteProviderArg)) if providerName == "" { - providerName = "across" + return clierr.New(clierr.CodeUsage, "--provider is required (across|lifi)") } provider, ok := s.bridgeProviders[providerName] if !ok { @@ -595,7 +619,7 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { }) }, } - quoteCmd.Flags().StringVar("eProviderArg, "provider", "across", "Bridge provider (across|lifi; no API key required)") + quoteCmd.Flags().StringVar("eProviderArg, "provider", "", "Bridge provider (across|lifi; no API key required)") quoteCmd.Flags().StringVar(&fromArg, "from", "", "Source chain") quoteCmd.Flags().StringVar(&toArg, "to", "", "Destination chain") quoteCmd.Flags().StringVar(&assetArg, "asset", "", "Asset (symbol/address/CAIP-19) on source chain") @@ -605,6 +629,7 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { _ = quoteCmd.MarkFlagRequired("from") _ = quoteCmd.MarkFlagRequired("to") _ = quoteCmd.MarkFlagRequired("asset") + _ = quoteCmd.MarkFlagRequired("provider") var listLimit int var includeChains bool @@ -672,71 +697,391 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { root.AddCommand(quoteCmd) root.AddCommand(listCmd) root.AddCommand(detailsCmd) + s.addBridgeExecutionSubcommands(root) return root } func (s *runtimeState) newSwapCommand() *cobra.Command { - root := &cobra.Command{Use: "swap", Short: "Swap quote commands"} - var providerArg, chainArg, fromAssetArg, toAssetArg string - var amountBase, amountDecimal string - cmd := &cobra.Command{ + root := &cobra.Command{Use: "swap", Short: "Swap quote and execution commands"} + + parseSwapRequest := func(chainArg, fromAssetArg, toAssetArg, amountBase, amountDecimal string) (providers.SwapQuoteRequest, error) { + chain, err := id.ParseChain(chainArg) + if err != nil { + return providers.SwapQuoteRequest{}, err + } + fromAsset, err := id.ParseAsset(fromAssetArg, chain) + if err != nil { + return providers.SwapQuoteRequest{}, err + } + toAsset, err := id.ParseAsset(toAssetArg, chain) + if err != nil { + return providers.SwapQuoteRequest{}, err + } + decimals := fromAsset.Decimals + if decimals <= 0 { + decimals = 18 + } + base, decimal, err := id.NormalizeAmount(amountBase, amountDecimal, decimals) + if err != nil { + return providers.SwapQuoteRequest{}, err + } + return providers.SwapQuoteRequest{ + Chain: chain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: base, + AmountDecimal: decimal, + }, nil + } + + var quoteProviderArg, quoteChainArg, quoteFromAssetArg, quoteToAssetArg string + var quoteAmountBase, quoteAmountDecimal string + quoteCmd := &cobra.Command{ Use: "quote", Short: "Get swap quote", RunE: func(cmd *cobra.Command, args []string) error { - providerName := strings.ToLower(strings.TrimSpace(providerArg)) + providerName := strings.ToLower(strings.TrimSpace(quoteProviderArg)) if providerName == "" { - providerName = "1inch" + return clierr.New(clierr.CodeUsage, "--provider is required (1inch|uniswap|taikoswap)") } provider, ok := s.swapProviders[providerName] if !ok { return clierr.New(clierr.CodeUnsupported, "unsupported swap provider") } - chain, err := id.ParseChain(chainArg) + reqStruct, err := parseSwapRequest(quoteChainArg, quoteFromAssetArg, quoteToAssetArg, quoteAmountBase, quoteAmountDecimal) if err != nil { return err } - fromAsset, err := id.ParseAsset(fromAssetArg, chain) + + key := cacheKey(trimRootPath(cmd.CommandPath()), map[string]any{ + "provider": providerName, + "chain": reqStruct.Chain.CAIP2, + "from": reqStruct.FromAsset.AssetID, + "to": reqStruct.ToAsset.AssetID, + "amount": reqStruct.AmountBaseUnits, + }) + return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 15*time.Second, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { + start := time.Now() + data, err := provider.QuoteSwap(ctx, reqStruct) + status := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + return data, status, nil, false, err + }) + }, + } + quoteCmd.Flags().StringVar("eProviderArg, "provider", "", "Swap provider (1inch|uniswap|taikoswap)") + quoteCmd.Flags().StringVar("eChainArg, "chain", "", "Chain identifier") + quoteCmd.Flags().StringVar("eFromAssetArg, "from-asset", "", "Input asset") + quoteCmd.Flags().StringVar("eToAssetArg, "to-asset", "", "Output asset") + quoteCmd.Flags().StringVar("eAmountBase, "amount", "", "Amount in base units") + quoteCmd.Flags().StringVar("eAmountDecimal, "amount-decimal", "", "Amount in decimal units") + _ = quoteCmd.MarkFlagRequired("chain") + _ = quoteCmd.MarkFlagRequired("from-asset") + _ = quoteCmd.MarkFlagRequired("to-asset") + _ = quoteCmd.MarkFlagRequired("provider") + + var planProviderArg, planChainArg, planFromAssetArg, planToAssetArg string + var planAmountBase, planAmountDecimal, planFromAddress, planRecipient string + var planSlippageBps int64 + var planSimulate bool + planCmd := &cobra.Command{ + Use: "plan", + Short: "Create and persist a swap action plan", + RunE: func(cmd *cobra.Command, args []string) error { + providerName := strings.ToLower(strings.TrimSpace(planProviderArg)) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") + } + provider, ok := s.swapProviders[providerName] + if !ok { + return clierr.New(clierr.CodeUnsupported, "unsupported swap provider") + } + execProvider, ok := provider.(providers.SwapExecutionProvider) + if !ok { + return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("provider %s does not support swap planning", providerName)) + } + reqStruct, err := parseSwapRequest(planChainArg, planFromAssetArg, planToAssetArg, planAmountBase, planAmountDecimal) if err != nil { return err } - toAsset, err := id.ParseAsset(toAssetArg, chain) + + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := execProvider.BuildSwapAction(ctx, reqStruct, providers.SwapExecutionOptions{ + Sender: planFromAddress, + Recipient: planRecipient, + SlippageBps: planSlippageBps, + Simulate: planSimulate, + }) + statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) return err } - decimals := fromAsset.Decimals - if decimals <= 0 { - decimals = 18 + if err := s.ensureActionStore(); err != nil { + return err } - base, decimal, err := id.NormalizeAmount(amountBase, amountDecimal, decimals) + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) + }, + } + planCmd.Flags().StringVar(&planProviderArg, "provider", "", "Swap provider (taikoswap)") + planCmd.Flags().StringVar(&planChainArg, "chain", "", "Chain identifier") + planCmd.Flags().StringVar(&planFromAssetArg, "from-asset", "", "Input asset") + planCmd.Flags().StringVar(&planToAssetArg, "to-asset", "", "Output asset") + planCmd.Flags().StringVar(&planAmountBase, "amount", "", "Amount in base units") + planCmd.Flags().StringVar(&planAmountDecimal, "amount-decimal", "", "Amount in decimal units") + planCmd.Flags().StringVar(&planFromAddress, "from-address", "", "Sender EOA address") + planCmd.Flags().StringVar(&planRecipient, "recipient", "", "Recipient address (defaults to --from-address)") + planCmd.Flags().Int64Var(&planSlippageBps, "slippage-bps", 50, "Max slippage in basis points") + planCmd.Flags().BoolVar(&planSimulate, "simulate", true, "Include simulation checks during execution") + _ = planCmd.MarkFlagRequired("chain") + _ = planCmd.MarkFlagRequired("from-asset") + _ = planCmd.MarkFlagRequired("to-asset") + _ = planCmd.MarkFlagRequired("from-address") + _ = planCmd.MarkFlagRequired("provider") + + var runProviderArg, runChainArg, runFromAssetArg, runToAssetArg string + var runAmountBase, runAmountDecimal, runFromAddress, runRecipient string + var runSlippageBps int64 + var runSimulate, runYes bool + var runSigner, runKeySource, runConfirmAddress string + var runPollInterval, runStepTimeout string + var runGasMultiplier float64 + var runMaxFeeGwei, runMaxPriorityFeeGwei string + runCmd := &cobra.Command{ + Use: "run", + Short: "Plan and execute a swap action in one command", + RunE: func(cmd *cobra.Command, args []string) error { + if !runYes { + return clierr.New(clierr.CodeUsage, "swap run requires --yes") + } + providerName := strings.ToLower(strings.TrimSpace(runProviderArg)) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") + } + provider, ok := s.swapProviders[providerName] + if !ok { + return clierr.New(clierr.CodeUnsupported, "unsupported swap provider") + } + execProvider, ok := provider.(providers.SwapExecutionProvider) + if !ok { + return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("provider %s does not support swap execution", providerName)) + } + reqStruct, err := parseSwapRequest(runChainArg, runFromAssetArg, runToAssetArg, runAmountBase, runAmountDecimal) if err != nil { return err } - reqStruct := providers.SwapQuoteRequest{Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: base, AmountDecimal: decimal} - key := cacheKey(trimRootPath(cmd.CommandPath()), map[string]any{ - "provider": providerName, - "chain": chain.CAIP2, - "from": fromAsset.AssetID, - "to": toAsset.AssetID, - "amount": base, - }) - return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 15*time.Second, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { - start := time.Now() - data, err := provider.QuoteSwap(ctx, reqStruct) - status := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} - return data, status, nil, false, err + + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, err := execProvider.BuildSwapAction(ctx, reqStruct, providers.SwapExecutionOptions{ + Sender: runFromAddress, + Recipient: runRecipient, + SlippageBps: runSlippageBps, + Simulate: runSimulate, }) + statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + if err := s.actionStore.Save(action); err != nil { + return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) + } + + txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if !strings.EqualFold(strings.TrimSpace(runFromAddress), txSigner.Address().Hex()) { + s.captureCommandDiagnostics(nil, statuses, false) + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } + execOpts, err := parseExecuteOptions(runSimulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + s.captureCommandDiagnostics(nil, statuses, false) + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - cmd.Flags().StringVar(&providerArg, "provider", "1inch", "Swap provider (1inch|uniswap; both require API keys)") - cmd.Flags().StringVar(&chainArg, "chain", "", "Chain identifier") - cmd.Flags().StringVar(&fromAssetArg, "from-asset", "", "Input asset") - cmd.Flags().StringVar(&toAssetArg, "to-asset", "", "Output asset") - cmd.Flags().StringVar(&amountBase, "amount", "", "Amount in base units") - cmd.Flags().StringVar(&amountDecimal, "amount-decimal", "", "Amount in decimal units") - _ = cmd.MarkFlagRequired("chain") - _ = cmd.MarkFlagRequired("from-asset") - _ = cmd.MarkFlagRequired("to-asset") - root.AddCommand(cmd) + runCmd.Flags().StringVar(&runProviderArg, "provider", "", "Swap provider (taikoswap)") + runCmd.Flags().StringVar(&runChainArg, "chain", "", "Chain identifier") + runCmd.Flags().StringVar(&runFromAssetArg, "from-asset", "", "Input asset") + runCmd.Flags().StringVar(&runToAssetArg, "to-asset", "", "Output asset") + runCmd.Flags().StringVar(&runAmountBase, "amount", "", "Amount in base units") + runCmd.Flags().StringVar(&runAmountDecimal, "amount-decimal", "", "Amount in decimal units") + runCmd.Flags().StringVar(&runFromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&runRecipient, "recipient", "", "Recipient address (defaults to --from-address)") + runCmd.Flags().Int64Var(&runSlippageBps, "slippage-bps", 50, "Max slippage in basis points") + runCmd.Flags().BoolVar(&runSimulate, "simulate", true, "Run preflight simulation before submission") + runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") + runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") + runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") + runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") + _ = runCmd.MarkFlagRequired("chain") + _ = runCmd.MarkFlagRequired("from-asset") + _ = runCmd.MarkFlagRequired("to-asset") + _ = runCmd.MarkFlagRequired("from-address") + _ = runCmd.MarkFlagRequired("provider") + + var submitActionID, submitPlanID string + var submitYes, submitSimulate bool + var submitSigner, submitKeySource, submitConfirmAddress string + var submitPollInterval, submitStepTimeout string + var submitGasMultiplier float64 + var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + submitCmd := &cobra.Command{ + Use: "submit", + Short: "Execute a previously planned swap action", + RunE: func(cmd *cobra.Command, args []string) error { + if !submitYes { + return clierr.New(clierr.CodeUsage, "swap submit requires --yes") + } + actionID, err := resolveActionID(submitActionID, submitPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + if action.IntentType != "swap" { + return clierr.New(clierr.CodeUsage, "action is not a swap intent") + } + if action.Status == execution.ActionStatusCompleted { + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) + } + + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + if err != nil { + return err + } + if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") + } + execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + if err != nil { + return err + } + if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier returned by swap plan/run") + submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") + submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") + submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") + submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") + submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") + submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") + submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + + var statusActionID, statusPlanID string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Get swap action status", + RunE: func(cmd *cobra.Command, args []string) error { + actionID, err := resolveActionID(statusActionID, statusPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) + }, + } + statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier returned by swap plan/run") + statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") + + root.AddCommand(quoteCmd) + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) + return root +} + +func (s *runtimeState) newActionsCommand() *cobra.Command { + root := &cobra.Command{Use: "actions", Short: "Execution action inspection commands"} + + var listStatus string + var listLimit int + listCmd := &cobra.Command{ + Use: "list", + Short: "List persisted actions", + RunE: func(cmd *cobra.Command, args []string) error { + if err := s.ensureActionStore(); err != nil { + return err + } + items, err := s.actionStore.List(strings.TrimSpace(listStatus), listLimit) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "list actions", err) + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), items, nil, cacheMetaBypass(), nil, false) + }, + } + listCmd.Flags().StringVar(&listStatus, "status", "", "Optional action status filter") + listCmd.Flags().IntVar(&listLimit, "limit", 20, "Maximum actions to return") + + var statusActionID, statusPlanID string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Get action details by action id", + RunE: func(cmd *cobra.Command, args []string) error { + actionID, err := resolveActionID(statusActionID, statusPlanID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + item, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), item, nil, cacheMetaBypass(), nil, false) + }, + } + statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") + statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") + + root.AddCommand(listCmd) + root.AddCommand(statusCmd) return root } @@ -991,6 +1336,16 @@ func (s *runtimeState) renderError(commandPath string, err error, warnings []str typ = "partial_results" case clierr.CodeBlocked: typ = "command_blocked" + case clierr.CodeActionPlan: + typ = "action_plan_error" + case clierr.CodeActionSim: + typ = "action_simulation_error" + case clierr.CodeActionPolicy: + typ = "action_policy_error" + case clierr.CodeActionTimeout: + typ = "action_timeout" + case clierr.CodeSigner: + typ = "signer_error" } } @@ -1337,22 +1692,138 @@ func staleFallbackAllowed(err error) bool { } func shouldOpenCache(commandPath string) bool { - switch normalizeCommandPath(commandPath) { + path := normalizeCommandPath(commandPath) + switch path { case "", "version", "schema", "providers", "providers list": return false - default: - return true } + if isExecutionCommandPath(path) { + return false + } + return true +} + +func shouldOpenActionStore(commandPath string) bool { + return isExecutionCommandPath(normalizeCommandPath(commandPath)) } func normalizeCommandPath(commandPath string) string { return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(commandPath))), " ") } +func isExecutionCommandPath(path string) bool { + switch path { + case "actions", "actions list", "actions status": + return true + } + parts := strings.Fields(path) + if len(parts) < 2 { + return false + } + switch parts[0] { + case "swap", "bridge", "approvals", "lend", "rewards": + last := parts[len(parts)-1] + return last == "plan" || last == "run" || last == "submit" || last == "status" + default: + return false + } +} + func assetHasResolvedSymbol(asset id.Asset) bool { return strings.TrimSpace(asset.Symbol) != "" } +func (s *runtimeState) ensureActionStore() error { + if s.actionStore != nil { + return nil + } + path := strings.TrimSpace(s.settings.ActionStorePath) + lockPath := strings.TrimSpace(s.settings.ActionLockPath) + if path == "" || lockPath == "" { + defaults, err := config.Load(config.GlobalFlags{}) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "resolve default action store settings", err) + } + if path == "" { + path = defaults.ActionStorePath + } + if lockPath == "" { + lockPath = defaults.ActionLockPath + } + } + store, err := execution.OpenStore(path, lockPath) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "open action store", err) + } + s.actionStore = store + return nil +} + +func resolveActionID(actionID, planID string) (string, error) { + actionID = strings.TrimSpace(actionID) + planID = strings.TrimSpace(planID) + if actionID == "" && planID == "" { + return "", clierr.New(clierr.CodeUsage, "action id is required (--action-id)") + } + if actionID != "" && planID != "" && !strings.EqualFold(actionID, planID) { + return "", clierr.New(clierr.CodeUsage, "--action-id and --plan-id must match when both are set") + } + if actionID != "" { + return actionID, nil + } + return planID, nil +} + +func newExecutionSigner(signerBackend, keySource, confirmAddress string) (execsigner.Signer, error) { + signerBackend = strings.ToLower(strings.TrimSpace(signerBackend)) + if signerBackend == "" { + signerBackend = "local" + } + if signerBackend != "local" { + return nil, clierr.New(clierr.CodeUnsupported, "only local signer is supported") + } + localSigner, err := execsigner.NewLocalSignerFromEnv(keySource) + if err != nil { + return nil, clierr.Wrap(clierr.CodeSigner, "initialize local signer", err) + } + if strings.TrimSpace(confirmAddress) != "" && !strings.EqualFold(confirmAddress, localSigner.Address().Hex()) { + return nil, clierr.New(clierr.CodeSigner, "signer address does not match --confirm-address") + } + return localSigner, nil +} + +func parseExecuteOptions(simulate bool, pollInterval, stepTimeout string, gasMultiplier float64, maxFeeGwei, maxPriorityFeeGwei string) (execution.ExecuteOptions, error) { + opts := execution.DefaultExecuteOptions() + opts.Simulate = simulate + if strings.TrimSpace(pollInterval) != "" { + d, err := time.ParseDuration(pollInterval) + if err != nil { + return execution.ExecuteOptions{}, clierr.Wrap(clierr.CodeUsage, "parse --poll-interval", err) + } + if d <= 0 { + return execution.ExecuteOptions{}, clierr.New(clierr.CodeUsage, "--poll-interval must be > 0") + } + opts.PollInterval = d + } + if strings.TrimSpace(stepTimeout) != "" { + d, err := time.ParseDuration(stepTimeout) + if err != nil { + return execution.ExecuteOptions{}, clierr.Wrap(clierr.CodeUsage, "parse --step-timeout", err) + } + if d <= 0 { + return execution.ExecuteOptions{}, clierr.New(clierr.CodeUsage, "--step-timeout must be > 0") + } + opts.StepTimeout = d + } + if gasMultiplier <= 0 { + return execution.ExecuteOptions{}, clierr.New(clierr.CodeUsage, "--gas-multiplier must be > 0") + } + opts.GasMultiplier = gasMultiplier + opts.MaxFeeGwei = strings.TrimSpace(maxFeeGwei) + opts.MaxPriorityFeeGwei = strings.TrimSpace(maxPriorityFeeGwei) + return opts, nil +} + func (s *runtimeState) resetCommandDiagnostics() { s.lastWarnings = nil s.lastProviders = nil diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go new file mode 100644 index 0000000..b0b7d19 --- /dev/null +++ b/internal/app/runner_actions_test.go @@ -0,0 +1,157 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" +) + +func TestResolveActionID(t *testing.T) { + id, err := resolveActionID("act_123", "") + if err != nil { + t.Fatalf("resolveActionID failed: %v", err) + } + if id != "act_123" { + t.Fatalf("unexpected action id: %s", id) + } + + id, err = resolveActionID("", "act_456") + if err != nil { + t.Fatalf("resolveActionID with plan id failed: %v", err) + } + if id != "act_456" { + t.Fatalf("unexpected plan-id resolution: %s", id) + } + + if _, err := resolveActionID("act_1", "act_2"); err == nil { + t.Fatal("expected mismatch error when action and plan id differ") + } +} + +func TestShouldOpenActionStore(t *testing.T) { + if !shouldOpenActionStore("swap run") { + t.Fatal("expected swap run to require action store") + } + if !shouldOpenActionStore("bridge plan") { + t.Fatal("expected bridge plan to require action store") + } + if !shouldOpenActionStore("approvals submit") { + t.Fatal("expected approvals submit to require action store") + } + if !shouldOpenActionStore("lend supply status") { + t.Fatal("expected lend supply status to require action store") + } + if !shouldOpenActionStore("rewards claim run") { + t.Fatal("expected rewards claim run to require action store") + } + if !shouldOpenActionStore("actions list") { + t.Fatal("expected actions list to require action store") + } + if shouldOpenActionStore("swap quote") { + t.Fatal("did not expect swap quote to require action store") + } + if shouldOpenActionStore("lend markets") { + t.Fatal("did not expect lend markets to require action store") + } +} + +func TestShouldOpenCacheBypassesExecutionCommands(t *testing.T) { + if shouldOpenCache("swap run") { + t.Fatal("did not expect swap run to open cache") + } + if shouldOpenCache("bridge submit") { + t.Fatal("did not expect bridge submit to open cache") + } + if shouldOpenCache("approvals status") { + t.Fatal("did not expect approvals status to open cache") + } + if shouldOpenCache("lend borrow plan") { + t.Fatal("did not expect lend borrow plan to open cache") + } + if shouldOpenCache("rewards compound run") { + t.Fatal("did not expect rewards compound run to open cache") + } + if !shouldOpenCache("lend rates") { + t.Fatal("expected lend rates to open cache") + } + if !shouldOpenCache("bridge quote") { + t.Fatal("expected bridge quote to open cache") + } +} + +func TestRunnerExecutionCommandsInSchema(t *testing.T) { + paths := []string{ + "bridge plan", + "bridge run", + "approvals plan", + "approvals run", + "lend supply plan", + "lend repay submit", + "rewards claim plan", + "rewards compound status", + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"schema", path, "--results-only"}) + if code != 0 { + t.Fatalf("expected exit 0 for %q, got %d stderr=%s", path, code, stderr.String()) + } + var doc map[string]any + if err := json.Unmarshal(stdout.Bytes(), &doc); err != nil { + t.Fatalf("failed to parse schema output for %q: %v output=%s", path, err, stdout.String()) + } + if got, _ := doc["path"].(string); got != fmt.Sprintf("defi %s", path) { + t.Fatalf("unexpected schema path for %q: got %q", path, got) + } + }) + } +} + +func TestRunnerSwapPlanRequiresFromAddress(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{ + "swap", "plan", + "--chain", "taiko", + "--from-asset", "USDC", + "--to-asset", "WETH", + "--amount", "1000000", + }) + if code != 2 { + t.Fatalf("expected usage exit code 2, got %d stderr=%s", code, stderr.String()) + } +} + +func TestRunnerActionsListBypassesCacheOpen(t *testing.T) { + setUnopenableCacheEnv(t) + + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"actions", "list", "--results-only"}) + if code != 0 { + t.Fatalf("expected exit 0, got %d stderr=%s", code, stderr.String()) + } + + var out []map[string]any + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("failed to parse actions output json: %v output=%s", err, stdout.String()) + } +} + +func TestRunnerExecutionStatusBypassesCacheOpen(t *testing.T) { + setUnopenableCacheEnv(t) + + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"approvals", "status", "--action-id", "act_missing"}) + if code != 2 { + t.Fatalf("expected usage exit code 2, got %d stderr=%s", code, stderr.String()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index bd7798b..a079b12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,9 +40,13 @@ type Settings struct { CacheEnabled bool CachePath string CacheLockPath string + ActionStorePath string + ActionLockPath string DefiLlamaAPIKey string UniswapAPIKey string OneInchAPIKey string + TaikoMainnetRPC string + TaikoHoodiRPC string } type fileConfig struct { @@ -56,6 +60,10 @@ type fileConfig struct { Path string `yaml:"path"` LockPath string `yaml:"lock_path"` } `yaml:"cache"` + Execution struct { + ActionsPath string `yaml:"actions_path"` + ActionsLockPath string `yaml:"actions_lock_path"` + } `yaml:"execution"` Providers struct { DefiLlama struct { APIKey string `yaml:"api_key"` @@ -69,6 +77,10 @@ type fileConfig struct { APIKey string `yaml:"api_key"` APIKeyEnv string `yaml:"api_key_env"` } `yaml:"oneinch"` + TaikoSwap struct { + MainnetRPC string `yaml:"mainnet_rpc"` + HoodiRPC string `yaml:"hoodi_rpc"` + } `yaml:"taikoswap"` } `yaml:"providers"` } @@ -114,14 +126,17 @@ func defaultSettings() (Settings, error) { if err != nil { return Settings{}, err } + cacheDir := filepath.Dir(cachePath) return Settings{ - OutputMode: "json", - Timeout: 10 * time.Second, - Retries: 2, - MaxStale: 5 * time.Minute, - CacheEnabled: true, - CachePath: cachePath, - CacheLockPath: lockPath, + OutputMode: "json", + Timeout: 10 * time.Second, + Retries: 2, + MaxStale: 5 * time.Minute, + CacheEnabled: true, + CachePath: cachePath, + CacheLockPath: lockPath, + ActionStorePath: filepath.Join(cacheDir, "actions.db"), + ActionLockPath: filepath.Join(cacheDir, "actions.lock"), }, nil } @@ -199,6 +214,12 @@ func applyFileConfig(path string, settings *Settings) error { if cfg.Cache.LockPath != "" { settings.CacheLockPath = cfg.Cache.LockPath } + if cfg.Execution.ActionsPath != "" { + settings.ActionStorePath = cfg.Execution.ActionsPath + } + if cfg.Execution.ActionsLockPath != "" { + settings.ActionLockPath = cfg.Execution.ActionsLockPath + } if cfg.Providers.Uniswap.APIKey != "" { settings.UniswapAPIKey = cfg.Providers.Uniswap.APIKey } @@ -217,6 +238,12 @@ func applyFileConfig(path string, settings *Settings) error { if cfg.Providers.OneInch.APIKeyEnv != "" { settings.OneInchAPIKey = os.Getenv(cfg.Providers.OneInch.APIKeyEnv) } + if cfg.Providers.TaikoSwap.MainnetRPC != "" { + settings.TaikoMainnetRPC = cfg.Providers.TaikoSwap.MainnetRPC + } + if cfg.Providers.TaikoSwap.HoodiRPC != "" { + settings.TaikoHoodiRPC = cfg.Providers.TaikoSwap.HoodiRPC + } return nil } @@ -261,6 +288,12 @@ func applyEnv(settings *Settings) { if v := os.Getenv("DEFI_CACHE_LOCK_PATH"); v != "" { settings.CacheLockPath = v } + if v := os.Getenv("DEFI_ACTIONS_PATH"); v != "" { + settings.ActionStorePath = v + } + if v := os.Getenv("DEFI_ACTIONS_LOCK_PATH"); v != "" { + settings.ActionLockPath = v + } if v := os.Getenv("DEFI_UNISWAP_API_KEY"); v != "" { settings.UniswapAPIKey = v } @@ -270,6 +303,12 @@ func applyEnv(settings *Settings) { if v := os.Getenv("DEFI_1INCH_API_KEY"); v != "" { settings.OneInchAPIKey = v } + if v := os.Getenv("DEFI_TAIKO_MAINNET_RPC_URL"); v != "" { + settings.TaikoMainnetRPC = v + } + if v := os.Getenv("DEFI_TAIKO_HOODI_RPC_URL"); v != "" { + settings.TaikoHoodiRPC = v + } } func applyFlags(flags GlobalFlags, settings *Settings) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c695f46..e0d5a74 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -54,3 +54,33 @@ func TestLoadDefiLlamaAPIKeyFromEnv(t *testing.T) { t.Fatalf("expected DefiLlama API key from env, got %q", settings.DefiLlamaAPIKey) } } + +func TestLoadExecutionPathsFromEnv(t *testing.T) { + t.Setenv("DEFI_ACTIONS_PATH", "/tmp/defi-actions.db") + t.Setenv("DEFI_ACTIONS_LOCK_PATH", "/tmp/defi-actions.lock") + settings, err := Load(GlobalFlags{}) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if settings.ActionStorePath != "/tmp/defi-actions.db" { + t.Fatalf("expected action store path from env, got %q", settings.ActionStorePath) + } + if settings.ActionLockPath != "/tmp/defi-actions.lock" { + t.Fatalf("expected action lock path from env, got %q", settings.ActionLockPath) + } +} + +func TestLoadTaikoRPCFromEnv(t *testing.T) { + t.Setenv("DEFI_TAIKO_MAINNET_RPC_URL", "https://rpc.example.mainnet") + t.Setenv("DEFI_TAIKO_HOODI_RPC_URL", "https://rpc.example.hoodi") + settings, err := Load(GlobalFlags{}) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if settings.TaikoMainnetRPC != "https://rpc.example.mainnet" { + t.Fatalf("unexpected mainnet rpc: %q", settings.TaikoMainnetRPC) + } + if settings.TaikoHoodiRPC != "https://rpc.example.hoodi" { + t.Fatalf("unexpected hoodi rpc: %q", settings.TaikoHoodiRPC) + } +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index b7e96f1..a128a75 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -19,6 +19,11 @@ const ( CodeStale Code = 14 CodePartialStrict Code = 15 CodeBlocked Code = 16 + CodeActionPlan Code = 20 + CodeActionSim Code = 21 + CodeActionPolicy Code = 22 + CodeActionTimeout Code = 23 + CodeSigner Code = 24 ) // Error is a typed CLI error that carries a stable error code. diff --git a/internal/execution/action.go b/internal/execution/action.go new file mode 100644 index 0000000..d15555d --- /dev/null +++ b/internal/execution/action.go @@ -0,0 +1,15 @@ +package execution + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +func NewActionID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "action-unknown" + } + return fmt.Sprintf("act_%s", hex.EncodeToString(b)) +} diff --git a/internal/execution/executor.go b/internal/execution/executor.go new file mode 100644 index 0000000..5251f1d --- /dev/null +++ b/internal/execution/executor.go @@ -0,0 +1,292 @@ +package execution + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution/signer" +) + +type ExecuteOptions struct { + Simulate bool + PollInterval time.Duration + StepTimeout time.Duration + GasMultiplier float64 + MaxFeeGwei string + MaxPriorityFeeGwei string +} + +func DefaultExecuteOptions() ExecuteOptions { + return ExecuteOptions{ + Simulate: true, + PollInterval: 2 * time.Second, + StepTimeout: 2 * time.Minute, + GasMultiplier: 1.2, + } +} + +func ExecuteAction(ctx context.Context, store *Store, action *Action, txSigner signer.Signer, opts ExecuteOptions) error { + if action == nil { + return clierr.New(clierr.CodeInternal, "missing action") + } + if txSigner == nil { + return clierr.New(clierr.CodeSigner, "missing signer") + } + if len(action.Steps) == 0 { + return clierr.New(clierr.CodeUsage, "action has no executable steps") + } + if opts.PollInterval <= 0 { + opts.PollInterval = 2 * time.Second + } + if opts.StepTimeout <= 0 { + opts.StepTimeout = 2 * time.Minute + } + if opts.GasMultiplier <= 1 { + opts.GasMultiplier = 1.2 + } + action.Status = ActionStatusRunning + action.FromAddress = txSigner.Address().Hex() + action.Touch() + if store != nil { + _ = store.Save(*action) + } + + for i := range action.Steps { + step := &action.Steps[i] + if step.Status == StepStatusConfirmed { + continue + } + if strings.TrimSpace(step.RPCURL) == "" { + markStepFailed(action, step, "missing rpc url") + if store != nil { + _ = store.Save(*action) + } + return clierr.New(clierr.CodeUsage, "missing rpc url for action step") + } + if strings.TrimSpace(step.Target) == "" { + markStepFailed(action, step, "missing target") + if store != nil { + _ = store.Save(*action) + } + return clierr.New(clierr.CodeUsage, "missing target for action step") + } + client, err := ethclient.DialContext(ctx, step.RPCURL) + if err != nil { + markStepFailed(action, step, err.Error()) + if store != nil { + _ = store.Save(*action) + } + return clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + + if err := executeStep(ctx, client, txSigner, step, opts); err != nil { + client.Close() + markStepFailed(action, step, err.Error()) + if store != nil { + _ = store.Save(*action) + } + return err + } + client.Close() + action.Touch() + if store != nil { + _ = store.Save(*action) + } + } + action.Status = ActionStatusCompleted + action.Touch() + if store != nil { + _ = store.Save(*action) + } + return nil +} + +func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer.Signer, step *ActionStep, opts ExecuteOptions) error { + chainID, err := client.ChainID(ctx) + if err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "read chain id", err) + } + if step.ChainID != "" { + expected := fmt.Sprintf("eip155:%d", chainID.Int64()) + if !strings.EqualFold(strings.TrimSpace(step.ChainID), expected) { + return clierr.New(clierr.CodeActionPlan, fmt.Sprintf("step chain mismatch: expected %s, got %s", expected, step.ChainID)) + } + } + target := common.HexToAddress(step.Target) + data, err := decodeHex(step.Data) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "decode step calldata", err) + } + value, ok := new(big.Int).SetString(step.Value, 10) + if !ok { + return clierr.New(clierr.CodeUsage, "invalid step value") + } + msg := ethereum.CallMsg{From: txSigner.Address(), To: &target, Value: value, Data: data} + + if opts.Simulate { + if _, err := client.CallContract(ctx, msg, nil); err != nil { + return clierr.Wrap(clierr.CodeActionSim, "simulate step (eth_call)", err) + } + step.Status = StepStatusSimulated + } + + gasLimit, err := client.EstimateGas(ctx, msg) + if err != nil { + return clierr.Wrap(clierr.CodeActionSim, "estimate gas", err) + } + gasLimit = uint64(float64(gasLimit) * opts.GasMultiplier) + + tipCap, err := resolveTipCap(ctx, client, opts.MaxPriorityFeeGwei) + if err != nil { + return err + } + header, err := client.HeaderByNumber(ctx, nil) + if err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "fetch latest header", err) + } + baseFee := header.BaseFee + if baseFee == nil { + baseFee = big.NewInt(1_000_000_000) + } + feeCap, err := resolveFeeCap(baseFee, tipCap, opts.MaxFeeGwei) + if err != nil { + return err + } + + nonce, err := client.PendingNonceAt(ctx, txSigner.Address()) + if err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "fetch nonce", err) + } + + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + GasTipCap: tipCap, + GasFeeCap: feeCap, + Gas: gasLimit, + To: &target, + Value: value, + Data: data, + }) + signed, err := txSigner.SignTx(chainID, tx) + if err != nil { + return clierr.Wrap(clierr.CodeSigner, "sign transaction", err) + } + if err := client.SendTransaction(ctx, signed); err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "broadcast transaction", err) + } + step.Status = StepStatusSubmitted + step.TxHash = signed.Hash().Hex() + + waitCtx, cancel := context.WithTimeout(ctx, opts.StepTimeout) + defer cancel() + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + for { + receipt, err := client.TransactionReceipt(waitCtx, signed.Hash()) + if err == nil && receipt != nil { + if receipt.Status == types.ReceiptStatusSuccessful { + step.Status = StepStatusConfirmed + return nil + } + return clierr.New(clierr.CodeUnavailable, "transaction reverted on-chain") + } + if waitCtx.Err() != nil { + return clierr.Wrap(clierr.CodeActionTimeout, "timed out waiting for receipt", waitCtx.Err()) + } + if err != nil && !errors.Is(err, ethereum.NotFound) { + // Ignore transient RPC polling failures until timeout. + } + select { + case <-waitCtx.Done(): + return clierr.Wrap(clierr.CodeActionTimeout, "timed out waiting for receipt", waitCtx.Err()) + case <-ticker.C: + } + } +} + +func resolveTipCap(ctx context.Context, client *ethclient.Client, overrideGwei string) (*big.Int, error) { + if strings.TrimSpace(overrideGwei) != "" { + v, err := parseGwei(overrideGwei) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUsage, "parse --max-priority-fee-gwei", err) + } + return v, nil + } + tipCap, err := client.SuggestGasTipCap(ctx) + if err != nil { + return big.NewInt(2_000_000_000), nil // 2 gwei fallback + } + return tipCap, nil +} + +func resolveFeeCap(baseFee, tipCap *big.Int, overrideGwei string) (*big.Int, error) { + if strings.TrimSpace(overrideGwei) != "" { + v, err := parseGwei(overrideGwei) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUsage, "parse --max-fee-gwei", err) + } + if v.Cmp(tipCap) < 0 { + return nil, clierr.New(clierr.CodeUsage, "--max-fee-gwei must be >= --max-priority-fee-gwei") + } + return v, nil + } + feeCap := new(big.Int).Mul(baseFee, big.NewInt(2)) + feeCap.Add(feeCap, tipCap) + return feeCap, nil +} + +func parseGwei(v string) (*big.Int, error) { + clean := strings.TrimSpace(v) + if clean == "" { + return nil, fmt.Errorf("empty gwei value") + } + rat, ok := new(big.Rat).SetString(clean) + if !ok { + return nil, fmt.Errorf("invalid numeric value %q", v) + } + if rat.Sign() < 0 { + return nil, fmt.Errorf("value must be non-negative") + } + scale := big.NewRat(1_000_000_000, 1) + rat.Mul(rat, scale) + out := new(big.Int) + if !rat.IsInt() { + return nil, fmt.Errorf("value must resolve to an integer wei amount") + } + out = new(big.Int).Set(rat.Num()) + return out, nil +} + +func markStepFailed(action *Action, step *ActionStep, msg string) { + step.Status = StepStatusFailed + step.Error = msg + action.Status = ActionStatusFailed + action.Touch() +} + +func decodeHex(v string) ([]byte, error) { + clean := strings.TrimSpace(v) + clean = strings.TrimPrefix(clean, "0x") + if clean == "" { + return []byte{}, nil + } + if len(clean)%2 != 0 { + clean = "0" + clean + } + buf, err := hex.DecodeString(clean) + if err != nil { + return nil, fmt.Errorf("invalid hex: %w", err) + } + return buf, nil +} diff --git a/internal/execution/planner/aave.go b/internal/execution/planner/aave.go new file mode 100644 index 0000000..ac69e22 --- /dev/null +++ b/internal/execution/planner/aave.go @@ -0,0 +1,545 @@ +package planner + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/registry" +) + +type AaveLendVerb string + +const ( + AaveVerbSupply AaveLendVerb = "supply" + AaveVerbWithdraw AaveLendVerb = "withdraw" + AaveVerbBorrow AaveLendVerb = "borrow" + AaveVerbRepay AaveLendVerb = "repay" +) + +type AaveLendRequest struct { + Verb AaveLendVerb + Chain id.Chain + Asset id.Asset + AmountBaseUnits string + Sender string + Recipient string + OnBehalfOf string + InterestRateMode int64 + Simulate bool + RPCURL string + PoolAddress string + PoolAddressesProvider string +} + +type AaveRewardsClaimRequest struct { + Chain id.Chain + Sender string + Recipient string + Assets []string + RewardToken string + AmountBaseUnits string + Simulate bool + RPCURL string + ControllerAddress string + PoolAddressesProvider string +} + +type AaveRewardsCompoundRequest struct { + Chain id.Chain + Sender string + Recipient string + Assets []string + RewardToken string + AmountBaseUnits string + Simulate bool + RPCURL string + ControllerAddress string + PoolAddress string + PoolAddressesProvider string + OnBehalfOf string +} + +func BuildAaveLendAction(ctx context.Context, req AaveLendRequest) (execution.Action, error) { + verb := strings.ToLower(strings.TrimSpace(string(req.Verb))) + sender, recipient, onBehalfOf, amount, rpcURL, tokenAddr, err := normalizeLendInputs(req) + if err != nil { + return execution.Action{}, err + } + + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + defer client.Close() + + poolAddr, err := resolveAavePoolAddress(ctx, client, req.Chain, req.PoolAddress, req.PoolAddressesProvider) + if err != nil { + return execution.Action{}, err + } + action := execution.NewAction(execution.NewActionID(), "lend_"+verb, req.Chain.CAIP2, execution.Constraints{Simulate: req.Simulate}) + action.Provider = "aave" + action.FromAddress = sender.Hex() + action.ToAddress = recipient.Hex() + action.InputAmount = amount.String() + action.Metadata = map[string]any{ + "protocol": "aave", + "asset_id": req.Asset.AssetID, + "pool": poolAddr.Hex(), + "on_behalf_of": onBehalfOf.Hex(), + "recipient": recipient.Hex(), + "rate_mode": req.InterestRateMode, + "lending_action": verb, + } + + switch verb { + case string(AaveVerbSupply): + if err := appendApprovalIfNeeded(ctx, client, &action, req.Chain.CAIP2, rpcURL, tokenAddr, sender, poolAddr, amount, "Approve token for Aave supply"); err != nil { + return execution.Action{}, err + } + data, err := aavePoolABI.Pack("supply", tokenAddr, amount, onBehalfOf, uint16(0)) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack aave supply calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "aave-supply", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Supply asset to Aave", + Target: poolAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + case string(AaveVerbWithdraw): + data, err := aavePoolABI.Pack("withdraw", tokenAddr, amount, recipient) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack aave withdraw calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "aave-withdraw", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Withdraw asset from Aave", + Target: poolAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + case string(AaveVerbBorrow): + rateMode := req.InterestRateMode + if rateMode == 0 { + rateMode = 2 + } + if rateMode != 1 && rateMode != 2 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "borrow interest rate mode must be 1 (stable) or 2 (variable)") + } + data, err := aavePoolABI.Pack("borrow", tokenAddr, amount, big.NewInt(rateMode), uint16(0), onBehalfOf) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack aave borrow calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "aave-borrow", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Borrow asset from Aave", + Target: poolAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + case string(AaveVerbRepay): + rateMode := req.InterestRateMode + if rateMode == 0 { + rateMode = 2 + } + if rateMode != 1 && rateMode != 2 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "repay interest rate mode must be 1 (stable) or 2 (variable)") + } + if err := appendApprovalIfNeeded(ctx, client, &action, req.Chain.CAIP2, rpcURL, tokenAddr, sender, poolAddr, amount, "Approve token for Aave repay"); err != nil { + return execution.Action{}, err + } + data, err := aavePoolABI.Pack("repay", tokenAddr, amount, big.NewInt(rateMode), onBehalfOf) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack aave repay calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "aave-repay", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Repay borrowed asset on Aave", + Target: poolAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + default: + return execution.Action{}, clierr.New(clierr.CodeUsage, "unsupported lend action verb") + } + + return action, nil +} + +func BuildAaveRewardsClaimAction(ctx context.Context, req AaveRewardsClaimRequest) (execution.Action, error) { + sender := strings.TrimSpace(req.Sender) + if !common.IsHexAddress(sender) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "rewards claim requires sender address") + } + recipient := strings.TrimSpace(req.Recipient) + if recipient == "" { + recipient = sender + } + if !common.IsHexAddress(recipient) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "invalid rewards recipient address") + } + if !common.IsHexAddress(req.RewardToken) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "reward token must be an address") + } + assets, err := normalizeAddressList(req.Assets) + if err != nil { + return execution.Action{}, err + } + if len(assets) == 0 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "rewards claim requires at least one asset in --assets") + } + + rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) + } + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + defer client.Close() + + controller, err := resolveIncentivesController(ctx, client, req.Chain, req.ControllerAddress, req.PoolAddressesProvider) + if err != nil { + return execution.Action{}, err + } + amount, err := parseRewardAmount(req.AmountBaseUnits) + if err != nil { + return execution.Action{}, err + } + assetAddrs := make([]common.Address, 0, len(assets)) + for _, a := range assets { + assetAddrs = append(assetAddrs, common.HexToAddress(a)) + } + data, err := aaveRewardsABI.Pack("claimRewards", assetAddrs, amount, common.HexToAddress(recipient), common.HexToAddress(req.RewardToken)) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack rewards claim calldata", err) + } + action := execution.NewAction(execution.NewActionID(), "claim_rewards", req.Chain.CAIP2, execution.Constraints{Simulate: req.Simulate}) + action.Provider = "aave" + action.FromAddress = common.HexToAddress(sender).Hex() + action.ToAddress = common.HexToAddress(recipient).Hex() + action.InputAmount = amount.String() + action.Metadata = map[string]any{ + "protocol": "aave", + "controller": controller.Hex(), + "reward_token": common.HexToAddress(req.RewardToken).Hex(), + "assets": assets, + "amount_base_units": amount.String(), + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "aave-claim-rewards", + Type: execution.StepTypeClaim, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Claim rewards from Aave incentives controller", + Target: controller.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + return action, nil +} + +func BuildAaveRewardsCompoundAction(ctx context.Context, req AaveRewardsCompoundRequest) (execution.Action, error) { + if strings.EqualFold(strings.TrimSpace(req.AmountBaseUnits), "max") { + return execution.Action{}, clierr.New(clierr.CodeUsage, "compound requires an explicit --amount in base units (max is unsupported)") + } + claimAction, err := BuildAaveRewardsClaimAction(ctx, AaveRewardsClaimRequest{ + Chain: req.Chain, + Sender: req.Sender, + Recipient: req.Recipient, + Assets: req.Assets, + RewardToken: req.RewardToken, + AmountBaseUnits: req.AmountBaseUnits, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + ControllerAddress: req.ControllerAddress, + PoolAddressesProvider: req.PoolAddressesProvider, + }) + if err != nil { + return execution.Action{}, err + } + claimAction.ActionID = execution.NewActionID() + claimAction.IntentType = "compound_rewards" + claimAction.Metadata["compound"] = true + + rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) + } + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + defer client.Close() + + poolAddr, err := resolveAavePoolAddress(ctx, client, req.Chain, req.PoolAddress, req.PoolAddressesProvider) + if err != nil { + return execution.Action{}, err + } + amount, ok := new(big.Int).SetString(strings.TrimSpace(req.AmountBaseUnits), 10) + if !ok || amount.Sign() <= 0 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "compound amount must be a positive integer in base units") + } + sender := common.HexToAddress(strings.TrimSpace(req.Sender)) + onBehalfOf := sender + if strings.TrimSpace(req.OnBehalfOf) != "" { + onBehalfOf = common.HexToAddress(req.OnBehalfOf) + } + rewardAddr := common.HexToAddress(req.RewardToken) + if err := appendApprovalIfNeeded(ctx, client, &claimAction, req.Chain.CAIP2, rpcURL, rewardAddr, sender, poolAddr, amount, "Approve reward token for Aave supply"); err != nil { + return execution.Action{}, err + } + supplyData, err := aavePoolABI.Pack("supply", rewardAddr, amount, onBehalfOf, uint16(0)) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack aave compound supply calldata", err) + } + claimAction.Steps = append(claimAction.Steps, execution.ActionStep{ + StepID: "aave-compound-supply", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Supply claimed reward token to Aave", + Target: poolAddr.Hex(), + Data: "0x" + common.Bytes2Hex(supplyData), + Value: "0", + }) + claimAction.Metadata["pool"] = poolAddr.Hex() + claimAction.Metadata["on_behalf_of"] = onBehalfOf.Hex() + return claimAction, nil +} + +func normalizeLendInputs(req AaveLendRequest) (common.Address, common.Address, common.Address, *big.Int, string, common.Address, error) { + sender := strings.TrimSpace(req.Sender) + if !common.IsHexAddress(sender) { + return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.New(clierr.CodeUsage, "lend action requires sender address") + } + recipient := strings.TrimSpace(req.Recipient) + if recipient == "" { + recipient = sender + } + if !common.IsHexAddress(recipient) { + return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.New(clierr.CodeUsage, "invalid recipient address") + } + onBehalfOf := strings.TrimSpace(req.OnBehalfOf) + if onBehalfOf == "" { + onBehalfOf = sender + } + if !common.IsHexAddress(onBehalfOf) { + return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.New(clierr.CodeUsage, "invalid on-behalf-of address") + } + if !common.IsHexAddress(req.Asset.Address) { + return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.New(clierr.CodeUsage, "lend asset must resolve to an ERC20 address") + } + amount, ok := new(big.Int).SetString(strings.TrimSpace(req.AmountBaseUnits), 10) + if !ok || amount.Sign() <= 0 { + return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.New(clierr.CodeUsage, "lend amount must be a positive integer in base units") + } + rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + if err != nil { + return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) + } + return common.HexToAddress(sender), common.HexToAddress(recipient), common.HexToAddress(onBehalfOf), amount, rpcURL, common.HexToAddress(req.Asset.Address), nil +} + +func resolveAavePoolAddress(ctx context.Context, client *ethclient.Client, chain id.Chain, poolAddress string, poolProvider string) (common.Address, error) { + if strings.TrimSpace(poolAddress) != "" { + if !common.IsHexAddress(poolAddress) { + return common.Address{}, clierr.New(clierr.CodeUsage, "invalid --pool-address") + } + return common.HexToAddress(poolAddress), nil + } + providerAddr := strings.TrimSpace(poolProvider) + if providerAddr == "" { + if discovered, ok := registry.AavePoolAddressProvider(chain.EVMChainID); ok { + providerAddr = discovered + } + } + if providerAddr == "" { + return common.Address{}, clierr.New(clierr.CodeUnsupported, "aave pool address provider is unavailable for this chain; pass --pool-address or --pool-address-provider") + } + if !common.IsHexAddress(providerAddr) { + return common.Address{}, clierr.New(clierr.CodeUsage, "invalid --pool-address-provider") + } + provider := common.HexToAddress(providerAddr) + callData, err := aavePoolAddressProviderABI.Pack("getPool") + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeInternal, "pack getPool calldata", err) + } + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &provider, Data: callData}, nil) + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "fetch aave pool address", err) + } + decoded, err := aavePoolAddressProviderABI.Unpack("getPool", out) + if err != nil || len(decoded) == 0 { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "decode aave pool address", err) + } + pool, ok := decoded[0].(common.Address) + if !ok { + if ptr, ok := decoded[0].(*common.Address); ok && ptr != nil { + pool = *ptr + } else { + return common.Address{}, clierr.New(clierr.CodeUnavailable, "invalid aave pool response") + } + } + if pool == (common.Address{}) { + return common.Address{}, clierr.New(clierr.CodeUnavailable, "aave pool address is zero") + } + return pool, nil +} + +func resolveIncentivesController(ctx context.Context, client *ethclient.Client, chain id.Chain, controllerAddress string, poolProvider string) (common.Address, error) { + if strings.TrimSpace(controllerAddress) != "" { + if !common.IsHexAddress(controllerAddress) { + return common.Address{}, clierr.New(clierr.CodeUsage, "invalid --controller-address") + } + return common.HexToAddress(controllerAddress), nil + } + providerAddr := strings.TrimSpace(poolProvider) + if providerAddr == "" { + if discovered, ok := registry.AavePoolAddressProvider(chain.EVMChainID); ok { + providerAddr = discovered + } + } + if providerAddr == "" { + return common.Address{}, clierr.New(clierr.CodeUnsupported, "aave incentives controller is unavailable for this chain; pass --controller-address") + } + if !common.IsHexAddress(providerAddr) { + return common.Address{}, clierr.New(clierr.CodeUsage, "invalid --pool-address-provider") + } + provider := common.HexToAddress(providerAddr) + slot := crypto.Keccak256Hash([]byte("INCENTIVES_CONTROLLER")) + callData, err := aavePoolAddressProviderABI.Pack("getAddress", slot) + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeInternal, "pack getAddress calldata", err) + } + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &provider, Data: callData}, nil) + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "fetch incentives controller address", err) + } + decoded, err := aavePoolAddressProviderABI.Unpack("getAddress", out) + if err != nil || len(decoded) == 0 { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "decode incentives controller address", err) + } + controller, ok := decoded[0].(common.Address) + if !ok { + if ptr, ok := decoded[0].(*common.Address); ok && ptr != nil { + controller = *ptr + } else { + return common.Address{}, clierr.New(clierr.CodeUnavailable, "invalid incentives controller response") + } + } + if controller == (common.Address{}) { + return common.Address{}, clierr.New(clierr.CodeUnavailable, "incentives controller address is zero") + } + return controller, nil +} + +func appendApprovalIfNeeded(ctx context.Context, client *ethclient.Client, action *execution.Action, chainID, rpcURL string, token, owner, spender common.Address, amount *big.Int, description string) error { + allowanceData, err := plannerERC20ABI.Pack("allowance", owner, spender) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "pack allowance calldata", err) + } + allowanceRaw, err := client.CallContract(ctx, ethereum.CallMsg{From: owner, To: &token, Data: allowanceData}, nil) + if err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "read token allowance", err) + } + allowanceOut, err := plannerERC20ABI.Unpack("allowance", allowanceRaw) + if err != nil || len(allowanceOut) == 0 { + return clierr.Wrap(clierr.CodeUnavailable, "decode token allowance", err) + } + currentAllowance, ok := allowanceOut[0].(*big.Int) + if !ok { + return clierr.New(clierr.CodeUnavailable, "invalid allowance response") + } + if currentAllowance.Cmp(amount) >= 0 { + return nil + } + approveData, err := plannerERC20ABI.Pack("approve", spender, amount) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "pack approve calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: fmt.Sprintf("approve-%s", strings.TrimPrefix(strings.ToLower(token.Hex()), "0x")), + Type: execution.StepTypeApproval, + Status: execution.StepStatusPending, + ChainID: chainID, + RPCURL: rpcURL, + Description: description, + Target: token.Hex(), + Data: "0x" + common.Bytes2Hex(approveData), + Value: "0", + }) + return nil +} + +func normalizeAddressList(values []string) ([]string, error) { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + norm := strings.TrimSpace(part) + if norm == "" { + continue + } + if !common.IsHexAddress(norm) { + return nil, clierr.New(clierr.CodeUsage, fmt.Sprintf("invalid address in --assets: %s", norm)) + } + canonical := common.HexToAddress(norm).Hex() + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + out = append(out, canonical) + } + } + return out, nil +} + +func parseRewardAmount(v string) (*big.Int, error) { + clean := strings.TrimSpace(v) + if clean == "" || strings.EqualFold(clean, "max") { + max := new(big.Int) + max.Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + return max, nil + } + amount, ok := new(big.Int).SetString(clean, 10) + if !ok || amount.Sign() <= 0 { + return nil, clierr.New(clierr.CodeUsage, "reward amount must be a positive integer in base units or 'max'") + } + return amount, nil +} + +var aavePoolAddressProviderABI = mustPlannerABI(registry.AavePoolAddressProviderABI) + +var aavePoolABI = mustPlannerABI(registry.AavePoolABI) + +var aaveRewardsABI = mustPlannerABI(registry.AaveRewardsABI) diff --git a/internal/execution/planner/aave_test.go b/internal/execution/planner/aave_test.go new file mode 100644 index 0000000..a9a7773 --- /dev/null +++ b/internal/execution/planner/aave_test.go @@ -0,0 +1,159 @@ +package planner + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ggonzalez94/defi-cli/internal/id" +) + +type plannerRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +func TestBuildAaveLendActionSupply(t *testing.T) { + rpc := newPlannerRPCServer(t, big.NewInt(0)) + defer rpc.Close() + + chain, err := id.ParseChain("ethereum") + if err != nil { + t.Fatalf("parse chain: %v", err) + } + asset, err := id.ParseAsset("USDC", chain) + if err != nil { + t.Fatalf("parse asset: %v", err) + } + action, err := BuildAaveLendAction(context.Background(), AaveLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: "0x00000000000000000000000000000000000000AA", + Recipient: "0x00000000000000000000000000000000000000BB", + Simulate: true, + RPCURL: rpc.URL, + PoolAddress: "0x00000000000000000000000000000000000000CC", + }) + if err != nil { + t.Fatalf("BuildAaveLendAction failed: %v", err) + } + if action.IntentType != "lend_supply" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + if len(action.Steps) != 2 { + t.Fatalf("expected approval + lend steps, got %d", len(action.Steps)) + } + if action.Steps[0].Type != "approval" { + t.Fatalf("expected first step approval, got %s", action.Steps[0].Type) + } + if action.Steps[1].Type != "lend_call" { + t.Fatalf("expected second step lend_call, got %s", action.Steps[1].Type) + } + if !strings.EqualFold(action.Steps[1].Target, "0x00000000000000000000000000000000000000CC") { + t.Fatalf("unexpected lend target: %s", action.Steps[1].Target) + } +} + +func TestBuildAaveRewardsCompoundAction(t *testing.T) { + rpc := newPlannerRPCServer(t, big.NewInt(0)) + defer rpc.Close() + + chain, err := id.ParseChain("ethereum") + if err != nil { + t.Fatalf("parse chain: %v", err) + } + action, err := BuildAaveRewardsCompoundAction(context.Background(), AaveRewardsCompoundRequest{ + Chain: chain, + Sender: "0x00000000000000000000000000000000000000AA", + Recipient: "0x00000000000000000000000000000000000000AA", + Assets: []string{"0x00000000000000000000000000000000000000D1"}, + RewardToken: "0x00000000000000000000000000000000000000D2", + AmountBaseUnits: "1000", + Simulate: true, + RPCURL: rpc.URL, + ControllerAddress: "0x00000000000000000000000000000000000000D3", + PoolAddress: "0x00000000000000000000000000000000000000D4", + }) + if err != nil { + t.Fatalf("BuildAaveRewardsCompoundAction failed: %v", err) + } + if action.IntentType != "compound_rewards" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + if len(action.Steps) != 3 { + t.Fatalf("expected claim + approval + supply steps, got %d", len(action.Steps)) + } + if action.Steps[0].Type != "claim" { + t.Fatalf("expected first step claim, got %s", action.Steps[0].Type) + } + if action.Steps[1].Type != "approval" { + t.Fatalf("expected second step approval, got %s", action.Steps[1].Type) + } + if action.Steps[2].Type != "lend_call" { + t.Fatalf("expected third step lend_call, got %s", action.Steps[2].Type) + } +} + +func TestBuildAaveLendActionRequiresSender(t *testing.T) { + chain, _ := id.ParseChain("ethereum") + asset, _ := id.ParseAsset("USDC", chain) + _, err := BuildAaveLendAction(context.Background(), AaveLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + }) + if err == nil { + t.Fatal("expected missing sender error") + } +} + +func newPlannerRPCServer(t *testing.T, allowance *big.Int) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var req plannerRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch req.Method { + case "eth_call": + encoded, err := plannerERC20ABI.Methods["allowance"].Outputs.Pack(allowance) + if err != nil { + t.Fatalf("pack allowance response: %v", err) + } + writePlannerRPCResult(w, req.ID, "0x"+hex.EncodeToString(encoded)) + default: + writePlannerRPCError(w, req.ID, -32601, fmt.Sprintf("method not supported in test: %s", req.Method)) + } + })) +} + +func writePlannerRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":%q}`, rawPlannerID(id), result) +} + +func writePlannerRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"error":{"code":%d,"message":%q}}`, rawPlannerID(id), code, message) +} + +func rawPlannerID(id json.RawMessage) string { + if len(id) == 0 { + return "1" + } + return string(id) +} diff --git a/internal/execution/planner/approvals.go b/internal/execution/planner/approvals.go new file mode 100644 index 0000000..afaa172 --- /dev/null +++ b/internal/execution/planner/approvals.go @@ -0,0 +1,83 @@ +package planner + +import ( + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/registry" +) + +type ApprovalRequest struct { + Chain id.Chain + Asset id.Asset + AmountBaseUnits string + Sender string + Spender string + Simulate bool + RPCURL string +} + +func BuildApprovalAction(req ApprovalRequest) (execution.Action, error) { + sender := strings.TrimSpace(req.Sender) + if sender == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "approval requires sender address") + } + spender := strings.TrimSpace(req.Spender) + if spender == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "approval requires spender address") + } + if !common.IsHexAddress(req.Asset.Address) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "approval requires ERC20 token address") + } + amount, ok := new(big.Int).SetString(strings.TrimSpace(req.AmountBaseUnits), 10) + if !ok || amount.Sign() <= 0 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "approval amount must be a positive integer in base units") + } + + rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) + } + + approveData, err := plannerERC20ABI.Pack("approve", common.HexToAddress(spender), amount) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack approval calldata", err) + } + action := execution.NewAction(execution.NewActionID(), "approve", req.Chain.CAIP2, execution.Constraints{Simulate: req.Simulate}) + action.Provider = "native" + action.FromAddress = common.HexToAddress(sender).Hex() + action.ToAddress = common.HexToAddress(spender).Hex() + action.InputAmount = amount.String() + action.Metadata = map[string]any{ + "asset_id": req.Asset.AssetID, + "spender": common.HexToAddress(spender).Hex(), + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "approve-token", + Type: execution.StepTypeApproval, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: fmt.Sprintf("Approve %s for spender", strings.ToUpper(req.Asset.Symbol)), + Target: common.HexToAddress(req.Asset.Address).Hex(), + Data: "0x" + common.Bytes2Hex(approveData), + Value: "0", + }) + return action, nil +} + +var plannerERC20ABI = mustPlannerABI(registry.ERC20MinimalABI) + +func mustPlannerABI(raw string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(raw)) + if err != nil { + panic(err) + } + return parsed +} diff --git a/internal/execution/planner/approvals_test.go b/internal/execution/planner/approvals_test.go new file mode 100644 index 0000000..e472bf2 --- /dev/null +++ b/internal/execution/planner/approvals_test.go @@ -0,0 +1,57 @@ +package planner + +import ( + "testing" + + "github.com/ggonzalez94/defi-cli/internal/id" +) + +func TestBuildApprovalAction(t *testing.T) { + chain, err := id.ParseChain("taiko") + if err != nil { + t.Fatalf("parse chain: %v", err) + } + asset, err := id.ParseAsset("USDC", chain) + if err != nil { + t.Fatalf("parse asset: %v", err) + } + action, err := BuildApprovalAction(ApprovalRequest{ + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: "0x00000000000000000000000000000000000000AA", + Spender: "0x00000000000000000000000000000000000000BB", + Simulate: true, + RPCURL: "http://127.0.0.1:8545", + }) + if err != nil { + t.Fatalf("BuildApprovalAction failed: %v", err) + } + if action.IntentType != "approve" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + if action.Provider != "native" { + t.Fatalf("unexpected provider: %s", action.Provider) + } + if len(action.Steps) != 1 { + t.Fatalf("expected one approval step, got %d", len(action.Steps)) + } + if action.Steps[0].Type != "approval" { + t.Fatalf("unexpected step type: %s", action.Steps[0].Type) + } +} + +func TestBuildApprovalActionRejectsInvalidAmount(t *testing.T) { + chain, _ := id.ParseChain("taiko") + asset, _ := id.ParseAsset("USDC", chain) + _, err := BuildApprovalAction(ApprovalRequest{ + Chain: chain, + Asset: asset, + AmountBaseUnits: "0", + Sender: "0x00000000000000000000000000000000000000AA", + Spender: "0x00000000000000000000000000000000000000BB", + }) + if err == nil { + t.Fatal("expected invalid amount error") + } +} diff --git a/internal/execution/rpc.go b/internal/execution/rpc.go new file mode 100644 index 0000000..1fc1e9c --- /dev/null +++ b/internal/execution/rpc.go @@ -0,0 +1,45 @@ +package execution + +import ( + "fmt" + "strings" +) + +var defaultRPCByChainID = map[int64]string{ + 1: "https://eth.llamarpc.com", + 10: "https://mainnet.optimism.io", + 56: "https://bsc-dataseed.binance.org", + 100: "https://rpc.gnosischain.com", + 137: "https://polygon-rpc.com", + 324: "https://mainnet.era.zksync.io", + 146: "https://rpc.soniclabs.com", + 252: "https://rpc.frax.com", + 480: "https://worldchain-mainnet.g.alchemy.com/public", + 5000: "https://rpc.mantle.xyz", + 8453: "https://mainnet.base.org", + 42220: "https://forno.celo.org", + 42161: "https://arb1.arbitrum.io/rpc", + 43114: "https://api.avax.network/ext/bc/C/rpc", + 57073: "https://rpc-gel.inkonchain.com", + 59144: "https://rpc.linea.build", + 80094: "https://rpc.berachain.com", + 81457: "https://rpc.blast.io", + 167000: "https://rpc.mainnet.taiko.xyz", + 167013: "https://rpc.hoodi.taiko.xyz", + 534352: "https://rpc.scroll.io", +} + +func DefaultRPCURL(chainID int64) (string, bool) { + v, ok := defaultRPCByChainID[chainID] + return v, ok +} + +func ResolveRPCURL(override string, chainID int64) (string, error) { + if strings.TrimSpace(override) != "" { + return strings.TrimSpace(override), nil + } + if v, ok := DefaultRPCURL(chainID); ok { + return v, nil + } + return "", fmt.Errorf("no default rpc configured for chain id %d; provide --rpc-url", chainID) +} diff --git a/internal/execution/signer/local.go b/internal/execution/signer/local.go new file mode 100644 index 0000000..5558bab --- /dev/null +++ b/internal/execution/signer/local.go @@ -0,0 +1,175 @@ +package signer + +import ( + "crypto/ecdsa" + "errors" + "fmt" + "math/big" + "os" + "strings" + + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + EnvPrivateKey = "DEFI_PRIVATE_KEY" + EnvPrivateKeyFile = "DEFI_PRIVATE_KEY_FILE" + EnvKeystorePath = "DEFI_KEYSTORE_PATH" + EnvKeystorePassword = "DEFI_KEYSTORE_PASSWORD" + EnvKeystorePasswordFile = "DEFI_KEYSTORE_PASSWORD_FILE" + + KeySourceAuto = "auto" + KeySourceEnv = "env" + KeySourceFile = "file" + KeySourceKeystore = "keystore" +) + +type LocalSigner struct { + privateKey *ecdsa.PrivateKey + address common.Address +} + +func (s *LocalSigner) Address() common.Address { + return s.address +} + +func (s *LocalSigner) SignTx(chainID *big.Int, tx *types.Transaction) (*types.Transaction, error) { + if s == nil || s.privateKey == nil { + return nil, errors.New("local signer is not initialized") + } + signer := types.LatestSignerForChainID(chainID) + return types.SignTx(tx, signer, s.privateKey) +} + +func NewLocalSignerFromEnv(source string) (*LocalSigner, error) { + source = strings.ToLower(strings.TrimSpace(source)) + if source == "" { + source = KeySourceAuto + } + privateKeyHex := strings.TrimSpace(os.Getenv(EnvPrivateKey)) + privateKeyFile := strings.TrimSpace(os.Getenv(EnvPrivateKeyFile)) + keystorePath := strings.TrimSpace(os.Getenv(EnvKeystorePath)) + keystorePassword := strings.TrimSpace(os.Getenv(EnvKeystorePassword)) + keystorePasswordFile := strings.TrimSpace(os.Getenv(EnvKeystorePasswordFile)) + + switch source { + case KeySourceAuto: + // Keep all values to preserve precedence in loadPrivateKey. + case KeySourceEnv: + privateKeyFile = "" + keystorePath = "" + keystorePassword = "" + keystorePasswordFile = "" + case KeySourceFile: + privateKeyHex = "" + keystorePath = "" + keystorePassword = "" + keystorePasswordFile = "" + case KeySourceKeystore: + privateKeyHex = "" + privateKeyFile = "" + default: + return nil, fmt.Errorf("unsupported key source %q (expected %s|%s|%s|%s)", source, KeySourceAuto, KeySourceEnv, KeySourceFile, KeySourceKeystore) + } + + return NewLocalSigner(LocalSignerConfig{ + PrivateKeyHex: privateKeyHex, + PrivateKeyFile: privateKeyFile, + KeystorePath: keystorePath, + KeystorePassword: keystorePassword, + KeystorePasswordFile: keystorePasswordFile, + }) +} + +type LocalSignerConfig struct { + PrivateKeyHex string + PrivateKeyFile string + KeystorePath string + KeystorePassword string + KeystorePasswordFile string +} + +func NewLocalSigner(cfg LocalSignerConfig) (*LocalSigner, error) { + pk, err := loadPrivateKey(cfg) + if err != nil { + return nil, err + } + pub, ok := pk.Public().(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("invalid ECDSA public key") + } + addr := crypto.PubkeyToAddress(*pub) + return &LocalSigner{privateKey: pk, address: addr}, nil +} + +func loadPrivateKey(cfg LocalSignerConfig) (*ecdsa.PrivateKey, error) { + if strings.TrimSpace(cfg.PrivateKeyHex) != "" { + return parseHexKey(cfg.PrivateKeyHex) + } + if strings.TrimSpace(cfg.PrivateKeyFile) != "" { + if err := validateFilePermissions(cfg.PrivateKeyFile); err != nil { + return nil, err + } + buf, err := os.ReadFile(cfg.PrivateKeyFile) + if err != nil { + return nil, fmt.Errorf("read private key file: %w", err) + } + return parseHexKey(string(buf)) + } + if strings.TrimSpace(cfg.KeystorePath) != "" { + if err := validateFilePermissions(cfg.KeystorePath); err != nil { + return nil, err + } + password := cfg.KeystorePassword + if strings.TrimSpace(password) == "" && strings.TrimSpace(cfg.KeystorePasswordFile) != "" { + if err := validateFilePermissions(cfg.KeystorePasswordFile); err != nil { + return nil, err + } + buf, err := os.ReadFile(cfg.KeystorePasswordFile) + if err != nil { + return nil, fmt.Errorf("read keystore password file: %w", err) + } + password = strings.TrimSpace(string(buf)) + } + if strings.TrimSpace(password) == "" { + return nil, fmt.Errorf("keystore password is required") + } + buf, err := os.ReadFile(cfg.KeystorePath) + if err != nil { + return nil, fmt.Errorf("read keystore file: %w", err) + } + key, err := keystore.DecryptKey(buf, password) + if err != nil { + return nil, fmt.Errorf("decrypt keystore: %w", err) + } + return key.PrivateKey, nil + } + return nil, fmt.Errorf("missing signing key: set %s or %s or %s", EnvPrivateKey, EnvPrivateKeyFile, EnvKeystorePath) +} + +func parseHexKey(raw string) (*ecdsa.PrivateKey, error) { + clean := strings.TrimSpace(raw) + clean = strings.TrimPrefix(clean, "0x") + if clean == "" { + return nil, fmt.Errorf("empty private key") + } + pk, err := crypto.HexToECDSA(clean) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + return pk, nil +} + +func validateFilePermissions(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat secret file: %w", err) + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("insecure file permissions on %s: expected 0600 or stricter", path) + } + return nil +} diff --git a/internal/execution/signer/local_test.go b/internal/execution/signer/local_test.go new file mode 100644 index 0000000..f40f174 --- /dev/null +++ b/internal/execution/signer/local_test.go @@ -0,0 +1,65 @@ +package signer + +import ( + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +const testPrivateKey = "59c6995e998f97a5a0044976f0945388cf9b7e5e5f4f9d2d9d8f1f5b7f6d11d1" + +func TestNewLocalSignerFromEnvHex(t *testing.T) { + t.Setenv(EnvPrivateKey, testPrivateKey) + s, err := NewLocalSignerFromEnv(KeySourceEnv) + if err != nil { + t.Fatalf("NewLocalSignerFromEnv failed: %v", err) + } + if s.Address() == (common.Address{}) { + t.Fatal("expected non-zero signer address") + } + tx := types.NewTx(&types.LegacyTx{ + Nonce: 0, + To: ptrAddress(common.HexToAddress("0x0000000000000000000000000000000000000001")), + Value: big.NewInt(0), + Gas: 21_000, + GasPrice: big.NewInt(1), + }) + if _, err := s.SignTx(common.Big1, tx); err != nil { + t.Fatalf("SignTx failed: %v", err) + } +} + +func TestNewLocalSignerFromEnvFile(t *testing.T) { + dir := t.TempDir() + keyFile := filepath.Join(dir, "key.txt") + if err := os.WriteFile(keyFile, []byte(testPrivateKey), 0o600); err != nil { + t.Fatalf("write key file: %v", err) + } + t.Setenv(EnvPrivateKeyFile, keyFile) + + s, err := NewLocalSignerFromEnv(KeySourceFile) + if err != nil { + t.Fatalf("NewLocalSignerFromEnv failed: %v", err) + } + if s.Address() == (common.Address{}) { + t.Fatal("expected non-zero signer address") + } +} + +func TestNewLocalSignerRejectsInsecurePermissions(t *testing.T) { + dir := t.TempDir() + keyFile := filepath.Join(dir, "key.txt") + if err := os.WriteFile(keyFile, []byte(testPrivateKey), 0o644); err != nil { + t.Fatalf("write key file: %v", err) + } + t.Setenv(EnvPrivateKeyFile, keyFile) + if _, err := NewLocalSignerFromEnv(KeySourceFile); err == nil { + t.Fatal("expected insecure permissions error") + } +} + +func ptrAddress(v common.Address) *common.Address { return &v } diff --git a/internal/execution/signer/signer.go b/internal/execution/signer/signer.go new file mode 100644 index 0000000..258df85 --- /dev/null +++ b/internal/execution/signer/signer.go @@ -0,0 +1,13 @@ +package signer + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type Signer interface { + Address() common.Address + SignTx(chainID *big.Int, tx *types.Transaction) (*types.Transaction, error) +} diff --git a/internal/execution/store.go b/internal/execution/store.go new file mode 100644 index 0000000..79bd7e7 --- /dev/null +++ b/internal/execution/store.go @@ -0,0 +1,169 @@ +package execution + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gofrs/flock" + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB + lock *flock.Flock +} + +func OpenStore(path, lockPath string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create action store directory: %w", err) + } + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return nil, fmt.Errorf("create action lock directory: %w", err) + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open action sqlite: %w", err) + } + + queries := []string{ + "PRAGMA journal_mode=WAL;", + "PRAGMA synchronous=NORMAL;", + `CREATE TABLE IF NOT EXISTS actions ( + action_id TEXT PRIMARY KEY, + intent_type TEXT NOT NULL, + status TEXT NOT NULL, + chain_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + payload BLOB NOT NULL + );`, + "CREATE INDEX IF NOT EXISTS idx_actions_status_updated ON actions(status, updated_at DESC);", + } + for _, q := range queries { + if _, err := db.Exec(q); err != nil { + _ = db.Close() + return nil, fmt.Errorf("init action schema: %w", err) + } + } + return &Store{db: db, lock: flock.New(lockPath)}, nil +} + +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +func (s *Store) Save(action Action) error { + if stringsTrim(action.ActionID) == "" { + return fmt.Errorf("save action: missing action id") + } + locked, err := s.lock.TryLockContext(context.Background(), 5*time.Second) + if err != nil { + return fmt.Errorf("lock action store: %w", err) + } + if !locked { + return fmt.Errorf("lock action store: timeout acquiring lock") + } + defer func() { _ = s.lock.Unlock() }() + + payload, err := json.Marshal(action) + if err != nil { + return fmt.Errorf("marshal action: %w", err) + } + createdUnix, _ := parseRFC3339Unix(action.CreatedAt) + updatedUnix, _ := parseRFC3339Unix(action.UpdatedAt) + if createdUnix == 0 { + createdUnix = time.Now().UTC().Unix() + } + if updatedUnix == 0 { + updatedUnix = time.Now().UTC().Unix() + } + + _, err = s.db.Exec(` + INSERT INTO actions (action_id, intent_type, status, chain_id, created_at, updated_at, payload) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(action_id) DO UPDATE SET + intent_type=excluded.intent_type, + status=excluded.status, + chain_id=excluded.chain_id, + updated_at=excluded.updated_at, + payload=excluded.payload + `, action.ActionID, action.IntentType, action.Status, action.ChainID, createdUnix, updatedUnix, payload) + if err != nil { + return fmt.Errorf("save action: %w", err) + } + return nil +} + +func (s *Store) Get(actionID string) (Action, error) { + var payload []byte + err := s.db.QueryRow("SELECT payload FROM actions WHERE action_id = ?", actionID).Scan(&payload) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Action{}, fmt.Errorf("action not found: %s", actionID) + } + return Action{}, fmt.Errorf("read action: %w", err) + } + var action Action + if err := json.Unmarshal(payload, &action); err != nil { + return Action{}, fmt.Errorf("decode action payload: %w", err) + } + return action, nil +} + +func (s *Store) List(status string, limit int) ([]Action, error) { + if limit <= 0 { + limit = 20 + } + var ( + rows *sql.Rows + err error + ) + if stringsTrim(status) == "" { + rows, err = s.db.Query("SELECT payload FROM actions ORDER BY updated_at DESC LIMIT ?", limit) + } else { + rows, err = s.db.Query("SELECT payload FROM actions WHERE status = ? ORDER BY updated_at DESC LIMIT ?", status, limit) + } + if err != nil { + return nil, fmt.Errorf("list actions: %w", err) + } + defer rows.Close() + + actions := make([]Action, 0) + for rows.Next() { + var payload []byte + if err := rows.Scan(&payload); err != nil { + return nil, fmt.Errorf("scan action row: %w", err) + } + var action Action + if err := json.Unmarshal(payload, &action); err != nil { + return nil, fmt.Errorf("decode action row: %w", err) + } + actions = append(actions, action) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate action rows: %w", err) + } + return actions, nil +} + +func stringsTrim(v string) string { + return strings.TrimSpace(v) +} + +func parseRFC3339Unix(v string) (int64, bool) { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return 0, false + } + return t.UTC().Unix(), true +} diff --git a/internal/execution/store_test.go b/internal/execution/store_test.go new file mode 100644 index 0000000..150f9b3 --- /dev/null +++ b/internal/execution/store_test.go @@ -0,0 +1,66 @@ +package execution + +import ( + "path/filepath" + "testing" +) + +func TestStoreSaveGetList(t *testing.T) { + dir := t.TempDir() + store, err := OpenStore(filepath.Join(dir, "actions.db"), filepath.Join(dir, "actions.lock")) + if err != nil { + t.Fatalf("OpenStore failed: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + action := NewAction(NewActionID(), "swap", "eip155:167000", Constraints{SlippageBps: 50, Simulate: true}) + action.Status = ActionStatusPlanned + action.Steps = append(action.Steps, ActionStep{ + StepID: "swap-1", + Type: StepTypeSwap, + Status: StepStatusPending, + ChainID: "eip155:167000", + Target: "0x0000000000000000000000000000000000000001", + Data: "0x", + Value: "0", + }) + if err := store.Save(action); err != nil { + t.Fatalf("Save failed: %v", err) + } + + got, err := store.Get(action.ActionID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.ActionID != action.ActionID { + t.Fatalf("unexpected action id: %s", got.ActionID) + } + if got.IntentType != "swap" { + t.Fatalf("unexpected intent type: %s", got.IntentType) + } + + got.Status = ActionStatusCompleted + if err := store.Save(got); err != nil { + t.Fatalf("Save update failed: %v", err) + } + completed, err := store.List(string(ActionStatusCompleted), 10) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(completed) != 1 { + t.Fatalf("expected one completed action, got %d", len(completed)) + } +} + +func TestStoreGetMissingAction(t *testing.T) { + dir := t.TempDir() + store, err := OpenStore(filepath.Join(dir, "actions.db"), filepath.Join(dir, "actions.lock")) + if err != nil { + t.Fatalf("OpenStore failed: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + if _, err := store.Get("missing"); err == nil { + t.Fatal("expected missing action error") + } +} diff --git a/internal/execution/types.go b/internal/execution/types.go new file mode 100644 index 0000000..c491420 --- /dev/null +++ b/internal/execution/types.go @@ -0,0 +1,88 @@ +package execution + +import "time" + +type ActionStatus string + +type StepStatus string + +type StepType string + +const ( + ActionStatusPlanned ActionStatus = "planned" + ActionStatusRunning ActionStatus = "running" + ActionStatusCompleted ActionStatus = "completed" + ActionStatusFailed ActionStatus = "failed" +) + +const ( + StepStatusPending StepStatus = "pending" + StepStatusSimulated StepStatus = "simulated" + StepStatusSubmitted StepStatus = "submitted" + StepStatusConfirmed StepStatus = "confirmed" + StepStatusFailed StepStatus = "failed" +) + +const ( + StepTypeApproval StepType = "approval" + StepTypeSwap StepType = "swap" + StepTypeBridge StepType = "bridge_send" + StepTypeLend StepType = "lend_call" + StepTypeClaim StepType = "claim" +) + +type Constraints struct { + SlippageBps int64 `json:"slippage_bps,omitempty"` + Deadline string `json:"deadline,omitempty"` + Simulate bool `json:"simulate"` +} + +type ActionStep struct { + StepID string `json:"step_id"` + Type StepType `json:"type"` + Status StepStatus `json:"status"` + ChainID string `json:"chain_id"` + RPCURL string `json:"rpc_url,omitempty"` + Description string `json:"description,omitempty"` + Target string `json:"target"` + Data string `json:"data"` + Value string `json:"value"` + ExpectedOutputs map[string]string `json:"expected_outputs,omitempty"` + TxHash string `json:"tx_hash,omitempty"` + Error string `json:"error,omitempty"` +} + +type Action struct { + ActionID string `json:"action_id"` + IntentType string `json:"intent_type"` + Provider string `json:"provider,omitempty"` + Status ActionStatus `json:"status"` + ChainID string `json:"chain_id"` + FromAddress string `json:"from_address,omitempty"` + ToAddress string `json:"to_address,omitempty"` + InputAmount string `json:"input_amount,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Constraints Constraints `json:"constraints"` + Steps []ActionStep `json:"steps"` + Metadata map[string]any `json:"metadata,omitempty"` + ProviderData map[string]interface{} `json:"provider_data,omitempty"` +} + +func NewAction(actionID, intentType, chainID string, constraints Constraints) Action { + now := time.Now().UTC().Format(time.RFC3339) + return Action{ + ActionID: actionID, + IntentType: intentType, + Status: ActionStatusPlanned, + ChainID: chainID, + CreatedAt: now, + UpdatedAt: now, + Constraints: constraints, + Steps: []ActionStep{}, + } +} + +func (a *Action) Touch() { + a.UpdatedAt = time.Now().UTC().Format(time.RFC3339) +} diff --git a/internal/id/id.go b/internal/id/id.go index 482dd2d..55361e0 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -68,6 +68,9 @@ var chainBySlug = map[string]Chain{ "taiko": {Name: "Taiko", Slug: "taiko", CAIP2: "eip155:167000", EVMChainID: 167000}, "taiko alethia": {Name: "Taiko", Slug: "taiko", CAIP2: "eip155:167000", EVMChainID: 167000}, "taiko-alethia": {Name: "Taiko", Slug: "taiko", CAIP2: "eip155:167000", EVMChainID: 167000}, + "taiko hoodi": {Name: "Taiko Hoodi", Slug: "taiko-hoodi", CAIP2: "eip155:167013", EVMChainID: 167013}, + "taiko-hoodi": {Name: "Taiko Hoodi", Slug: "taiko-hoodi", CAIP2: "eip155:167013", EVMChainID: 167013}, + "hoodi": {Name: "Taiko Hoodi", Slug: "taiko-hoodi", CAIP2: "eip155:167013", EVMChainID: 167013}, } var chainByID = map[int64]Chain{ @@ -90,6 +93,7 @@ var chainByID = map[int64]Chain{ 80094: chainBySlug["berachain"], 81457: chainBySlug["blast"], 167000: chainBySlug["taiko"], + 167013: chainBySlug["taiko-hoodi"], 534352: chainBySlug["scroll"], } @@ -173,6 +177,12 @@ var tokenRegistry = map[string][]Token{ "eip155:167000": { {Symbol: "USDC", Address: "0x07d83526730c7438048D55A4fc0b850e2aaB6f0b", Decimals: 6}, {Symbol: "WETH", Address: "0xA51894664A773981C6C112C43ce576f315d5b1B6", Decimals: 18}, + {Symbol: "USDT", Address: "0x2DEF195713CF4a606B49D07E520e22C17899a736", Decimals: 6}, + }, + "eip155:167013": { + {Symbol: "USDC", Address: "0x18d5bB147f3D05D5f6c5E60Caf1daeeDBF5155B6", Decimals: 6}, + {Symbol: "WETH", Address: "0x3B39685B5495359c892DDD1057B5712F49976835", Decimals: 18}, + {Symbol: "USDT", Address: "0xeb4e8Eb83d6FFBa2ce0d8F62ACe60648d1ECE116", Decimals: 6}, }, } diff --git a/internal/id/id_test.go b/internal/id/id_test.go index c5b2071..e797535 100644 --- a/internal/id/id_test.go +++ b/internal/id/id_test.go @@ -82,6 +82,9 @@ func TestParseChainExpandedCoverage(t *testing.T) { {input: "taiko", chainID: 167000, caip2: "eip155:167000", slug: "taiko"}, {input: "taiko alethia", chainID: 167000, caip2: "eip155:167000", slug: "taiko"}, {input: "taiko-alethia", chainID: 167000, caip2: "eip155:167000", slug: "taiko"}, + {input: "taiko hoodi", chainID: 167013, caip2: "eip155:167013", slug: "taiko-hoodi"}, + {input: "taiko-hoodi", chainID: 167013, caip2: "eip155:167013", slug: "taiko-hoodi"}, + {input: "hoodi", chainID: 167013, caip2: "eip155:167013", slug: "taiko-hoodi"}, {input: "zksync", chainID: 324, caip2: "eip155:324", slug: "zksync"}, {input: "zksync era", chainID: 324, caip2: "eip155:324", slug: "zksync"}, {input: "zksync-era", chainID: 324, caip2: "eip155:324", slug: "zksync"}, @@ -92,6 +95,7 @@ func TestParseChainExpandedCoverage(t *testing.T) { {input: "252", chainID: 252, caip2: "eip155:252", slug: "fraxtal"}, {input: "480", chainID: 480, caip2: "eip155:480", slug: "world-chain"}, {input: "167000", chainID: 167000, caip2: "eip155:167000", slug: "taiko"}, + {input: "167013", chainID: 167013, caip2: "eip155:167013", slug: "taiko-hoodi"}, } for _, tc := range tests { @@ -127,6 +131,7 @@ func TestParseAssetExpandedChainRegistry(t *testing.T) { {chainInput: "sonic", symbol: "USDC"}, {chainInput: "celo", symbol: "USDC"}, {chainInput: "taiko", symbol: "USDC"}, + {chainInput: "hoodi", symbol: "USDC"}, {chainInput: "zksync", symbol: "USDC"}, } diff --git a/internal/providers/aave/client.go b/internal/providers/aave/client.go index bd365eb..a34a08c 100644 --- a/internal/providers/aave/client.go +++ b/internal/providers/aave/client.go @@ -41,6 +41,10 @@ func (c *Client) Info() model.ProviderInfo { "lend.markets", "lend.rates", "yield.opportunities", + "lend.plan", + "lend.execute", + "rewards.plan", + "rewards.execute", }, } } diff --git a/internal/providers/lifi/client.go b/internal/providers/lifi/client.go index bc44bdb..3c8b87c 100644 --- a/internal/providers/lifi/client.go +++ b/internal/providers/lifi/client.go @@ -3,20 +3,26 @@ package lifi import ( "context" "fmt" + "math/big" "net/http" "net/url" "strconv" + "strings" "time" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" "github.com/ggonzalez94/defi-cli/internal/httpx" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" "github.com/ggonzalez94/defi-cli/internal/providers" + "github.com/ggonzalez94/defi-cli/internal/registry" ) -const defaultBase = "https://li.quest/v1" - type Client struct { http *httpx.Client baseURL string @@ -24,7 +30,7 @@ type Client struct { } func New(httpClient *httpx.Client) *Client { - return &Client{http: httpClient, baseURL: defaultBase, now: time.Now} + return &Client{http: httpClient, baseURL: registry.LiFiBaseURL, now: time.Now} } func (c *Client) Info() model.ProviderInfo { @@ -34,14 +40,18 @@ func (c *Client) Info() model.ProviderInfo { RequiresKey: false, Capabilities: []string{ "bridge.quote", + "bridge.plan", + "bridge.execute", }, } } type quoteResponse struct { Estimate struct { - ToAmount string `json:"toAmount"` - FeeCosts []struct { + ToAmount string `json:"toAmount"` + ToAmountMin string `json:"toAmountMin"` + ApprovalAddress string `json:"approvalAddress"` + FeeCosts []struct { AmountUSD string `json:"amountUSD"` } `json:"feeCosts"` GasCosts []struct { @@ -52,6 +62,16 @@ type quoteResponse struct { ToolDetails struct { Name string `json:"name"` } `json:"toolDetails"` + Tool string `json:"tool"` + TransactionRequest struct { + To string `json:"to"` + From string `json:"from"` + Data string `json:"data"` + Value string `json:"value"` + ChainID int64 `json:"chainId"` + GasLimit string `json:"gasLimit"` + GasPrice string `json:"gasPrice"` + } `json:"transactionRequest"` } func (c *Client) QuoteBridge(ctx context.Context, req providers.BridgeQuoteRequest) (model.BridgeQuote, error) { @@ -115,3 +135,204 @@ func (c *Client) QuoteBridge(ctx context.Context, req providers.BridgeQuoteReque FetchedAt: c.now().UTC().Format(time.RFC3339), }, nil } + +func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuoteRequest, opts providers.BridgeExecutionOptions) (execution.Action, error) { + sender := strings.TrimSpace(opts.Sender) + if sender == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution requires sender address") + } + if !common.IsHexAddress(sender) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution sender must be a valid EVM address") + } + recipient := strings.TrimSpace(opts.Recipient) + if recipient == "" { + recipient = sender + } + if !common.IsHexAddress(recipient) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution recipient must be a valid EVM address") + } + if !common.IsHexAddress(req.FromAsset.Address) || !common.IsHexAddress(req.ToAsset.Address) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution requires ERC20 token addresses for from/to assets") + } + slippageBps := opts.SlippageBps + if slippageBps <= 0 { + slippageBps = 50 + } + if slippageBps >= 10_000 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "slippage bps must be less than 10000") + } + + vals := url.Values{} + vals.Set("fromChain", strconv.FormatInt(req.FromChain.EVMChainID, 10)) + vals.Set("toChain", strconv.FormatInt(req.ToChain.EVMChainID, 10)) + vals.Set("fromToken", strings.ToLower(req.FromAsset.Address)) + vals.Set("toToken", strings.ToLower(req.ToAsset.Address)) + vals.Set("fromAmount", req.AmountBaseUnits) + vals.Set("slippage", formatSlippage(slippageBps)) + vals.Set("fromAddress", sender) + vals.Set("toAddress", recipient) + + reqURL := c.baseURL + "/quote?" + vals.Encode() + hReq, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "build lifi execution quote request", err) + } + var resp quoteResponse + if _, err := c.http.DoJSON(ctx, hReq, &resp); err != nil { + return execution.Action{}, err + } + if strings.TrimSpace(resp.TransactionRequest.To) == "" || strings.TrimSpace(resp.TransactionRequest.Data) == "" { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "lifi quote missing executable transaction payload") + } + if resp.TransactionRequest.ChainID != 0 && resp.TransactionRequest.ChainID != req.FromChain.EVMChainID { + return execution.Action{}, clierr.New(clierr.CodeActionPlan, "lifi transaction chain does not match source chain") + } + + rpcURL, err := execution.ResolveRPCURL(opts.RPCURL, req.FromChain.EVMChainID) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) + } + + action := execution.NewAction(execution.NewActionID(), "bridge", req.FromChain.CAIP2, execution.Constraints{ + SlippageBps: slippageBps, + Simulate: opts.Simulate, + }) + action.Provider = "lifi" + action.FromAddress = sender + action.ToAddress = recipient + action.InputAmount = req.AmountBaseUnits + action.Metadata = map[string]any{ + "to_chain_id": req.ToChain.CAIP2, + "from_asset_id": req.FromAsset.AssetID, + "to_asset_id": req.ToAsset.AssetID, + "route": firstNonEmpty(resp.ToolDetails.Name, resp.Tool), + "approval_spender": resp.Estimate.ApprovalAddress, + } + + if shouldAddApproval(req.FromAsset.Address, resp.Estimate.ApprovalAddress) { + if !common.IsHexAddress(resp.Estimate.ApprovalAddress) { + return execution.Action{}, clierr.New(clierr.CodeActionPlan, "lifi quote returned invalid approval address") + } + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "connect source chain rpc for allowance check", err) + } + defer client.Close() + + amountIn, ok := new(big.Int).SetString(req.AmountBaseUnits, 10) + if !ok { + return execution.Action{}, clierr.New(clierr.CodeUsage, "invalid amount base units") + } + tokenAddr := common.HexToAddress(req.FromAsset.Address) + ownerAddr := common.HexToAddress(sender) + spenderAddr := common.HexToAddress(resp.Estimate.ApprovalAddress) + allowanceData, err := lifiERC20ABI.Pack("allowance", ownerAddr, spenderAddr) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack allowance call", err) + } + allowanceRaw, err := client.CallContract(ctx, ethereum.CallMsg{From: ownerAddr, To: &tokenAddr, Data: allowanceData}, nil) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "read allowance", err) + } + allowanceOut, err := lifiERC20ABI.Unpack("allowance", allowanceRaw) + if err != nil || len(allowanceOut) == 0 { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "decode allowance", err) + } + currentAllowance, ok := allowanceOut[0].(*big.Int) + if !ok { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "invalid allowance response type") + } + if currentAllowance.Cmp(amountIn) < 0 { + approveData, err := lifiERC20ABI.Pack("approve", spenderAddr, amountIn) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack approve calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "approve-bridge-token", + Type: execution.StepTypeApproval, + Status: execution.StepStatusPending, + ChainID: req.FromChain.CAIP2, + RPCURL: rpcURL, + Description: "Approve bridge spender for source token", + Target: tokenAddr.Hex(), + Data: "0x" + common.Bytes2Hex(approveData), + Value: "0", + }) + } + } + + bridgeValue, err := hexToDecimal(resp.TransactionRequest.Value) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeActionPlan, "parse bridge transaction value", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "bridge-transfer", + Type: execution.StepTypeBridge, + Status: execution.StepStatusPending, + ChainID: req.FromChain.CAIP2, + RPCURL: rpcURL, + Description: "Bridge transfer via LiFi route", + Target: common.HexToAddress(resp.TransactionRequest.To).Hex(), + Data: ensureHexPrefix(resp.TransactionRequest.Data), + Value: bridgeValue, + ExpectedOutputs: map[string]string{ + "to_amount_min": firstNonEmpty(resp.Estimate.ToAmountMin, resp.Estimate.ToAmount), + }, + }) + return action, nil +} + +var lifiERC20ABI = mustLifiABI(registry.ERC20MinimalABI) + +func mustLifiABI(raw string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(raw)) + if err != nil { + panic(err) + } + return parsed +} + +func shouldAddApproval(tokenAddr, spender string) bool { + if strings.TrimSpace(tokenAddr) == "" || strings.TrimSpace(spender) == "" { + return false + } + if !common.IsHexAddress(tokenAddr) || !common.IsHexAddress(spender) { + return false + } + return !strings.EqualFold(strings.TrimSpace(tokenAddr), "0x0000000000000000000000000000000000000000") +} + +func formatSlippage(bps int64) string { + return strconv.FormatFloat(float64(bps)/10000, 'f', 6, 64) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func ensureHexPrefix(v string) string { + clean := strings.TrimSpace(v) + if strings.HasPrefix(clean, "0x") || strings.HasPrefix(clean, "0X") { + return clean + } + return "0x" + clean +} + +func hexToDecimal(v string) (string, error) { + clean := strings.TrimSpace(v) + if clean == "" { + return "0", nil + } + clean = strings.TrimPrefix(clean, "0x") + clean = strings.TrimPrefix(clean, "0X") + n := new(big.Int) + if _, ok := n.SetString(clean, 16); !ok { + return "", fmt.Errorf("invalid hex value %q", v) + } + return n.String(), nil +} diff --git a/internal/providers/lifi/client_test.go b/internal/providers/lifi/client_test.go new file mode 100644 index 0000000..26c1f4d --- /dev/null +++ b/internal/providers/lifi/client_test.go @@ -0,0 +1,202 @@ +package lifi + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ggonzalez94/defi-cli/internal/httpx" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/providers" +) + +type lifiRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` +} + +func TestQuoteBridge(t *testing.T) { + quoteServer := newLiFiQuoteServer(t, "0x0000000000000000000000000000000000000ABC") + defer quoteServer.Close() + + c := New(httpx.New(2*time.Second, 0)) + c.baseURL = quoteServer.URL + fromChain, _ := id.ParseChain("ethereum") + toChain, _ := id.ParseChain("base") + fromAsset, _ := id.ParseAsset("USDC", fromChain) + toAsset, _ := id.ParseAsset("USDC", toChain) + + quote, err := c.QuoteBridge(context.Background(), providers.BridgeQuoteRequest{ + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: "1000000", + AmountDecimal: "1", + }) + if err != nil { + t.Fatalf("QuoteBridge failed: %v", err) + } + if quote.Provider != "lifi" { + t.Fatalf("unexpected provider: %s", quote.Provider) + } + if quote.EstimatedOut.AmountBaseUnits != "950000" { + t.Fatalf("unexpected estimated out: %s", quote.EstimatedOut.AmountBaseUnits) + } + if quote.EstimatedFeeUSD <= 0 { + t.Fatalf("expected positive fee estimate, got %f", quote.EstimatedFeeUSD) + } +} + +func TestBuildBridgeActionAddsApprovalStep(t *testing.T) { + quoteServer := newLiFiQuoteServer(t, "0x0000000000000000000000000000000000000ABC") + defer quoteServer.Close() + rpcServer := newLiFiRPCServer(t, big.NewInt(0)) + defer rpcServer.Close() + + c := New(httpx.New(2*time.Second, 0)) + c.baseURL = quoteServer.URL + + fromChain, _ := id.ParseChain("ethereum") + toChain, _ := id.ParseChain("base") + fromAsset, _ := id.ParseAsset("USDC", fromChain) + toAsset, _ := id.ParseAsset("USDC", toChain) + + action, err := c.BuildBridgeAction(context.Background(), providers.BridgeQuoteRequest{ + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: "1000000", + AmountDecimal: "1", + }, providers.BridgeExecutionOptions{ + Sender: "0x00000000000000000000000000000000000000AA", + Recipient: "0x00000000000000000000000000000000000000BB", + SlippageBps: 50, + Simulate: true, + RPCURL: rpcServer.URL, + }) + if err != nil { + t.Fatalf("BuildBridgeAction failed: %v", err) + } + if action.IntentType != "bridge" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + if len(action.Steps) != 2 { + t.Fatalf("expected approval + bridge steps, got %d", len(action.Steps)) + } + if action.Steps[0].Type != "approval" { + t.Fatalf("expected first step approval, got %s", action.Steps[0].Type) + } + if action.Steps[1].Type != "bridge_send" { + t.Fatalf("expected second step bridge_send, got %s", action.Steps[1].Type) + } +} + +func TestBuildBridgeActionSkipsApprovalWhenSpenderMissing(t *testing.T) { + quoteServer := newLiFiQuoteServer(t, "") + defer quoteServer.Close() + + c := New(httpx.New(2*time.Second, 0)) + c.baseURL = quoteServer.URL + + fromChain, _ := id.ParseChain("ethereum") + toChain, _ := id.ParseChain("base") + fromAsset, _ := id.ParseAsset("USDC", fromChain) + toAsset, _ := id.ParseAsset("USDC", toChain) + + action, err := c.BuildBridgeAction(context.Background(), providers.BridgeQuoteRequest{ + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: "1000000", + AmountDecimal: "1", + }, providers.BridgeExecutionOptions{ + Sender: "0x00000000000000000000000000000000000000AA", + Simulate: true, + RPCURL: "http://127.0.0.1:1", + Recipient: "0x00000000000000000000000000000000000000AA", + }) + if err != nil { + t.Fatalf("BuildBridgeAction failed: %v", err) + } + if len(action.Steps) != 1 { + t.Fatalf("expected bridge-only step, got %d", len(action.Steps)) + } + if action.Steps[0].Type != "bridge_send" { + t.Fatalf("expected bridge_send step, got %s", action.Steps[0].Type) + } +} + +func newLiFiQuoteServer(t *testing.T, approvalAddress string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "estimate": { + "toAmount": "950000", + "toAmountMin": "940000", + "approvalAddress": %q, + "feeCosts": [{"amountUSD":"0.40"}], + "gasCosts": [{"amountUSD":"0.60"}], + "executionDuration": 120 + }, + "toolDetails": {"name":"across"}, + "tool": "across", + "transactionRequest": { + "to": "0x0000000000000000000000000000000000000DDD", + "from": "0x00000000000000000000000000000000000000AA", + "data": "0x1234", + "value": "0x0", + "chainId": 1 + } + }`, approvalAddress) + })) +} + +func newLiFiRPCServer(t *testing.T, allowance *big.Int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var req lifiRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch req.Method { + case "eth_call": + encoded, err := lifiERC20ABI.Methods["allowance"].Outputs.Pack(allowance) + if err != nil { + t.Fatalf("pack allowance response: %v", err) + } + writeLiFiRPCResult(w, req.ID, "0x"+hex.EncodeToString(encoded)) + default: + writeLiFiRPCError(w, req.ID, -32601, fmt.Sprintf("method not supported in test: %s", req.Method)) + } + })) +} + +func writeLiFiRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":%q}`, rawLiFiID(id), result) +} + +func writeLiFiRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"error":{"code":%d,"message":%q}}`, rawLiFiID(id), code, message) +} + +func rawLiFiID(id json.RawMessage) string { + if len(id) == 0 { + return "1" + } + return string(id) +} diff --git a/internal/providers/taikoswap/client.go b/internal/providers/taikoswap/client.go new file mode 100644 index 0000000..5da2c15 --- /dev/null +++ b/internal/providers/taikoswap/client.go @@ -0,0 +1,307 @@ +package taikoswap + +import ( + "context" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/httpx" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/model" + "github.com/ggonzalez94/defi-cli/internal/providers" + "github.com/ggonzalez94/defi-cli/internal/registry" +) + +const ( + defaultMainnetRPC = "https://rpc.mainnet.taiko.xyz" + defaultHoodiRPC = "https://rpc.hoodi.taiko.xyz" +) + +var ( + feeTiers = []uint32{100, 500, 3000, 10000} + + quoterABI = mustABI(registry.TaikoSwapQuoterV2ABI) + erc20ABI = mustABI(registry.ERC20MinimalABI) + routerABI = mustABI(registry.TaikoSwapRouterABI) +) + +type Client struct { + http *httpx.Client + mainnetRPC string + hoodiRPC string + now func() time.Time +} + +func New(httpClient *httpx.Client, mainnetRPC, hoodiRPC string) *Client { + if strings.TrimSpace(mainnetRPC) == "" { + mainnetRPC = defaultMainnetRPC + } + if strings.TrimSpace(hoodiRPC) == "" { + hoodiRPC = defaultHoodiRPC + } + return &Client{http: httpClient, mainnetRPC: mainnetRPC, hoodiRPC: hoodiRPC, now: time.Now} +} + +func (c *Client) Info() model.ProviderInfo { + return model.ProviderInfo{ + Name: "taikoswap", + Type: "swap", + RequiresKey: false, + Capabilities: []string{ + "swap.quote", + "swap.plan", + "swap.execute", + }, + } +} + +type quoteExactInputSingleParams struct { + TokenIn common.Address `abi:"tokenIn"` + TokenOut common.Address `abi:"tokenOut"` + AmountIn *big.Int `abi:"amountIn"` + Fee *big.Int `abi:"fee"` + SqrtPriceLimitX96 *big.Int `abi:"sqrtPriceLimitX96"` +} + +type exactInputSingleParams struct { + TokenIn common.Address `abi:"tokenIn"` + TokenOut common.Address `abi:"tokenOut"` + Fee *big.Int `abi:"fee"` + Recipient common.Address `abi:"recipient"` + AmountIn *big.Int `abi:"amountIn"` + AmountOutMinimum *big.Int `abi:"amountOutMinimum"` + SqrtPriceLimitX96 *big.Int `abi:"sqrtPriceLimitX96"` +} + +func (c *Client) QuoteSwap(ctx context.Context, req providers.SwapQuoteRequest) (model.SwapQuote, error) { + rpcURL, quoter, _, err := c.chainConfig(req.Chain) + if err != nil { + return model.SwapQuote{}, err + } + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return model.SwapQuote{}, clierr.Wrap(clierr.CodeUnavailable, "connect taiko rpc", err) + } + defer client.Close() + amountIn, ok := new(big.Int).SetString(req.AmountBaseUnits, 10) + if !ok { + return model.SwapQuote{}, clierr.New(clierr.CodeUsage, "invalid amount base units") + } + from := common.HexToAddress(req.FromAsset.Address) + to := common.HexToAddress(req.ToAsset.Address) + quoteOut, bestFee, _, err := quoteBestFee(ctx, client, quoter, from, to, amountIn) + if err != nil { + return model.SwapQuote{}, err + } + return model.SwapQuote{ + Provider: "taikoswap", + ChainID: req.Chain.CAIP2, + FromAssetID: req.FromAsset.AssetID, + ToAssetID: req.ToAsset.AssetID, + InputAmount: model.AmountInfo{AmountBaseUnits: req.AmountBaseUnits, AmountDecimal: req.AmountDecimal, Decimals: req.FromAsset.Decimals}, + EstimatedOut: model.AmountInfo{ + AmountBaseUnits: quoteOut.String(), + AmountDecimal: id.FormatDecimalCompat(quoteOut.String(), req.ToAsset.Decimals), + Decimals: req.ToAsset.Decimals, + }, + EstimatedGasUSD: 0, + PriceImpactPct: 0, + Route: fmt.Sprintf("taikoswap-v3-fee-%d", bestFee), + SourceURL: "https://swap.taiko.xyz", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }, nil +} + +func (c *Client) BuildSwapAction(ctx context.Context, req providers.SwapQuoteRequest, opts providers.SwapExecutionOptions) (execution.Action, error) { + if strings.TrimSpace(opts.Sender) == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "swap execution requires sender address") + } + rpcURL, quoter, router, err := c.chainConfig(req.Chain) + if err != nil { + return execution.Action{}, err + } + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "connect taiko rpc", err) + } + defer client.Close() + + amountIn, ok := new(big.Int).SetString(req.AmountBaseUnits, 10) + if !ok { + return execution.Action{}, clierr.New(clierr.CodeUsage, "invalid amount base units") + } + fromToken := common.HexToAddress(req.FromAsset.Address) + toToken := common.HexToAddress(req.ToAsset.Address) + recipient := strings.TrimSpace(opts.Recipient) + if recipient == "" { + recipient = opts.Sender + } + recipientAddr := common.HexToAddress(recipient) + senderAddr := common.HexToAddress(opts.Sender) + + quotedOut, bestFee, _, err := quoteBestFee(ctx, client, quoter, fromToken, toToken, amountIn) + if err != nil { + return execution.Action{}, err + } + slippage := opts.SlippageBps + if slippage <= 0 { + slippage = 50 + } + if slippage >= 10_000 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "slippage bps must be less than 10000") + } + amountOutMin := new(big.Int).Mul(quotedOut, big.NewInt(10_000-slippage)) + amountOutMin.Div(amountOutMin, big.NewInt(10_000)) + + action := execution.NewAction(execution.NewActionID(), "swap", req.Chain.CAIP2, execution.Constraints{SlippageBps: slippage, Simulate: opts.Simulate}) + action.Provider = "taikoswap" + action.FromAddress = senderAddr.Hex() + action.ToAddress = recipientAddr.Hex() + action.InputAmount = req.AmountBaseUnits + action.Metadata = map[string]any{ + "token_in": fromToken.Hex(), + "token_out": toToken.Hex(), + "fee": bestFee, + "quoted_amount": quotedOut.String(), + "amount_out_min": amountOutMin.String(), + } + + allowanceData, err := erc20ABI.Pack("allowance", senderAddr, router) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack allowance call", err) + } + allowanceOut, err := client.CallContract(ctx, ethereum.CallMsg{From: senderAddr, To: &fromToken, Data: allowanceData}, nil) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "read allowance", err) + } + values, err := erc20ABI.Unpack("allowance", allowanceOut) + if err != nil || len(values) == 0 { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "decode allowance", err) + } + allowance, ok := values[0].(*big.Int) + if !ok { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "invalid allowance response") + } + + if allowance.Cmp(amountIn) < 0 { + approveData, err := erc20ABI.Pack("approve", router, amountIn) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack approve calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "approve-token-in", + Type: execution.StepTypeApproval, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Approve token spending for swap router", + Target: fromToken.Hex(), + Data: "0x" + common.Bytes2Hex(approveData), + Value: "0", + }) + } + + swapData, err := routerABI.Pack("exactInputSingle", exactInputSingleParams{ + TokenIn: fromToken, + TokenOut: toToken, + Fee: big.NewInt(int64(bestFee)), + Recipient: recipientAddr, + AmountIn: amountIn, + AmountOutMinimum: amountOutMin, + SqrtPriceLimitX96: big.NewInt(0), + }) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack swap calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "swap-exact-input-single", + Type: execution.StepTypeSwap, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Swap exact input via TaikoSwap router", + Target: router.Hex(), + Data: "0x" + common.Bytes2Hex(swapData), + Value: "0", + ExpectedOutputs: map[string]string{ + "amount_out_min": amountOutMin.String(), + }, + }) + return action, nil +} + +func (c *Client) chainConfig(chain id.Chain) (rpc string, quoter common.Address, router common.Address, err error) { + quoterRaw, routerRaw, ok := registry.TaikoSwapContracts(chain.EVMChainID) + if !ok { + return "", common.Address{}, common.Address{}, clierr.New(clierr.CodeUnsupported, "taikoswap only supports taiko mainnet/hoodi chains") + } + switch chain.EVMChainID { + case 167000: + return c.mainnetRPC, common.HexToAddress(quoterRaw), common.HexToAddress(routerRaw), nil + case 167013: + return c.hoodiRPC, common.HexToAddress(quoterRaw), common.HexToAddress(routerRaw), nil + default: + return "", common.Address{}, common.Address{}, clierr.New(clierr.CodeUnsupported, "taikoswap only supports taiko mainnet/hoodi chains") + } +} + +func quoteBestFee(ctx context.Context, client *ethclient.Client, quoter, tokenIn, tokenOut common.Address, amountIn *big.Int) (*big.Int, uint32, *big.Int, error) { + var ( + bestOut *big.Int + bestGas *big.Int + bestFee uint32 + ) + for _, fee := range feeTiers { + callData, err := quoterABI.Pack("quoteExactInputSingle", quoteExactInputSingleParams{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: amountIn, + Fee: big.NewInt(int64(fee)), + SqrtPriceLimitX96: big.NewInt(0), + }) + if err != nil { + return nil, 0, nil, clierr.Wrap(clierr.CodeInternal, "pack quoter calldata", err) + } + out, err := client.CallContract(ctx, ethereum.CallMsg{To: "er, Data: callData}, nil) + if err != nil { + continue + } + decoded, err := quoterABI.Unpack("quoteExactInputSingle", out) + if err != nil || len(decoded) < 4 { + continue + } + amountOut, ok := decoded[0].(*big.Int) + if !ok || amountOut == nil || amountOut.Sign() <= 0 { + continue + } + gasEstimate, ok := decoded[3].(*big.Int) + if !ok || gasEstimate == nil { + gasEstimate = big.NewInt(0) + } + if bestOut == nil || amountOut.Cmp(bestOut) > 0 || (amountOut.Cmp(bestOut) == 0 && gasEstimate.Cmp(bestGas) < 0) { + bestOut = new(big.Int).Set(amountOut) + bestGas = new(big.Int).Set(gasEstimate) + bestFee = fee + } + } + if bestOut == nil { + return nil, 0, nil, clierr.New(clierr.CodeUnavailable, "taikoswap quote unavailable for token pair") + } + return bestOut, bestFee, bestGas, nil +} + +func mustABI(raw string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(raw)) + if err != nil { + panic(err) + } + return parsed +} diff --git a/internal/providers/taikoswap/client_test.go b/internal/providers/taikoswap/client_test.go new file mode 100644 index 0000000..0baae50 --- /dev/null +++ b/internal/providers/taikoswap/client_test.go @@ -0,0 +1,172 @@ +package taikoswap + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/ggonzalez94/defi-cli/internal/httpx" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/providers" +) + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +func TestQuoteSwapChoosesBestFeeRoute(t *testing.T) { + server := newMockRPCServer(t, false) + defer server.Close() + + c := New(httpx.New(2*time.Second, 0), server.URL, "") + chain, _ := id.ParseChain("taiko") + fromAsset, _ := id.ParseAsset("USDC", chain) + toAsset, _ := id.ParseAsset("WETH", chain) + quote, err := c.QuoteSwap(context.Background(), providers.SwapQuoteRequest{ + Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", + }) + if err != nil { + t.Fatalf("QuoteSwap failed: %v", err) + } + if quote.Provider != "taikoswap" { + t.Fatalf("unexpected provider: %s", quote.Provider) + } + if !strings.Contains(quote.Route, "fee-500") { + t.Fatalf("expected best fee tier 500 in route, got %s", quote.Route) + } + if quote.EstimatedOut.AmountBaseUnits != "2000" { + t.Fatalf("expected estimated out 2000, got %s", quote.EstimatedOut.AmountBaseUnits) + } +} + +func TestBuildSwapActionAddsApprovalWhenNeeded(t *testing.T) { + server := newMockRPCServer(t, true) + defer server.Close() + + c := New(httpx.New(2*time.Second, 0), server.URL, "") + chain, _ := id.ParseChain("taiko") + fromAsset, _ := id.ParseAsset("USDC", chain) + toAsset, _ := id.ParseAsset("WETH", chain) + action, err := c.BuildSwapAction(context.Background(), providers.SwapQuoteRequest{ + Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", + }, providers.SwapExecutionOptions{ + Sender: "0x00000000000000000000000000000000000000AA", + Recipient: "0x00000000000000000000000000000000000000BB", + SlippageBps: 100, + Simulate: true, + }) + if err != nil { + t.Fatalf("BuildSwapAction failed: %v", err) + } + if action.IntentType != "swap" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + if len(action.Steps) != 2 { + t.Fatalf("expected approval + swap steps, got %d", len(action.Steps)) + } + if action.Steps[0].Type != "approval" { + t.Fatalf("expected first step approval, got %s", action.Steps[0].Type) + } + if action.Steps[1].Type != "swap" { + t.Fatalf("expected second step swap, got %s", action.Steps[1].Type) + } +} + +func TestBuildSwapActionRequiresSender(t *testing.T) { + c := New(httpx.New(2*time.Second, 0), defaultMainnetRPC, "") + chain, _ := id.ParseChain("taiko") + fromAsset, _ := id.ParseAsset("USDC", chain) + toAsset, _ := id.ParseAsset("WETH", chain) + _, err := c.BuildSwapAction(context.Background(), providers.SwapQuoteRequest{ + Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", + }, providers.SwapExecutionOptions{}) + if err == nil { + t.Fatal("expected missing sender error") + } +} + +func newMockRPCServer(t *testing.T, includeAllowance bool) *httptest.Server { + t.Helper() + + var mu sync.Mutex + callCount := 0 + + handler := func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var req rpcRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch req.Method { + case "eth_call": + mu.Lock() + callCount++ + index := callCount + mu.Unlock() + + if includeAllowance && index == 5 { + allowancePayload, err := erc20ABI.Methods["allowance"].Outputs.Pack(big.NewInt(0)) + if err != nil { + t.Fatalf("pack allowance output: %v", err) + } + writeRPCResult(w, req.ID, "0x"+hex.EncodeToString(allowancePayload)) + return + } + + amountOut := big.NewInt(0) + switch index { + case 1: + amountOut = big.NewInt(1000) + case 2: + amountOut = big.NewInt(2000) + case 3: + amountOut = big.NewInt(1500) + default: + amountOut = big.NewInt(500) + } + out, err := quoterABI.Methods["quoteExactInputSingle"].Outputs.Pack( + amountOut, + big.NewInt(0), // sqrtPriceX96After + uint32(0), // initializedTicksCrossed + big.NewInt(70_000), + ) + if err != nil { + t.Fatalf("pack quote output: %v", err) + } + writeRPCResult(w, req.ID, "0x"+hex.EncodeToString(out)) + default: + writeRPCError(w, req.ID, -32601, fmt.Sprintf("method not supported in test: %s", req.Method)) + } + } + + return httptest.NewServer(http.HandlerFunc(handler)) +} + +func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":%q}`, rawIDOrDefault(id), result) +} + +func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"error":{"code":%d,"message":%q}}`, rawIDOrDefault(id), code, message) +} + +func rawIDOrDefault(id json.RawMessage) string { + if len(id) == 0 { + return "1" + } + return string(id) +} diff --git a/internal/providers/types.go b/internal/providers/types.go index 94e88e1..f39e3b6 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -3,6 +3,7 @@ package providers import ( "context" + "github.com/ggonzalez94/defi-cli/internal/execution" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" ) @@ -47,6 +48,11 @@ type BridgeProvider interface { QuoteBridge(ctx context.Context, req BridgeQuoteRequest) (model.BridgeQuote, error) } +type BridgeExecutionProvider interface { + BridgeProvider + BuildBridgeAction(ctx context.Context, req BridgeQuoteRequest, opts BridgeExecutionOptions) (execution.Action, error) +} + type BridgeDataProvider interface { Provider ListBridges(ctx context.Context, req BridgeListRequest) ([]model.BridgeSummary, error) @@ -72,11 +78,24 @@ type BridgeDetailsRequest struct { IncludeChainBreakdown bool } +type BridgeExecutionOptions struct { + Sender string + Recipient string + SlippageBps int64 + Simulate bool + RPCURL string +} + type SwapProvider interface { Provider QuoteSwap(ctx context.Context, req SwapQuoteRequest) (model.SwapQuote, error) } +type SwapExecutionProvider interface { + SwapProvider + BuildSwapAction(ctx context.Context, req SwapQuoteRequest, opts SwapExecutionOptions) (execution.Action, error) +} + type SwapQuoteRequest struct { Chain id.Chain FromAsset id.Asset @@ -84,3 +103,10 @@ type SwapQuoteRequest struct { AmountBaseUnits string AmountDecimal string } + +type SwapExecutionOptions struct { + Sender string + Recipient string + SlippageBps int64 + Simulate bool +} diff --git a/internal/registry/execution_data.go b/internal/registry/execution_data.go new file mode 100644 index 0000000..d3cddb1 --- /dev/null +++ b/internal/registry/execution_data.go @@ -0,0 +1,71 @@ +package registry + +const ( + // Execution provider endpoints. + LiFiBaseURL = "https://li.quest/v1" +) + +// Canonical contracts used by TaikoSwap execution/quoting. +var taikoSwapContractsByChainID = map[int64]struct { + QuoterV2 string + Router string +}{ + 167000: { + QuoterV2: "0xcBa70D57be34aA26557B8E80135a9B7754680aDb", + Router: "0x1A0c3a0Cfd1791FAC7798FA2b05208B66aaadfeD", + }, + 167013: { + QuoterV2: "0xAC8D93657DCc5C0dE9d9AF2772aF9eA3A032a1C6", + Router: "0x482233e4DBD56853530fA1918157CE59B60dF230", + }, +} + +func TaikoSwapContracts(chainID int64) (quoterV2 string, router string, ok bool) { + contracts, ok := taikoSwapContractsByChainID[chainID] + if !ok { + return "", "", false + } + return contracts.QuoterV2, contracts.Router, true +} + +// Canonical Aave V3 PoolAddressesProvider contracts used by planners. +var aavePoolAddressProviderByChainID = map[int64]string{ + 1: "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e", +} + +func AavePoolAddressProvider(chainID int64) (string, bool) { + value, ok := aavePoolAddressProviderByChainID[chainID] + return value, ok +} + +// ABI fragments used across execution planners/providers. +const ( + ERC20MinimalABI = `[ + {"name":"allowance","type":"function","stateMutability":"view","inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"approve","type":"function","stateMutability":"nonpayable","inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]} + ]` + + TaikoSwapQuoterV2ABI = `[ + {"name":"quoteExactInputSingle","type":"function","stateMutability":"nonpayable","inputs":[{"name":"params","type":"tuple","components":[{"name":"tokenIn","type":"address"},{"name":"tokenOut","type":"address"},{"name":"amountIn","type":"uint256"},{"name":"fee","type":"uint24"},{"name":"sqrtPriceLimitX96","type":"uint160"}]}],"outputs":[{"name":"amountOut","type":"uint256"},{"name":"sqrtPriceX96After","type":"uint160"},{"name":"initializedTicksCrossed","type":"uint32"},{"name":"gasEstimate","type":"uint256"}]} + ]` + + TaikoSwapRouterABI = `[ + {"name":"exactInputSingle","type":"function","stateMutability":"payable","inputs":[{"name":"params","type":"tuple","components":[{"name":"tokenIn","type":"address"},{"name":"tokenOut","type":"address"},{"name":"fee","type":"uint24"},{"name":"recipient","type":"address"},{"name":"amountIn","type":"uint256"},{"name":"amountOutMinimum","type":"uint256"},{"name":"sqrtPriceLimitX96","type":"uint160"}]}],"outputs":[{"name":"amountOut","type":"uint256"}]} + ]` + + AavePoolAddressProviderABI = `[ + {"name":"getPool","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]}, + {"name":"getAddress","type":"function","stateMutability":"view","inputs":[{"name":"id","type":"bytes32"}],"outputs":[{"name":"","type":"address"}]} + ]` + + AavePoolABI = `[ + {"name":"supply","type":"function","stateMutability":"nonpayable","inputs":[{"name":"asset","type":"address"},{"name":"amount","type":"uint256"},{"name":"onBehalfOf","type":"address"},{"name":"referralCode","type":"uint16"}],"outputs":[]}, + {"name":"withdraw","type":"function","stateMutability":"nonpayable","inputs":[{"name":"asset","type":"address"},{"name":"amount","type":"uint256"},{"name":"to","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"borrow","type":"function","stateMutability":"nonpayable","inputs":[{"name":"asset","type":"address"},{"name":"amount","type":"uint256"},{"name":"interestRateMode","type":"uint256"},{"name":"referralCode","type":"uint16"},{"name":"onBehalfOf","type":"address"}],"outputs":[]}, + {"name":"repay","type":"function","stateMutability":"nonpayable","inputs":[{"name":"asset","type":"address"},{"name":"amount","type":"uint256"},{"name":"interestRateMode","type":"uint256"},{"name":"onBehalfOf","type":"address"}],"outputs":[{"name":"","type":"uint256"}]} + ]` + + AaveRewardsABI = `[ + {"name":"claimRewards","type":"function","stateMutability":"nonpayable","inputs":[{"name":"assets","type":"address[]"},{"name":"amount","type":"uint256"},{"name":"to","type":"address"},{"name":"reward","type":"address"}],"outputs":[{"name":"","type":"uint256"}]} + ]` +) diff --git a/internal/registry/execution_data_test.go b/internal/registry/execution_data_test.go new file mode 100644 index 0000000..02be5b1 --- /dev/null +++ b/internal/registry/execution_data_test.go @@ -0,0 +1,48 @@ +package registry + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +func TestTaikoSwapContracts(t *testing.T) { + quoter, router, ok := TaikoSwapContracts(167000) + if !ok { + t.Fatal("expected taiko mainnet contracts to exist") + } + if quoter == "" || router == "" { + t.Fatalf("unexpected empty taikoswap contract values: quoter=%q router=%q", quoter, router) + } + + if _, _, ok := TaikoSwapContracts(1); ok { + t.Fatal("did not expect taikoswap contracts for unsupported chain") + } +} + +func TestAavePoolAddressProvider(t *testing.T) { + addr, ok := AavePoolAddressProvider(1) + if !ok || addr == "" { + t.Fatal("expected aave pool address provider for chain 1") + } + if _, ok := AavePoolAddressProvider(8453); ok { + t.Fatal("did not expect aave pool address provider default for base") + } +} + +func TestExecutionABIConstantsParse(t *testing.T) { + abis := []string{ + ERC20MinimalABI, + TaikoSwapQuoterV2ABI, + TaikoSwapRouterABI, + AavePoolAddressProviderABI, + AavePoolABI, + AaveRewardsABI, + } + for _, raw := range abis { + if _, err := abi.JSON(strings.NewReader(raw)); err != nil { + t.Fatalf("failed to parse abi json: %v", err) + } + } +} diff --git a/scripts/nightly_execution_smoke.sh b/scripts/nightly_execution_smoke.sh new file mode 100644 index 0000000..3ceadc8 --- /dev/null +++ b/scripts/nightly_execution_smoke.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +go build -o defi ./cmd/defi + +./defi providers list --results-only >/dev/null + +./defi swap quote \ + --provider taikoswap \ + --chain taiko \ + --from-asset USDC \ + --to-asset WETH \ + --amount 1000000 \ + --results-only >/dev/null + +./defi bridge quote \ + --provider lifi \ + --from 1 \ + --to 8453 \ + --asset USDC \ + --amount 1000000 \ + --results-only >/dev/null + +./defi approvals plan \ + --chain taiko \ + --asset USDC \ + --spender 0x00000000000000000000000000000000000000bb \ + --amount 1000000 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --results-only >/dev/null + +./defi bridge plan \ + --provider lifi \ + --from 1 \ + --to 8453 \ + --asset USDC \ + --amount 1000000 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --results-only >/dev/null + +./defi lend supply plan \ + --protocol aave \ + --chain 1 \ + --asset USDC \ + --amount 1000000 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --results-only >/dev/null + +./defi rewards claim plan \ + --protocol aave \ + --chain 1 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --assets 0x00000000000000000000000000000000000000d1 \ + --reward-token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + --results-only >/dev/null + +./defi rewards compound plan \ + --protocol aave \ + --chain 1 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --assets 0x00000000000000000000000000000000000000d1 \ + --reward-token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + --amount 1000 \ + --results-only >/dev/null + +rm -f ./defi From 14b313490c19b55cfc8fb28d088307c9f04af547 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Tue, 24 Feb 2026 17:15:02 -0400 Subject: [PATCH 02/18] Add Morpho lend execution and Across bridge execution support --- AGENTS.md | 8 +- CHANGELOG.md | 14 +- README.md | 19 +- docs/act-execution-design.md | 2 +- internal/app/approvals_command.go | 5 +- internal/app/bridge_execution_commands.go | 73 ++-- internal/app/execution_helpers.go | 14 + internal/app/lend_execution_commands.go | 80 ++-- internal/app/rewards_command.go | 8 +- internal/app/runner.go | 33 +- internal/app/runner_actions_test.go | 21 ++ internal/execution/executor.go | 257 +++++++++++++ .../executor_bridge_settlement_test.go | 169 +++++++++ internal/execution/planner/morpho.go | 350 ++++++++++++++++++ internal/execution/planner/morpho_test.go | 101 +++++ internal/model/types.go | 26 +- internal/providers/across/client.go | 187 ++++++++++ internal/providers/across/client_test.go | 74 +++- internal/providers/lifi/client.go | 104 +++++- internal/providers/lifi/client_test.go | 77 +++- internal/providers/morpho/client.go | 2 + internal/providers/types.go | 24 +- internal/registry/execution_data.go | 14 +- internal/registry/execution_data_test.go | 14 +- 24 files changed, 1555 insertions(+), 121 deletions(-) create mode 100644 internal/app/execution_helpers.go create mode 100644 internal/execution/executor_bridge_settlement_test.go create mode 100644 internal/execution/planner/morpho.go create mode 100644 internal/execution/planner/morpho_test.go diff --git a/AGENTS.md b/AGENTS.md index c30edb6..b90a070 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,13 +70,17 @@ README.md # user-facing usage + caveats - TaikoSwap quote/planning does not require an API key; execution uses local signer env inputs (`DEFI_PRIVATE_KEY{,_FILE}` or keystore envs). - Execution commands currently available: - `swap plan|run|submit|status` - - `bridge plan|run|submit|status` (LiFi) + - `bridge plan|run|submit|status` (Across, LiFi) - `approvals plan|run|submit|status` - - `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave) + - `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave, Morpho) - `rewards claim|compound plan|run|submit|status` (Aave) - `actions list|status` - All execution `run` / `submit` commands require `--yes` and can broadcast transactions. +- LiFi bridge quote/plan/run support optional `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). +- Bridge execution status for Across/LiFi waits for destination settlement (`/deposit/status` or `/status`) before marking bridge steps complete. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. +- Aave execution has default pool-address-provider coverage for chain IDs `1`, `10`, `137`, `8453`, `42161`, and `43114`; override with `--pool-address` / `--pool-address-provider` otherwise. +- Morpho lend execution requires `--market-id` (Morpho market unique key bytes32). - Key requirements are command + provider specific; `providers list` is metadata only and should remain callable without provider keys. - Prefer env vars for provider keys in docs/examples; keep config file usage optional and focused on non-secret defaults. - `--chain` supports CAIP-2, numeric chain IDs, and aliases; aliases include `mantle`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, `taiko hoodi`/`hoodi`, and `zksync`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fb88b..bce4239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,9 @@ Format: ### Added - Added TaikoSwap provider support for `swap quote` using on-chain quoter contract calls (no API key required). - Added swap execution workflow commands: `swap plan`, `swap run`, `swap submit`, and `swap status`. -- Added bridge execution workflow commands: `bridge plan`, `bridge run`, `bridge submit`, and `bridge status` (LiFi provider). +- Added bridge execution workflow commands: `bridge plan`, `bridge run`, `bridge submit`, and `bridge status` (Across and LiFi providers). - Added approvals workflow commands: `approvals plan`, `approvals run`, `approvals submit`, and `approvals status`. -- Added lend execution workflow commands under `lend supply|withdraw|borrow|repay ... plan|run|submit|status` (Aave). +- Added lend execution workflow commands under `lend supply|withdraw|borrow|repay ... plan|run|submit|status` (Aave and Morpho). - Added rewards execution workflow commands under `rewards claim|compound ... plan|run|submit|status` (Aave). - Added action persistence and inspection commands: `actions list` and `actions status`. - Added local signer support for execution with env/file/keystore key sources and strict file-permission checks. @@ -26,13 +26,21 @@ Format: ### Changed - `providers list` now includes TaikoSwap execution capabilities (`swap.plan`, `swap.execute`) alongside quote metadata. - `providers list` now includes LiFi bridge execution capabilities (`bridge.plan`, `bridge.execute`). +- `providers list` now includes Across bridge execution capabilities (`bridge.plan`, `bridge.execute`). +- `providers list` now includes Morpho lend execution capabilities (`lend.plan`, `lend.execute`). - Added execution-specific exit codes (`20`-`24`) for plan/simulation/policy/timeout/signer failures. - Added execution config/env support for action store paths and Taiko RPC overrides. - Execution command cache/action-store policy now covers `swap|bridge|approvals|lend|rewards ... plan|run|submit|status`. - Removed implicit defaults for multi-provider command paths; `--provider`/`--protocol` must be set explicitly where applicable. +- Added bridge gas-top-up request support via `--from-amount-for-gas` for LiFi quote/plan/run flows. +- Bridge execution now tracks LiFi destination settlement status before finalizing bridge steps. +- Bridge execution now tracks Across destination settlement status before finalizing bridge steps. +- Aave execution registry defaults now include PoolAddressesProvider mappings for Base, Arbitrum, Optimism, Polygon, and Avalanche in addition to Ethereum. +- Execution `run`/`submit` commands now propagate command timeout/cancel context through on-chain execution. +- Morpho lend execution now requires explicit `--market-id` to avoid ambiguous market selection. ### Fixed -- None yet. +- Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. ### Docs - Documented bridge/lend/rewards/approvals execution flows, signer env inputs, command behavior, and exit codes in `README.md`. diff --git a/README.md b/README.md index 348f6a2..c60301e 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,13 @@ defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit defi bridge list --limit 10 --results-only # Requires DEFI_DEFILLAMA_API_KEY defi bridge details --bridge layerzero --results-only # Requires DEFI_DEFILLAMA_API_KEY defi bridge quote --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --results-only +defi bridge quote --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-amount-for-gas 100000 --results-only defi swap quote --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --results-only defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --from-address 0xYourEOA --results-only -defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only +defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --from-amount-for-gas 100000 --results-only +defi bridge plan --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only defi lend supply plan --protocol aave --chain 1 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only +defi lend supply plan --protocol morpho --chain 1 --asset USDC --market-id 0x... --amount 1000000 --from-address 0xYourEOA --results-only defi rewards claim plan --protocol aave --chain 1 --from-address 0xYourEOA --assets 0x... --reward-token 0x... --results-only defi approvals plan --chain taiko --asset USDC --spender 0xSpender --amount 1000000 --from-address 0xYourEOA --results-only defi swap status --action-id --results-only @@ -91,6 +94,7 @@ Bridge quote examples: ```bash defi bridge quote --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --results-only defi bridge quote --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --results-only +defi bridge quote --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-amount-for-gas 100000 --results-only ``` Swap quote examples: @@ -132,9 +136,9 @@ defi swap run \ Execution command surface: - `swap plan|run|submit|status` -- `bridge plan|run|submit|status` (provider: `lifi`) +- `bridge plan|run|submit|status` (provider: `across|lifi`) - `approvals plan|run|submit|status` -- `lend supply|withdraw|borrow|repay plan|run|submit|status` (protocol: `aave`) +- `lend supply|withdraw|borrow|repay plan|run|submit|status` (protocol: `aave|morpho`) - `rewards claim|compound plan|run|submit|status` (protocol: `aave`) - `actions list|status` @@ -239,8 +243,13 @@ providers: - For chains without bootstrap symbol entries, pass token address or CAIP-19 via `--asset`/`--from-asset`/`--to-asset` for deterministic resolution. - For `lend`/`yield`, unresolved asset symbols skip DefiLlama-based symbol matching and may disable fallback/provider selection to avoid unsafe broad matches. - Swap execution currently supports TaikoSwap only. -- Bridge execution currently supports LiFi only. -- Lend and rewards execution currently support Aave only. +- Bridge execution currently supports Across and LiFi. +- Lend execution supports Aave and Morpho (`--market-id` required for Morpho). +- Rewards execution currently supports Aave only. +- Aave execution resolves pool addresses automatically on Ethereum, Optimism, Polygon, Base, Arbitrum, and Avalanche; use `--pool-address` / `--pool-address-provider` on unsupported chains. +- LiFi bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. +- Across bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. +- LiFi bridge quote/plan/run support `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). - All `run` / `submit` execution commands require `--yes` and will broadcast signed transactions. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. - Provider/protocol selection is explicit for multi-provider flows; pass `--provider` or `--protocol` (no implicit defaults). diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index c4dbbd4..6676e9c 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -24,7 +24,7 @@ Implemented in this repository: Not yet implemented from full roadmap: - Additional signer backends (`safe`, external wallets, hardware) -- Broader execution provider coverage beyond current defaults (TaikoSwap/LiFi/Aave) +- Broader execution provider coverage beyond current defaults (TaikoSwap/Across/LiFi/Aave/Morpho) ## 1. Problem Statement diff --git a/internal/app/approvals_command.go b/internal/app/approvals_command.go index 6ef6752..c7e3416 100644 --- a/internal/app/approvals_command.go +++ b/internal/app/approvals_command.go @@ -1,7 +1,6 @@ package app import ( - "context" "strings" "time" @@ -126,7 +125,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if err != nil { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { return err } s.captureCommandDiagnostics(nil, status, false) @@ -192,7 +191,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if err != nil { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { return err } return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) diff --git a/internal/app/bridge_execution_commands.go b/internal/app/bridge_execution_commands.go index 2fef63a..005495b 100644 --- a/internal/app/bridge_execution_commands.go +++ b/internal/app/bridge_execution_commands.go @@ -2,11 +2,12 @@ package app import ( "context" + "fmt" + "sort" "strings" "time" clierr "github.com/ggonzalez94/defi-cli/internal/errors" - "github.com/ggonzalez94/defi-cli/internal/execution" execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" @@ -15,7 +16,7 @@ import ( ) func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { - buildRequest := func(fromArg, toArg, assetArg, toAssetArg, amountBase, amountDecimal string) (providers.BridgeQuoteRequest, error) { + buildRequest := func(fromArg, toArg, assetArg, toAssetArg, amountBase, amountDecimal, fromAmountForGas string) (providers.BridgeQuoteRequest, error) { fromChain, err := id.ParseChain(fromArg) if err != nil { return providers.BridgeQuoteRequest{}, err @@ -48,17 +49,18 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { return providers.BridgeQuoteRequest{}, err } return providers.BridgeQuoteRequest{ - FromChain: fromChain, - ToChain: toChain, - FromAsset: fromAsset, - ToAsset: toAsset, - AmountBaseUnits: base, - AmountDecimal: decimal, + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: base, + AmountDecimal: decimal, + FromAmountForGas: strings.TrimSpace(fromAmountForGas), }, nil } var planProviderArg, planFromArg, planToArg, planAssetArg, planToAssetArg string - var planAmountBase, planAmountDecimal, planFromAddress, planRecipient string + var planAmountBase, planAmountDecimal, planFromAddress, planRecipient, planFromAmountForGas string var planSlippageBps int64 var planSimulate bool var planRPCURL string @@ -76,9 +78,9 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { } execProvider, ok := provider.(providers.BridgeExecutionProvider) if !ok { - return clierr.New(clierr.CodeUnsupported, "selected bridge provider does not support execution") + return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("bridge provider %q is quote-only; execution providers: %s", providerName, strings.Join(bridgeExecutionProviderNames(s.bridgeProviders), ","))) } - reqStruct, err := buildRequest(planFromArg, planToArg, planAssetArg, planToAssetArg, planAmountBase, planAmountDecimal) + reqStruct, err := buildRequest(planFromArg, planToArg, planAssetArg, planToAssetArg, planAmountBase, planAmountDecimal, planFromAmountForGas) if err != nil { return err } @@ -86,11 +88,12 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { defer cancel() start := time.Now() action, err := execProvider.BuildBridgeAction(ctx, reqStruct, providers.BridgeExecutionOptions{ - Sender: planFromAddress, - Recipient: planRecipient, - SlippageBps: planSlippageBps, - Simulate: planSimulate, - RPCURL: planRPCURL, + Sender: planFromAddress, + Recipient: planRecipient, + SlippageBps: planSlippageBps, + Simulate: planSimulate, + RPCURL: planRPCURL, + FromAmountForGas: planFromAmountForGas, }) statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { @@ -107,13 +110,14 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - planCmd.Flags().StringVar(&planProviderArg, "provider", "", "Bridge provider (lifi)") + planCmd.Flags().StringVar(&planProviderArg, "provider", "", "Bridge provider (across|lifi)") planCmd.Flags().StringVar(&planFromArg, "from", "", "Source chain") planCmd.Flags().StringVar(&planToArg, "to", "", "Destination chain") planCmd.Flags().StringVar(&planAssetArg, "asset", "", "Asset on source chain") planCmd.Flags().StringVar(&planToAssetArg, "to-asset", "", "Destination asset override") planCmd.Flags().StringVar(&planAmountBase, "amount", "", "Amount in base units") planCmd.Flags().StringVar(&planAmountDecimal, "amount-decimal", "", "Amount in decimal units") + planCmd.Flags().StringVar(&planFromAmountForGas, "from-amount-for-gas", "", "Optional amount in source token base units to reserve for destination native gas (LiFi)") planCmd.Flags().StringVar(&planFromAddress, "from-address", "", "Sender EOA address") planCmd.Flags().StringVar(&planRecipient, "recipient", "", "Recipient address (defaults to --from-address)") planCmd.Flags().Int64Var(&planSlippageBps, "slippage-bps", 50, "Max slippage in basis points") @@ -126,7 +130,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { _ = planCmd.MarkFlagRequired("provider") var runProviderArg, runFromArg, runToArg, runAssetArg, runToAssetArg string - var runAmountBase, runAmountDecimal, runFromAddress, runRecipient string + var runAmountBase, runAmountDecimal, runFromAddress, runRecipient, runFromAmountForGas string var runSlippageBps int64 var runSimulate, runYes bool var runRPCURL string @@ -150,9 +154,9 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { } execProvider, ok := provider.(providers.BridgeExecutionProvider) if !ok { - return clierr.New(clierr.CodeUnsupported, "selected bridge provider does not support execution") + return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("bridge provider %q is quote-only; execution providers: %s", providerName, strings.Join(bridgeExecutionProviderNames(s.bridgeProviders), ","))) } - reqStruct, err := buildRequest(runFromArg, runToArg, runAssetArg, runToAssetArg, runAmountBase, runAmountDecimal) + reqStruct, err := buildRequest(runFromArg, runToArg, runAssetArg, runToAssetArg, runAmountBase, runAmountDecimal, runFromAmountForGas) if err != nil { return err } @@ -160,11 +164,12 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { defer cancel() start := time.Now() action, err := execProvider.BuildBridgeAction(ctx, reqStruct, providers.BridgeExecutionOptions{ - Sender: runFromAddress, - Recipient: runRecipient, - SlippageBps: runSlippageBps, - Simulate: runSimulate, - RPCURL: runRPCURL, + Sender: runFromAddress, + Recipient: runRecipient, + SlippageBps: runSlippageBps, + Simulate: runSimulate, + RPCURL: runRPCURL, + FromAmountForGas: runFromAmountForGas, }) statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { @@ -191,7 +196,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { s.captureCommandDiagnostics(nil, statuses, false) return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err } @@ -199,13 +204,14 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - runCmd.Flags().StringVar(&runProviderArg, "provider", "", "Bridge provider (lifi)") + runCmd.Flags().StringVar(&runProviderArg, "provider", "", "Bridge provider (across|lifi)") runCmd.Flags().StringVar(&runFromArg, "from", "", "Source chain") runCmd.Flags().StringVar(&runToArg, "to", "", "Destination chain") runCmd.Flags().StringVar(&runAssetArg, "asset", "", "Asset on source chain") runCmd.Flags().StringVar(&runToAssetArg, "to-asset", "", "Destination asset override") runCmd.Flags().StringVar(&runAmountBase, "amount", "", "Amount in base units") runCmd.Flags().StringVar(&runAmountDecimal, "amount-decimal", "", "Amount in decimal units") + runCmd.Flags().StringVar(&runFromAmountForGas, "from-amount-for-gas", "", "Optional amount in source token base units to reserve for destination native gas (LiFi)") runCmd.Flags().StringVar(&runFromAddress, "from-address", "", "Sender EOA address") runCmd.Flags().StringVar(&runRecipient, "recipient", "", "Recipient address (defaults to --from-address)") runCmd.Flags().Int64Var(&runSlippageBps, "slippage-bps", 50, "Max slippage in basis points") @@ -263,7 +269,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if err != nil { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { return err } return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) @@ -309,3 +315,14 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { root.AddCommand(submitCmd) root.AddCommand(statusCmd) } + +func bridgeExecutionProviderNames(all map[string]providers.BridgeProvider) []string { + names := make([]string, 0, len(all)) + for name, provider := range all { + if _, ok := provider.(providers.BridgeExecutionProvider); ok { + names = append(names, name) + } + } + sort.Strings(names) + return names +} diff --git a/internal/app/execution_helpers.go b/internal/app/execution_helpers.go new file mode 100644 index 0000000..75c935e --- /dev/null +++ b/internal/app/execution_helpers.go @@ -0,0 +1,14 @@ +package app + +import ( + "context" + + "github.com/ggonzalez94/defi-cli/internal/execution" + execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" +) + +func (s *runtimeState) executeActionWithTimeout(action *execution.Action, txSigner execsigner.Signer, opts execution.ExecuteOptions) error { + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + return execution.ExecuteAction(ctx, s.actionStore, action, txSigner, opts) +} diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go index b875ef5..a81e69f 100644 --- a/internal/app/lend_execution_commands.go +++ b/internal/app/lend_execution_commands.go @@ -32,6 +32,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh protocol string chainArg string assetArg string + marketID string amountBase string amountDecimal string fromAddress string @@ -48,9 +49,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if protocol == "" { return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") } - if protocol != "aave" { - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports only protocol=aave") - } chain, asset, err := parseChainAsset(args.chainArg, args.assetArg) if err != nil { @@ -65,20 +63,38 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh return execution.Action{}, err } - return planner.BuildAaveLendAction(ctx, planner.AaveLendRequest{ - Verb: verb, - Chain: chain, - Asset: asset, - AmountBaseUnits: base, - Sender: args.fromAddress, - Recipient: args.recipient, - OnBehalfOf: args.onBehalfOf, - InterestRateMode: args.interestRateMode, - Simulate: args.simulate, - RPCURL: args.rpcURL, - PoolAddress: args.poolAddress, - PoolAddressesProvider: args.poolAddressProvider, - }) + switch protocol { + case "aave": + return planner.BuildAaveLendAction(ctx, planner.AaveLendRequest{ + Verb: verb, + Chain: chain, + Asset: asset, + AmountBaseUnits: base, + Sender: args.fromAddress, + Recipient: args.recipient, + OnBehalfOf: args.onBehalfOf, + InterestRateMode: args.interestRateMode, + Simulate: args.simulate, + RPCURL: args.rpcURL, + PoolAddress: args.poolAddress, + PoolAddressesProvider: args.poolAddressProvider, + }) + case "morpho": + return planner.BuildMorphoLendAction(ctx, planner.MorphoLendRequest{ + Verb: verb, + Chain: chain, + Asset: asset, + MarketID: args.marketID, + AmountBaseUnits: base, + Sender: args.fromAddress, + Recipient: args.recipient, + OnBehalfOf: args.onBehalfOf, + Simulate: args.simulate, + RPCURL: args.rpcURL, + }) + default: + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports protocol=aave|morpho") + } } var plan lendArgs @@ -90,7 +106,11 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh defer cancel() start := time.Now() action, err := buildAction(ctx, plan) - statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + providerName := normalizeLendingProtocol(plan.protocol) + if providerName == "" { + providerName = "lend" + } + statuses := []model.ProviderStatus{{Name: providerName, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -105,15 +125,16 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Lending protocol (aave)") + planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Lending protocol (aave|morpho)") planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") planCmd.Flags().StringVar(&plan.assetArg, "asset", "", "Asset symbol/address/CAIP-19") + planCmd.Flags().StringVar(&plan.marketID, "market-id", "", "Morpho market unique key (required for --protocol morpho)") planCmd.Flags().StringVar(&plan.amountBase, "amount", "", "Amount in base units") planCmd.Flags().StringVar(&plan.amountDecimal, "amount-decimal", "", "Amount in decimal units") planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") planCmd.Flags().StringVar(&plan.recipient, "recipient", "", "Recipient address (defaults to --from-address)") - planCmd.Flags().StringVar(&plan.onBehalfOf, "on-behalf-of", "", "Aave onBehalfOf address (defaults to --from-address)") - planCmd.Flags().Int64Var(&plan.interestRateMode, "interest-rate-mode", 2, "Interest rate mode for borrow/repay (1=stable,2=variable)") + planCmd.Flags().StringVar(&plan.onBehalfOf, "on-behalf-of", "", "Position owner address (defaults to --from-address)") + planCmd.Flags().Int64Var(&plan.interestRateMode, "interest-rate-mode", 2, "Aave borrow/repay mode (1=stable,2=variable)") planCmd.Flags().BoolVar(&plan.simulate, "simulate", true, "Include simulation checks during execution") planCmd.Flags().StringVar(&plan.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") planCmd.Flags().StringVar(&plan.poolAddress, "pool-address", "", "Aave pool address override") @@ -139,7 +160,11 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh defer cancel() start := time.Now() action, err := buildAction(ctx, run) - statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + providerName := normalizeLendingProtocol(run.protocol) + if providerName == "" { + providerName = "lend" + } + statuses := []model.ProviderStatus{{Name: providerName, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -164,7 +189,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh s.captureCommandDiagnostics(nil, statuses, false) return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err } @@ -172,15 +197,16 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Lending protocol (aave)") + runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Lending protocol (aave|morpho)") runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") runCmd.Flags().StringVar(&run.assetArg, "asset", "", "Asset symbol/address/CAIP-19") + runCmd.Flags().StringVar(&run.marketID, "market-id", "", "Morpho market unique key (required for --protocol morpho)") runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Amount in base units") runCmd.Flags().StringVar(&run.amountDecimal, "amount-decimal", "", "Amount in decimal units") runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") - runCmd.Flags().StringVar(&run.onBehalfOf, "on-behalf-of", "", "Aave onBehalfOf address (defaults to --from-address)") - runCmd.Flags().Int64Var(&run.interestRateMode, "interest-rate-mode", 2, "Interest rate mode for borrow/repay (1=stable,2=variable)") + runCmd.Flags().StringVar(&run.onBehalfOf, "on-behalf-of", "", "Position owner address (defaults to --from-address)") + runCmd.Flags().Int64Var(&run.interestRateMode, "interest-rate-mode", 2, "Aave borrow/repay mode (1=stable,2=variable)") runCmd.Flags().BoolVar(&run.simulate, "simulate", true, "Run preflight simulation before submission") runCmd.Flags().StringVar(&run.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") runCmd.Flags().StringVar(&run.poolAddress, "pool-address", "", "Aave pool address override") @@ -239,7 +265,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if err != nil { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { return err } return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) diff --git a/internal/app/rewards_command.go b/internal/app/rewards_command.go index 6579b6e..9eb1648 100644 --- a/internal/app/rewards_command.go +++ b/internal/app/rewards_command.go @@ -154,7 +154,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { s.captureCommandDiagnostics(nil, statuses, false) return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err } @@ -228,7 +228,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { if err != nil { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { return err } return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) @@ -419,7 +419,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { s.captureCommandDiagnostics(nil, statuses, false) return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err } @@ -496,7 +496,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { if err != nil { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { return err } return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) diff --git a/internal/app/runner.go b/internal/app/runner.go index 2f5b052..e21b39a 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -547,7 +547,7 @@ func (s *runtimeState) newLendCommand() *cobra.Command { func (s *runtimeState) newBridgeCommand() *cobra.Command { root := &cobra.Command{Use: "bridge", Short: "Bridge quote and analytics commands"} - var quoteProviderArg, fromArg, toArg, assetArg, toAssetArg string + var quoteProviderArg, fromArg, toArg, assetArg, toAssetArg, fromAmountForGas string var amountBase, amountDecimal string quoteCmd := &cobra.Command{ Use: "quote", @@ -596,20 +596,22 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { } reqStruct := providers.BridgeQuoteRequest{ - FromChain: fromChain, - ToChain: toChain, - FromAsset: fromAsset, - ToAsset: toAsset, - AmountBaseUnits: base, - AmountDecimal: decimal, + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: base, + AmountDecimal: decimal, + FromAmountForGas: strings.TrimSpace(fromAmountForGas), } key := cacheKey(trimRootPath(cmd.CommandPath()), map[string]any{ - "provider": providerName, - "from": fromChain.CAIP2, - "to": toChain.CAIP2, - "from_asset": fromAsset.AssetID, - "to_asset": toAsset.AssetID, - "amount": base, + "provider": providerName, + "from": fromChain.CAIP2, + "to": toChain.CAIP2, + "from_asset": fromAsset.AssetID, + "to_asset": toAsset.AssetID, + "amount": base, + "from_amount_for_gas": reqStruct.FromAmountForGas, }) return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 15*time.Second, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { start := time.Now() @@ -626,6 +628,7 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { quoteCmd.Flags().StringVar(&toAssetArg, "to-asset", "", "Destination asset override (symbol/address/CAIP-19)") quoteCmd.Flags().StringVar(&amountBase, "amount", "", "Amount in base units") quoteCmd.Flags().StringVar(&amountDecimal, "amount-decimal", "", "Amount in decimal units") + quoteCmd.Flags().StringVar(&fromAmountForGas, "from-amount-for-gas", "", "Optional amount in source token base units to reserve for destination native gas (LiFi)") _ = quoteCmd.MarkFlagRequired("from") _ = quoteCmd.MarkFlagRequired("to") _ = quoteCmd.MarkFlagRequired("asset") @@ -912,7 +915,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err } @@ -987,7 +990,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if err != nil { return err } - if err := execution.ExecuteAction(context.Background(), s.actionStore, &action, txSigner, execOpts); err != nil { + if err := s.executeActionWithTimeout(&action, txSigner, execOpts); err != nil { return err } return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), nil, false) diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go index b0b7d19..a0dad25 100644 --- a/internal/app/runner_actions_test.go +++ b/internal/app/runner_actions_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" "testing" ) @@ -127,6 +128,26 @@ func TestRunnerSwapPlanRequiresFromAddress(t *testing.T) { } } +func TestRunnerMorphoLendPlanRequiresMarketID(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{ + "lend", "supply", "plan", + "--protocol", "morpho", + "--chain", "1", + "--asset", "USDC", + "--amount", "1000000", + "--from-address", "0x00000000000000000000000000000000000000aa", + }) + if code != 2 { + t.Fatalf("expected usage exit code 2, got %d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "--market-id") { + t.Fatalf("expected market-id guidance in error output, got: %s", stderr.String()) + } +} + func TestRunnerActionsListBypassesCacheOpen(t *testing.T) { setUnopenableCacheEnv(t) diff --git a/internal/execution/executor.go b/internal/execution/executor.go index 5251f1d..4378747 100644 --- a/internal/execution/executor.go +++ b/internal/execution/executor.go @@ -3,9 +3,12 @@ package execution import ( "context" "encoding/hex" + "encoding/json" "errors" "fmt" "math/big" + "net/http" + "net/url" "strings" "time" @@ -196,6 +199,9 @@ func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer. receipt, err := client.TransactionReceipt(waitCtx, signed.Hash()) if err == nil && receipt != nil { if receipt.Status == types.ReceiptStatusSuccessful { + if err := verifyBridgeSettlement(ctx, step, signed.Hash().Hex(), opts); err != nil { + return err + } step.Status = StepStatusConfirmed return nil } @@ -215,6 +221,257 @@ func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer. } } +func verifyBridgeSettlement(ctx context.Context, step *ActionStep, sourceTxHash string, opts ExecuteOptions) error { + if step == nil || step.Type != StepTypeBridge { + return nil + } + if step.ExpectedOutputs == nil { + return nil + } + provider := strings.ToLower(strings.TrimSpace(step.ExpectedOutputs["settlement_provider"])) + if provider == "" { + return nil + } + switch provider { + case "lifi": + statusEndpoint := strings.TrimSpace(step.ExpectedOutputs["settlement_status_endpoint"]) + if statusEndpoint == "" { + statusEndpoint = "https://li.quest/v1/status" + } + return waitForLiFiSettlement(ctx, step, sourceTxHash, statusEndpoint, opts) + case "across": + statusEndpoint := strings.TrimSpace(step.ExpectedOutputs["settlement_status_endpoint"]) + if statusEndpoint == "" { + statusEndpoint = "https://app.across.to/api/deposit/status" + } + return waitForAcrossSettlement(ctx, step, sourceTxHash, statusEndpoint, opts) + default: + return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("unsupported bridge settlement provider %q", provider)) + } +} + +type liFiStatusResponse struct { + Status string `json:"status"` + Substatus string `json:"substatus"` + SubstatusMessage string `json:"substatusMessage"` + Message string `json:"message"` + Code int `json:"code"` + LiFiExplorerLink string `json:"lifiExplorerLink"` + Receiving struct { + TxHash string `json:"txHash"` + Amount string `json:"amount"` + } `json:"receiving"` +} + +func waitForLiFiSettlement(ctx context.Context, step *ActionStep, sourceTxHash, statusEndpoint string, opts ExecuteOptions) error { + waitCtx, cancel := context.WithTimeout(ctx, opts.StepTimeout) + defer cancel() + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + resp, err := queryLiFiStatus(waitCtx, sourceTxHash, statusEndpoint, step.ExpectedOutputs) + if err == nil { + status := strings.ToUpper(strings.TrimSpace(resp.Status)) + if status != "" { + setStepOutput(step, "settlement_status", status) + } + if strings.TrimSpace(resp.Substatus) != "" { + setStepOutput(step, "settlement_substatus", strings.TrimSpace(resp.Substatus)) + } + if strings.TrimSpace(resp.SubstatusMessage) != "" { + setStepOutput(step, "settlement_message", strings.TrimSpace(resp.SubstatusMessage)) + } + if strings.TrimSpace(resp.LiFiExplorerLink) != "" { + setStepOutput(step, "settlement_explorer_url", strings.TrimSpace(resp.LiFiExplorerLink)) + } + if strings.TrimSpace(resp.Receiving.TxHash) != "" { + setStepOutput(step, "destination_tx_hash", strings.TrimSpace(resp.Receiving.TxHash)) + } + + switch status { + case "DONE": + return nil + case "FAILED", "INVALID": + msg := firstNonEmpty(strings.TrimSpace(resp.SubstatusMessage), strings.TrimSpace(resp.Message), "LiFi transfer reported failure") + return clierr.New(clierr.CodeUnavailable, "bridge settlement failed: "+msg) + } + } + if waitCtx.Err() != nil { + return clierr.Wrap(clierr.CodeActionTimeout, "timed out waiting for bridge settlement", waitCtx.Err()) + } + select { + case <-waitCtx.Done(): + return clierr.Wrap(clierr.CodeActionTimeout, "timed out waiting for bridge settlement", waitCtx.Err()) + case <-ticker.C: + } + } +} + +type acrossStatusResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Error string `json:"error"` + DepositTxHash string `json:"depositTxHash"` + FillTx string `json:"fillTx"` + DepositRefundTx string `json:"depositRefundTxHash"` + OriginChainID int64 `json:"originChainId"` + DestinationChain int64 `json:"destinationChainId"` +} + +func waitForAcrossSettlement(ctx context.Context, step *ActionStep, sourceTxHash, statusEndpoint string, opts ExecuteOptions) error { + waitCtx, cancel := context.WithTimeout(ctx, opts.StepTimeout) + defer cancel() + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + resp, err := queryAcrossStatus(waitCtx, sourceTxHash, statusEndpoint, step.ExpectedOutputs) + if err == nil { + status := strings.ToLower(strings.TrimSpace(resp.Status)) + if status != "" { + setStepOutput(step, "settlement_status", status) + } + if strings.TrimSpace(resp.FillTx) != "" { + setStepOutput(step, "destination_tx_hash", strings.TrimSpace(resp.FillTx)) + } + if strings.TrimSpace(resp.DepositRefundTx) != "" { + setStepOutput(step, "refund_tx_hash", strings.TrimSpace(resp.DepositRefundTx)) + } + + switch status { + case "filled": + return nil + case "refunded": + return clierr.New(clierr.CodeUnavailable, "bridge settlement refunded") + case "pending", "unfilled": + // keep polling + default: + if strings.TrimSpace(status) != "" { + // Keep polling unknown statuses until timeout. + } + } + } + if waitCtx.Err() != nil { + return clierr.Wrap(clierr.CodeActionTimeout, "timed out waiting for bridge settlement", waitCtx.Err()) + } + select { + case <-waitCtx.Done(): + return clierr.Wrap(clierr.CodeActionTimeout, "timed out waiting for bridge settlement", waitCtx.Err()) + case <-ticker.C: + } + } +} + +func queryLiFiStatus(ctx context.Context, sourceTxHash, statusEndpoint string, expected map[string]string) (liFiStatusResponse, error) { + var out liFiStatusResponse + + endpoint := strings.TrimSpace(statusEndpoint) + if endpoint == "" { + endpoint = "https://li.quest/v1/status" + } + parsed, err := url.Parse(endpoint) + if err != nil { + return out, err + } + query := parsed.Query() + query.Set("txHash", strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(sourceTxHash), "0x"), "0X")) + if bridge := strings.TrimSpace(expected["settlement_bridge"]); bridge != "" { + query.Set("bridge", bridge) + } + if fromChain := strings.TrimSpace(expected["settlement_from_chain"]); fromChain != "" { + query.Set("fromChain", fromChain) + } + if toChain := strings.TrimSpace(expected["settlement_to_chain"]); toChain != "" { + query.Set("toChain", toChain) + } + parsed.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return out, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return out, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return out, err + } + if out.Code != 0 && out.Status == "" { + // LiFi can report pending/non-indexed transfers with API-level codes. + if out.Code == 1003 || out.Code == 1011 { + return out, nil + } + return out, errors.New(firstNonEmpty(strings.TrimSpace(out.Message), "unexpected status response")) + } + return out, nil +} + +func queryAcrossStatus(ctx context.Context, sourceTxHash, statusEndpoint string, expected map[string]string) (acrossStatusResponse, error) { + var out acrossStatusResponse + + endpoint := strings.TrimSpace(statusEndpoint) + if endpoint == "" { + endpoint = "https://app.across.to/api/deposit/status" + } + parsed, err := url.Parse(endpoint) + if err != nil { + return out, err + } + query := parsed.Query() + query.Set("depositTxHash", strings.TrimSpace(sourceTxHash)) + if origin := strings.TrimSpace(expected["settlement_origin_chain"]); origin != "" { + query.Set("originChainId", origin) + } + if recipient := strings.TrimSpace(expected["settlement_recipient"]); recipient != "" { + query.Set("recipient", recipient) + } + parsed.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return out, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return out, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return out, err + } + if strings.TrimSpace(out.Error) != "" { + if strings.EqualFold(strings.TrimSpace(out.Error), "DepositNotFoundException") { + return out, nil + } + return out, errors.New(firstNonEmpty(strings.TrimSpace(out.Message), strings.TrimSpace(out.Error), "unexpected across status response")) + } + return out, nil +} + +func setStepOutput(step *ActionStep, key, value string) { + if step == nil || strings.TrimSpace(key) == "" { + return + } + if step.ExpectedOutputs == nil { + step.ExpectedOutputs = map[string]string{} + } + step.ExpectedOutputs[key] = value +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + func resolveTipCap(ctx context.Context, client *ethclient.Client, overrideGwei string) (*big.Int, error) { if strings.TrimSpace(overrideGwei) != "" { v, err := parseGwei(overrideGwei) diff --git a/internal/execution/executor_bridge_settlement_test.go b/internal/execution/executor_bridge_settlement_test.go new file mode 100644 index 0000000..73dbb5d --- /dev/null +++ b/internal/execution/executor_bridge_settlement_test.go @@ -0,0 +1,169 @@ +package execution + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" +) + +func TestVerifyBridgeSettlementNoopForNonBridgeStep(t *testing.T) { + step := &ActionStep{Type: StepTypeApproval} + err := verifyBridgeSettlement(context.Background(), step, "0xabc", ExecuteOptions{ + PollInterval: 5 * time.Millisecond, + StepTimeout: 100 * time.Millisecond, + }) + if err != nil { + t.Fatalf("expected no-op settlement verification, got err=%v", err) + } +} + +func TestVerifyBridgeSettlementLiFiSuccess(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if got := r.URL.Query().Get("txHash"); got != "abc" { + t.Fatalf("expected txHash query param without 0x prefix, got %q", got) + } + if calls == 1 { + _, _ = fmt.Fprint(w, `{"status":"PENDING","substatus":"WAIT_DESTINATION_TRANSACTION"}`) + return + } + _, _ = fmt.Fprint(w, `{"status":"DONE","substatus":"COMPLETED","receiving":{"txHash":"0xdestination"}}`) + })) + defer srv.Close() + + step := &ActionStep{ + Type: StepTypeBridge, + ExpectedOutputs: map[string]string{ + "settlement_provider": "lifi", + "settlement_status_endpoint": srv.URL, + "settlement_bridge": "across", + "settlement_from_chain": "1", + "settlement_to_chain": "8453", + }, + } + err := verifyBridgeSettlement(context.Background(), step, "0xabc", ExecuteOptions{ + PollInterval: 5 * time.Millisecond, + StepTimeout: 200 * time.Millisecond, + }) + if err != nil { + t.Fatalf("expected successful settlement verification, got err=%v", err) + } + if step.ExpectedOutputs["settlement_status"] != "DONE" { + t.Fatalf("expected settlement status DONE, got %q", step.ExpectedOutputs["settlement_status"]) + } + if step.ExpectedOutputs["destination_tx_hash"] != "0xdestination" { + t.Fatalf("expected destination tx hash, got %q", step.ExpectedOutputs["destination_tx_hash"]) + } +} + +func TestVerifyBridgeSettlementLiFiFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"status":"FAILED","substatusMessage":"bridge route failed"}`) + })) + defer srv.Close() + + step := &ActionStep{ + Type: StepTypeBridge, + ExpectedOutputs: map[string]string{ + "settlement_provider": "lifi", + "settlement_status_endpoint": srv.URL, + }, + } + err := verifyBridgeSettlement(context.Background(), step, "0xabc", ExecuteOptions{ + PollInterval: 5 * time.Millisecond, + StepTimeout: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected settlement failure error") + } + if !strings.Contains(err.Error(), "bridge settlement failed") { + t.Fatalf("expected bridge settlement failed error, got %v", err) + } +} + +func TestVerifyBridgeSettlementUnsupportedProvider(t *testing.T) { + step := &ActionStep{ + Type: StepTypeBridge, + ExpectedOutputs: map[string]string{ + "settlement_provider": "unknown", + }, + } + err := verifyBridgeSettlement(context.Background(), step, "0xabc", ExecuteOptions{ + PollInterval: 5 * time.Millisecond, + StepTimeout: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected unsupported settlement provider error") + } + cErr, ok := clierr.As(err) + if !ok || cErr.Code != clierr.CodeUnsupported { + t.Fatalf("expected unsupported code, got err=%v", err) + } +} + +func TestVerifyBridgeSettlementAcrossSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("depositTxHash"); got != "0xabc" { + t.Fatalf("expected depositTxHash 0xabc, got %q", got) + } + if got := r.URL.Query().Get("originChainId"); got != "1" { + t.Fatalf("expected originChainId=1, got %q", got) + } + _, _ = fmt.Fprint(w, `{"status":"filled","fillTx":"0xdestination"}`) + })) + defer srv.Close() + + step := &ActionStep{ + Type: StepTypeBridge, + ExpectedOutputs: map[string]string{ + "settlement_provider": "across", + "settlement_status_endpoint": srv.URL, + "settlement_origin_chain": "1", + }, + } + err := verifyBridgeSettlement(context.Background(), step, "0xabc", ExecuteOptions{ + PollInterval: 5 * time.Millisecond, + StepTimeout: 200 * time.Millisecond, + }) + if err != nil { + t.Fatalf("expected successful across settlement verification, got err=%v", err) + } + if step.ExpectedOutputs["settlement_status"] != "filled" { + t.Fatalf("expected settlement status filled, got %q", step.ExpectedOutputs["settlement_status"]) + } + if step.ExpectedOutputs["destination_tx_hash"] != "0xdestination" { + t.Fatalf("expected destination tx hash, got %q", step.ExpectedOutputs["destination_tx_hash"]) + } +} + +func TestVerifyBridgeSettlementAcrossRefunded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"status":"refunded","depositRefundTxHash":"0xrefund"}`) + })) + defer srv.Close() + + step := &ActionStep{ + Type: StepTypeBridge, + ExpectedOutputs: map[string]string{ + "settlement_provider": "across", + "settlement_status_endpoint": srv.URL, + }, + } + err := verifyBridgeSettlement(context.Background(), step, "0xabc", ExecuteOptions{ + PollInterval: 5 * time.Millisecond, + StepTimeout: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected across refunded status to fail") + } + if !strings.Contains(err.Error(), "refunded") { + t.Fatalf("expected refunded error, got %v", err) + } +} diff --git a/internal/execution/planner/morpho.go b/internal/execution/planner/morpho.go new file mode 100644 index 0000000..18d44f2 --- /dev/null +++ b/internal/execution/planner/morpho.go @@ -0,0 +1,350 @@ +package planner + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/httpx" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/registry" +) + +const defaultMorphoGraphQLEndpoint = "https://api.morpho.org/graphql" + +var morphoGraphQLEndpoint = defaultMorphoGraphQLEndpoint + +const morphoMarketByIDQuery = `query Market($chain:Int!,$key:String!){ + markets(first: 1, where:{ chainId_in: [$chain], uniqueKey_in: [$key], listed: true }){ + items{ + uniqueKey + irmAddress + lltv + morphoBlue{ address } + oracle{ address } + loanAsset{ address symbol decimals chain{ id } } + collateralAsset{ address symbol decimals } + state{ supplyAssetsUsd liquidityAssetsUsd } + } + } +}` + +type MorphoLendRequest struct { + Verb AaveLendVerb + Chain id.Chain + Asset id.Asset + AmountBaseUnits string + Sender string + Recipient string + OnBehalfOf string + Simulate bool + RPCURL string + MarketID string +} + +type morphoMarketByIDResponse struct { + Data struct { + Markets struct { + Items []struct { + UniqueKey string `json:"uniqueKey"` + IRM string `json:"irmAddress"` + LLTV string `json:"lltv"` + Morpho struct { + Address string `json:"address"` + } `json:"morphoBlue"` + Oracle struct { + Address string `json:"address"` + } `json:"oracle"` + LoanAsset struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + Chain struct { + ID int64 `json:"id"` + } `json:"chain"` + } `json:"loanAsset"` + CollateralAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + } `json:"collateralAsset"` + } `json:"items"` + } `json:"markets"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type morphoMarketParamsABI struct { + LoanToken common.Address `abi:"loanToken"` + CollateralToken common.Address `abi:"collateralToken"` + Oracle common.Address `abi:"oracle"` + IRM common.Address `abi:"irm"` + LLTV *big.Int `abi:"lltv"` +} + +func BuildMorphoLendAction(ctx context.Context, req MorphoLendRequest) (execution.Action, error) { + verb := strings.ToLower(strings.TrimSpace(string(req.Verb))) + sender, recipient, onBehalfOf, amount, rpcURL, tokenAddr, err := normalizeLendInputs(AaveLendRequest{ + Verb: req.Verb, + Chain: req.Chain, + Asset: req.Asset, + AmountBaseUnits: req.AmountBaseUnits, + Sender: req.Sender, + Recipient: req.Recipient, + OnBehalfOf: req.OnBehalfOf, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + }) + if err != nil { + return execution.Action{}, err + } + + marketID, err := normalizeMorphoMarketID(req.MarketID) + if err != nil { + return execution.Action{}, err + } + market, err := fetchMorphoMarketByID(ctx, req.Chain.EVMChainID, marketID) + if err != nil { + return execution.Action{}, err + } + if !strings.EqualFold(strings.TrimSpace(market.LoanAsset.Address), tokenAddr.Hex()) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "selected morpho market loan token does not match --asset") + } + if strings.TrimSpace(market.Morpho.Address) == "" || !common.IsHexAddress(market.Morpho.Address) { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "morpho market missing executable morpho contract address") + } + if strings.TrimSpace(market.Oracle.Address) == "" || !common.IsHexAddress(market.Oracle.Address) { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "morpho market missing oracle address") + } + if strings.TrimSpace(market.IRM) == "" || !common.IsHexAddress(market.IRM) { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "morpho market missing irm address") + } + if market.CollateralAsset == nil || !common.IsHexAddress(market.CollateralAsset.Address) { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "morpho market missing collateral token address") + } + lltv, ok := new(big.Int).SetString(strings.TrimSpace(market.LLTV), 10) + if !ok || lltv.Sign() <= 0 { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "morpho market returned invalid lltv") + } + + morphoAddr := common.HexToAddress(market.Morpho.Address) + loanToken := common.HexToAddress(market.LoanAsset.Address) + params := morphoMarketParamsABI{ + LoanToken: loanToken, + CollateralToken: common.HexToAddress(market.CollateralAsset.Address), + Oracle: common.HexToAddress(market.Oracle.Address), + IRM: common.HexToAddress(market.IRM), + LLTV: lltv, + } + + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + defer client.Close() + + action := execution.NewAction(execution.NewActionID(), "lend_"+verb, req.Chain.CAIP2, execution.Constraints{Simulate: req.Simulate}) + action.Provider = "morpho" + action.FromAddress = sender.Hex() + action.ToAddress = recipient.Hex() + action.InputAmount = amount.String() + action.Metadata = map[string]any{ + "protocol": "morpho", + "asset_id": req.Asset.AssetID, + "market_id": marketID, + "loan_token": loanToken.Hex(), + "collateral_token": params.CollateralToken.Hex(), + "oracle": params.Oracle.Hex(), + "irm": params.IRM.Hex(), + "lltv": lltv.String(), + "morpho_address": morphoAddr.Hex(), + "on_behalf_of": onBehalfOf.Hex(), + "recipient": recipient.Hex(), + "lending_action": verb, + "market_loan_symbol": strings.ToUpper(strings.TrimSpace(market.LoanAsset.Symbol)), + "market_collat_symbol": strings.ToUpper(strings.TrimSpace(market.CollateralAsset.Symbol)), + } + + zero := big.NewInt(0) + switch verb { + case string(AaveVerbSupply): + if err := appendApprovalIfNeeded(ctx, client, &action, req.Chain.CAIP2, rpcURL, loanToken, sender, morphoAddr, amount, "Approve token for Morpho supply"); err != nil { + return execution.Action{}, err + } + data, err := morphoBlueABI.Pack("supply", params, amount, zero, onBehalfOf, []byte{}) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack morpho supply calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "morpho-supply", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Supply asset to Morpho market", + Target: morphoAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + case string(AaveVerbWithdraw): + data, err := morphoBlueABI.Pack("withdraw", params, amount, zero, onBehalfOf, recipient) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack morpho withdraw calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "morpho-withdraw", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Withdraw supplied assets from Morpho market", + Target: morphoAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + case string(AaveVerbBorrow): + data, err := morphoBlueABI.Pack("borrow", params, amount, zero, onBehalfOf, recipient) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack morpho borrow calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "morpho-borrow", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Borrow asset from Morpho market", + Target: morphoAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + case string(AaveVerbRepay): + if err := appendApprovalIfNeeded(ctx, client, &action, req.Chain.CAIP2, rpcURL, loanToken, sender, morphoAddr, amount, "Approve token for Morpho repay"); err != nil { + return execution.Action{}, err + } + data, err := morphoBlueABI.Pack("repay", params, amount, zero, onBehalfOf, []byte{}) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack morpho repay calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "morpho-repay", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Repay borrowed assets in Morpho market", + Target: morphoAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + default: + return execution.Action{}, clierr.New(clierr.CodeUsage, "unsupported lend action verb") + } + + return action, nil +} + +func normalizeMorphoMarketID(marketID string) (string, error) { + clean := strings.TrimSpace(marketID) + if clean == "" { + return "", clierr.New(clierr.CodeUsage, "morpho lend execution requires --market-id") + } + if !strings.HasPrefix(clean, "0x") && !strings.HasPrefix(clean, "0X") { + return "", clierr.New(clierr.CodeUsage, "morpho --market-id must be a 0x-prefixed bytes32 value") + } + raw := strings.TrimPrefix(strings.TrimPrefix(clean, "0x"), "0X") + if len(raw) != 64 { + return "", clierr.New(clierr.CodeUsage, "morpho --market-id must be a 32-byte hex value") + } + if _, err := hex.DecodeString(raw); err != nil { + return "", clierr.New(clierr.CodeUsage, "morpho --market-id must be valid hex") + } + return "0x" + strings.ToLower(raw), nil +} + +func fetchMorphoMarketByID(ctx context.Context, chainID int64, marketID string) (struct { + UniqueKey string `json:"uniqueKey"` + IRM string `json:"irmAddress"` + LLTV string `json:"lltv"` + Morpho struct { + Address string `json:"address"` + } `json:"morphoBlue"` + Oracle struct { + Address string `json:"address"` + } `json:"oracle"` + LoanAsset struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + Chain struct { + ID int64 `json:"id"` + } `json:"chain"` + } `json:"loanAsset"` + CollateralAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + } `json:"collateralAsset"` +}, error) { + var market struct { + UniqueKey string `json:"uniqueKey"` + IRM string `json:"irmAddress"` + LLTV string `json:"lltv"` + Morpho struct { + Address string `json:"address"` + } `json:"morphoBlue"` + Oracle struct { + Address string `json:"address"` + } `json:"oracle"` + LoanAsset struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + Chain struct { + ID int64 `json:"id"` + } `json:"chain"` + } `json:"loanAsset"` + CollateralAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + } `json:"collateralAsset"` + } + + body, err := json.Marshal(map[string]any{ + "query": morphoMarketByIDQuery, + "variables": map[string]any{ + "chain": chainID, + "key": marketID, + }, + }) + if err != nil { + return market, clierr.Wrap(clierr.CodeInternal, "marshal morpho market lookup query", err) + } + + client := httpx.New(10*time.Second, 0) + var resp morphoMarketByIDResponse + if _, err := httpx.DoBodyJSON(ctx, client, http.MethodPost, morphoGraphQLEndpoint, body, nil, &resp); err != nil { + return market, err + } + if len(resp.Errors) > 0 { + return market, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("morpho graphql error: %s", resp.Errors[0].Message)) + } + if len(resp.Data.Markets.Items) == 0 { + return market, clierr.New(clierr.CodeUsage, "morpho market-id not found for selected chain") + } + return resp.Data.Markets.Items[0], nil +} + +var morphoBlueABI = mustPlannerABI(registry.MorphoBlueABI) diff --git a/internal/execution/planner/morpho_test.go b/internal/execution/planner/morpho_test.go new file mode 100644 index 0000000..c982a0d --- /dev/null +++ b/internal/execution/planner/morpho_test.go @@ -0,0 +1,101 @@ +package planner + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ggonzalez94/defi-cli/internal/id" +) + +func TestBuildMorphoLendActionSupply(t *testing.T) { + rpc := newPlannerRPCServer(t, big.NewInt(0)) + defer rpc.Close() + morpho := newMorphoGraphQLServer(t) + defer morpho.Close() + + prev := morphoGraphQLEndpoint + morphoGraphQLEndpoint = morpho.URL + t.Cleanup(func() { morphoGraphQLEndpoint = prev }) + + chain, err := id.ParseChain("ethereum") + if err != nil { + t.Fatalf("parse chain: %v", err) + } + asset, err := id.ParseAsset("USDC", chain) + if err != nil { + t.Fatalf("parse asset: %v", err) + } + + action, err := BuildMorphoLendAction(context.Background(), MorphoLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + MarketID: "0x64d65c9a2d91c36d56fbc42d69e979335320169b3df63bf92789e2c8883fcc64", + AmountBaseUnits: "1000000", + Sender: "0x00000000000000000000000000000000000000AA", + Recipient: "0x00000000000000000000000000000000000000BB", + Simulate: true, + RPCURL: rpc.URL, + }) + if err != nil { + t.Fatalf("BuildMorphoLendAction failed: %v", err) + } + if action.IntentType != "lend_supply" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + if action.Provider != "morpho" { + t.Fatalf("unexpected provider: %s", action.Provider) + } + if len(action.Steps) != 2 { + t.Fatalf("expected approval + lend steps, got %d", len(action.Steps)) + } + if action.Steps[0].Type != "approval" { + t.Fatalf("expected first step approval, got %s", action.Steps[0].Type) + } + if action.Steps[1].Type != "lend_call" { + t.Fatalf("expected second step lend_call, got %s", action.Steps[1].Type) + } + if action.Steps[1].Target != "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" { + t.Fatalf("unexpected morpho target: %s", action.Steps[1].Target) + } +} + +func TestBuildMorphoLendActionRequiresMarketID(t *testing.T) { + chain, _ := id.ParseChain("ethereum") + asset, _ := id.ParseAsset("USDC", chain) + _, err := BuildMorphoLendAction(context.Background(), MorphoLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: "0x00000000000000000000000000000000000000AA", + }) + if err == nil { + t.Fatal("expected missing market id error") + } +} + +func newMorphoGraphQLServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": { + "markets": { + "items": [{ + "uniqueKey": "0x64d65c9a2d91c36d56fbc42d69e979335320169b3df63bf92789e2c8883fcc64", + "irmAddress": "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC", + "lltv": "860000000000000000", + "morphoBlue": {"address":"0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"}, + "oracle": {"address":"0xA6D6950c9F177F1De7f7757FB33539e3Ec60182a"}, + "loanAsset": {"address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","symbol":"USDC","decimals":6,"chain":{"id":1}}, + "collateralAsset": {"address":"0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf","symbol":"cbBTC","decimals":8} + }] + } + } + }`)) + })) +} diff --git a/internal/model/types.go b/internal/model/types.go index 413e2fa..b17aae0 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -181,18 +181,20 @@ type BridgeDetails struct { } type BridgeQuote struct { - Provider string `json:"provider"` - FromChainID string `json:"from_chain_id"` - ToChainID string `json:"to_chain_id"` - FromAssetID string `json:"from_asset_id"` - ToAssetID string `json:"to_asset_id"` - InputAmount AmountInfo `json:"input_amount"` - EstimatedOut AmountInfo `json:"estimated_out"` - EstimatedFeeUSD float64 `json:"estimated_fee_usd"` - EstimatedTimeS int64 `json:"estimated_time_s"` - Route string `json:"route"` - SourceURL string `json:"source_url,omitempty"` - FetchedAt string `json:"fetched_at"` + Provider string `json:"provider"` + FromChainID string `json:"from_chain_id"` + ToChainID string `json:"to_chain_id"` + FromAssetID string `json:"from_asset_id"` + ToAssetID string `json:"to_asset_id"` + InputAmount AmountInfo `json:"input_amount"` + FromAmountForGas string `json:"from_amount_for_gas,omitempty"` + EstimatedDestinationNative *AmountInfo `json:"estimated_destination_native,omitempty"` + EstimatedOut AmountInfo `json:"estimated_out"` + EstimatedFeeUSD float64 `json:"estimated_fee_usd"` + EstimatedTimeS int64 `json:"estimated_time_s"` + Route string `json:"route"` + SourceURL string `json:"source_url,omitempty"` + FetchedAt string `json:"fetched_at"` } type SwapQuote struct { diff --git a/internal/providers/across/client.go b/internal/providers/across/client.go index 5e6dfad..1642d74 100644 --- a/internal/providers/across/client.go +++ b/internal/providers/across/client.go @@ -3,13 +3,16 @@ package across import ( "context" "fmt" + "math/big" "net/http" "net/url" "strconv" "strings" "time" + "github.com/ethereum/go-ethereum/common" clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" "github.com/ggonzalez94/defi-cli/internal/httpx" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" @@ -35,6 +38,8 @@ func (c *Client) Info() model.ProviderInfo { RequiresKey: false, Capabilities: []string{ "bridge.quote", + "bridge.plan", + "bridge.execute", }, } } @@ -111,6 +116,149 @@ func (c *Client) QuoteBridge(ctx context.Context, req providers.BridgeQuoteReque }, nil } +type swapApprovalResponse struct { + ApprovalTxns []struct { + ChainID int64 `json:"chainId"` + To string `json:"to"` + Data string `json:"data"` + Value string `json:"value"` + } `json:"approvalTxns"` + SwapTx struct { + ChainID int64 `json:"chainId"` + To string `json:"to"` + Data string `json:"data"` + Value string `json:"value"` + } `json:"swapTx"` + MinOutputAmount string `json:"minOutputAmount"` + ExpectedOutputAmount string `json:"expectedOutputAmount"` + ExpectedFillTime int64 `json:"expectedFillTime"` + Steps struct { + Bridge struct { + OutputAmount string `json:"outputAmount"` + } `json:"bridge"` + } `json:"steps"` + Fees struct { + Total struct { + AmountUSD string `json:"amountUsd"` + } `json:"total"` + } `json:"fees"` +} + +func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuoteRequest, opts providers.BridgeExecutionOptions) (execution.Action, error) { + sender := strings.TrimSpace(opts.Sender) + if sender == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution requires sender address") + } + if !common.IsHexAddress(sender) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution sender must be a valid EVM address") + } + recipient := strings.TrimSpace(opts.Recipient) + if recipient == "" { + recipient = sender + } + if !common.IsHexAddress(recipient) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution recipient must be a valid EVM address") + } + if !common.IsHexAddress(req.FromAsset.Address) || !common.IsHexAddress(req.ToAsset.Address) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "bridge execution requires ERC20 token addresses for from/to assets") + } + slippageBps := opts.SlippageBps + if slippageBps <= 0 { + slippageBps = 50 + } + if slippageBps >= 10_000 { + return execution.Action{}, clierr.New(clierr.CodeUsage, "slippage bps must be less than 10000") + } + + vals := url.Values{} + vals.Set("amount", req.AmountBaseUnits) + vals.Set("inputToken", req.FromAsset.Address) + vals.Set("outputToken", req.ToAsset.Address) + vals.Set("originChainId", strconv.FormatInt(req.FromChain.EVMChainID, 10)) + vals.Set("destinationChainId", strconv.FormatInt(req.ToChain.EVMChainID, 10)) + vals.Set("depositor", sender) + vals.Set("recipient", recipient) + vals.Set("slippage", formatSlippage(slippageBps)) + + reqURL := c.baseURL + "/swap/approval?" + vals.Encode() + hReq, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "build across execution request", err) + } + var resp swapApprovalResponse + if _, err := c.http.DoJSON(ctx, hReq, &resp); err != nil { + return execution.Action{}, err + } + if strings.TrimSpace(resp.SwapTx.To) == "" || strings.TrimSpace(resp.SwapTx.Data) == "" { + return execution.Action{}, clierr.New(clierr.CodeUnavailable, "across execution response missing swap transaction payload") + } + if resp.SwapTx.ChainID != 0 && resp.SwapTx.ChainID != req.FromChain.EVMChainID { + return execution.Action{}, clierr.New(clierr.CodeActionPlan, "across swap transaction chain does not match source chain") + } + + rpcURL, err := execution.ResolveRPCURL(opts.RPCURL, req.FromChain.EVMChainID) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) + } + + action := execution.NewAction(execution.NewActionID(), "bridge", req.FromChain.CAIP2, execution.Constraints{ + SlippageBps: slippageBps, + Simulate: opts.Simulate, + }) + action.Provider = "across" + action.FromAddress = common.HexToAddress(sender).Hex() + action.ToAddress = common.HexToAddress(recipient).Hex() + action.InputAmount = req.AmountBaseUnits + action.Metadata = map[string]any{ + "to_chain_id": req.ToChain.CAIP2, + "from_asset_id": req.FromAsset.AssetID, + "to_asset_id": req.ToAsset.AssetID, + "route": "across", + } + + for i, approval := range resp.ApprovalTxns { + if strings.TrimSpace(approval.To) == "" || strings.TrimSpace(approval.Data) == "" { + continue + } + if approval.ChainID != 0 && approval.ChainID != req.FromChain.EVMChainID { + continue + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: fmt.Sprintf("approve-bridge-token-%d", i+1), + Type: execution.StepTypeApproval, + Status: execution.StepStatusPending, + ChainID: req.FromChain.CAIP2, + RPCURL: rpcURL, + Description: "Approve across bridge contract for source token", + Target: common.HexToAddress(approval.To).Hex(), + Data: ensureHexPrefix(approval.Data), + Value: normalizeTransactionValue(approval.Value), + }) + } + + swapValue := normalizeTransactionValue(resp.SwapTx.Value) + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "bridge-transfer", + Type: execution.StepTypeBridge, + Status: execution.StepStatusPending, + ChainID: req.FromChain.CAIP2, + RPCURL: rpcURL, + Description: "Bridge transfer via Across", + Target: common.HexToAddress(resp.SwapTx.To).Hex(), + Data: ensureHexPrefix(resp.SwapTx.Data), + Value: swapValue, + ExpectedOutputs: map[string]string{ + "to_amount_min": firstNonEmpty(resp.MinOutputAmount, resp.ExpectedOutputAmount, resp.Steps.Bridge.OutputAmount), + "settlement_provider": "across", + "settlement_status_endpoint": c.baseURL + "/deposit/status", + "settlement_origin_chain": strconv.FormatInt(req.FromChain.EVMChainID, 10), + "settlement_recipient": common.HexToAddress(recipient).Hex(), + "settlement_destination_chain": strconv.FormatInt(req.ToChain.EVMChainID, 10), + }, + }) + return action, nil +} + func checkAmountWithinLimits(amount string, limits map[string]any) bool { min := pickNumberString(limits, "minDeposit", "minLimit") max := pickNumberString(limits, "maxDeposit", "maxLimit") @@ -224,3 +372,42 @@ func toDigits(v string) string { } return trimLeadingZeros(v) } + +func formatSlippage(bps int64) string { + return strconv.FormatFloat(float64(bps)/10000, 'f', 6, 64) +} + +func ensureHexPrefix(v string) string { + clean := strings.TrimSpace(v) + if strings.HasPrefix(clean, "0x") || strings.HasPrefix(clean, "0X") { + return clean + } + return "0x" + clean +} + +func normalizeTransactionValue(v string) string { + clean := strings.TrimSpace(v) + if clean == "" { + return "0" + } + if strings.HasPrefix(clean, "0x") || strings.HasPrefix(clean, "0X") { + n := new(big.Int) + if _, ok := n.SetString(strings.TrimPrefix(strings.TrimPrefix(clean, "0x"), "0X"), 16); ok { + return n.String() + } + return "0" + } + if n, ok := new(big.Int).SetString(clean, 10); ok { + return n.String() + } + return "0" +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/internal/providers/across/client_test.go b/internal/providers/across/client_test.go index cdaa2d6..2b0b023 100644 --- a/internal/providers/across/client_test.go +++ b/internal/providers/across/client_test.go @@ -1,6 +1,16 @@ package across -import "testing" +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ggonzalez94/defi-cli/internal/httpx" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/providers" +) func TestBaseUnitMathHelpers(t *testing.T) { if compareBaseUnits("100", "99") <= 0 { @@ -13,3 +23,65 @@ func TestBaseUnitMathHelpers(t *testing.T) { t.Fatalf("unexpected underflow result: %s", out) } } + +func TestBuildBridgeAction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/swap/approval": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "approvalTxns": [{ + "chainId": 1, + "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "data": "0x095ea7b3", + "value": "0" + }], + "swapTx": { + "chainId": 1, + "to": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", + "data": "0xad5425c6", + "value": "0x0" + }, + "minOutputAmount": "990000", + "expectedOutputAmount": "995000", + "expectedFillTime": 5 + }`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0)) + c.baseURL = srv.URL + fromChain, _ := id.ParseChain("ethereum") + toChain, _ := id.ParseChain("base") + fromAsset, _ := id.ParseAsset("USDC", fromChain) + toAsset, _ := id.ParseAsset("USDC", toChain) + + action, err := c.BuildBridgeAction(context.Background(), providers.BridgeQuoteRequest{ + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: "1000000", + AmountDecimal: "1", + }, providers.BridgeExecutionOptions{ + Sender: "0x00000000000000000000000000000000000000AA", + Recipient: "0x00000000000000000000000000000000000000BB", + SlippageBps: 50, + Simulate: true, + }) + if err != nil { + t.Fatalf("BuildBridgeAction failed: %v", err) + } + if action.Provider != "across" { + t.Fatalf("unexpected provider: %s", action.Provider) + } + if len(action.Steps) != 2 { + t.Fatalf("expected approval + bridge steps, got %d", len(action.Steps)) + } + if action.Steps[1].ExpectedOutputs["settlement_provider"] != "across" { + t.Fatalf("expected across settlement provider, got %q", action.Steps[1].ExpectedOutputs["settlement_provider"]) + } +} diff --git a/internal/providers/lifi/client.go b/internal/providers/lifi/client.go index 3c8b87c..cf672fd 100644 --- a/internal/providers/lifi/client.go +++ b/internal/providers/lifi/client.go @@ -47,6 +47,7 @@ func (c *Client) Info() model.ProviderInfo { } type quoteResponse struct { + ID string `json:"id"` Estimate struct { ToAmount string `json:"toAmount"` ToAmountMin string `json:"toAmountMin"` @@ -60,9 +61,11 @@ type quoteResponse struct { ExecutionDuration int64 `json:"executionDuration"` } `json:"estimate"` ToolDetails struct { + Key string `json:"key"` Name string `json:"name"` } `json:"toolDetails"` - Tool string `json:"tool"` + Tool string `json:"tool"` + IncludedSteps []quoteStep `json:"includedSteps"` TransactionRequest struct { To string `json:"to"` From string `json:"from"` @@ -74,7 +77,25 @@ type quoteResponse struct { } `json:"transactionRequest"` } +type quoteStep struct { + Action struct { + ToChainID int64 `json:"toChainId"` + ToToken struct { + Address string `json:"address"` + Decimals int `json:"decimals"` + } `json:"toToken"` + } `json:"action"` + Estimate struct { + ToAmount string `json:"toAmount"` + } `json:"estimate"` +} + func (c *Client) QuoteBridge(ctx context.Context, req providers.BridgeQuoteRequest) (model.BridgeQuote, error) { + fromAmountForGas, err := normalizeOptionalBaseUnits(req.FromAmountForGas) + if err != nil { + return model.BridgeQuote{}, clierr.Wrap(clierr.CodeUsage, "parse bridge gas reserve amount", err) + } + vals := url.Values{} vals.Set("fromChain", strconv.FormatInt(req.FromChain.EVMChainID, 10)) vals.Set("toChain", strconv.FormatInt(req.ToChain.EVMChainID, 10)) @@ -83,6 +104,9 @@ func (c *Client) QuoteBridge(ctx context.Context, req providers.BridgeQuoteReque vals.Set("fromAmount", req.AmountBaseUnits) vals.Set("slippage", "0.005") vals.Set("fromAddress", "0x0000000000000000000000000000000000000001") + if fromAmountForGas != "" { + vals.Set("fromAmountForGas", fromAmountForGas) + } url := c.baseURL + "/quote?" + vals.Encode() hReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -112,6 +136,8 @@ func (c *Client) QuoteBridge(ctx context.Context, req providers.BridgeQuoteReque route = fmt.Sprintf("%s->%s", req.FromChain.Slug, req.ToChain.Slug) } + nativeEstimate := destinationNativeEstimate(resp.IncludedSteps, req.ToChain.EVMChainID) + return model.BridgeQuote{ Provider: "lifi", FromChainID: req.FromChain.CAIP2, @@ -123,6 +149,8 @@ func (c *Client) QuoteBridge(ctx context.Context, req providers.BridgeQuoteReque AmountDecimal: req.AmountDecimal, Decimals: req.FromAsset.Decimals, }, + FromAmountForGas: fromAmountForGas, + EstimatedDestinationNative: nativeEstimate, EstimatedOut: model.AmountInfo{ AmountBaseUnits: resp.Estimate.ToAmount, AmountDecimal: id.FormatDecimalCompat(resp.Estimate.ToAmount, req.ToAsset.Decimals), @@ -161,6 +189,10 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot if slippageBps >= 10_000 { return execution.Action{}, clierr.New(clierr.CodeUsage, "slippage bps must be less than 10000") } + fromAmountForGas, err := normalizeOptionalBaseUnits(firstNonEmpty(opts.FromAmountForGas, req.FromAmountForGas)) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "parse bridge gas reserve amount", err) + } vals := url.Values{} vals.Set("fromChain", strconv.FormatInt(req.FromChain.EVMChainID, 10)) @@ -171,6 +203,9 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot vals.Set("slippage", formatSlippage(slippageBps)) vals.Set("fromAddress", sender) vals.Set("toAddress", recipient) + if fromAmountForGas != "" { + vals.Set("fromAmountForGas", fromAmountForGas) + } reqURL := c.baseURL + "/quote?" + vals.Encode() hReq, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) @@ -192,6 +227,7 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } + nativeEstimate := destinationNativeEstimate(resp.IncludedSteps, req.ToChain.EVMChainID) action := execution.NewAction(execution.NewActionID(), "bridge", req.FromChain.CAIP2, execution.Constraints{ SlippageBps: slippageBps, @@ -208,6 +244,12 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot "route": firstNonEmpty(resp.ToolDetails.Name, resp.Tool), "approval_spender": resp.Estimate.ApprovalAddress, } + if fromAmountForGas != "" { + action.Metadata["from_amount_for_gas"] = fromAmountForGas + } + if nativeEstimate != nil { + action.Metadata["estimated_destination_native_base_units"] = nativeEstimate.AmountBaseUnits + } if shouldAddApproval(req.FromAsset.Address, resp.Estimate.ApprovalAddress) { if !common.IsHexAddress(resp.Estimate.ApprovalAddress) { @@ -265,6 +307,7 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeActionPlan, "parse bridge transaction value", err) } + statusEndpoint := strings.TrimSuffix(c.baseURL, "/") + "/status" action.Steps = append(action.Steps, execution.ActionStep{ StepID: "bridge-transfer", Type: execution.StepTypeBridge, @@ -276,9 +319,18 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot Data: ensureHexPrefix(resp.TransactionRequest.Data), Value: bridgeValue, ExpectedOutputs: map[string]string{ - "to_amount_min": firstNonEmpty(resp.Estimate.ToAmountMin, resp.Estimate.ToAmount), + "to_amount_min": firstNonEmpty(resp.Estimate.ToAmountMin, resp.Estimate.ToAmount), + "settlement_provider": "lifi", + "settlement_status_endpoint": statusEndpoint, + "settlement_bridge": firstNonEmpty(resp.ToolDetails.Key, resp.Tool), + "settlement_from_chain": strconv.FormatInt(req.FromChain.EVMChainID, 10), + "settlement_to_chain": strconv.FormatInt(req.ToChain.EVMChainID, 10), + "settlement_quote_response_id": resp.ID, }, }) + if nativeEstimate != nil { + action.Steps[len(action.Steps)-1].ExpectedOutputs["destination_native_estimated"] = nativeEstimate.AmountBaseUnits + } return action, nil } @@ -302,6 +354,54 @@ func shouldAddApproval(tokenAddr, spender string) bool { return !strings.EqualFold(strings.TrimSpace(tokenAddr), "0x0000000000000000000000000000000000000000") } +func destinationNativeEstimate(steps []quoteStep, destinationChainID int64) *model.AmountInfo { + for _, step := range steps { + if step.Action.ToChainID != destinationChainID { + continue + } + addr := strings.TrimSpace(step.Action.ToToken.Address) + if !isNativeTokenAddress(addr) { + continue + } + amount := strings.TrimSpace(step.Estimate.ToAmount) + if amount == "" { + continue + } + decimals := step.Action.ToToken.Decimals + if decimals <= 0 { + decimals = 18 + } + return &model.AmountInfo{ + AmountBaseUnits: amount, + AmountDecimal: id.FormatDecimalCompat(amount, decimals), + Decimals: decimals, + } + } + return nil +} + +func isNativeTokenAddress(addr string) bool { + if strings.EqualFold(addr, "0x0000000000000000000000000000000000000000") { + return true + } + return strings.EqualFold(addr, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") +} + +func normalizeOptionalBaseUnits(v string) (string, error) { + clean := strings.TrimSpace(v) + if clean == "" { + return "", nil + } + amount, ok := new(big.Int).SetString(clean, 10) + if !ok { + return "", fmt.Errorf("amount must be an integer base-unit value") + } + if amount.Sign() <= 0 { + return "", fmt.Errorf("amount must be greater than zero") + } + return amount.String(), nil +} + func formatSlippage(bps int64) string { return strconv.FormatFloat(float64(bps)/10000, 'f', 6, 64) } diff --git a/internal/providers/lifi/client_test.go b/internal/providers/lifi/client_test.go index 26c1f4d..3268978 100644 --- a/internal/providers/lifi/client_test.go +++ b/internal/providers/lifi/client_test.go @@ -55,6 +55,73 @@ func TestQuoteBridge(t *testing.T) { } } +func TestQuoteBridgeWithFromAmountForGas(t *testing.T) { + var gotFromAmountForGas string + quoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotFromAmountForGas = r.URL.Query().Get("fromAmountForGas") + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{ + "estimate": { + "toAmount": "900000", + "toAmountMin": "890000", + "approvalAddress": "0x0000000000000000000000000000000000000ABC", + "feeCosts": [{"amountUSD":"0.40"}], + "gasCosts": [{"amountUSD":"0.60"}], + "executionDuration": 45 + }, + "toolDetails": {"key":"across","name":"across"}, + "tool": "across", + "includedSteps": [{ + "action": { + "toChainId": 8453, + "toToken": {"address":"0x0000000000000000000000000000000000000000","decimals":18} + }, + "estimate": {"toAmount":"500000000000000"} + }], + "transactionRequest": { + "to": "0x0000000000000000000000000000000000000DDD", + "from": "0x00000000000000000000000000000000000000AA", + "data": "0x1234", + "value": "0x0", + "chainId": 1 + } + }`) + })) + defer quoteServer.Close() + + c := New(httpx.New(2*time.Second, 0)) + c.baseURL = quoteServer.URL + fromChain, _ := id.ParseChain("ethereum") + toChain, _ := id.ParseChain("base") + fromAsset, _ := id.ParseAsset("USDC", fromChain) + toAsset, _ := id.ParseAsset("USDC", toChain) + + quote, err := c.QuoteBridge(context.Background(), providers.BridgeQuoteRequest{ + FromChain: fromChain, + ToChain: toChain, + FromAsset: fromAsset, + ToAsset: toAsset, + AmountBaseUnits: "1000000", + AmountDecimal: "1", + FromAmountForGas: "100000", + }) + if err != nil { + t.Fatalf("QuoteBridge failed: %v", err) + } + if gotFromAmountForGas != "100000" { + t.Fatalf("expected fromAmountForGas query param, got %q", gotFromAmountForGas) + } + if quote.FromAmountForGas != "100000" { + t.Fatalf("expected quote from_amount_for_gas=100000, got %q", quote.FromAmountForGas) + } + if quote.EstimatedDestinationNative == nil { + t.Fatal("expected destination native estimate to be populated") + } + if quote.EstimatedDestinationNative.AmountBaseUnits != "500000000000000" { + t.Fatalf("unexpected destination native estimate: %s", quote.EstimatedDestinationNative.AmountBaseUnits) + } +} + func TestBuildBridgeActionAddsApprovalStep(t *testing.T) { quoteServer := newLiFiQuoteServer(t, "0x0000000000000000000000000000000000000ABC") defer quoteServer.Close() @@ -98,6 +165,12 @@ func TestBuildBridgeActionAddsApprovalStep(t *testing.T) { if action.Steps[1].Type != "bridge_send" { t.Fatalf("expected second step bridge_send, got %s", action.Steps[1].Type) } + if action.Steps[1].ExpectedOutputs["settlement_provider"] != "lifi" { + t.Fatalf("expected settlement provider lifi, got %q", action.Steps[1].ExpectedOutputs["settlement_provider"]) + } + if action.Steps[1].ExpectedOutputs["settlement_status_endpoint"] == "" { + t.Fatal("expected settlement status endpoint metadata") + } } func TestBuildBridgeActionSkipsApprovalWhenSpenderMissing(t *testing.T) { @@ -141,6 +214,7 @@ func newLiFiQuoteServer(t *testing.T, approvalAddress string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintf(w, `{ + "id": "quote-id:0", "estimate": { "toAmount": "950000", "toAmountMin": "940000", @@ -149,8 +223,9 @@ func newLiFiQuoteServer(t *testing.T, approvalAddress string) *httptest.Server { "gasCosts": [{"amountUSD":"0.60"}], "executionDuration": 120 }, - "toolDetails": {"name":"across"}, + "toolDetails": {"key":"across","name":"across"}, "tool": "across", + "includedSteps": [], "transactionRequest": { "to": "0x0000000000000000000000000000000000000DDD", "from": "0x00000000000000000000000000000000000000AA", diff --git a/internal/providers/morpho/client.go b/internal/providers/morpho/client.go index 82c5316..72e3383 100644 --- a/internal/providers/morpho/client.go +++ b/internal/providers/morpho/client.go @@ -40,6 +40,8 @@ func (c *Client) Info() model.ProviderInfo { "lend.markets", "lend.rates", "yield.opportunities", + "lend.plan", + "lend.execute", }, } } diff --git a/internal/providers/types.go b/internal/providers/types.go index f39e3b6..599b227 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -60,12 +60,13 @@ type BridgeDataProvider interface { } type BridgeQuoteRequest struct { - FromChain id.Chain - ToChain id.Chain - FromAsset id.Asset - ToAsset id.Asset - AmountBaseUnits string - AmountDecimal string + FromChain id.Chain + ToChain id.Chain + FromAsset id.Asset + ToAsset id.Asset + AmountBaseUnits string + AmountDecimal string + FromAmountForGas string } type BridgeListRequest struct { @@ -79,11 +80,12 @@ type BridgeDetailsRequest struct { } type BridgeExecutionOptions struct { - Sender string - Recipient string - SlippageBps int64 - Simulate bool - RPCURL string + Sender string + Recipient string + SlippageBps int64 + Simulate bool + RPCURL string + FromAmountForGas string } type SwapProvider interface { diff --git a/internal/registry/execution_data.go b/internal/registry/execution_data.go index d3cddb1..8d74d1b 100644 --- a/internal/registry/execution_data.go +++ b/internal/registry/execution_data.go @@ -30,7 +30,12 @@ func TaikoSwapContracts(chainID int64) (quoterV2 string, router string, ok bool) // Canonical Aave V3 PoolAddressesProvider contracts used by planners. var aavePoolAddressProviderByChainID = map[int64]string{ - 1: "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e", + 1: "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e", // Ethereum + 10: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Optimism + 137: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Polygon + 8453: "0xe20fCBdBfFC4Dd138cE8b2E6FBb6CB49777ad64D", // Base + 42161: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Arbitrum + 43114: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Avalanche } func AavePoolAddressProvider(chainID int64) (string, bool) { @@ -68,4 +73,11 @@ const ( AaveRewardsABI = `[ {"name":"claimRewards","type":"function","stateMutability":"nonpayable","inputs":[{"name":"assets","type":"address[]"},{"name":"amount","type":"uint256"},{"name":"to","type":"address"},{"name":"reward","type":"address"}],"outputs":[{"name":"","type":"uint256"}]} ]` + + MorphoBlueABI = `[ + {"name":"supply","type":"function","stateMutability":"nonpayable","inputs":[{"name":"marketParams","type":"tuple","components":[{"name":"loanToken","type":"address"},{"name":"collateralToken","type":"address"},{"name":"oracle","type":"address"},{"name":"irm","type":"address"},{"name":"lltv","type":"uint256"}]},{"name":"assets","type":"uint256"},{"name":"shares","type":"uint256"},{"name":"onBehalf","type":"address"},{"name":"data","type":"bytes"}],"outputs":[{"name":"assetsSupplied","type":"uint256"},{"name":"sharesSupplied","type":"uint256"}]}, + {"name":"withdraw","type":"function","stateMutability":"nonpayable","inputs":[{"name":"marketParams","type":"tuple","components":[{"name":"loanToken","type":"address"},{"name":"collateralToken","type":"address"},{"name":"oracle","type":"address"},{"name":"irm","type":"address"},{"name":"lltv","type":"uint256"}]},{"name":"assets","type":"uint256"},{"name":"shares","type":"uint256"},{"name":"onBehalf","type":"address"},{"name":"receiver","type":"address"}],"outputs":[{"name":"assetsWithdrawn","type":"uint256"},{"name":"sharesWithdrawn","type":"uint256"}]}, + {"name":"borrow","type":"function","stateMutability":"nonpayable","inputs":[{"name":"marketParams","type":"tuple","components":[{"name":"loanToken","type":"address"},{"name":"collateralToken","type":"address"},{"name":"oracle","type":"address"},{"name":"irm","type":"address"},{"name":"lltv","type":"uint256"}]},{"name":"assets","type":"uint256"},{"name":"shares","type":"uint256"},{"name":"onBehalf","type":"address"},{"name":"receiver","type":"address"}],"outputs":[{"name":"assetsBorrowed","type":"uint256"},{"name":"sharesBorrowed","type":"uint256"}]}, + {"name":"repay","type":"function","stateMutability":"nonpayable","inputs":[{"name":"marketParams","type":"tuple","components":[{"name":"loanToken","type":"address"},{"name":"collateralToken","type":"address"},{"name":"oracle","type":"address"},{"name":"irm","type":"address"},{"name":"lltv","type":"uint256"}]},{"name":"assets","type":"uint256"},{"name":"shares","type":"uint256"},{"name":"onBehalf","type":"address"},{"name":"data","type":"bytes"}],"outputs":[{"name":"assetsRepaid","type":"uint256"},{"name":"sharesRepaid","type":"uint256"}]} + ]` ) diff --git a/internal/registry/execution_data_test.go b/internal/registry/execution_data_test.go index 02be5b1..f880720 100644 --- a/internal/registry/execution_data_test.go +++ b/internal/registry/execution_data_test.go @@ -22,12 +22,15 @@ func TestTaikoSwapContracts(t *testing.T) { } func TestAavePoolAddressProvider(t *testing.T) { - addr, ok := AavePoolAddressProvider(1) - if !ok || addr == "" { - t.Fatal("expected aave pool address provider for chain 1") + cases := []int64{1, 8453, 42161, 10, 137, 43114} + for _, chainID := range cases { + addr, ok := AavePoolAddressProvider(chainID) + if !ok || addr == "" { + t.Fatalf("expected aave pool address provider for chain %d", chainID) + } } - if _, ok := AavePoolAddressProvider(8453); ok { - t.Fatal("did not expect aave pool address provider default for base") + if _, ok := AavePoolAddressProvider(167000); ok { + t.Fatal("did not expect aave pool address provider for unsupported chain") } } @@ -39,6 +42,7 @@ func TestExecutionABIConstantsParse(t *testing.T) { AavePoolAddressProviderABI, AavePoolABI, AaveRewardsABI, + MorphoBlueABI, } for _, raw := range abis { if _, err := abi.JSON(strings.NewReader(raw)); err != nil { From ae26ad9141c8d9995e94fe9d4a470aadac935275 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Tue, 24 Feb 2026 23:21:10 -0400 Subject: [PATCH 03/18] execution: unify action builders and drop --yes confirmation --- AGENTS.md | 6 +- CHANGELOG.md | 3 + README.md | 3 +- docs/act-execution-design.md | 654 +++++------------- internal/app/approvals_command.go | 23 +- internal/app/bridge_execution_commands.go | 65 +- internal/app/lend_execution_commands.go | 76 +- internal/app/rewards_command.go | 108 +-- internal/app/runner.go | 86 +-- internal/app/runner_actions_test.go | 14 +- internal/execution/actionbuilder/registry.go | 224 ++++++ .../execution/actionbuilder/registry_test.go | 113 +++ 12 files changed, 658 insertions(+), 717 deletions(-) create mode 100644 internal/execution/actionbuilder/registry.go create mode 100644 internal/execution/actionbuilder/registry_test.go diff --git a/AGENTS.md b/AGENTS.md index b90a070..44fc3eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,10 @@ README.md # user-facing usage + caveats - `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave, Morpho) - `rewards claim|compound plan|run|submit|status` (Aave) - `actions list|status` -- All execution `run` / `submit` commands require `--yes` and can broadcast transactions. +- Execution builder architecture is intentionally split: + - `swap`/`bridge` action construction is provider capability based (`BuildSwapAction` / `BuildBridgeAction`) because route payloads are provider-specific. + - `lend`/`rewards`/`approvals` action construction uses internal planners for deterministic contract-call composition. +- All execution `run` / `submit` commands can broadcast transactions. - LiFi bridge quote/plan/run support optional `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). - Bridge execution status for Across/LiFi waits for destination settlement (`/deposit/status` or `/status`) before marking bridge steps complete. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. @@ -126,6 +129,7 @@ README.md # user-facing usage + caveats - Keep entries concise and action-oriented (what changed for users, not internal refactors unless user impact exists). - On release, move `Unreleased` items into `## [vX.Y.Z] - YYYY-MM-DD` and update compare links at the bottom. - If a section has no updates while editing, use `- None yet.` to keep structure stable. +- Keep README/AGENTS focused on current behavior; track version-to-version deltas in CHANGELOG/release notes instead of adding temporary in-progress migration notes. ## Maintenance note diff --git a/CHANGELOG.md b/CHANGELOG.md index bce4239..0824252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ Format: - Aave execution registry defaults now include PoolAddressesProvider mappings for Base, Arbitrum, Optimism, Polygon, and Avalanche in addition to Ethereum. - Execution `run`/`submit` commands now propagate command timeout/cancel context through on-chain execution. - Morpho lend execution now requires explicit `--market-id` to avoid ambiguous market selection. +- Execution `run`/`submit` commands no longer require `--yes`; command intent now gates execution. +- Unified execution action-construction dispatch under a shared ActionBuilder registry while preserving existing command semantics. ### Fixed - Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. @@ -46,6 +48,7 @@ Format: - Documented bridge/lend/rewards/approvals execution flows, signer env inputs, command behavior, and exit codes in `README.md`. - Updated `AGENTS.md` with expanded execution command coverage and caveats. - Updated `docs/act-execution-design.md` implementation status to reflect the shipped Phase 2 surface. +- Clarified execution builder architecture split (provider-backed route builders for swap/bridge vs internal planners for lend/rewards/approvals) in `AGENTS.md` and execution design docs. ### Security - None yet. diff --git a/README.md b/README.md index c60301e..1182449 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,6 @@ defi swap run \ --to-asset WETH \ --amount 1000000 \ --from-address 0xYourEOA \ - --yes \ --results-only ``` @@ -250,7 +249,7 @@ providers: - LiFi bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. - Across bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. - LiFi bridge quote/plan/run support `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). -- All `run` / `submit` execution commands require `--yes` and will broadcast signed transactions. +- All `run` / `submit` execution commands will broadcast signed transactions. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. - Provider/protocol selection is explicit for multi-provider flows; pass `--provider` or `--protocol` (no implicit defaults). diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index 6676e9c..6df6e02 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -1,578 +1,306 @@ -# Execution ("act") Design for `defi-cli` +# Execution Component Design (`plan|run|submit|status`) -Status: Phase 2 Implemented (swap/bridge/lend/rewards/approvals execution) -Author: CLI architecture proposal -Last Updated: 2026-02-23 +Status: Implemented (v1) +Last Updated: 2026-02-24 +Scope: Current implementation in this branch (not a forward-looking proposal) -## Implementation Status (Current) +## 1. Purpose -Implemented in this repository: +`defi-cli` started as read-only retrieval. The execution component adds safe transaction workflows while preserving the existing CLI contract: -- `swap plan|run|submit|status` command family -- `bridge plan|run|submit|status` (LiFi execution planner) -- `approvals plan|run|submit|status` -- `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave) -- `rewards claim|compound plan|run|submit|status` (Aave) -- `actions list|status` action inspection commands -- Local signer backend (`env|file|keystore`) with signer abstraction -- Sqlite-backed action persistence with resumable step states -- TaikoSwap on-chain quote + swap action planning (approval + swap steps) -- Centralized execution registry (`internal/registry`) for endpoints, contract addresses, and ABI fragments -- Simulation, gas estimation, signing, submission, and receipt tracking in execution engine -- Nightly live execution-planning smoke workflow (`.github/workflows/nightly-execution-smoke.yml`) +- Stable envelope output +- Deterministic command semantics +- Clear execution lifecycle and resumability -Not yet implemented from full roadmap: +Execution is integrated inside existing domain commands (for example `swap`, `bridge`, `lend`) instead of a separate top-level `act` namespace. -- Additional signer backends (`safe`, external wallets, hardware) -- Broader execution provider coverage beyond current defaults (TaikoSwap/Across/LiFi/Aave/Morpho) +## 2. Current Execution Surface -## 1. Problem Statement +| Domain | Commands | Selector Requirement | Execution Coverage | +|---|---|---|---| +| Swap | `swap plan|run|submit|status` | `--provider` required | `taikoswap` execution today | +| Bridge | `bridge plan|run|submit|status` | `--provider` required | `across`, `lifi` execution | +| Lend | `lend plan|run|submit|status` | `--protocol` required | `aave`, `morpho` execution (`morpho` requires `--market-id`) | +| Rewards | `rewards plan|run|submit|status` | `--protocol` required | `aave` execution | +| Approvals | `approvals plan|run|submit|status` | no provider selector | native ERC-20 approval execution | +| Action inspection | `actions list|status` | optional `--status` filter | persisted action inspection | -`defi-cli` currently focuses on read-only data retrieval (`quote`, `markets`, `yield`, `bridge details`, etc). -We want to add execution capability ("act") so the CLI can perform transactions across protocols and chains while preserving: +Notes: -- Stable machine-consumable JSON envelope -- Stable exit code behavior -- Deterministic IDs/amount normalization -- Safety and auditability +- Multi-provider commands do not have implicit defaults. Users must pass `--provider` or `--protocol`. -## 2. Goals and Non-Goals +## 3. Architecture Overview -### Goals +### 3.1 Command Integration -- Add a safe, deterministic execution workflow for major user actions. -- Support multi-protocol and multi-chain execution with resumable state. -- Keep provider integrations modular and testable. -- Maintain a clear source of truth for API endpoints, contract addresses, and ABIs. +Execution wiring lives in `internal/app/runner.go` and domain files: -### Non-Goals (v1) +- `internal/app/bridge_execution_commands.go` +- `internal/app/lend_execution_commands.go` +- `internal/app/rewards_command.go` +- `internal/app/approvals_command.go` -- Fully autonomous rebalancing without explicit user invocation. -- Strategy DSL / scheduling engine. -- Support for every protocol from day one. +Design decision: -## 3. Core Architectural Decision +- Keep execution verbs under the same domain as read paths (`swap`, `bridge`, `lend`, etc). -Execution should be modeled as a two-phase workflow: +Tradeoff: -1. `plan`: produce a deterministic action plan (steps, calldata/intents, constraints, expected outputs). -2. `execute`: execute an existing plan and track state until terminal status. +- Better command discoverability and API consistency, but more command wiring complexity in each domain. -This separates route selection from transaction submission, improves reproducibility, and enables audit/resume. +### 3.2 Unified ActionBuilder Registry -## 4. Integration with Existing CLI +Command handlers route action construction through a shared registry: -### 4.1 Command Surface +- `internal/execution/actionbuilder/registry.go` -Execution should be integrated into existing domains instead of a separate top-level `act` namespace. +Registry responsibility: -Examples: +- Resolve provider-backed action builders for swap/bridge. +- Resolve planner-backed action builders for lend/rewards/approvals. +- Keep command-level orchestration (`plan|run|submit|status`) consistent across domains. -- `defi swap quote ...` (existing) -- `defi swap plan ...` (new, plan only) -- `defi swap run ...` (new, plan + execute in one invocation) -- `defi swap submit --plan-id ...` (new, execute an existing saved plan) -- `defi swap status --action-id ...` (new lifecycle tracking) +Design decision: -Equivalent command families should exist for: +- Centralize action-construction dispatch while preserving domain-specific provider/planner implementations. -- `bridge` -- `lend` -- `rewards` (new command group for claim/compound) +Tradeoff: -This keeps the API intuitive by action domain and avoids a catch-all command surface. +- Better consistency and less duplicated dispatch logic in command files, at the cost of one additional abstraction layer. -### 4.2 Code Integration (proposed package layout) +### 3.3 Capability Interfaces -```text -internal/ - execution/ - planner.go # generic planning orchestration - executor.go # execution orchestration - tracker.go # status polling + lifecycle transitions - store.go # action persistence (sqlite) - types.go # ActionPlan, ActionStep, statuses - simulate.go # preflight simulation hooks - signer/ - signer.go # signer interface - local.go # local key signer (v1) - txbuilder.go # EIP-1559 tx assembly - registry/ - loader.go # endpoint/address/abi loader + validation - types.go - providers/ - types.go # extend interfaces for execution capabilities - taikoswap/ # quote + execution planner for taiko swap -``` +Execution providers are opt-in capability interfaces in `internal/providers/types.go`: -`internal/app/runner.go` adds domain-specific subcommands (`swap plan/run/submit/status`, etc) while reusing envelope/output/error handling patterns. +- `SwapExecutionProvider` +- `BridgeExecutionProvider` -### 4.3 Provider Capability Model +Lend/rewards/approvals currently use internal planners in `internal/execution/planner` instead of provider interfaces. -Add capability-specific interfaces (without breaking read-only interfaces): +Design decision: -- `SwapExecutionPlanner` -- `BridgeExecutionPlanner` -- `LendExecutionPlanner` -- `RewardsExecutionPlanner` +- Capability interfaces avoid forcing all providers to implement execution. -Each provider returns provider-specific plan steps in a shared normalized action format. +Tradeoff: -## 5. Source of Truth for Endpoints, Addresses, ABIs +- Mixed architecture today (provider-based for swap/bridge, planner-based for lend/rewards) increases conceptual surface. -### 5.1 Registry Design +### 3.4 Action Model -Track interaction metadata in a versioned registry under repository control: +Canonical action model is in `internal/execution/types.go`: -```text -internal/registry/data/ - providers/ - uniswap.yaml - taikoswap.yaml - across.yaml - lifi.yaml - contracts/ - taiko-mainnet.yaml - ethereum-mainnet.yaml - abis/ - uniswap/ - quoter_v2.json - swap_router_02.json - universal_router.json - erc20/ - erc20_minimal.json - permit2.json -``` +- `Action`: intent metadata + ordered steps +- `ActionStep`: executable transaction step +- `Constraints`: execution constraints -### 5.2 What each file tracks +Lifecycle states: -#### Provider endpoint entry +- Action: `planned -> running -> completed|failed` +- Step: `pending -> simulated -> submitted -> confirmed|failed` -- Provider name and version -- Base URLs and path templates (e.g. quote/swap/status endpoints) -- Auth method and env var names -- Supported chains per endpoint -- Rate-limit hints and timeout defaults +Step order is the dependency model (no separate DAG). This keeps execution deterministic and straightforward. -#### Contract entry +### 3.5 Persistence -- `chain_id` (CAIP/EVM ID) -- protocol name -- contract role (router, quoter, factory, permit2, etc) -- address -- ABI reference path -- source verification URL (block explorer / repo) -- metadata (deployed block, notes) +Persistence is in `internal/execution/store.go` (SQLite + file lock): -#### ABI entry +- single `actions` table +- full action JSON blob stored in `payload` +- indexed by `status` and `updated_at` -- Canonical ABI JSON (minimal ABI fragments where possible) -- Optional selector map for validation -- Version/source metadata +Design decision: -### 5.3 Validation Requirements +- JSON blob persistence with a light relational index. -Add validation checks in CI/unit tests: +Tradeoff: -- Address format and non-zero checks -- ABI JSON parse + required method presence -- Registry schema validation -- Provider endpoint presence for declared capabilities +- Easy compatibility/migrations and exact replay of serialized actions, but weaker SQL-level querying of step internals. -Optional integration validation (nightly): +## 4. Command Semantics -- `eth_getCode` non-empty for configured addresses -- dry-run `eth_call` smoke on critical view methods +### 4.1 `plan` -### 5.4 Override Mechanism +- Builds an action and persists it to action store. +- Performs planning-time checks required by each planner/provider (for example allowance reads, route fetches, address resolution). +- Does not broadcast transactions. -Support local override for rapid hotfixes: +### 4.2 `run` -- `DEFI_REGISTRY_PATH=/path/to/registry-overrides` +- Performs plan + execute in one invocation. +- Persists action first, then executes steps. -Precedence: +### 4.3 `submit` -1. CLI flags (if exposed) -2. env override registry -3. bundled registry in repo +- Loads a previously persisted action by `--action-id`. +- Executes remaining steps. -## 6. Forge `cast` Dependency Decision +### 4.4 `status` and `actions` -### 6.1 Recommendation +- Domain `status` commands fetch one action. +- `actions list` gives cross-domain recent actions. +- `actions status` fetches any action by ID. -Do **not** make `cast` a runtime dependency. +## 5. Signing and Key Handling -Reasoning: +Signer abstractions: -- Adds external binary dependency for all users. -- Makes release artifacts less self-contained. -- Process spawning is slower and harder to test deterministically. -- Native Go JSON-RPC and ABI encoding is more portable and CI-friendly. +- Interface: `internal/execution/signer/signer.go` +- Local signer implementation: `internal/execution/signer/local.go` +- Command-level signer setup: `newExecutionSigner(...)` in `internal/app/runner.go` -### 6.2 Where `cast` should be used +Supported backend today: -Use `cast` as developer tooling only: +- `--signer local` only (other backends intentionally not implemented yet) -- `scripts/verify/*.sh` for parity checks -- troubleshooting registry/address issues -- reproducing on-chain call results in docs/tests +Key sources: -## 7. Signer Architecture (v1 local key) +- `--key-source auto|env|file|keystore` +- Environment variables: + - `DEFI_PRIVATE_KEY` + - `DEFI_PRIVATE_KEY_FILE` + - `DEFI_KEYSTORE_PATH` + - `DEFI_KEYSTORE_PASSWORD` + - `DEFI_KEYSTORE_PASSWORD_FILE` -### 7.1 Scope +`auto` precedence in current code: -v1 supports a local key signer only, while keeping the signer layer extensible for: +1. `DEFI_PRIVATE_KEY` +2. `DEFI_PRIVATE_KEY_FILE` +3. `DEFI_KEYSTORE_PATH` (+ password input) -- external wallet providers -- Safe/multisig -- hardware signers -- remote signers +Security controls: -### 7.2 Signer Interface +- strict secret file permissions (`0600` or stricter) for key files and keystore/password files +- optional `--confirm-address` signer guard +- explicit `--from-address` to signer-address match checks in run flows -Use a signer abstraction in `internal/execution/signer/signer.go`: +Design decision: -- `Address() string` -- `SignTx(chainID, tx) -> rawTx` -- `SignMessage(payload) -> signature` (future-proofing) +- Local key signing first, with backend abstraction retained for future expansion. -Execution orchestration consumes only this interface. +Tradeoff: -### 7.3 Local key ingestion (v1) +- Fast delivery and low integration complexity now, but no hardware wallet, Safe, or remote signer support yet. -Avoid requiring private key values in CLI args. Preferred sources: +## 6. Endpoint, Contract, and ABI Management -1. `DEFI_PRIVATE_KEY_FILE` (hex key in file, strict file-permission checks) -2. `DEFI_KEYSTORE_PATH` + `DEFI_KEYSTORE_PASSWORD` or `DEFI_KEYSTORE_PASSWORD_FILE` -3. `DEFI_PRIVATE_KEY` (supported, but discouraged in shell history environments) +Canonical execution metadata currently lives in `internal/registry/execution_data.go`: -Optional explicit flag: +- Provider endpoint constants (for example `LiFiBaseURL`) +- Contract address registries: + - TaikoSwap contracts by chain + - Aave PoolAddressesProvider by chain +- ABI fragments: + - ERC-20 minimal + - TaikoSwap quoter/router + - Aave pool/rewards/provider + - Morpho Blue -- `--key-source env|file|keystore` (for deterministic automation) +Important nuance: -### 7.4 Transaction signing flow +- Not all provider endpoints are centralized there yet (for example Across base URL is in provider code). -For each executable step: +Design decision: -1. Resolve sender address from signer. -2. Fetch nonce (`eth_getTransactionCount`, pending). -3. Build tx params: - - EIP-1559 (`maxFeePerGas`, `maxPriorityFeePerGas`) by default - - `gasLimit` from simulation/estimation with safety multiplier -4. Build unsigned tx from step data (`to`, `data`, `value`, `chainId`). -5. Sign locally. -6. Broadcast (`eth_sendRawTransaction`). -7. Persist tx hash and receipt status. +- Compile-time Go registry values instead of external YAML/JSON loading. -Implementation note: use native Go libraries for EVM tx construction/signing (e.g., go-ethereum transaction types and secp256k1 signing utilities), not shelling out to external binaries. +Tradeoff: -### 7.5 Security controls +- Strong type safety and fewer runtime failure modes, but lower operational flexibility for hotfixing metadata without a release. -- Never print private key material in logs or envelopes. -- Redact signer secrets in errors. -- Validate key/address match before first execution. -- Enforce minimum key file permissions for file-based keys. -- Add `--confirm-address` optional check for CI/ops workflows. +## 7. Execution Engine, Simulation, and Consistency -### 7.6 Agent and automation key handling +Core executor: `internal/execution/executor.go`. -Expected automation pattern: +Per step execution flow: -1. Agent injects key source via environment variables before command execution. -2. CLI resolves signer source via normal precedence (`flags > env > config > defaults`). -3. CLI emits signer address metadata only (never key material). +1. Validate RPC URL, target, and chain match. +2. Optional simulation (`eth_call`) when `--simulate=true`. +3. Gas estimation (`eth_estimateGas`) with configurable multiplier. +4. EIP-1559 fee resolution (suggested or overridden by flags). +5. Nonce resolution from pending state. +6. Local signing and broadcast. +7. Receipt polling until success/failure/timeout. -Recommended usage for agents/CI: +Bridge-specific consistency: -- Use short-lived per-command environment injection. -- Prefer file/keystore based key sources over raw-key env values. -- Set `--confirm-address` for high-safety pipelines. +- For `bridge_send` steps, executor also waits for destination settlement via provider APIs: + - LiFi `/status` + - Across `/deposit/status` +- Settlement metadata is persisted in `step.expected_outputs` (for example destination tx hash, settlement status). -## 8. Command API Design (Draft) +Context and timeout behavior: -### 8.1 Common Principles +- Command timeout is propagated to run/submit execution via `executeActionWithTimeout(...)`. +- Per-step timeout and poll interval are configurable (`--step-timeout`, `--poll-interval`). -- `plan` is safe/read-only by default. -- `run` performs plan + execute in one command (with explicit confirmation flag). -- `submit` executes an already-created plan. -- Every command returns standard envelope with `action_id` and step-level metadata. -- No hidden side effects in plan phase. -- Avoid overloaded verbs. Command names should directly describe behavior. +Design decision: -### 8.2 Command Sketch +- Simulation defaults to on, and bridge completion requires both source receipt and provider settlement. -#### Plan commands +Tradeoff: -- `defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000` -- `defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000` -- `defi lend supply plan --protocol aave --chain 1 --asset USDC --amount 1000000` -- `defi approvals plan --chain taiko --asset USDC --spender --amount 1000000` -- `defi rewards claim plan --protocol aave --chain 1 --asset AAVE` +- Better safety and operational visibility, but slower execution paths and dependence on provider status APIs. -#### Run commands (plan + execute) +Current limitation: -- `defi swap run --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --yes` -- `defi bridge run --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --yes` -- `defi lend supply run --protocol aave --chain 1 --asset USDC --amount 1000000 --yes` -- `defi approvals run --chain taiko --asset USDC --spender --amount 1000000 --yes` +- Bridge settlement success is API-confirmed; no universal destination on-chain balance verification is enforced yet. -#### Submit commands (execute existing plan) +## 8. Dependency Strategy (`cast` / Foundry) -- `defi swap submit --plan-id --yes` -- `defi bridge submit --plan-id --yes` -- `defi lend supply submit --plan-id --yes` +Decision: -#### Lifecycle commands +- Do not require `forge cast` as a runtime dependency. -- `defi swap status --action-id ` -- `defi bridge status --action-id ` -- `defi lend status --action-id ` -- `defi actions list --status pending` (optional global view) -- `defi actions resume --action-id ` (optional global resume) +Rationale: -### 8.3 Global Execution Flags (proposed) +- Runtime binary dependency increases installation complexity. +- Native Go (`go-ethereum`) gives deterministic behavior in CI and releases. -- `--wallet` (address only mode) -- `--signer` (`local|external|walletconnect|safe`) (future) -- `--simulate` (default true for `run` and `submit`) -- `--slippage-bps` -- `--deadline` -- `--max-fee-gwei`, `--max-priority-fee-gwei` -- `--nonce-policy` (`next|fixed`) -- `--yes` (required for `run`/`submit`) +Tradeoff: -## 9. Action Plan and Tracking Model +- Less convenient ad-hoc debugging for some users who prefer Foundry tooling, but cleaner production runtime. -### 9.1 ActionPlan (normalized) +## 9. Testing and Nightly Drift Checks -Core fields: +Standard quality gates: -- `action_id` -- `intent_type` (`swap`, `bridge`, `lend_supply`, `approve`, `claim`, etc) -- `created_at` -- `constraints` (slippage, deadline, policy) -- `steps[]` +- `go test ./...` +- `go test -race ./...` +- `go vet ./...` -Each step includes: +Execution-related tests include planner, executor, settlement polling, and command wiring coverage. -- `step_id` -- `chain_id` -- `type` (`approval`, `swap`, `bridge_send`, `bridge_finalize`, `lend_call`, `claim`) -- `target` (contract / endpoint) -- `call_data` or provider instruction payload -- `value` -- `expected_outputs` -- `depends_on[]` -- `status` +Nightly workflow: -### 9.2 Persistence (sqlite) +- Workflow: `.github/workflows/nightly-execution-smoke.yml` +- Script: `scripts/nightly_execution_smoke.sh` +- Current scope: live smoke for quote/plan paths across key execution surfaces. -Add action tables: +Design decision: -- `actions` -- `action_steps` -- `action_events` +- Nightly job validates external dependency drift without requiring broadcast transactions. -Track: +Tradeoff: -- tx hashes -- bridge transfer IDs/message IDs -- retries -- error details (mapped to CLI error codes) +- Detects endpoint/RPC/contract drift early, but does not prove end-to-end transaction broadcasting on every run. -### 9.3 Status Lifecycle +## 10. Major Decisions and Tradeoffs Summary -`planned -> validated -> awaiting_signature -> submitted -> confirmed -> completed` +| Decision | Why | Tradeoff | +|---|---|---| +| Keep execution under domain commands | Consistent CLI API and easier discoverability | More domain-specific wiring | +| Remove defaults for multi-provider commands | Avoid ambiguous behavior and future provider-addition regressions | More required flags for users | +| Local signer only for v1 | Fast, reliable implementation | No external signer ecosystems yet | +| Store action payload as JSON blob | Easy persistence and replay semantics | Limited SQL-native analytics on steps | +| Compile-time registry | Type-safe and deterministic | Slower metadata hotfix cadence | +| Runtime simulation + settlement polling | Better safety and finality confidence | Longer run time and external API dependency | +| No `cast` runtime dependency | Portable binary releases | Less shell-tool parity for debugging | -Failure paths: +## 11. Known Gaps and Next Increments -`failed`, `timed_out`, `partial` (multi-step actions) - -## 10. Main Use Cases (Phase 1 Scope) - -### 10.1 Swap Execute - -User flow: - -1. Build swap plan (route + approvals + minOut constraint). -2. Simulate each transaction step. -3. Execute approval (if required). -4. Execute swap. -5. Confirm and return final out amount and tx hash. - -Initial support: - -- single-chain swap -- exact input -- taikoswap + existing aggregator providers where feasible - -### 10.2 Bridge Execute - -User flow: - -1. Plan source transaction and destination settlement expectations. -2. Execute source chain tx. -3. Track async transfer status. -4. Mark complete when destination settlement confirms. - -Initial support: - -- bridge-only transfers first -- bridge+destination swap as phase 2 extension - -### 10.3 Approve / Revoke - -User flow: - -1. Plan approval delta (exact amount by default). -2. Execute and confirm. -3. Optional revoke command sets allowance to zero. - -### 10.4 Lend Actions - -Initial verbs: - -- `supply` -- `withdraw` -- `borrow` -- `repay` - -Each action follows plan + execute with health-factor and liquidity checks in planning. - -### 10.5 Rewards Claim / Compound - -Initial verbs: - -- `claim` -- `compound` (where protocol supports single-tx or known workflow) - -## 11. Safety, Policy, and UX Guardrails - -- Policy allowlist checks (protocol, spender, chain, asset). -- Simulation-before-execution default (opt-out should be explicit and discouraged). -- Slippage and deadline required for swap-like actions. -- Exact approval default, unlimited approval only with explicit flag. -- Step-by-step decoded previews before execution. -- Stable and explicit partial-failure semantics. - -## 12. Simulation, Consistency, and Nightly Validation - -### 12.1 Simulation layers - -Use layered checks to reduce execution surprises: - -1. Static plan validation - - chain/provider support - - token/asset normalization - - spender and target allowlist checks -2. Preflight state checks - - balances - - allowances - - protocol preconditions (where available) -3. Transaction simulation - - `eth_call` with exact tx payload (`to`, `data`, `value`, `from`) - - gas estimation (`eth_estimateGas`) with margin policy -4. Optional deep trace simulation - - `debug_traceCall` when RPC supports it - - classify likely failure reasons for better errors - -### 12.2 Consistency between plan and execution - -Each plan should record: - -- simulation block number -- simulation timestamp -- RPC endpoint fingerprint -- route/quote hash -- slippage/deadline constraints - -Execution should enforce revalidation triggers: - -- plan age exceeds configured max age -- chain head drift exceeds configured block delta -- quote hash changes (provider route changed) -- simulation indicates constraints are now unsafe - -When any trigger fails, command exits with a deterministic replan-required error. - -### 12.3 Cross-chain consistency model - -For bridge flows: - -- source-chain tx is simulated and executed deterministically. -- destination outcome is tracked asynchronously via provider status APIs and/or chain events. -- action remains `pending` until destination settlement reaches terminal status. - -Bridge plans must include explicit timeout/SLA metadata per provider route. - -### 12.4 Nightly validation jobs - -Add a nightly workflow (separate from PR CI) for live-environment checks: - -1. Registry integrity - - schema validation - - ABI parsing - - non-zero address checks -2. On-chain contract liveness - - `eth_getCode` must be non-empty for configured contracts -3. Critical method smoke calls - - e.g., quoter/factory read calls on representative pairs -4. Provider endpoint liveness - - health checks and minimal quote/simulation calls -5. Drift report artifact - - list failing chains/providers/contracts - - include first-seen timestamp for regressions - -Failures should open/annotate issues but not block all contributor PRs by default. - -### 12.5 Test strategy split - -- PR CI: deterministic unit tests + mocked RPC/provider tests. -- Nightly CI: live RPC/provider validation. -- Optional weekly: broader matrix with additional chains/providers. - -## 13. Exit Code Extensions (proposed) - -Existing exit code contract remains. Add action-specific codes: - -- `20`: action plan validation failed -- `21`: simulation failed -- `22`: execution rejected by policy -- `23`: action timed out / pending too long -- `24`: signer unavailable / signing failed - -## 14. Rollout Plan - -### Phase 0: Foundations - -- Add domain command scaffolding (`swap|bridge|lend|rewards` with `plan|run|submit|status`). -- Add action storage and status plumbing. -- Add registry framework and validators. - -### Phase 1: Core Execution - -- swap run/submit (single-chain) -- approve/revoke -- status/resume/list - -### Phase 2: Cross-Chain - -- bridge run/submit with async tracking - -### Phase 3: Lending + Rewards - -- supply/withdraw/borrow/repay -- claim/compound - -### Phase 4: UX and Automation Hardening - -- richer signer integrations -- policy presets -- batched operations and optional smart account flows - -## 15. Open Questions - -- Which signer backend should be next after local key (`external wallet`, `Safe`, or remote signer)? -- How much protocol-specific simulation is required beyond `eth_call`? -- What SLA should define `timed_out` for bridges by provider/route type? -- What should default plan-expiry and block-drift thresholds be per command type? +- Additional signer backends (`safe`, hardware wallets, remote signers). +- Swap execution for additional providers beyond `taikoswap`. +- Registry centralization for all execution endpoints (not just selected constants). +- Stronger destination-chain verification for bridge completion beyond provider API status. +- Plan freshness/revalidation policy (block drift / quote drift thresholds) before submit. diff --git a/internal/app/approvals_command.go b/internal/app/approvals_command.go index c7e3416..0cdc40a 100644 --- a/internal/app/approvals_command.go +++ b/internal/app/approvals_command.go @@ -43,7 +43,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if err != nil { return execution.Action{}, err } - return planner.BuildApprovalAction(planner.ApprovalRequest{ + return s.actionBuilderRegistry().BuildApprovalAction(planner.ApprovalRequest{ Chain: chain, Asset: asset, AmountBaseUnits: base, @@ -90,7 +90,6 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("from-address") var run approvalArgs - var runYes bool var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string @@ -98,9 +97,6 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { Use: "run", Short: "Plan and execute an approval action", RunE: func(cmd *cobra.Command, _ []string) error { - if !runYes { - return clierr.New(clierr.CodeUsage, "approvals run requires --yes") - } start := time.Now() action, err := buildAction(run) status := []model.ProviderStatus{{Name: "native", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} @@ -148,14 +144,13 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("asset") _ = runCmd.MarkFlagRequired("spender") _ = runCmd.MarkFlagRequired("from-address") - var submitActionID, submitPlanID string - var submitYes, submitSimulate bool + var submitActionID string + var submitSimulate bool var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string @@ -163,10 +158,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { Use: "submit", Short: "Execute an existing approval action", RunE: func(cmd *cobra.Command, _ []string) error { - if !submitYes { - return clierr.New(clierr.CodeUsage, "approvals submit requires --yes") - } - actionID, err := resolveActionID(submitActionID, submitPlanID) + actionID, err := resolveActionID(submitActionID) if err != nil { return err } @@ -198,8 +190,6 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { }, } submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") - submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") - submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") @@ -210,12 +200,12 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - var statusActionID, statusPlanID string + var statusActionID string statusCmd := &cobra.Command{ Use: "status", Short: "Get approval action status", RunE: func(cmd *cobra.Command, _ []string) error { - actionID, err := resolveActionID(statusActionID, statusPlanID) + actionID, err := resolveActionID(statusActionID) if err != nil { return err } @@ -230,7 +220,6 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { }, } statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") - statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") root.AddCommand(planCmd) root.AddCommand(runCmd) diff --git a/internal/app/bridge_execution_commands.go b/internal/app/bridge_execution_commands.go index 005495b..5fd71db 100644 --- a/internal/app/bridge_execution_commands.go +++ b/internal/app/bridge_execution_commands.go @@ -2,8 +2,6 @@ package app import ( "context" - "fmt" - "sort" "strings" "time" @@ -72,14 +70,6 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } - provider, ok := s.bridgeProviders[providerName] - if !ok { - return clierr.New(clierr.CodeUnsupported, "unsupported bridge provider") - } - execProvider, ok := provider.(providers.BridgeExecutionProvider) - if !ok { - return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("bridge provider %q is quote-only; execution providers: %s", providerName, strings.Join(bridgeExecutionProviderNames(s.bridgeProviders), ","))) - } reqStruct, err := buildRequest(planFromArg, planToArg, planAssetArg, planToAssetArg, planAmountBase, planAmountDecimal, planFromAmountForGas) if err != nil { return err @@ -87,7 +77,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() - action, err := execProvider.BuildBridgeAction(ctx, reqStruct, providers.BridgeExecutionOptions{ + action, providerInfoName, err := s.actionBuilderRegistry().BuildBridgeAction(ctx, providerName, reqStruct, providers.BridgeExecutionOptions{ Sender: planFromAddress, Recipient: planRecipient, SlippageBps: planSlippageBps, @@ -95,7 +85,10 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { RPCURL: planRPCURL, FromAmountForGas: planFromAmountForGas, }) - statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if strings.TrimSpace(providerInfoName) == "" { + providerInfoName = providerName + } + statuses := []model.ProviderStatus{{Name: providerInfoName, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -132,7 +125,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { var runProviderArg, runFromArg, runToArg, runAssetArg, runToAssetArg string var runAmountBase, runAmountDecimal, runFromAddress, runRecipient, runFromAmountForGas string var runSlippageBps int64 - var runSimulate, runYes bool + var runSimulate bool var runRPCURL string var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string var runGasMultiplier float64 @@ -141,21 +134,10 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { Use: "run", Short: "Plan and execute a bridge action", RunE: func(cmd *cobra.Command, _ []string) error { - if !runYes { - return clierr.New(clierr.CodeUsage, "bridge run requires --yes") - } providerName := strings.ToLower(strings.TrimSpace(runProviderArg)) if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } - provider, ok := s.bridgeProviders[providerName] - if !ok { - return clierr.New(clierr.CodeUnsupported, "unsupported bridge provider") - } - execProvider, ok := provider.(providers.BridgeExecutionProvider) - if !ok { - return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("bridge provider %q is quote-only; execution providers: %s", providerName, strings.Join(bridgeExecutionProviderNames(s.bridgeProviders), ","))) - } reqStruct, err := buildRequest(runFromArg, runToArg, runAssetArg, runToAssetArg, runAmountBase, runAmountDecimal, runFromAmountForGas) if err != nil { return err @@ -163,7 +145,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() - action, err := execProvider.BuildBridgeAction(ctx, reqStruct, providers.BridgeExecutionOptions{ + action, providerInfoName, err := s.actionBuilderRegistry().BuildBridgeAction(ctx, providerName, reqStruct, providers.BridgeExecutionOptions{ Sender: runFromAddress, Recipient: runRecipient, SlippageBps: runSlippageBps, @@ -171,7 +153,10 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { RPCURL: runRPCURL, FromAmountForGas: runFromAmountForGas, }) - statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if strings.TrimSpace(providerInfoName) == "" { + providerInfoName = providerName + } + statuses := []model.ProviderStatus{{Name: providerInfoName, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -225,15 +210,14 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") _ = runCmd.MarkFlagRequired("from") _ = runCmd.MarkFlagRequired("to") _ = runCmd.MarkFlagRequired("asset") _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("provider") - var submitActionID, submitPlanID string - var submitYes, submitSimulate bool + var submitActionID string + var submitSimulate bool var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string @@ -241,10 +225,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { Use: "submit", Short: "Execute an existing bridge action", RunE: func(cmd *cobra.Command, _ []string) error { - if !submitYes { - return clierr.New(clierr.CodeUsage, "bridge submit requires --yes") - } - actionID, err := resolveActionID(submitActionID, submitPlanID) + actionID, err := resolveActionID(submitActionID) if err != nil { return err } @@ -276,8 +257,6 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { }, } submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") - submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") - submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") @@ -288,12 +267,12 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - var statusActionID, statusPlanID string + var statusActionID string statusCmd := &cobra.Command{ Use: "status", Short: "Get bridge action status", RunE: func(cmd *cobra.Command, _ []string) error { - actionID, err := resolveActionID(statusActionID, statusPlanID) + actionID, err := resolveActionID(statusActionID) if err != nil { return err } @@ -308,21 +287,9 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { }, } statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") - statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") root.AddCommand(planCmd) root.AddCommand(runCmd) root.AddCommand(submitCmd) root.AddCommand(statusCmd) } - -func bridgeExecutionProviderNames(all map[string]providers.BridgeProvider) []string { - names := make([]string, 0, len(all)) - for name, provider := range all { - if _, ok := provider.(providers.BridgeExecutionProvider); ok { - names = append(names, name) - } - } - sort.Strings(names) - return names -} diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go index a81e69f..f71ef9e 100644 --- a/internal/app/lend_execution_commands.go +++ b/internal/app/lend_execution_commands.go @@ -7,6 +7,7 @@ import ( clierr "github.com/ggonzalez94/defi-cli/internal/errors" "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/execution/actionbuilder" "github.com/ggonzalez94/defi-cli/internal/execution/planner" execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" "github.com/ggonzalez94/defi-cli/internal/id" @@ -45,11 +46,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh poolAddressProvider string } buildAction := func(ctx context.Context, args lendArgs) (execution.Action, error) { - protocol := normalizeLendingProtocol(args.protocol) - if protocol == "" { - return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") - } - chain, asset, err := parseChainAsset(args.chainArg, args.assetArg) if err != nil { return execution.Action{}, err @@ -62,39 +58,22 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if err != nil { return execution.Action{}, err } - - switch protocol { - case "aave": - return planner.BuildAaveLendAction(ctx, planner.AaveLendRequest{ - Verb: verb, - Chain: chain, - Asset: asset, - AmountBaseUnits: base, - Sender: args.fromAddress, - Recipient: args.recipient, - OnBehalfOf: args.onBehalfOf, - InterestRateMode: args.interestRateMode, - Simulate: args.simulate, - RPCURL: args.rpcURL, - PoolAddress: args.poolAddress, - PoolAddressesProvider: args.poolAddressProvider, - }) - case "morpho": - return planner.BuildMorphoLendAction(ctx, planner.MorphoLendRequest{ - Verb: verb, - Chain: chain, - Asset: asset, - MarketID: args.marketID, - AmountBaseUnits: base, - Sender: args.fromAddress, - Recipient: args.recipient, - OnBehalfOf: args.onBehalfOf, - Simulate: args.simulate, - RPCURL: args.rpcURL, - }) - default: - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports protocol=aave|morpho") - } + return s.actionBuilderRegistry().BuildLendAction(ctx, actionbuilder.LendRequest{ + Protocol: args.protocol, + Verb: verb, + Chain: chain, + Asset: asset, + MarketID: args.marketID, + AmountBaseUnits: base, + Sender: args.fromAddress, + Recipient: args.recipient, + OnBehalfOf: args.onBehalfOf, + InterestRateMode: args.interestRateMode, + Simulate: args.simulate, + RPCURL: args.rpcURL, + PoolAddress: args.poolAddress, + PoolAddressProvider: args.poolAddressProvider, + }) } var plan lendArgs @@ -145,7 +124,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh _ = planCmd.MarkFlagRequired("protocol") var run lendArgs - var runYes bool var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string @@ -153,9 +131,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh Use: "run", Short: "Plan and execute a lend action", RunE: func(cmd *cobra.Command, _ []string) error { - if !runYes { - return clierr.New(clierr.CodeUsage, "lend run requires --yes") - } ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() @@ -219,14 +194,13 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("asset") _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("protocol") - var submitActionID, submitPlanID string - var submitYes, submitSimulate bool + var submitActionID string + var submitSimulate bool var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string @@ -234,10 +208,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh Use: "submit", Short: "Execute an existing lend action", RunE: func(cmd *cobra.Command, _ []string) error { - if !submitYes { - return clierr.New(clierr.CodeUsage, "lend submit requires --yes") - } - actionID, err := resolveActionID(submitActionID, submitPlanID) + actionID, err := resolveActionID(submitActionID) if err != nil { return err } @@ -272,8 +243,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh }, } submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") - submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") - submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") @@ -284,12 +253,12 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - var statusActionID, statusPlanID string + var statusActionID string statusCmd := &cobra.Command{ Use: "status", Short: "Get lend action status", RunE: func(cmd *cobra.Command, _ []string) error { - actionID, err := resolveActionID(statusActionID, statusPlanID) + actionID, err := resolveActionID(statusActionID) if err != nil { return err } @@ -307,7 +276,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh }, } statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") - statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") root.AddCommand(planCmd) root.AddCommand(runCmd) diff --git a/internal/app/rewards_command.go b/internal/app/rewards_command.go index 9eb1648..52e6bff 100644 --- a/internal/app/rewards_command.go +++ b/internal/app/rewards_command.go @@ -7,7 +7,7 @@ import ( clierr "github.com/ggonzalez94/defi-cli/internal/errors" "github.com/ggonzalez94/defi-cli/internal/execution" - "github.com/ggonzalez94/defi-cli/internal/execution/planner" + "github.com/ggonzalez94/defi-cli/internal/execution/actionbuilder" execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" @@ -39,13 +39,6 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { poolAddressProvider string } buildAction := func(ctx context.Context, args claimArgs) (execution.Action, error) { - protocol := normalizeLendingProtocol(args.protocol) - if protocol == "" { - return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") - } - if protocol != "aave" { - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") - } chain, err := id.ParseChain(args.chainArg) if err != nil { return execution.Action{}, err @@ -58,17 +51,18 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { if amount == "" { amount = "max" } - return planner.BuildAaveRewardsClaimAction(ctx, planner.AaveRewardsClaimRequest{ - Chain: chain, - Sender: args.fromAddress, - Recipient: args.recipient, - Assets: assets, - RewardToken: args.rewardToken, - AmountBaseUnits: amount, - Simulate: args.simulate, - RPCURL: args.rpcURL, - ControllerAddress: args.controllerAddress, - PoolAddressesProvider: args.poolAddressProvider, + return s.actionBuilderRegistry().BuildRewardsClaimAction(ctx, actionbuilder.RewardsClaimRequest{ + Protocol: args.protocol, + Chain: chain, + Sender: args.fromAddress, + Recipient: args.recipient, + Assets: assets, + RewardToken: args.rewardToken, + AmountBaseUnits: amount, + Simulate: args.simulate, + RPCURL: args.rpcURL, + ControllerAddress: args.controllerAddress, + PoolAddressProvider: args.poolAddressProvider, }) } @@ -114,7 +108,6 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("protocol") var run claimArgs - var runYes bool var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string @@ -122,9 +115,6 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { Use: "run", Short: "Plan and execute a rewards-claim action", RunE: func(cmd *cobra.Command, _ []string) error { - if !runYes { - return clierr.New(clierr.CodeUsage, "rewards claim run requires --yes") - } ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() @@ -181,15 +171,14 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("assets") _ = runCmd.MarkFlagRequired("reward-token") _ = runCmd.MarkFlagRequired("protocol") - var submitActionID, submitPlanID string - var submitYes, submitSimulate bool + var submitActionID string + var submitSimulate bool var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string @@ -197,10 +186,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { Use: "submit", Short: "Execute an existing rewards-claim action", RunE: func(cmd *cobra.Command, _ []string) error { - if !submitYes { - return clierr.New(clierr.CodeUsage, "rewards claim submit requires --yes") - } - actionID, err := resolveActionID(submitActionID, submitPlanID) + actionID, err := resolveActionID(submitActionID) if err != nil { return err } @@ -235,8 +221,6 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { }, } submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") - submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") - submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") @@ -247,12 +231,12 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - var statusActionID, statusPlanID string + var statusActionID string statusCmd := &cobra.Command{ Use: "status", Short: "Get rewards-claim action status", RunE: func(cmd *cobra.Command, _ []string) error { - actionID, err := resolveActionID(statusActionID, statusPlanID) + actionID, err := resolveActionID(statusActionID) if err != nil { return err } @@ -270,7 +254,6 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { }, } statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") - statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") root.AddCommand(planCmd) root.AddCommand(runCmd) @@ -299,13 +282,6 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { poolAddressProvider string } buildAction := func(ctx context.Context, args compoundArgs) (execution.Action, error) { - protocol := normalizeLendingProtocol(args.protocol) - if protocol == "" { - return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") - } - if protocol != "aave" { - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") - } chain, err := id.ParseChain(args.chainArg) if err != nil { return execution.Action{}, err @@ -318,19 +294,20 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { if amount == "" { return execution.Action{}, clierr.New(clierr.CodeUsage, "--amount is required") } - return planner.BuildAaveRewardsCompoundAction(ctx, planner.AaveRewardsCompoundRequest{ - Chain: chain, - Sender: args.fromAddress, - Recipient: args.recipient, - Assets: assets, - RewardToken: args.rewardToken, - AmountBaseUnits: amount, - Simulate: args.simulate, - RPCURL: args.rpcURL, - ControllerAddress: args.controllerAddress, - PoolAddress: args.poolAddress, - PoolAddressesProvider: args.poolAddressProvider, - OnBehalfOf: args.onBehalfOf, + return s.actionBuilderRegistry().BuildRewardsCompoundAction(ctx, actionbuilder.RewardsCompoundRequest{ + Protocol: args.protocol, + Chain: chain, + Sender: args.fromAddress, + Recipient: args.recipient, + OnBehalfOf: args.onBehalfOf, + Assets: assets, + RewardToken: args.rewardToken, + AmountBaseUnits: amount, + Simulate: args.simulate, + RPCURL: args.rpcURL, + ControllerAddress: args.controllerAddress, + PoolAddress: args.poolAddress, + PoolAddressProvider: args.poolAddressProvider, }) } @@ -379,7 +356,6 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("protocol") var run compoundArgs - var runYes bool var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string @@ -387,9 +363,6 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { Use: "run", Short: "Plan and execute a rewards-compound action", RunE: func(cmd *cobra.Command, _ []string) error { - if !runYes { - return clierr.New(clierr.CodeUsage, "rewards compound run requires --yes") - } ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() @@ -448,7 +421,6 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("assets") @@ -456,8 +428,8 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { _ = runCmd.MarkFlagRequired("amount") _ = runCmd.MarkFlagRequired("protocol") - var submitActionID, submitPlanID string - var submitYes, submitSimulate bool + var submitActionID string + var submitSimulate bool var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string @@ -465,10 +437,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { Use: "submit", Short: "Execute an existing rewards-compound action", RunE: func(cmd *cobra.Command, _ []string) error { - if !submitYes { - return clierr.New(clierr.CodeUsage, "rewards compound submit requires --yes") - } - actionID, err := resolveActionID(submitActionID, submitPlanID) + actionID, err := resolveActionID(submitActionID) if err != nil { return err } @@ -503,8 +472,6 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { }, } submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier") - submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") - submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") @@ -515,12 +482,12 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - var statusActionID, statusPlanID string + var statusActionID string statusCmd := &cobra.Command{ Use: "status", Short: "Get rewards-compound action status", RunE: func(cmd *cobra.Command, _ []string) error { - actionID, err := resolveActionID(statusActionID, statusPlanID) + actionID, err := resolveActionID(statusActionID) if err != nil { return err } @@ -538,7 +505,6 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { }, } statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") - statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") root.AddCommand(planCmd) root.AddCommand(runCmd) diff --git a/internal/app/runner.go b/internal/app/runner.go index ef165ea..48b1053 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -17,6 +17,7 @@ import ( "github.com/ggonzalez94/defi-cli/internal/config" clierr "github.com/ggonzalez94/defi-cli/internal/errors" "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/execution/actionbuilder" execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" "github.com/ggonzalez94/defi-cli/internal/httpx" "github.com/ggonzalez94/defi-cli/internal/id" @@ -65,6 +66,7 @@ type runtimeState struct { settings config.Settings cache *cache.Store actionStore *execution.Store + actionBuilder *actionbuilder.Registry root *cobra.Command lastCommand string lastWarnings []string @@ -187,6 +189,11 @@ func (s *runtimeState) newRootCommand() *cobra.Command { s.swapProviders["fibrous"].Info(), } } + if s.actionBuilder == nil { + s.actionBuilder = actionbuilder.New(s.swapProviders, s.bridgeProviders) + } else { + s.actionBuilder.Configure(s.swapProviders, s.bridgeProviders) + } if settings.CacheEnabled && shouldOpenCache(path) && s.cache == nil { cacheStore, err := cache.Open(settings.CachePath, settings.CacheLockPath) @@ -775,14 +782,6 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } - provider, ok := s.swapProviders[providerName] - if !ok { - return clierr.New(clierr.CodeUnsupported, "unsupported swap provider") - } - execProvider, ok := provider.(providers.SwapExecutionProvider) - if !ok { - return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("provider %s does not support swap planning", providerName)) - } reqStruct, err := parseSwapRequest(planChainArg, planFromAssetArg, planToAssetArg, planAmountBase, planAmountDecimal) if err != nil { return err @@ -791,13 +790,16 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() - action, err := execProvider.BuildSwapAction(ctx, reqStruct, providers.SwapExecutionOptions{ + action, providerInfoName, err := s.actionBuilderRegistry().BuildSwapAction(ctx, providerName, "plan", reqStruct, providers.SwapExecutionOptions{ Sender: planFromAddress, Recipient: planRecipient, SlippageBps: planSlippageBps, Simulate: planSimulate, }) - statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if strings.TrimSpace(providerInfoName) == "" { + providerInfoName = providerName + } + statuses := []model.ProviderStatus{{Name: providerInfoName, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -831,7 +833,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var runProviderArg, runChainArg, runFromAssetArg, runToAssetArg string var runAmountBase, runAmountDecimal, runFromAddress, runRecipient string var runSlippageBps int64 - var runSimulate, runYes bool + var runSimulate bool var runSigner, runKeySource, runConfirmAddress string var runPollInterval, runStepTimeout string var runGasMultiplier float64 @@ -840,21 +842,10 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { Use: "run", Short: "Plan and execute a swap action in one command", RunE: func(cmd *cobra.Command, args []string) error { - if !runYes { - return clierr.New(clierr.CodeUsage, "swap run requires --yes") - } providerName := strings.ToLower(strings.TrimSpace(runProviderArg)) if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } - provider, ok := s.swapProviders[providerName] - if !ok { - return clierr.New(clierr.CodeUnsupported, "unsupported swap provider") - } - execProvider, ok := provider.(providers.SwapExecutionProvider) - if !ok { - return clierr.New(clierr.CodeUnsupported, fmt.Sprintf("provider %s does not support swap execution", providerName)) - } reqStruct, err := parseSwapRequest(runChainArg, runFromAssetArg, runToAssetArg, runAmountBase, runAmountDecimal) if err != nil { return err @@ -863,13 +854,16 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() - action, err := execProvider.BuildSwapAction(ctx, reqStruct, providers.SwapExecutionOptions{ + action, providerInfoName, err := s.actionBuilderRegistry().BuildSwapAction(ctx, providerName, "execution", reqStruct, providers.SwapExecutionOptions{ Sender: runFromAddress, Recipient: runRecipient, SlippageBps: runSlippageBps, Simulate: runSimulate, }) - statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + if strings.TrimSpace(providerInfoName) == "" { + providerInfoName = providerName + } + statuses := []model.ProviderStatus{{Name: providerInfoName, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -922,15 +916,14 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - runCmd.Flags().BoolVar(&runYes, "yes", false, "Confirm execution") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("from-asset") _ = runCmd.MarkFlagRequired("to-asset") _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("provider") - var submitActionID, submitPlanID string - var submitYes, submitSimulate bool + var submitActionID string + var submitSimulate bool var submitSigner, submitKeySource, submitConfirmAddress string var submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 @@ -939,10 +932,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { Use: "submit", Short: "Execute a previously planned swap action", RunE: func(cmd *cobra.Command, args []string) error { - if !submitYes { - return clierr.New(clierr.CodeUsage, "swap submit requires --yes") - } - actionID, err := resolveActionID(submitActionID, submitPlanID) + actionID, err := resolveActionID(submitActionID) if err != nil { return err } @@ -978,8 +968,6 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { }, } submitCmd.Flags().StringVar(&submitActionID, "action-id", "", "Action identifier returned by swap plan/run") - submitCmd.Flags().StringVar(&submitPlanID, "plan-id", "", "Deprecated alias for --action-id") - submitCmd.Flags().BoolVar(&submitYes, "yes", false, "Confirm execution") submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") @@ -990,12 +978,12 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") - var statusActionID, statusPlanID string + var statusActionID string statusCmd := &cobra.Command{ Use: "status", Short: "Get swap action status", RunE: func(cmd *cobra.Command, args []string) error { - actionID, err := resolveActionID(statusActionID, statusPlanID) + actionID, err := resolveActionID(statusActionID) if err != nil { return err } @@ -1010,7 +998,6 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { }, } statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier returned by swap plan/run") - statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") root.AddCommand(quoteCmd) root.AddCommand(planCmd) @@ -1042,12 +1029,12 @@ func (s *runtimeState) newActionsCommand() *cobra.Command { listCmd.Flags().StringVar(&listStatus, "status", "", "Optional action status filter") listCmd.Flags().IntVar(&listLimit, "limit", 20, "Maximum actions to return") - var statusActionID, statusPlanID string + var statusActionID string statusCmd := &cobra.Command{ Use: "status", Short: "Get action details by action id", RunE: func(cmd *cobra.Command, args []string) error { - actionID, err := resolveActionID(statusActionID, statusPlanID) + actionID, err := resolveActionID(statusActionID) if err != nil { return err } @@ -1062,7 +1049,6 @@ func (s *runtimeState) newActionsCommand() *cobra.Command { }, } statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") - statusCmd.Flags().StringVar(&statusPlanID, "plan-id", "", "Deprecated alias for --action-id") root.AddCommand(listCmd) root.AddCommand(statusCmd) @@ -1729,19 +1715,21 @@ func (s *runtimeState) ensureActionStore() error { return nil } -func resolveActionID(actionID, planID string) (string, error) { +func (s *runtimeState) actionBuilderRegistry() *actionbuilder.Registry { + if s.actionBuilder == nil { + s.actionBuilder = actionbuilder.New(s.swapProviders, s.bridgeProviders) + } else { + s.actionBuilder.Configure(s.swapProviders, s.bridgeProviders) + } + return s.actionBuilder +} + +func resolveActionID(actionID string) (string, error) { actionID = strings.TrimSpace(actionID) - planID = strings.TrimSpace(planID) - if actionID == "" && planID == "" { + if actionID == "" { return "", clierr.New(clierr.CodeUsage, "action id is required (--action-id)") } - if actionID != "" && planID != "" && !strings.EqualFold(actionID, planID) { - return "", clierr.New(clierr.CodeUsage, "--action-id and --plan-id must match when both are set") - } - if actionID != "" { - return actionID, nil - } - return planID, nil + return actionID, nil } func newExecutionSigner(signerBackend, keySource, confirmAddress string) (execsigner.Signer, error) { diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go index a0dad25..148b770 100644 --- a/internal/app/runner_actions_test.go +++ b/internal/app/runner_actions_test.go @@ -9,7 +9,7 @@ import ( ) func TestResolveActionID(t *testing.T) { - id, err := resolveActionID("act_123", "") + id, err := resolveActionID("act_123") if err != nil { t.Fatalf("resolveActionID failed: %v", err) } @@ -17,16 +17,8 @@ func TestResolveActionID(t *testing.T) { t.Fatalf("unexpected action id: %s", id) } - id, err = resolveActionID("", "act_456") - if err != nil { - t.Fatalf("resolveActionID with plan id failed: %v", err) - } - if id != "act_456" { - t.Fatalf("unexpected plan-id resolution: %s", id) - } - - if _, err := resolveActionID("act_1", "act_2"); err == nil { - t.Fatal("expected mismatch error when action and plan id differ") + if _, err := resolveActionID(""); err == nil { + t.Fatal("expected error when action id is missing") } } diff --git a/internal/execution/actionbuilder/registry.go b/internal/execution/actionbuilder/registry.go new file mode 100644 index 0000000..693cd1f --- /dev/null +++ b/internal/execution/actionbuilder/registry.go @@ -0,0 +1,224 @@ +package actionbuilder + +import ( + "context" + "fmt" + "sort" + "strings" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution" + "github.com/ggonzalez94/defi-cli/internal/execution/planner" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/providers" +) + +type Registry struct { + swapProviders map[string]providers.SwapProvider + bridgeProviders map[string]providers.BridgeProvider +} + +func New(swapProviders map[string]providers.SwapProvider, bridgeProviders map[string]providers.BridgeProvider) *Registry { + return &Registry{ + swapProviders: swapProviders, + bridgeProviders: bridgeProviders, + } +} + +func (r *Registry) Configure(swapProviders map[string]providers.SwapProvider, bridgeProviders map[string]providers.BridgeProvider) { + r.swapProviders = swapProviders + r.bridgeProviders = bridgeProviders +} + +func (r *Registry) BuildSwapAction(ctx context.Context, providerName, op string, req providers.SwapQuoteRequest, opts providers.SwapExecutionOptions) (execution.Action, string, error) { + providerName = strings.ToLower(strings.TrimSpace(providerName)) + if providerName == "" { + return execution.Action{}, "", clierr.New(clierr.CodeUsage, "--provider is required") + } + provider, ok := r.swapProviders[providerName] + if !ok { + return execution.Action{}, "", clierr.New(clierr.CodeUnsupported, "unsupported swap provider") + } + execProvider, ok := provider.(providers.SwapExecutionProvider) + if !ok { + switch strings.ToLower(strings.TrimSpace(op)) { + case "plan", "planning": + return execution.Action{}, provider.Info().Name, clierr.New(clierr.CodeUnsupported, fmt.Sprintf("provider %s does not support swap planning", providerName)) + default: + return execution.Action{}, provider.Info().Name, clierr.New(clierr.CodeUnsupported, fmt.Sprintf("provider %s does not support swap execution", providerName)) + } + } + action, err := execProvider.BuildSwapAction(ctx, req, opts) + return action, provider.Info().Name, err +} + +func (r *Registry) BuildBridgeAction(ctx context.Context, providerName string, req providers.BridgeQuoteRequest, opts providers.BridgeExecutionOptions) (execution.Action, string, error) { + providerName = strings.ToLower(strings.TrimSpace(providerName)) + if providerName == "" { + return execution.Action{}, "", clierr.New(clierr.CodeUsage, "--provider is required") + } + provider, ok := r.bridgeProviders[providerName] + if !ok { + return execution.Action{}, "", clierr.New(clierr.CodeUnsupported, "unsupported bridge provider") + } + execProvider, ok := provider.(providers.BridgeExecutionProvider) + if !ok { + return execution.Action{}, provider.Info().Name, clierr.New( + clierr.CodeUnsupported, + fmt.Sprintf("bridge provider %q is quote-only; execution providers: %s", providerName, strings.Join(r.BridgeExecutionProviderNames(), ",")), + ) + } + action, err := execProvider.BuildBridgeAction(ctx, req, opts) + return action, provider.Info().Name, err +} + +func (r *Registry) BridgeExecutionProviderNames() []string { + names := make([]string, 0, len(r.bridgeProviders)) + for name, provider := range r.bridgeProviders { + if _, ok := provider.(providers.BridgeExecutionProvider); ok { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +type LendRequest struct { + Protocol string + Verb planner.AaveLendVerb + Chain id.Chain + Asset id.Asset + MarketID string + AmountBaseUnits string + Sender string + Recipient string + OnBehalfOf string + InterestRateMode int64 + Simulate bool + RPCURL string + PoolAddress string + PoolAddressProvider string +} + +func (r *Registry) BuildLendAction(ctx context.Context, req LendRequest) (execution.Action, error) { + protocol := normalizeLendingProtocol(req.Protocol) + if protocol == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + } + switch protocol { + case "aave": + return planner.BuildAaveLendAction(ctx, planner.AaveLendRequest{ + Verb: req.Verb, + Chain: req.Chain, + Asset: req.Asset, + AmountBaseUnits: req.AmountBaseUnits, + Sender: req.Sender, + Recipient: req.Recipient, + OnBehalfOf: req.OnBehalfOf, + InterestRateMode: req.InterestRateMode, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + PoolAddress: req.PoolAddress, + PoolAddressesProvider: req.PoolAddressProvider, + }) + case "morpho": + return planner.BuildMorphoLendAction(ctx, planner.MorphoLendRequest{ + Verb: req.Verb, + Chain: req.Chain, + Asset: req.Asset, + MarketID: req.MarketID, + AmountBaseUnits: req.AmountBaseUnits, + Sender: req.Sender, + Recipient: req.Recipient, + OnBehalfOf: req.OnBehalfOf, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + }) + default: + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports protocol=aave|morpho") + } +} + +type RewardsClaimRequest struct { + Protocol string + Chain id.Chain + Sender string + Recipient string + Assets []string + RewardToken string + AmountBaseUnits string + Simulate bool + RPCURL string + ControllerAddress string + PoolAddressProvider string +} + +func (r *Registry) BuildRewardsClaimAction(ctx context.Context, req RewardsClaimRequest) (execution.Action, error) { + protocol := normalizeLendingProtocol(req.Protocol) + if protocol == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + } + if protocol != "aave" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") + } + return planner.BuildAaveRewardsClaimAction(ctx, planner.AaveRewardsClaimRequest{ + Chain: req.Chain, + Sender: req.Sender, + Recipient: req.Recipient, + Assets: req.Assets, + RewardToken: req.RewardToken, + AmountBaseUnits: req.AmountBaseUnits, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + ControllerAddress: req.ControllerAddress, + PoolAddressesProvider: req.PoolAddressProvider, + }) +} + +type RewardsCompoundRequest struct { + Protocol string + Chain id.Chain + Sender string + Recipient string + OnBehalfOf string + Assets []string + RewardToken string + AmountBaseUnits string + Simulate bool + RPCURL string + ControllerAddress string + PoolAddress string + PoolAddressProvider string +} + +func (r *Registry) BuildRewardsCompoundAction(ctx context.Context, req RewardsCompoundRequest) (execution.Action, error) { + protocol := normalizeLendingProtocol(req.Protocol) + if protocol == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + } + if protocol != "aave" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") + } + return planner.BuildAaveRewardsCompoundAction(ctx, planner.AaveRewardsCompoundRequest{ + Chain: req.Chain, + Sender: req.Sender, + Recipient: req.Recipient, + Assets: req.Assets, + RewardToken: req.RewardToken, + AmountBaseUnits: req.AmountBaseUnits, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + ControllerAddress: req.ControllerAddress, + PoolAddress: req.PoolAddress, + PoolAddressesProvider: req.PoolAddressProvider, + OnBehalfOf: req.OnBehalfOf, + }) +} + +func (r *Registry) BuildApprovalAction(req planner.ApprovalRequest) (execution.Action, error) { + return planner.BuildApprovalAction(req) +} + +func normalizeLendingProtocol(v string) string { + return strings.ToLower(strings.TrimSpace(v)) +} diff --git a/internal/execution/actionbuilder/registry_test.go b/internal/execution/actionbuilder/registry_test.go new file mode 100644 index 0000000..7f448d6 --- /dev/null +++ b/internal/execution/actionbuilder/registry_test.go @@ -0,0 +1,113 @@ +package actionbuilder + +import ( + "context" + "strings" + "testing" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/execution/planner" + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/model" + "github.com/ggonzalez94/defi-cli/internal/providers" +) + +func TestBuildSwapActionRejectsQuoteOnlyProvider(t *testing.T) { + reg := New(map[string]providers.SwapProvider{ + "quoteonly": swapQuoteOnlyProvider{}, + }, nil) + + _, _, err := reg.BuildSwapAction(context.Background(), "quoteonly", "plan", providers.SwapQuoteRequest{}, providers.SwapExecutionOptions{}) + if err == nil { + t.Fatal("expected quote-only swap provider to fail for plan") + } + if !strings.Contains(strings.ToLower(err.Error()), "does not support swap planning") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildBridgeActionRejectsQuoteOnlyProvider(t *testing.T) { + reg := New(nil, map[string]providers.BridgeProvider{ + "quoteonly": bridgeQuoteOnlyProvider{}, + }) + + _, _, err := reg.BuildBridgeAction(context.Background(), "quoteonly", providers.BridgeQuoteRequest{}, providers.BridgeExecutionOptions{}) + if err == nil { + t.Fatal("expected quote-only bridge provider to fail for execution") + } + if !strings.Contains(strings.ToLower(err.Error()), "quote-only") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildLendActionRejectsUnsupportedProtocol(t *testing.T) { + reg := New(nil, nil) + _, err := reg.BuildLendAction(context.Background(), LendRequest{Protocol: "kamino"}) + if err == nil { + t.Fatal("expected unsupported protocol error") + } + cErr, ok := clierr.As(err) + if !ok || cErr.Code != clierr.CodeUnsupported { + t.Fatalf("expected unsupported cli error, got %v", err) + } +} + +func TestBuildRewardsClaimActionRejectsUnsupportedProtocol(t *testing.T) { + reg := New(nil, nil) + _, err := reg.BuildRewardsClaimAction(context.Background(), RewardsClaimRequest{Protocol: "morpho"}) + if err == nil { + t.Fatal("expected unsupported protocol error") + } + cErr, ok := clierr.As(err) + if !ok || cErr.Code != clierr.CodeUnsupported { + t.Fatalf("expected unsupported cli error, got %v", err) + } +} + +func TestBuildApprovalActionRoutesToPlanner(t *testing.T) { + reg := New(nil, nil) + chain, err := id.ParseChain("1") + if err != nil { + t.Fatalf("parse chain: %v", err) + } + asset, err := id.ParseAsset("USDC", chain) + if err != nil { + t.Fatalf("parse asset: %v", err) + } + + action, err := reg.BuildApprovalAction(planner.ApprovalRequest{ + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000", + Sender: "0x00000000000000000000000000000000000000aa", + Spender: "0x00000000000000000000000000000000000000bb", + Simulate: true, + RPCURL: "https://eth.llamarpc.com", + }) + if err != nil { + t.Fatalf("BuildApprovalAction failed: %v", err) + } + if action.IntentType != "approve" { + t.Fatalf("unexpected intent: %s", action.IntentType) + } +} + +type swapQuoteOnlyProvider struct{} + +func (swapQuoteOnlyProvider) Info() model.ProviderInfo { + return model.ProviderInfo{Name: "quoteonly", Type: "swap"} +} + +func (swapQuoteOnlyProvider) QuoteSwap(context.Context, providers.SwapQuoteRequest) (model.SwapQuote, error) { + return model.SwapQuote{}, nil +} + +type bridgeQuoteOnlyProvider struct{} + +func (bridgeQuoteOnlyProvider) Info() model.ProviderInfo { + return model.ProviderInfo{Name: "quoteonly", Type: "bridge"} +} + +func (bridgeQuoteOnlyProvider) QuoteBridge(context.Context, providers.BridgeQuoteRequest) (model.BridgeQuote, error) { + return model.BridgeQuote{}, nil +} From 27a2d1e7c96c0eb97821c2b25bc0eefb512f7f1e Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Tue, 24 Feb 2026 23:51:37 -0400 Subject: [PATCH 04/18] Simplify local signer UX and default sender behavior --- AGENTS.md | 2 +- CHANGELOG.md | 6 ++- README.md | 8 ++- docs/act-execution-design.md | 8 +-- internal/app/approvals_command.go | 31 +++++------ internal/app/bridge_execution_commands.go | 30 +++++------ internal/app/lend_execution_commands.go | 33 ++++++------ internal/app/rewards_command.go | 66 +++++++++++------------ internal/app/runner.go | 48 +++++++++-------- internal/app/runner_actions_test.go | 32 +++++++++++ internal/execution/signer/local.go | 37 ++++++++----- internal/execution/signer/local_test.go | 30 +++++++++-- 12 files changed, 201 insertions(+), 130 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44fc3eb..38b3600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ README.md # user-facing usage + caveats - Most commands do not require provider API keys. - Key-gated routes: `swap quote --provider 1inch` (`DEFI_1INCH_API_KEY`), `swap quote --provider uniswap` (`DEFI_UNISWAP_API_KEY`), `chains assets`, and `bridge list` / `bridge details` via DefiLlama (`DEFI_DEFILLAMA_API_KEY`). - Multi-provider command paths require explicit provider/protocol selection (`--provider` or `--protocol`); no implicit defaults. -- TaikoSwap quote/planning does not require an API key; execution uses local signer env inputs (`DEFI_PRIVATE_KEY{,_FILE}` or keystore envs). +- TaikoSwap quote/planning does not require an API key; execution uses local signer env inputs (`DEFI_PRIVATE_KEY{,_FILE}` or keystore envs) and also auto-discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. - Execution commands currently available: - `swap plan|run|submit|status` - `bridge plan|run|submit|status` (Across, LiFi) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0824252..2048d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Format: - Added lend execution workflow commands under `lend supply|withdraw|borrow|repay ... plan|run|submit|status` (Aave and Morpho). - Added rewards execution workflow commands under `rewards claim|compound ... plan|run|submit|status` (Aave). - Added action persistence and inspection commands: `actions list` and `actions status`. -- Added local signer support for execution with env/file/keystore key sources and strict file-permission checks. +- Added local signer support for execution with env/file/keystore key sources. - Added Taiko Hoodi chain alias and token registry entries (`USDC`, `USDT`, `WETH`) for deterministic asset parsing. - Added planner unit tests for approvals, Aave lend/rewards flows, and LiFi bridge action building. - Added centralized execution registry data in `internal/registry` for endpoint, contract, and ABI references. @@ -40,6 +40,10 @@ Format: - Morpho lend execution now requires explicit `--market-id` to avoid ambiguous market selection. - Execution `run`/`submit` commands no longer require `--yes`; command intent now gates execution. - Unified execution action-construction dispatch under a shared ActionBuilder registry while preserving existing command semantics. +- Execution commands now use `--from-address` as the single signer-address guard; `--confirm-address` has been removed. +- Execution `run` commands now default sender to signer address when `--from-address` is omitted. +- Local signer `--key-source auto` now discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. +- Local signer key/keystore file loading no longer hard-fails on non-`0600` file permissions. ### Fixed - Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. diff --git a/README.md b/README.md index 1182449..b1b2fe7 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ defi swap quote --provider taikoswap --chain taiko --from-asset USDC --to-asset Swap execution flow (local signer): ```bash -export DEFI_PRIVATE_KEY_FILE=~/.config/defi/key.hex # chmod 600 +export DEFI_PRIVATE_KEY_FILE=~/.config/defi/key.hex # 1) Plan only defi swap plan \ @@ -181,11 +181,15 @@ Execution `run`/`submit` commands currently support a local key signer. Key env inputs (in precedence order when `--key-source auto`): - `DEFI_PRIVATE_KEY` (hex string, supported but less safe) -- `DEFI_PRIVATE_KEY_FILE` (preferred; file must be `0600` or stricter) +- `DEFI_PRIVATE_KEY_FILE` (preferred explicit key-file path) +- default key file: `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` - `DEFI_KEYSTORE_PATH` + (`DEFI_KEYSTORE_PASSWORD` or `DEFI_KEYSTORE_PASSWORD_FILE`) You can force source selection with `--key-source env|file|keystore`. +`run` commands default sender to the loaded signer address; when `--from-address` is provided, it must match the signer. +`submit` commands support optional `--from-address` as an explicit signer-address guard. + ## Config (Optional) Most users only need env vars for provider keys. Use config when you want persistent non-secret defaults (output mode, timeout/retries, cache behavior). diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index 6df6e02..98136b0 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -166,13 +166,13 @@ Key sources: 1. `DEFI_PRIVATE_KEY` 2. `DEFI_PRIVATE_KEY_FILE` -3. `DEFI_KEYSTORE_PATH` (+ password input) +3. `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` (default key-file fallback when present) +4. `DEFI_KEYSTORE_PATH` (+ password input) Security controls: -- strict secret file permissions (`0600` or stricter) for key files and keystore/password files -- optional `--confirm-address` signer guard -- explicit `--from-address` to signer-address match checks in run flows +- run flows derive sender from signer when omitted; if `--from-address` is provided it must match signer address +- optional `--from-address` signer-address check in submit flows Design decision: diff --git a/internal/app/approvals_command.go b/internal/app/approvals_command.go index 0cdc40a..dd342e9 100644 --- a/internal/app/approvals_command.go +++ b/internal/app/approvals_command.go @@ -90,15 +90,22 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("from-address") var run approvalArgs - var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute an approval action", RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + if err != nil { + return err + } + runArgs := run + runArgs.fromAddress = runSenderAddress + start := time.Now() - action, err := buildAction(run) + action, err := buildAction(runArgs) status := []model.ProviderStatus{{Name: "native", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, status, false) @@ -110,13 +117,6 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) - if err != nil { - return err - } - if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { - return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") - } execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) if err != nil { return err @@ -133,12 +133,11 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { runCmd.Flags().StringVar(&run.spender, "spender", "", "Spender address") runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Amount in base units") runCmd.Flags().StringVar(&run.amountDecimal, "amount-decimal", "", "Amount in decimal units") - runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().BoolVar(&run.simulate, "simulate", true, "Run preflight simulation before submission") runCmd.Flags().StringVar(&run.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -147,11 +146,10 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("asset") _ = runCmd.MarkFlagRequired("spender") - _ = runCmd.MarkFlagRequired("from-address") var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string submitCmd := &cobra.Command{ @@ -172,10 +170,13 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if action.IntentType != "approve" { return clierr.New(clierr.CodeUsage, "action is not an approval intent") } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource) if err != nil { return err } + if strings.TrimSpace(submitFromAddress) != "" && !strings.EqualFold(strings.TrimSpace(submitFromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } @@ -193,7 +194,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") diff --git a/internal/app/bridge_execution_commands.go b/internal/app/bridge_execution_commands.go index 5fd71db..1e8a722 100644 --- a/internal/app/bridge_execution_commands.go +++ b/internal/app/bridge_execution_commands.go @@ -127,7 +127,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { var runSlippageBps int64 var runSimulate bool var runRPCURL string - var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string runCmd := &cobra.Command{ @@ -138,6 +138,10 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runFromAddress) + if err != nil { + return err + } reqStruct, err := buildRequest(runFromArg, runToArg, runAssetArg, runToAssetArg, runAmountBase, runAmountDecimal, runFromAmountForGas) if err != nil { return err @@ -146,7 +150,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { defer cancel() start := time.Now() action, providerInfoName, err := s.actionBuilderRegistry().BuildBridgeAction(ctx, providerName, reqStruct, providers.BridgeExecutionOptions{ - Sender: runFromAddress, + Sender: runSenderAddress, Recipient: runRecipient, SlippageBps: runSlippageBps, Simulate: runSimulate, @@ -167,15 +171,6 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) - if err != nil { - s.captureCommandDiagnostics(nil, statuses, false) - return err - } - if !strings.EqualFold(strings.TrimSpace(runFromAddress), txSigner.Address().Hex()) { - s.captureCommandDiagnostics(nil, statuses, false) - return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") - } execOpts, err := parseExecuteOptions(runSimulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) @@ -197,14 +192,13 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { runCmd.Flags().StringVar(&runAmountBase, "amount", "", "Amount in base units") runCmd.Flags().StringVar(&runAmountDecimal, "amount-decimal", "", "Amount in decimal units") runCmd.Flags().StringVar(&runFromAmountForGas, "from-amount-for-gas", "", "Optional amount in source token base units to reserve for destination native gas (LiFi)") - runCmd.Flags().StringVar(&runFromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&runFromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().StringVar(&runRecipient, "recipient", "", "Recipient address (defaults to --from-address)") runCmd.Flags().Int64Var(&runSlippageBps, "slippage-bps", 50, "Max slippage in basis points") runCmd.Flags().BoolVar(&runSimulate, "simulate", true, "Run preflight simulation before submission") runCmd.Flags().StringVar(&runRPCURL, "rpc-url", "", "RPC URL override for source chain") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -213,12 +207,11 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { _ = runCmd.MarkFlagRequired("from") _ = runCmd.MarkFlagRequired("to") _ = runCmd.MarkFlagRequired("asset") - _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("provider") var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string submitCmd := &cobra.Command{ @@ -239,10 +232,13 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if action.IntentType != "bridge" { return clierr.New(clierr.CodeUsage, "action is not a bridge intent") } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource) if err != nil { return err } + if strings.TrimSpace(submitFromAddress) != "" && !strings.EqualFold(strings.TrimSpace(submitFromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } @@ -260,7 +256,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go index f71ef9e..78af292 100644 --- a/internal/app/lend_execution_commands.go +++ b/internal/app/lend_execution_commands.go @@ -124,17 +124,24 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh _ = planCmd.MarkFlagRequired("protocol") var run lendArgs - var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a lend action", RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + if err != nil { + return err + } + runArgs := run + runArgs.fromAddress = runSenderAddress + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() - action, err := buildAction(ctx, run) + action, err := buildAction(ctx, runArgs) providerName := normalizeLendingProtocol(run.protocol) if providerName == "" { providerName = "lend" @@ -150,15 +157,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) - if err != nil { - s.captureCommandDiagnostics(nil, statuses, false) - return err - } - if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { - s.captureCommandDiagnostics(nil, statuses, false) - return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") - } execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) @@ -178,7 +176,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh runCmd.Flags().StringVar(&run.marketID, "market-id", "", "Morpho market unique key (required for --protocol morpho)") runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Amount in base units") runCmd.Flags().StringVar(&run.amountDecimal, "amount-decimal", "", "Amount in decimal units") - runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") runCmd.Flags().StringVar(&run.onBehalfOf, "on-behalf-of", "", "Position owner address (defaults to --from-address)") runCmd.Flags().Int64Var(&run.interestRateMode, "interest-rate-mode", 2, "Aave borrow/repay mode (1=stable,2=variable)") @@ -188,7 +186,6 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -196,12 +193,11 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("asset") - _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("protocol") var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string submitCmd := &cobra.Command{ @@ -225,10 +221,13 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if action.Status == execution.ActionStatusCompleted { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource) if err != nil { return err } + if strings.TrimSpace(submitFromAddress) != "" && !strings.EqualFold(strings.TrimSpace(submitFromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } @@ -246,7 +245,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") diff --git a/internal/app/rewards_command.go b/internal/app/rewards_command.go index 52e6bff..ef6ed8c 100644 --- a/internal/app/rewards_command.go +++ b/internal/app/rewards_command.go @@ -108,17 +108,24 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("protocol") var run claimArgs - var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a rewards-claim action", RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + if err != nil { + return err + } + runArgs := run + runArgs.fromAddress = runSenderAddress + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() - action, err := buildAction(ctx, run) + action, err := buildAction(ctx, runArgs) statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) @@ -130,15 +137,6 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) - if err != nil { - s.captureCommandDiagnostics(nil, statuses, false) - return err - } - if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { - s.captureCommandDiagnostics(nil, statuses, false) - return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") - } execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) @@ -154,7 +152,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { } runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Rewards protocol (aave)") runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") - runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") runCmd.Flags().StringVar(&run.assetsCSV, "assets", "", "Comma-separated rewards source asset addresses") runCmd.Flags().StringVar(&run.rewardToken, "reward-token", "", "Reward token address") @@ -165,21 +163,19 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") _ = runCmd.MarkFlagRequired("chain") - _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("assets") _ = runCmd.MarkFlagRequired("reward-token") _ = runCmd.MarkFlagRequired("protocol") var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string submitCmd := &cobra.Command{ @@ -203,10 +199,13 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { if action.Status == execution.ActionStatusCompleted { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource) if err != nil { return err } + if strings.TrimSpace(submitFromAddress) != "" && !strings.EqualFold(strings.TrimSpace(submitFromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } @@ -224,7 +223,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -356,17 +355,24 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("protocol") var run compoundArgs - var runSigner, runKeySource, runConfirmAddress, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a rewards-compound action", RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + if err != nil { + return err + } + runArgs := run + runArgs.fromAddress = runSenderAddress + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() - action, err := buildAction(ctx, run) + action, err := buildAction(ctx, runArgs) statuses := []model.ProviderStatus{{Name: "aave", Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { s.captureCommandDiagnostics(nil, statuses, false) @@ -378,15 +384,6 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) - if err != nil { - s.captureCommandDiagnostics(nil, statuses, false) - return err - } - if !strings.EqualFold(strings.TrimSpace(run.fromAddress), txSigner.Address().Hex()) { - s.captureCommandDiagnostics(nil, statuses, false) - return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") - } execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) @@ -402,7 +399,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { } runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Rewards protocol (aave)") runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") - runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") runCmd.Flags().StringVar(&run.onBehalfOf, "on-behalf-of", "", "Aave onBehalfOf address for compounding supply") runCmd.Flags().StringVar(&run.assetsCSV, "assets", "", "Comma-separated rewards source asset addresses") @@ -415,14 +412,12 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") _ = runCmd.MarkFlagRequired("chain") - _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("assets") _ = runCmd.MarkFlagRequired("reward-token") _ = runCmd.MarkFlagRequired("amount") @@ -430,7 +425,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitConfirmAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string submitCmd := &cobra.Command{ @@ -454,10 +449,13 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { if action.Status == execution.ActionStatusCompleted { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource) if err != nil { return err } + if strings.TrimSpace(submitFromAddress) != "" && !strings.EqualFold(strings.TrimSpace(submitFromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } @@ -475,7 +473,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") diff --git a/internal/app/runner.go b/internal/app/runner.go index 48b1053..41b2f1c 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -834,7 +834,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var runAmountBase, runAmountDecimal, runFromAddress, runRecipient string var runSlippageBps int64 var runSimulate bool - var runSigner, runKeySource, runConfirmAddress string + var runSigner, runKeySource string var runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string @@ -850,12 +850,16 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if err != nil { return err } + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runFromAddress) + if err != nil { + return err + } ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) defer cancel() start := time.Now() action, providerInfoName, err := s.actionBuilderRegistry().BuildSwapAction(ctx, providerName, "execution", reqStruct, providers.SwapExecutionOptions{ - Sender: runFromAddress, + Sender: runSenderAddress, Recipient: runRecipient, SlippageBps: runSlippageBps, Simulate: runSimulate, @@ -874,16 +878,6 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - - txSigner, err := newExecutionSigner(runSigner, runKeySource, runConfirmAddress) - if err != nil { - s.captureCommandDiagnostics(nil, statuses, false) - return err - } - if !strings.EqualFold(strings.TrimSpace(runFromAddress), txSigner.Address().Hex()) { - s.captureCommandDiagnostics(nil, statuses, false) - return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") - } execOpts, err := parseExecuteOptions(runSimulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) @@ -904,13 +898,12 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { runCmd.Flags().StringVar(&runToAssetArg, "to-asset", "", "Output asset") runCmd.Flags().StringVar(&runAmountBase, "amount", "", "Amount in base units") runCmd.Flags().StringVar(&runAmountDecimal, "amount-decimal", "", "Amount in decimal units") - runCmd.Flags().StringVar(&runFromAddress, "from-address", "", "Sender EOA address") + runCmd.Flags().StringVar(&runFromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().StringVar(&runRecipient, "recipient", "", "Recipient address (defaults to --from-address)") runCmd.Flags().Int64Var(&runSlippageBps, "slippage-bps", 50, "Max slippage in basis points") runCmd.Flags().BoolVar(&runSimulate, "simulate", true, "Run preflight simulation before submission") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - runCmd.Flags().StringVar(&runConfirmAddress, "confirm-address", "", "Require signer address to match this value") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -919,12 +912,11 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("from-asset") _ = runCmd.MarkFlagRequired("to-asset") - _ = runCmd.MarkFlagRequired("from-address") _ = runCmd.MarkFlagRequired("provider") var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitConfirmAddress string + var submitSigner, submitKeySource, submitFromAddress string var submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string @@ -950,10 +942,13 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitConfirmAddress) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource) if err != nil { return err } + if strings.TrimSpace(submitFromAddress) != "" && !strings.EqualFold(strings.TrimSpace(submitFromAddress), txSigner.Address().Hex()) { + return clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } @@ -971,7 +966,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") - submitCmd.Flags().StringVar(&submitConfirmAddress, "confirm-address", "", "Require signer address to match this value") + submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -1732,7 +1727,7 @@ func resolveActionID(actionID string) (string, error) { return actionID, nil } -func newExecutionSigner(signerBackend, keySource, confirmAddress string) (execsigner.Signer, error) { +func newExecutionSigner(signerBackend, keySource string) (execsigner.Signer, error) { signerBackend = strings.ToLower(strings.TrimSpace(signerBackend)) if signerBackend == "" { signerBackend = "local" @@ -1744,12 +1739,21 @@ func newExecutionSigner(signerBackend, keySource, confirmAddress string) (execsi if err != nil { return nil, clierr.Wrap(clierr.CodeSigner, "initialize local signer", err) } - if strings.TrimSpace(confirmAddress) != "" && !strings.EqualFold(confirmAddress, localSigner.Address().Hex()) { - return nil, clierr.New(clierr.CodeSigner, "signer address does not match --confirm-address") - } return localSigner, nil } +func resolveRunSignerAndFromAddress(signerBackend, keySource, fromAddress string) (execsigner.Signer, string, error) { + txSigner, err := newExecutionSigner(signerBackend, keySource) + if err != nil { + return nil, "", err + } + signerAddress := txSigner.Address().Hex() + if strings.TrimSpace(fromAddress) != "" && !strings.EqualFold(strings.TrimSpace(fromAddress), signerAddress) { + return nil, "", clierr.New(clierr.CodeSigner, "signer address does not match --from-address") + } + return txSigner, signerAddress, nil +} + func parseExecuteOptions(simulate bool, pollInterval, stepTimeout string, gasMultiplier float64, maxFeeGwei, maxPriorityFeeGwei string) (execution.ExecuteOptions, error) { opts := execution.DefaultExecuteOptions() opts.Simulate = simulate diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go index 148b770..1a1efa1 100644 --- a/internal/app/runner_actions_test.go +++ b/internal/app/runner_actions_test.go @@ -6,8 +6,12 @@ import ( "fmt" "strings" "testing" + + execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" ) +const runSignerTestPrivateKey = "59c6995e998f97a5a0044976f0945388cf9b7e5e5f4f9d2d9d8f1f5b7f6d11d1" + func TestResolveActionID(t *testing.T) { id, err := resolveActionID("act_123") if err != nil { @@ -22,6 +26,34 @@ func TestResolveActionID(t *testing.T) { } } +func TestResolveRunSignerAndFromAddressDefaultsToSigner(t *testing.T) { + t.Setenv(execsigner.EnvPrivateKey, runSignerTestPrivateKey) + txSigner, fromAddress, err := resolveRunSignerAndFromAddress("local", execsigner.KeySourceEnv, "") + if err != nil { + t.Fatalf("resolveRunSignerAndFromAddress failed: %v", err) + } + if txSigner == nil { + t.Fatal("expected non-nil signer") + } + if fromAddress == "" { + t.Fatal("expected non-empty from address") + } + if !strings.EqualFold(fromAddress, txSigner.Address().Hex()) { + t.Fatalf("expected from address %s to match signer %s", fromAddress, txSigner.Address().Hex()) + } +} + +func TestResolveRunSignerAndFromAddressRejectsMismatch(t *testing.T) { + t.Setenv(execsigner.EnvPrivateKey, runSignerTestPrivateKey) + _, _, err := resolveRunSignerAndFromAddress("local", execsigner.KeySourceEnv, "0x0000000000000000000000000000000000000001") + if err == nil { + t.Fatal("expected mismatch error") + } + if !strings.Contains(err.Error(), "--from-address") { + t.Fatalf("expected --from-address mismatch error, got: %v", err) + } +} + func TestShouldOpenActionStore(t *testing.T) { if !shouldOpenActionStore("swap run") { t.Fatal("expected swap run to require action store") diff --git a/internal/execution/signer/local.go b/internal/execution/signer/local.go index 5558bab..5da50ed 100644 --- a/internal/execution/signer/local.go +++ b/internal/execution/signer/local.go @@ -6,6 +6,7 @@ import ( "fmt" "math/big" "os" + "path/filepath" "strings" "github.com/ethereum/go-ethereum/accounts/keystore" @@ -25,6 +26,8 @@ const ( KeySourceEnv = "env" KeySourceFile = "file" KeySourceKeystore = "keystore" + + defaultPrivateKeyRelativePath = "defi/key.hex" ) type LocalSigner struct { @@ -54,6 +57,9 @@ func NewLocalSignerFromEnv(source string) (*LocalSigner, error) { keystorePath := strings.TrimSpace(os.Getenv(EnvKeystorePath)) keystorePassword := strings.TrimSpace(os.Getenv(EnvKeystorePassword)) keystorePasswordFile := strings.TrimSpace(os.Getenv(EnvKeystorePasswordFile)) + if privateKeyFile == "" { + privateKeyFile = discoverDefaultPrivateKeyFile() + } switch source { case KeySourceAuto: @@ -110,9 +116,6 @@ func loadPrivateKey(cfg LocalSignerConfig) (*ecdsa.PrivateKey, error) { return parseHexKey(cfg.PrivateKeyHex) } if strings.TrimSpace(cfg.PrivateKeyFile) != "" { - if err := validateFilePermissions(cfg.PrivateKeyFile); err != nil { - return nil, err - } buf, err := os.ReadFile(cfg.PrivateKeyFile) if err != nil { return nil, fmt.Errorf("read private key file: %w", err) @@ -120,14 +123,8 @@ func loadPrivateKey(cfg LocalSignerConfig) (*ecdsa.PrivateKey, error) { return parseHexKey(string(buf)) } if strings.TrimSpace(cfg.KeystorePath) != "" { - if err := validateFilePermissions(cfg.KeystorePath); err != nil { - return nil, err - } password := cfg.KeystorePassword if strings.TrimSpace(password) == "" && strings.TrimSpace(cfg.KeystorePasswordFile) != "" { - if err := validateFilePermissions(cfg.KeystorePasswordFile); err != nil { - return nil, err - } buf, err := os.ReadFile(cfg.KeystorePasswordFile) if err != nil { return nil, fmt.Errorf("read keystore password file: %w", err) @@ -163,13 +160,25 @@ func parseHexKey(raw string) (*ecdsa.PrivateKey, error) { return pk, nil } -func validateFilePermissions(path string) error { +func discoverDefaultPrivateKeyFile() string { + base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")) + if base == "" { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "" + } + base = filepath.Join(home, ".config") + } + path := filepath.Join(base, defaultPrivateKeyRelativePath) + if path == "" { + return "" + } info, err := os.Stat(path) if err != nil { - return fmt.Errorf("stat secret file: %w", err) + return "" } - if info.Mode().Perm()&0o077 != 0 { - return fmt.Errorf("insecure file permissions on %s: expected 0600 or stricter", path) + if info.IsDir() { + return "" } - return nil + return path } diff --git a/internal/execution/signer/local_test.go b/internal/execution/signer/local_test.go index f40f174..0ffc003 100644 --- a/internal/execution/signer/local_test.go +++ b/internal/execution/signer/local_test.go @@ -50,15 +50,39 @@ func TestNewLocalSignerFromEnvFile(t *testing.T) { } } -func TestNewLocalSignerRejectsInsecurePermissions(t *testing.T) { +func TestNewLocalSignerFromEnvFileAllowsNonStrictPermissions(t *testing.T) { dir := t.TempDir() keyFile := filepath.Join(dir, "key.txt") if err := os.WriteFile(keyFile, []byte(testPrivateKey), 0o644); err != nil { t.Fatalf("write key file: %v", err) } t.Setenv(EnvPrivateKeyFile, keyFile) - if _, err := NewLocalSignerFromEnv(KeySourceFile); err == nil { - t.Fatal("expected insecure permissions error") + if _, err := NewLocalSignerFromEnv(KeySourceFile); err != nil { + t.Fatalf("expected non-strict permission key file to load: %v", err) + } +} + +func TestNewLocalSignerFromEnvAutoUsesDefaultKeyFile(t *testing.T) { + cfgDir := t.TempDir() + keyDir := filepath.Join(cfgDir, "defi") + keyFile := filepath.Join(keyDir, "key.hex") + if err := os.MkdirAll(keyDir, 0o755); err != nil { + t.Fatalf("create config dir: %v", err) + } + if err := os.WriteFile(keyFile, []byte(testPrivateKey), 0o644); err != nil { + t.Fatalf("write key file: %v", err) + } + t.Setenv("XDG_CONFIG_HOME", cfgDir) + t.Setenv(EnvPrivateKey, "") + t.Setenv(EnvPrivateKeyFile, "") + t.Setenv(EnvKeystorePath, "") + + s, err := NewLocalSignerFromEnv(KeySourceAuto) + if err != nil { + t.Fatalf("expected auto key-source to use default key path: %v", err) + } + if s.Address() == (common.Address{}) { + t.Fatal("expected non-zero signer address") } } From 7c56f423aa8cea1abcb8a10fe7d5ed5de78e9fb4 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 08:42:55 -0400 Subject: [PATCH 05/18] execution: harden simulation, signing, and tx broadcast flows --- CHANGELOG.md | 3 + internal/app/approvals_command.go | 28 ++- internal/app/bridge_execution_commands.go | 28 ++- internal/app/lend_execution_commands.go | 28 ++- internal/app/rewards_command.go | 56 +++++- internal/app/runner.go | 43 ++++- internal/app/runner_actions_test.go | 22 +++ internal/execution/executor.go | 224 ++++++++++++++++------ internal/execution/executor_error_test.go | 149 ++++++++++++++ internal/execution/planner/morpho.go | 2 +- internal/execution/policy_basic.go | 170 ++++++++++++++++ internal/execution/policy_basic_test.go | 83 ++++++++ internal/providers/across/client.go | 5 +- internal/providers/lifi/client.go | 2 +- internal/providers/morpho/client.go | 3 +- internal/registry/execution_data.go | 99 +++++++++- internal/registry/execution_data_test.go | 41 ++++ 17 files changed, 911 insertions(+), 75 deletions(-) create mode 100644 internal/execution/executor_error_test.go create mode 100644 internal/execution/policy_basic.go create mode 100644 internal/execution/policy_basic_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2048d69..956ba62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ Format: - Execution `run` commands now default sender to signer address when `--from-address` is omitted. - Local signer `--key-source auto` now discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. - Local signer key/keystore file loading no longer hard-fails on non-`0600` file permissions. +- Execution endpoint defaults for Across/LiFi settlement polling and Morpho GraphQL planning are now centralized in `internal/registry`. +- Execution pre-sign validation now enforces bounded ERC-20 approvals by default and validates TaikoSwap router/selector invariants before signing. +- Execution `run`/`submit` commands now expose `--allow-max-approval` and `--unsafe-provider-tx` overrides for advanced/provider-specific flows. ### Fixed - Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. diff --git a/internal/app/approvals_command.go b/internal/app/approvals_command.go index dd342e9..3c4a84f 100644 --- a/internal/app/approvals_command.go +++ b/internal/app/approvals_command.go @@ -93,6 +93,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string + var runAllowMaxApproval, runUnsafeProviderTx bool runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute an approval action", @@ -117,7 +118,16 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) if err != nil { return err } @@ -143,6 +153,8 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + runCmd.Flags().BoolVar(&runUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("asset") _ = runCmd.MarkFlagRequired("spender") @@ -152,6 +164,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + var submitAllowMaxApproval, submitUnsafeProviderTx bool submitCmd := &cobra.Command{ Use: "submit", Short: "Execute an existing approval action", @@ -180,7 +193,16 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } - execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) if err != nil { return err } @@ -200,6 +222,8 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + submitCmd.Flags().BoolVar(&submitAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + submitCmd.Flags().BoolVar(&submitUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") var statusActionID string statusCmd := &cobra.Command{ diff --git a/internal/app/bridge_execution_commands.go b/internal/app/bridge_execution_commands.go index 1e8a722..afbd0a4 100644 --- a/internal/app/bridge_execution_commands.go +++ b/internal/app/bridge_execution_commands.go @@ -130,6 +130,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string + var runAllowMaxApproval, runUnsafeProviderTx bool runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a bridge action", @@ -171,7 +172,16 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - execOpts, err := parseExecuteOptions(runSimulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + runSimulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -204,6 +214,8 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + runCmd.Flags().BoolVar(&runUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") _ = runCmd.MarkFlagRequired("from") _ = runCmd.MarkFlagRequired("to") _ = runCmd.MarkFlagRequired("asset") @@ -214,6 +226,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + var submitAllowMaxApproval, submitUnsafeProviderTx bool submitCmd := &cobra.Command{ Use: "submit", Short: "Execute an existing bridge action", @@ -242,7 +255,16 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } - execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) if err != nil { return err } @@ -262,6 +284,8 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + submitCmd.Flags().BoolVar(&submitAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + submitCmd.Flags().BoolVar(&submitUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") var statusActionID string statusCmd := &cobra.Command{ diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go index 78af292..c153017 100644 --- a/internal/app/lend_execution_commands.go +++ b/internal/app/lend_execution_commands.go @@ -127,6 +127,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string + var runAllowMaxApproval, runUnsafeProviderTx bool runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a lend action", @@ -157,7 +158,16 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -191,6 +201,8 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + runCmd.Flags().BoolVar(&runUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("asset") _ = runCmd.MarkFlagRequired("protocol") @@ -200,6 +212,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + var submitAllowMaxApproval, submitUnsafeProviderTx bool submitCmd := &cobra.Command{ Use: "submit", Short: "Execute an existing lend action", @@ -231,7 +244,16 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } - execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) if err != nil { return err } @@ -251,6 +273,8 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + submitCmd.Flags().BoolVar(&submitAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + submitCmd.Flags().BoolVar(&submitUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") var statusActionID string statusCmd := &cobra.Command{ diff --git a/internal/app/rewards_command.go b/internal/app/rewards_command.go index ef6ed8c..6d8deee 100644 --- a/internal/app/rewards_command.go +++ b/internal/app/rewards_command.go @@ -111,6 +111,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string + var runAllowMaxApproval, runUnsafeProviderTx bool runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a rewards-claim action", @@ -137,7 +138,16 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -168,6 +178,8 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + runCmd.Flags().BoolVar(&runUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("assets") _ = runCmd.MarkFlagRequired("reward-token") @@ -178,6 +190,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + var submitAllowMaxApproval, submitUnsafeProviderTx bool submitCmd := &cobra.Command{ Use: "submit", Short: "Execute an existing rewards-claim action", @@ -209,7 +222,16 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } - execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) if err != nil { return err } @@ -229,6 +251,8 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + submitCmd.Flags().BoolVar(&submitAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + submitCmd.Flags().BoolVar(&submitUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") var statusActionID string statusCmd := &cobra.Command{ @@ -358,6 +382,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { var runSigner, runKeySource, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string + var runAllowMaxApproval, runUnsafeProviderTx bool runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a rewards-compound action", @@ -384,7 +409,16 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - execOpts, err := parseExecuteOptions(run.simulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -417,6 +451,8 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + runCmd.Flags().BoolVar(&runUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("assets") _ = runCmd.MarkFlagRequired("reward-token") @@ -428,6 +464,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + var submitAllowMaxApproval, submitUnsafeProviderTx bool submitCmd := &cobra.Command{ Use: "submit", Short: "Execute an existing rewards-compound action", @@ -459,7 +496,16 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } - execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) if err != nil { return err } @@ -479,6 +525,8 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + submitCmd.Flags().BoolVar(&submitAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + submitCmd.Flags().BoolVar(&submitUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") var statusActionID string statusCmd := &cobra.Command{ diff --git a/internal/app/runner.go b/internal/app/runner.go index 41b2f1c..13ac5f0 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -838,6 +838,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string + var runAllowMaxApproval, runUnsafeProviderTx bool runCmd := &cobra.Command{ Use: "run", Short: "Plan and execute a swap action in one command", @@ -878,7 +879,16 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if err := s.actionStore.Save(action); err != nil { return clierr.Wrap(clierr.CodeInternal, "persist planned action", err) } - execOpts, err := parseExecuteOptions(runSimulate, runPollInterval, runStepTimeout, runGasMultiplier, runMaxFeeGwei, runMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + runSimulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) if err != nil { s.captureCommandDiagnostics(nil, statuses, false) return err @@ -909,6 +919,8 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") runCmd.Flags().StringVar(&runMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") runCmd.Flags().StringVar(&runMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + runCmd.Flags().BoolVar(&runAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + runCmd.Flags().BoolVar(&runUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("from-asset") _ = runCmd.MarkFlagRequired("to-asset") @@ -920,6 +932,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string + var submitAllowMaxApproval, submitUnsafeProviderTx bool submitCmd := &cobra.Command{ Use: "submit", Short: "Execute a previously planned swap action", @@ -952,7 +965,16 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if strings.TrimSpace(action.FromAddress) != "" && !strings.EqualFold(strings.TrimSpace(action.FromAddress), txSigner.Address().Hex()) { return clierr.New(clierr.CodeSigner, "signer address does not match planned action sender") } - execOpts, err := parseExecuteOptions(submitSimulate, submitPollInterval, submitStepTimeout, submitGasMultiplier, submitMaxFeeGwei, submitMaxPriorityFeeGwei) + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) if err != nil { return err } @@ -972,6 +994,8 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { submitCmd.Flags().Float64Var(&submitGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") submitCmd.Flags().StringVar(&submitMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") submitCmd.Flags().StringVar(&submitMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + submitCmd.Flags().BoolVar(&submitAllowMaxApproval, "allow-max-approval", false, "Allow approval amounts greater than planned input amount") + submitCmd.Flags().BoolVar(&submitUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") var statusActionID string statusCmd := &cobra.Command{ @@ -1754,7 +1778,14 @@ func resolveRunSignerAndFromAddress(signerBackend, keySource, fromAddress string return txSigner, signerAddress, nil } -func parseExecuteOptions(simulate bool, pollInterval, stepTimeout string, gasMultiplier float64, maxFeeGwei, maxPriorityFeeGwei string) (execution.ExecuteOptions, error) { +func parseExecuteOptions( + simulate bool, + pollInterval, stepTimeout string, + gasMultiplier float64, + maxFeeGwei, maxPriorityFeeGwei string, + allowMaxApproval bool, + unsafeProviderTx bool, +) (execution.ExecuteOptions, error) { opts := execution.DefaultExecuteOptions() opts.Simulate = simulate if strings.TrimSpace(pollInterval) != "" { @@ -1777,12 +1808,14 @@ func parseExecuteOptions(simulate bool, pollInterval, stepTimeout string, gasMul } opts.StepTimeout = d } - if gasMultiplier <= 0 { - return execution.ExecuteOptions{}, clierr.New(clierr.CodeUsage, "--gas-multiplier must be > 0") + if gasMultiplier <= 1 { + return execution.ExecuteOptions{}, clierr.New(clierr.CodeUsage, "--gas-multiplier must be > 1") } opts.GasMultiplier = gasMultiplier opts.MaxFeeGwei = strings.TrimSpace(maxFeeGwei) opts.MaxPriorityFeeGwei = strings.TrimSpace(maxPriorityFeeGwei) + opts.AllowMaxApproval = allowMaxApproval + opts.UnsafeProviderTx = unsafeProviderTx return opts, nil } diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go index 1a1efa1..015f2b5 100644 --- a/internal/app/runner_actions_test.go +++ b/internal/app/runner_actions_test.go @@ -54,6 +54,28 @@ func TestResolveRunSignerAndFromAddressRejectsMismatch(t *testing.T) { } } +func TestParseExecuteOptionsRejectsGasMultiplierLTEOne(t *testing.T) { + if _, err := parseExecuteOptions(true, "2s", "2m", 1, "", "", false, false); err == nil { + t.Fatal("expected gas multiplier <= 1 to fail") + } +} + +func TestParseExecuteOptionsAcceptsGasMultiplierAboveOne(t *testing.T) { + opts, err := parseExecuteOptions(true, "2s", "2m", 1.05, "", "", true, true) + if err != nil { + t.Fatalf("expected parseExecuteOptions to succeed, got %v", err) + } + if opts.GasMultiplier != 1.05 { + t.Fatalf("expected gas multiplier 1.05, got %f", opts.GasMultiplier) + } + if !opts.AllowMaxApproval { + t.Fatal("expected AllowMaxApproval=true") + } + if !opts.UnsafeProviderTx { + t.Fatal("expected UnsafeProviderTx=true") + } +} + func TestShouldOpenActionStore(t *testing.T) { if !shouldOpenActionStore("swap run") { t.Fatal("expected swap run to require action store") diff --git a/internal/execution/executor.go b/internal/execution/executor.go index 4378747..38bbcff 100644 --- a/internal/execution/executor.go +++ b/internal/execution/executor.go @@ -3,21 +3,24 @@ package execution import ( "context" "encoding/hex" - "encoding/json" "errors" "fmt" "math/big" "net/http" "net/url" "strings" + "sync" "time" "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" clierr "github.com/ggonzalez94/defi-cli/internal/errors" "github.com/ggonzalez94/defi-cli/internal/execution/signer" + "github.com/ggonzalez94/defi-cli/internal/httpx" + "github.com/ggonzalez94/defi-cli/internal/registry" ) type ExecuteOptions struct { @@ -27,8 +30,15 @@ type ExecuteOptions struct { GasMultiplier float64 MaxFeeGwei string MaxPriorityFeeGwei string + AllowMaxApproval bool + UnsafeProviderTx bool } +var ( + settlementHTTPClient = httpx.New(10*time.Second, 2) + signerNonceLocks sync.Map +) + func DefaultExecuteOptions() ExecuteOptions { return ExecuteOptions{ Simulate: true, @@ -55,14 +65,17 @@ func ExecuteAction(ctx context.Context, store *Store, action *Action, txSigner s opts.StepTimeout = 2 * time.Minute } if opts.GasMultiplier <= 1 { - opts.GasMultiplier = 1.2 + return clierr.New(clierr.CodeUsage, "gas multiplier must be > 1") + } + persist := func() { + action.Touch() + if store != nil { + _ = store.Save(*action) + } } action.Status = ActionStatusRunning action.FromAddress = txSigner.Address().Hex() - action.Touch() - if store != nil { - _ = store.Save(*action) - } + persist() for i := range action.Steps { step := &action.Steps[i] @@ -71,50 +84,43 @@ func ExecuteAction(ctx context.Context, store *Store, action *Action, txSigner s } if strings.TrimSpace(step.RPCURL) == "" { markStepFailed(action, step, "missing rpc url") - if store != nil { - _ = store.Save(*action) - } + persist() return clierr.New(clierr.CodeUsage, "missing rpc url for action step") } if strings.TrimSpace(step.Target) == "" { markStepFailed(action, step, "missing target") - if store != nil { - _ = store.Save(*action) - } + persist() return clierr.New(clierr.CodeUsage, "missing target for action step") } + if !common.IsHexAddress(step.Target) { + markStepFailed(action, step, "invalid target address") + persist() + return clierr.New(clierr.CodeUsage, "invalid target address for action step") + } client, err := ethclient.DialContext(ctx, step.RPCURL) if err != nil { markStepFailed(action, step, err.Error()) - if store != nil { - _ = store.Save(*action) - } + persist() return clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) } - if err := executeStep(ctx, client, txSigner, step, opts); err != nil { + if err := executeStep(ctx, client, txSigner, action, step, opts, persist); err != nil { client.Close() - markStepFailed(action, step, err.Error()) - if store != nil { - _ = store.Save(*action) + if step.Status != StepStatusFailed { + markStepFailed(action, step, err.Error()) } + persist() return err } client.Close() - action.Touch() - if store != nil { - _ = store.Save(*action) - } + persist() } action.Status = ActionStatusCompleted - action.Touch() - if store != nil { - _ = store.Save(*action) - } + persist() return nil } -func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer.Signer, step *ActionStep, opts ExecuteOptions) error { +func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer.Signer, action *Action, step *ActionStep, opts ExecuteOptions, persist func()) error { chainID, err := client.ChainID(ctx) if err != nil { return clierr.Wrap(clierr.CodeUnavailable, "read chain id", err) @@ -125,29 +131,45 @@ func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer. return clierr.New(clierr.CodeActionPlan, fmt.Sprintf("step chain mismatch: expected %s, got %s", expected, step.ChainID)) } } + if !common.IsHexAddress(step.Target) { + return clierr.New(clierr.CodeUsage, "invalid step target address") + } target := common.HexToAddress(step.Target) + step.Target = target.Hex() data, err := decodeHex(step.Data) if err != nil { return clierr.Wrap(clierr.CodeUsage, "decode step calldata", err) } + if err := validateStepPolicy(action, step, chainID.Int64(), data, opts); err != nil { + return err + } value, ok := new(big.Int).SetString(step.Value, 10) if !ok { return clierr.New(clierr.CodeUsage, "invalid step value") } msg := ethereum.CallMsg{From: txSigner.Address(), To: &target, Value: value, Data: data} + if txHash, ok := normalizeStepTxHash(step.TxHash); ok { + step.Status = StepStatusSubmitted + safePersist(persist) + return waitForStepConfirmation(ctx, client, step, msg, txHash, opts, persist) + } if opts.Simulate { if _, err := client.CallContract(ctx, msg, nil); err != nil { - return clierr.Wrap(clierr.CodeActionSim, "simulate step (eth_call)", err) + return wrapEVMExecutionError(clierr.CodeActionSim, "simulate step (eth_call)", err) } step.Status = StepStatusSimulated + safePersist(persist) } gasLimit, err := client.EstimateGas(ctx, msg) if err != nil { - return clierr.Wrap(clierr.CodeActionSim, "estimate gas", err) + return wrapEVMExecutionError(clierr.CodeActionSim, "estimate gas", err) } gasLimit = uint64(float64(gasLimit) * opts.GasMultiplier) + if gasLimit == 0 { + return clierr.New(clierr.CodeActionSim, "estimate gas returned zero") + } tipCap, err := resolveTipCap(ctx, client, opts.MaxPriorityFeeGwei) if err != nil { @@ -165,7 +187,8 @@ func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer. if err != nil { return err } - + unlockNonce := acquireSignerNonceLock(chainID, txSigner.Address()) + defer unlockNonce() nonce, err := client.PendingNonceAt(ctx, txSigner.Address()) if err != nil { return clierr.Wrap(clierr.CodeUnavailable, "fetch nonce", err) @@ -186,25 +209,33 @@ func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer. return clierr.Wrap(clierr.CodeSigner, "sign transaction", err) } if err := client.SendTransaction(ctx, signed); err != nil { - return clierr.Wrap(clierr.CodeUnavailable, "broadcast transaction", err) + return wrapEVMExecutionError(clierr.CodeUnavailable, "broadcast transaction", err) } step.Status = StepStatusSubmitted step.TxHash = signed.Hash().Hex() + safePersist(persist) + return waitForStepConfirmation(ctx, client, step, msg, signed.Hash(), opts, persist) +} +func waitForStepConfirmation(ctx context.Context, client *ethclient.Client, step *ActionStep, msg ethereum.CallMsg, txHash common.Hash, opts ExecuteOptions, persist func()) error { waitCtx, cancel := context.WithTimeout(ctx, opts.StepTimeout) defer cancel() ticker := time.NewTicker(opts.PollInterval) defer ticker.Stop() for { - receipt, err := client.TransactionReceipt(waitCtx, signed.Hash()) + receipt, err := client.TransactionReceipt(waitCtx, txHash) if err == nil && receipt != nil { if receipt.Status == types.ReceiptStatusSuccessful { - if err := verifyBridgeSettlement(ctx, step, signed.Hash().Hex(), opts); err != nil { + if err := verifyBridgeSettlement(ctx, step, txHash.Hex(), opts); err != nil { return err } step.Status = StepStatusConfirmed + safePersist(persist) return nil } + if reason := decodeReceiptRevertReason(waitCtx, client, msg, receipt.BlockNumber); reason != "" { + return clierr.New(clierr.CodeUnavailable, "transaction reverted on-chain: "+reason) + } return clierr.New(clierr.CodeUnavailable, "transaction reverted on-chain") } if waitCtx.Err() != nil { @@ -221,6 +252,103 @@ func executeStep(ctx context.Context, client *ethclient.Client, txSigner signer. } } +func safePersist(persist func()) { + if persist == nil { + return + } + persist() +} + +func normalizeStepTxHash(value string) (common.Hash, bool) { + hash := strings.TrimSpace(value) + if hash == "" { + return common.Hash{}, false + } + decoded, err := decodeHex(hash) + if err != nil || len(decoded) != common.HashLength { + return common.Hash{}, false + } + return common.HexToHash(hash), true +} + +func acquireSignerNonceLock(chainID *big.Int, signerAddress common.Address) func() { + key := strings.ToLower(chainID.String() + ":" + signerAddress.Hex()) + lockAny, _ := signerNonceLocks.LoadOrStore(key, &sync.Mutex{}) + lock := lockAny.(*sync.Mutex) + lock.Lock() + return lock.Unlock +} + +func wrapEVMExecutionError(code clierr.Code, operation string, err error) error { + revert := decodeRevertFromError(err) + if revert == "" { + return clierr.Wrap(code, operation, err) + } + return clierr.Wrap(code, operation+": "+revert, err) +} + +func decodeReceiptRevertReason(ctx context.Context, client *ethclient.Client, msg ethereum.CallMsg, blockNumber *big.Int) string { + if client == nil { + return "" + } + callCtx := ctx + if callCtx == nil { + callCtx = context.Background() + } + callCtx, cancel := context.WithTimeout(callCtx, 5*time.Second) + defer cancel() + _, err := client.CallContract(callCtx, msg, blockNumber) + return decodeRevertFromError(err) +} + +type rpcDataError interface { + error + ErrorData() interface{} +} + +func decodeRevertFromError(err error) string { + if err == nil { + return "" + } + var dataErr rpcDataError + if errors.As(err, &dataErr) { + return decodeRevertData(dataErr.ErrorData()) + } + return "" +} + +func decodeRevertData(data any) string { + bytesData, ok := normalizeErrorData(data) + if !ok || len(bytesData) == 0 { + return "" + } + if reason, err := abi.UnpackRevert(bytesData); err == nil && strings.TrimSpace(reason) != "" { + return reason + } + if len(bytesData) >= 4 { + return fmt.Sprintf("custom error selector 0x%s", hex.EncodeToString(bytesData[:4])) + } + return "" +} + +func normalizeErrorData(data any) ([]byte, bool) { + switch v := data.(type) { + case []byte: + if len(v) == 0 { + return nil, false + } + return v, true + case string: + decoded, err := decodeHex(v) + if err != nil || len(decoded) == 0 { + return nil, false + } + return decoded, true + default: + return nil, false + } +} + func verifyBridgeSettlement(ctx context.Context, step *ActionStep, sourceTxHash string, opts ExecuteOptions) error { if step == nil || step.Type != StepTypeBridge { return nil @@ -236,13 +364,13 @@ func verifyBridgeSettlement(ctx context.Context, step *ActionStep, sourceTxHash case "lifi": statusEndpoint := strings.TrimSpace(step.ExpectedOutputs["settlement_status_endpoint"]) if statusEndpoint == "" { - statusEndpoint = "https://li.quest/v1/status" + statusEndpoint = registry.LiFiSettlementURL } return waitForLiFiSettlement(ctx, step, sourceTxHash, statusEndpoint, opts) case "across": statusEndpoint := strings.TrimSpace(step.ExpectedOutputs["settlement_status_endpoint"]) if statusEndpoint == "" { - statusEndpoint = "https://app.across.to/api/deposit/status" + statusEndpoint = registry.AcrossSettlementURL } return waitForAcrossSettlement(ctx, step, sourceTxHash, statusEndpoint, opts) default: @@ -368,7 +496,7 @@ func queryLiFiStatus(ctx context.Context, sourceTxHash, statusEndpoint string, e endpoint := strings.TrimSpace(statusEndpoint) if endpoint == "" { - endpoint = "https://li.quest/v1/status" + endpoint = registry.LiFiSettlementURL } parsed, err := url.Parse(endpoint) if err != nil { @@ -391,14 +519,8 @@ func queryLiFiStatus(ctx context.Context, sourceTxHash, statusEndpoint string, e if err != nil { return out, err } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return out, err - } - defer resp.Body.Close() - - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return out, err + if _, err := settlementHTTPClient.DoJSON(ctx, req, &out); err != nil { + return out, clierr.Wrap(clierr.CodeUnavailable, "query lifi settlement status", err) } if out.Code != 0 && out.Status == "" { // LiFi can report pending/non-indexed transfers with API-level codes. @@ -415,7 +537,7 @@ func queryAcrossStatus(ctx context.Context, sourceTxHash, statusEndpoint string, endpoint := strings.TrimSpace(statusEndpoint) if endpoint == "" { - endpoint = "https://app.across.to/api/deposit/status" + endpoint = registry.AcrossSettlementURL } parsed, err := url.Parse(endpoint) if err != nil { @@ -435,14 +557,8 @@ func queryAcrossStatus(ctx context.Context, sourceTxHash, statusEndpoint string, if err != nil { return out, err } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return out, err - } - defer resp.Body.Close() - - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return out, err + if _, err := settlementHTTPClient.DoJSON(ctx, req, &out); err != nil { + return out, clierr.Wrap(clierr.CodeUnavailable, "query across settlement status", err) } if strings.TrimSpace(out.Error) != "" { if strings.EqualFold(strings.TrimSpace(out.Error), "DepositNotFoundException") { diff --git a/internal/execution/executor_error_test.go b/internal/execution/executor_error_test.go new file mode 100644 index 0000000..29ae483 --- /dev/null +++ b/internal/execution/executor_error_test.go @@ -0,0 +1,149 @@ +package execution + +import ( + "context" + "errors" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" +) + +type testRPCDataError struct { + msg string + data any +} + +func (e testRPCDataError) Error() string { return e.msg } + +func (e testRPCDataError) ErrorData() interface{} { return e.data } + +func TestDecodeRevertDataReasonString(t *testing.T) { + revertData := encodeErrorString(t, "slippage too high") + reason := decodeRevertData(revertData) + if reason != "slippage too high" { + t.Fatalf("expected decoded revert reason, got %q", reason) + } +} + +func TestDecodeRevertDataCustomErrorSelector(t *testing.T) { + revertData := common.FromHex("0x12345678") + reason := decodeRevertData(revertData) + if !strings.Contains(reason, "0x12345678") { + t.Fatalf("expected custom error selector in reason, got %q", reason) + } +} + +func TestDecodeRevertFromErrorWithDataError(t *testing.T) { + revertData := encodeErrorString(t, "insufficient output amount") + err := testRPCDataError{ + msg: "execution reverted", + data: "0x" + common.Bytes2Hex(revertData), + } + reason := decodeRevertFromError(err) + if reason != "insufficient output amount" { + t.Fatalf("unexpected decoded reason: %q", reason) + } +} + +func TestWrapEVMExecutionErrorIncludesDecodedRevert(t *testing.T) { + revertData := encodeErrorString(t, "panic path") + rootErr := testRPCDataError{ + msg: "execution reverted", + data: "0x" + common.Bytes2Hex(revertData), + } + wrapped := wrapEVMExecutionError(clierr.CodeActionSim, "simulate step (eth_call)", rootErr) + var typed *clierr.Error + if !errors.As(wrapped, &typed) { + t.Fatalf("expected typed cli error, got %T", wrapped) + } + if !strings.Contains(typed.Error(), "panic path") { + t.Fatalf("expected decoded reason in wrapped error, got: %v", typed) + } +} + +func TestNormalizeStepTxHash(t *testing.T) { + validHash := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if _, ok := normalizeStepTxHash(validHash); !ok { + t.Fatal("expected valid tx hash to parse") + } + if _, ok := normalizeStepTxHash("0x1234"); ok { + t.Fatal("expected short tx hash to fail") + } +} + +func TestExecuteActionRejectsInvalidStepTargetBeforeRPCDial(t *testing.T) { + action := NewAction("act_test", "swap", "eip155:1", Constraints{Simulate: true}) + action.Steps = append(action.Steps, ActionStep{ + StepID: "step-1", + Type: StepTypeSwap, + Status: StepStatusPending, + ChainID: "eip155:1", + RPCURL: "http://127.0.0.1:65535", + Target: "not-an-address", + Data: "0x", + Value: "0", + }) + err := ExecuteAction(context.Background(), nil, &action, staticSigner{}, DefaultExecuteOptions()) + if err == nil { + t.Fatal("expected invalid target error") + } + typed, ok := clierr.As(err) + if !ok || typed.Code != clierr.CodeUsage { + t.Fatalf("expected usage error, got %v", err) + } + if action.Steps[0].Status != StepStatusFailed { + t.Fatalf("expected step to be marked failed, got %s", action.Steps[0].Status) + } +} + +func TestAcquireSignerNonceLockSerializesSameSignerChain(t *testing.T) { + unlock := acquireSignerNonceLock(big.NewInt(1), common.HexToAddress("0x00000000000000000000000000000000000000aa")) + secondAcquired := make(chan struct{}) + go func() { + unlockSecond := acquireSignerNonceLock(big.NewInt(1), common.HexToAddress("0x00000000000000000000000000000000000000aa")) + close(secondAcquired) + unlockSecond() + }() + + select { + case <-secondAcquired: + t.Fatal("expected second lock attempt to block while first lock is held") + case <-time.After(50 * time.Millisecond): + } + unlock() + select { + case <-secondAcquired: + case <-time.After(250 * time.Millisecond): + t.Fatal("expected second lock attempt to acquire after unlock") + } +} + +func encodeErrorString(t *testing.T, reason string) []byte { + t.Helper() + stringTy, err := abi.NewType("string", "", nil) + if err != nil { + t.Fatalf("create abi string type: %v", err) + } + args := abi.Arguments{{Type: stringTy}} + encoded, err := args.Pack(reason) + if err != nil { + t.Fatalf("pack revert reason: %v", err) + } + return append(common.FromHex("0x08c379a0"), encoded...) +} + +type staticSigner struct{} + +func (staticSigner) Address() common.Address { + return common.HexToAddress("0x00000000000000000000000000000000000000aa") +} + +func (staticSigner) SignTx(_ *big.Int, tx *types.Transaction) (*types.Transaction, error) { + return tx, nil +} diff --git a/internal/execution/planner/morpho.go b/internal/execution/planner/morpho.go index 18d44f2..301eb13 100644 --- a/internal/execution/planner/morpho.go +++ b/internal/execution/planner/morpho.go @@ -19,7 +19,7 @@ import ( "github.com/ggonzalez94/defi-cli/internal/registry" ) -const defaultMorphoGraphQLEndpoint = "https://api.morpho.org/graphql" +const defaultMorphoGraphQLEndpoint = registry.MorphoGraphQLEndpoint var morphoGraphQLEndpoint = defaultMorphoGraphQLEndpoint diff --git a/internal/execution/policy_basic.go b/internal/execution/policy_basic.go new file mode 100644 index 0000000..2ce778a --- /dev/null +++ b/internal/execution/policy_basic.go @@ -0,0 +1,170 @@ +package execution + +import ( + "bytes" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + "github.com/ggonzalez94/defi-cli/internal/registry" +) + +var ( + policyERC20ABI = mustPolicyABI(registry.ERC20MinimalABI) + policyTaikoRouterABI = mustPolicyABI(registry.TaikoSwapRouterABI) + + policyApproveSelector = policyERC20ABI.Methods["approve"].ID + policyTaikoSwapMethod = policyTaikoRouterABI.Methods["exactInputSingle"].ID +) + +func validateStepPolicy(action *Action, step *ActionStep, chainID int64, data []byte, opts ExecuteOptions) error { + if step == nil { + return clierr.New(clierr.CodeInternal, "missing action step") + } + if !common.IsHexAddress(step.Target) { + return clierr.New(clierr.CodeUsage, "invalid step target address") + } + + switch step.Type { + case StepTypeApproval: + return validateApprovalPolicy(action, data, opts) + case StepTypeSwap: + return validateSwapPolicy(action, step, chainID, data) + case StepTypeBridge: + return validateBridgePolicy(action, step, opts) + default: + return nil + } +} + +func validateApprovalPolicy(action *Action, data []byte, opts ExecuteOptions) error { + if len(data) < 4 || !bytes.Equal(data[:4], policyApproveSelector) { + return clierr.New(clierr.CodeActionPlan, "approval step must use ERC20 approve(spender,amount)") + } + args, err := policyERC20ABI.Methods["approve"].Inputs.Unpack(data[4:]) + if err != nil || len(args) != 2 { + return clierr.New(clierr.CodeActionPlan, "approval step calldata is invalid") + } + spender, ok := toAddress(args[0]) + if !ok || spender == (common.Address{}) { + return clierr.New(clierr.CodeActionPlan, "approval step has invalid spender") + } + amount, ok := toBigInt(args[1]) + if !ok || amount.Sign() <= 0 { + return clierr.New(clierr.CodeActionPlan, "approval step has invalid approval amount") + } + if opts.AllowMaxApproval { + return nil + } + if action == nil { + return clierr.New(clierr.CodeActionPlan, "cannot validate approval bounds without action context") + } + requested, ok := parsePositiveBaseUnits(action.InputAmount) + if !ok { + return clierr.New(clierr.CodeActionPlan, "cannot validate approval bounds for non-numeric input amount; use --allow-max-approval to override") + } + if amount.Cmp(requested) > 0 { + return clierr.New( + clierr.CodeActionPlan, + fmt.Sprintf("approval amount %s exceeds requested input amount %s; use --allow-max-approval to override", amount.String(), requested.String()), + ) + } + return nil +} + +func validateSwapPolicy(action *Action, step *ActionStep, chainID int64, data []byte) error { + if action == nil || !strings.EqualFold(strings.TrimSpace(action.Provider), "taikoswap") { + return nil + } + if len(data) < 4 || !bytes.Equal(data[:4], policyTaikoSwapMethod) { + return clierr.New(clierr.CodeActionPlan, "taikoswap swap step must call exactInputSingle") + } + _, router, ok := registry.TaikoSwapContracts(chainID) + if !ok { + return clierr.New(clierr.CodeActionPlan, "taikoswap swap step has unsupported chain") + } + expectedRouter := common.HexToAddress(router).Hex() + if !strings.EqualFold(common.HexToAddress(step.Target).Hex(), expectedRouter) { + return clierr.New(clierr.CodeActionPlan, "taikoswap swap step target does not match canonical router") + } + return nil +} + +func validateBridgePolicy(action *Action, step *ActionStep, opts ExecuteOptions) error { + if opts.UnsafeProviderTx { + return nil + } + provider := "" + if step.ExpectedOutputs != nil { + provider = strings.ToLower(strings.TrimSpace(step.ExpectedOutputs["settlement_provider"])) + } + if provider == "" && action != nil { + provider = strings.ToLower(strings.TrimSpace(action.Provider)) + } + if provider != "lifi" && provider != "across" { + return clierr.New(clierr.CodeActionPlan, "bridge step has unknown settlement provider; use --unsafe-provider-tx to override") + } + if action != nil && strings.TrimSpace(action.Provider) != "" && !strings.EqualFold(strings.TrimSpace(action.Provider), provider) { + return clierr.New(clierr.CodeActionPlan, "bridge step provider does not match action provider") + } + statusEndpoint := "" + if step.ExpectedOutputs != nil { + statusEndpoint = strings.TrimSpace(step.ExpectedOutputs["settlement_status_endpoint"]) + } + if !registry.IsAllowedBridgeSettlementURL(provider, statusEndpoint) { + return clierr.New(clierr.CodeActionPlan, "bridge step settlement endpoint is not allowed; use --unsafe-provider-tx to override") + } + return nil +} + +func parsePositiveBaseUnits(value string) (*big.Int, bool) { + v := strings.TrimSpace(value) + if v == "" { + return nil, false + } + parsed, ok := new(big.Int).SetString(v, 10) + if !ok || parsed.Sign() <= 0 { + return nil, false + } + return parsed, true +} + +func toAddress(v any) (common.Address, bool) { + switch value := v.(type) { + case common.Address: + return value, true + case *common.Address: + if value == nil { + return common.Address{}, false + } + return *value, true + default: + return common.Address{}, false + } +} + +func toBigInt(v any) (*big.Int, bool) { + switch value := v.(type) { + case *big.Int: + if value == nil { + return nil, false + } + return value, true + case big.Int: + cpy := value + return &cpy, true + default: + return nil, false + } +} + +func mustPolicyABI(raw string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(raw)) + if err != nil { + panic(err) + } + return parsed +} diff --git a/internal/execution/policy_basic_test.go b/internal/execution/policy_basic_test.go new file mode 100644 index 0000000..ab6c8f6 --- /dev/null +++ b/internal/execution/policy_basic_test.go @@ -0,0 +1,83 @@ +package execution + +import ( + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestValidateApprovalPolicyBounded(t *testing.T) { + data, err := policyERC20ABI.Pack("approve", common.HexToAddress("0x00000000000000000000000000000000000000ab"), big.NewInt(100)) + if err != nil { + t.Fatalf("pack approval calldata: %v", err) + } + action := &Action{InputAmount: "100"} + step := &ActionStep{Type: StepTypeApproval, Target: "0x00000000000000000000000000000000000000cd"} + + if err := validateStepPolicy(action, step, 1, data, ExecuteOptions{}); err != nil { + t.Fatalf("expected bounded approval to pass, got err=%v", err) + } +} + +func TestValidateApprovalPolicyRejectsUnlimitedByDefault(t *testing.T) { + data, err := policyERC20ABI.Pack("approve", common.HexToAddress("0x00000000000000000000000000000000000000ab"), big.NewInt(101)) + if err != nil { + t.Fatalf("pack approval calldata: %v", err) + } + action := &Action{InputAmount: "100"} + step := &ActionStep{Type: StepTypeApproval, Target: "0x00000000000000000000000000000000000000cd"} + + err = validateStepPolicy(action, step, 1, data, ExecuteOptions{}) + if err == nil { + t.Fatal("expected bounded-approval validation to fail") + } + if !strings.Contains(err.Error(), "allow-max-approval") { + t.Fatalf("expected override hint, got err=%v", err) + } +} + +func TestValidateApprovalPolicyAllowsOverride(t *testing.T) { + data, err := policyERC20ABI.Pack("approve", common.HexToAddress("0x00000000000000000000000000000000000000ab"), big.NewInt(101)) + if err != nil { + t.Fatalf("pack approval calldata: %v", err) + } + action := &Action{InputAmount: "100"} + step := &ActionStep{Type: StepTypeApproval, Target: "0x00000000000000000000000000000000000000cd"} + + if err := validateStepPolicy(action, step, 1, data, ExecuteOptions{AllowMaxApproval: true}); err != nil { + t.Fatalf("expected approval override to pass, got err=%v", err) + } +} + +func TestValidateSwapPolicyTaikoRouter(t *testing.T) { + action := &Action{Provider: "taikoswap"} + step := &ActionStep{ + Type: StepTypeSwap, + Target: "0x00000000000000000000000000000000000000cd", + } + err := validateStepPolicy(action, step, 167000, policyTaikoSwapMethod, ExecuteOptions{}) + if err == nil { + t.Fatal("expected taikoswap router mismatch to fail") + } +} + +func TestValidateBridgePolicyEndpointGuard(t *testing.T) { + action := &Action{Provider: "lifi"} + step := &ActionStep{ + Type: StepTypeBridge, + Target: "0x00000000000000000000000000000000000000cd", + ExpectedOutputs: map[string]string{ + "settlement_provider": "lifi", + "settlement_status_endpoint": "https://evil.example/status", + }, + } + err := validateStepPolicy(action, step, 1, []byte{0x01}, ExecuteOptions{}) + if err == nil { + t.Fatal("expected invalid settlement endpoint to fail") + } + if err := validateStepPolicy(action, step, 1, []byte{0x01}, ExecuteOptions{UnsafeProviderTx: true}); err != nil { + t.Fatalf("expected unsafe provider override to pass, got err=%v", err) + } +} diff --git a/internal/providers/across/client.go b/internal/providers/across/client.go index eb97004..1465cd2 100644 --- a/internal/providers/across/client.go +++ b/internal/providers/across/client.go @@ -17,9 +17,10 @@ import ( "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" "github.com/ggonzalez94/defi-cli/internal/providers" + "github.com/ggonzalez94/defi-cli/internal/registry" ) -const defaultBase = "https://app.across.to/api" +const defaultBase = registry.AcrossBaseURL type Client struct { http *httpx.Client @@ -267,7 +268,7 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot ExpectedOutputs: map[string]string{ "to_amount_min": firstNonEmpty(resp.MinOutputAmount, resp.ExpectedOutputAmount, resp.Steps.Bridge.OutputAmount), "settlement_provider": "across", - "settlement_status_endpoint": c.baseURL + "/deposit/status", + "settlement_status_endpoint": registry.AcrossSettlementURL, "settlement_origin_chain": strconv.FormatInt(req.FromChain.EVMChainID, 10), "settlement_recipient": common.HexToAddress(recipient).Hex(), "settlement_destination_chain": strconv.FormatInt(req.ToChain.EVMChainID, 10), diff --git a/internal/providers/lifi/client.go b/internal/providers/lifi/client.go index a6c7578..81dc70a 100644 --- a/internal/providers/lifi/client.go +++ b/internal/providers/lifi/client.go @@ -325,7 +325,7 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeActionPlan, "parse bridge transaction value", err) } - statusEndpoint := strings.TrimSuffix(c.baseURL, "/") + "/status" + statusEndpoint := registry.LiFiSettlementURL action.Steps = append(action.Steps, execution.ActionStep{ StepID: "bridge-transfer", Type: execution.StepTypeBridge, diff --git a/internal/providers/morpho/client.go b/internal/providers/morpho/client.go index bc50f05..40c06fb 100644 --- a/internal/providers/morpho/client.go +++ b/internal/providers/morpho/client.go @@ -17,9 +17,10 @@ import ( "github.com/ggonzalez94/defi-cli/internal/model" "github.com/ggonzalez94/defi-cli/internal/providers" "github.com/ggonzalez94/defi-cli/internal/providers/yieldutil" + "github.com/ggonzalez94/defi-cli/internal/registry" ) -const defaultEndpoint = "https://api.morpho.org/graphql" +const defaultEndpoint = registry.MorphoGraphQLEndpoint type Client struct { http *httpx.Client diff --git a/internal/registry/execution_data.go b/internal/registry/execution_data.go index 8d74d1b..0e43b22 100644 --- a/internal/registry/execution_data.go +++ b/internal/registry/execution_data.go @@ -1,10 +1,107 @@ package registry +import ( + "net" + "net/url" + "strings" +) + const ( // Execution provider endpoints. - LiFiBaseURL = "https://li.quest/v1" + LiFiBaseURL = "https://li.quest/v1" + LiFiSettlementURL = "https://li.quest/v1/status" + AcrossBaseURL = "https://app.across.to/api" + AcrossSettlementURL = "https://app.across.to/api/deposit/status" + MorphoGraphQLEndpoint = "https://api.morpho.org/graphql" ) +func BridgeSettlementURL(provider string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "lifi": + return LiFiSettlementURL, true + case "across": + return AcrossSettlementURL, true + default: + return "", false + } +} + +func IsAllowedBridgeSettlementURL(provider, endpoint string) bool { + if strings.TrimSpace(endpoint) == "" { + return true + } + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return false + } + if strings.TrimSpace(parsed.Hostname()) == "" { + return false + } + if isLoopbackHost(parsed.Hostname()) { + scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) + return scheme == "" || scheme == "http" || scheme == "https" + } + if !strings.EqualFold(strings.TrimSpace(parsed.Scheme), "https") { + return false + } + allowedRaw, ok := BridgeSettlementURL(provider) + if !ok { + return false + } + allowed, err := url.Parse(allowedRaw) + if err != nil { + return false + } + if !strings.EqualFold(parsed.Scheme, allowed.Scheme) { + return false + } + if !strings.EqualFold(parsed.Hostname(), allowed.Hostname()) { + return false + } + if normalizedURLPort(parsed) != normalizedURLPort(allowed) { + return false + } + return normalizedURLPath(parsed.Path) == normalizedURLPath(allowed.Path) +} + +func isLoopbackHost(host string) bool { + h := strings.TrimSpace(strings.ToLower(host)) + if h == "localhost" { + return true + } + ip := net.ParseIP(h) + return ip != nil && ip.IsLoopback() +} + +func normalizedURLPort(parsed *url.URL) string { + if parsed == nil { + return "" + } + if port := strings.TrimSpace(parsed.Port()); port != "" { + return port + } + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + +func normalizedURLPath(path string) string { + p := strings.TrimSpace(path) + if p == "" { + return "/" + } + p = strings.TrimSuffix(p, "/") + if p == "" { + return "/" + } + return p +} + // Canonical contracts used by TaikoSwap execution/quoting. var taikoSwapContractsByChainID = map[int64]struct { QuoterV2 string diff --git a/internal/registry/execution_data_test.go b/internal/registry/execution_data_test.go index f880720..f1ffc02 100644 --- a/internal/registry/execution_data_test.go +++ b/internal/registry/execution_data_test.go @@ -50,3 +50,44 @@ func TestExecutionABIConstantsParse(t *testing.T) { } } } + +func TestBridgeSettlementURL(t *testing.T) { + got, ok := BridgeSettlementURL("lifi") + if !ok || got != LiFiSettlementURL { + t.Fatalf("unexpected lifi settlement url: ok=%v url=%q", ok, got) + } + got, ok = BridgeSettlementURL("across") + if !ok || got != AcrossSettlementURL { + t.Fatalf("unexpected across settlement url: ok=%v url=%q", ok, got) + } + if _, ok := BridgeSettlementURL("unknown"); ok { + t.Fatal("did not expect settlement url for unknown provider") + } +} + +func TestIsAllowedBridgeSettlementURL(t *testing.T) { + if !IsAllowedBridgeSettlementURL("lifi", "") { + t.Fatal("expected empty endpoint to be allowed") + } + if !IsAllowedBridgeSettlementURL("lifi", LiFiSettlementURL) { + t.Fatal("expected canonical lifi endpoint to be allowed") + } + if !IsAllowedBridgeSettlementURL("lifi", "https://li.quest:443/v1/status") { + t.Fatal("expected canonical endpoint with explicit default port to be allowed") + } + if IsAllowedBridgeSettlementURL("lifi", AcrossSettlementURL) { + t.Fatal("did not expect across endpoint to be allowed for lifi") + } + if IsAllowedBridgeSettlementURL("lifi", "http://li.quest/v1/status") { + t.Fatal("did not expect non-https endpoint to be allowed for non-loopback") + } + if IsAllowedBridgeSettlementURL("lifi", "https://li.quest/v1/other") { + t.Fatal("did not expect non-canonical lifi path to be allowed") + } + if !IsAllowedBridgeSettlementURL("across", "http://127.0.0.1:8080/status") { + t.Fatal("expected loopback endpoint to be allowed for tests/dev") + } + if IsAllowedBridgeSettlementURL("across", "not-a-url") { + t.Fatal("did not expect malformed endpoint to be allowed") + } +} From db0f8d47c5aa9e3b69b04331bd8f1e1bb9ac8a25 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 08:43:39 -0400 Subject: [PATCH 06/18] docs: document execution pre-sign guardrails and endpoint registry --- AGENTS.md | 2 ++ README.md | 2 ++ docs/act-execution-design.md | 23 ++++++++++++++--------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 38b3600..b20a279 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ README.md # user-facing usage + caveats - `swap`/`bridge` action construction is provider capability based (`BuildSwapAction` / `BuildBridgeAction`) because route payloads are provider-specific. - `lend`/`rewards`/`approvals` action construction uses internal planners for deterministic contract-call composition. - All execution `run` / `submit` commands can broadcast transactions. +- Execution pre-sign checks enforce bounded ERC-20 approvals by default; `--allow-max-approval` opts into larger approvals when required. +- Bridge execution pre-sign checks validate provider settlement metadata/endpoints by default; `--unsafe-provider-tx` bypasses these guardrails. - LiFi bridge quote/plan/run support optional `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). - Bridge execution status for Across/LiFi waits for destination settlement (`/deposit/status` or `/status`) before marking bridge steps complete. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. diff --git a/README.md b/README.md index b1b2fe7..2b5353a 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ providers: - LiFi bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. - Across bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. - LiFi bridge quote/plan/run support `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). +- Execution pre-sign checks enforce bounded ERC-20 approvals (`approve <= planned input amount`) by default; use `--allow-max-approval` when a route requires larger approvals. +- Bridge execution pre-sign checks validate settlement provider metadata and known endpoint hosts for Across/LiFi; use `--unsafe-provider-tx` to bypass these guardrails. - All `run` / `submit` execution commands will broadcast signed transactions. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. - Provider/protocol selection is explicit for multi-provider flows; pass `--provider` or `--protocol` (no implicit defaults). diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index 98136b0..ee55355 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -186,7 +186,10 @@ Tradeoff: Canonical execution metadata currently lives in `internal/registry/execution_data.go`: -- Provider endpoint constants (for example `LiFiBaseURL`) +- Execution endpoint constants: + - LiFi quote/status endpoints + - Across quote/status endpoints + - Morpho GraphQL endpoint used by execution planners - Contract address registries: - TaikoSwap contracts by chain - Aave PoolAddressesProvider by chain @@ -198,7 +201,7 @@ Canonical execution metadata currently lives in `internal/registry/execution_dat Important nuance: -- Not all provider endpoints are centralized there yet (for example Across base URL is in provider code). +- Execution-critical endpoints are centralized; quote-only/read-only provider endpoints may still remain adapter-local. Design decision: @@ -215,12 +218,13 @@ Core executor: `internal/execution/executor.go`. Per step execution flow: 1. Validate RPC URL, target, and chain match. -2. Optional simulation (`eth_call`) when `--simulate=true`. -3. Gas estimation (`eth_estimateGas`) with configurable multiplier. -4. EIP-1559 fee resolution (suggested or overridden by flags). -5. Nonce resolution from pending state. -6. Local signing and broadcast. -7. Receipt polling until success/failure/timeout. +2. Apply lightweight pre-sign policy checks (approval bounds, TaikoSwap target/selector checks, bridge settlement metadata checks). +3. Optional simulation (`eth_call`) when `--simulate=true`. +4. Gas estimation (`eth_estimateGas`) with configurable multiplier. +5. EIP-1559 fee resolution (suggested or overridden by flags). +6. Nonce resolution from pending state. +7. Local signing and broadcast. +8. Receipt polling until success/failure/timeout. Bridge-specific consistency: @@ -236,11 +240,12 @@ Context and timeout behavior: Design decision: -- Simulation defaults to on, and bridge completion requires both source receipt and provider settlement. +- Simulation defaults to on, bridge completion requires both source receipt and provider settlement, and pre-sign policy checks are fail-closed by default. Tradeoff: - Better safety and operational visibility, but slower execution paths and dependence on provider status APIs. +- Advanced users may need explicit overrides (`--allow-max-approval`, `--unsafe-provider-tx`) for provider-specific edge cases. Current limitation: From a465c9a4abba85e380996c8382c735ecdc7bee94 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 08:50:30 -0400 Subject: [PATCH 07/18] docs: document execution guardrails and endpoint policy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b5353a..f3df374 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ providers: - Across bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. - LiFi bridge quote/plan/run support `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). - Execution pre-sign checks enforce bounded ERC-20 approvals (`approve <= planned input amount`) by default; use `--allow-max-approval` when a route requires larger approvals. -- Bridge execution pre-sign checks validate settlement provider metadata and known endpoint hosts for Across/LiFi; use `--unsafe-provider-tx` to bypass these guardrails. +- Bridge execution pre-sign checks validate settlement provider metadata and known settlement endpoint URLs for Across/LiFi; use `--unsafe-provider-tx` to bypass these guardrails. - All `run` / `submit` execution commands will broadcast signed transactions. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. - Provider/protocol selection is explicit for multi-provider flows; pass `--provider` or `--protocol` (no implicit defaults). From 2fb97531815b8d0c56690be9231b8f94d3aada14 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 09:10:40 -0400 Subject: [PATCH 08/18] feat(execution): add private-key override and drop actions status alias --- AGENTS.md | 6 +- CHANGELOG.md | 3 +- README.md | 12 +++- docs/act-execution-design.md | 14 +++-- internal/app/approvals_command.go | 10 ++-- internal/app/bridge_execution_commands.go | 10 ++-- internal/app/lend_execution_commands.go | 10 ++-- internal/app/rewards_command.go | 20 ++++--- internal/app/runner.go | 73 ++++++++++++++--------- internal/app/runner_actions_test.go | 70 +++++++++++++++++++++- internal/execution/signer/local.go | 11 ++++ internal/execution/signer/local_test.go | 25 ++++++++ 12 files changed, 200 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b20a279..7fb7e72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,14 +67,14 @@ README.md # user-facing usage + caveats - Most commands do not require provider API keys. - Key-gated routes: `swap quote --provider 1inch` (`DEFI_1INCH_API_KEY`), `swap quote --provider uniswap` (`DEFI_UNISWAP_API_KEY`), `chains assets`, and `bridge list` / `bridge details` via DefiLlama (`DEFI_DEFILLAMA_API_KEY`). - Multi-provider command paths require explicit provider/protocol selection (`--provider` or `--protocol`); no implicit defaults. -- TaikoSwap quote/planning does not require an API key; execution uses local signer env inputs (`DEFI_PRIVATE_KEY{,_FILE}` or keystore envs) and also auto-discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. +- TaikoSwap quote/planning does not require an API key; execution uses local signer inputs (`--private-key` override, `DEFI_PRIVATE_KEY{,_FILE}`, or keystore envs) and also auto-discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. - Execution commands currently available: - `swap plan|run|submit|status` - `bridge plan|run|submit|status` (Across, LiFi) - `approvals plan|run|submit|status` - `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave, Morpho) - `rewards claim|compound plan|run|submit|status` (Aave) - - `actions list|status` + - `actions list|show` - Execution builder architecture is intentionally split: - `swap`/`bridge` action construction is provider capability based (`BuildSwapAction` / `BuildBridgeAction`) because route payloads are provider-specific. - `lend`/`rewards`/`approvals` action construction uses internal planners for deterministic contract-call composition. @@ -94,7 +94,7 @@ README.md # user-facing usage + caveats - Morpho can emit extreme APYs in tiny markets; use `--min-tvl-usd` in ranking/filters. - Fresh cache hits (`age <= ttl`) skip provider calls; once TTL expires, the CLI re-fetches providers and only serves stale data within `max_stale` on temporary provider failures. - Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. -- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|status`) bypass cache initialization. +- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|show`) bypass cache initialization. - For `lend`/`yield`, unresolved asset symbols skip DefiLlama symbol matching and fallback/provider selection where symbol-based matching would be unsafe. - Amounts used for swaps/bridges are base units; keep both base and decimal forms consistent. - Release artifacts are built on `v*` tags via `.github/workflows/release.yml` and `.goreleaser.yml`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 956ba62..cd4d70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Format: - Added approvals workflow commands: `approvals plan`, `approvals run`, `approvals submit`, and `approvals status`. - Added lend execution workflow commands under `lend supply|withdraw|borrow|repay ... plan|run|submit|status` (Aave and Morpho). - Added rewards execution workflow commands under `rewards claim|compound ... plan|run|submit|status` (Aave). -- Added action persistence and inspection commands: `actions list` and `actions status`. +- Added action persistence and inspection commands: `actions list` and `actions show`. - Added local signer support for execution with env/file/keystore key sources. - Added Taiko Hoodi chain alias and token registry entries (`USDC`, `USDT`, `WETH`) for deterministic asset parsing. - Added planner unit tests for approvals, Aave lend/rewards flows, and LiFi bridge action building. @@ -42,6 +42,7 @@ Format: - Unified execution action-construction dispatch under a shared ActionBuilder registry while preserving existing command semantics. - Execution commands now use `--from-address` as the single signer-address guard; `--confirm-address` has been removed. - Execution `run` commands now default sender to signer address when `--from-address` is omitted. +- Execution `run`/`submit` commands now support `--private-key` as a one-off local signer override (highest precedence). - Local signer `--key-source auto` now discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. - Local signer key/keystore file loading no longer hard-fails on non-`0600` file permissions. - Execution endpoint defaults for Across/LiFi settlement polling and Morpho GraphQL planning are now centralized in `internal/registry`. diff --git a/README.md b/README.md index f3df374..062d159 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Swap execution flow (local signer): ```bash export DEFI_PRIVATE_KEY_FILE=~/.config/defi/key.hex +# or pass --private-key 0x... on run/submit commands for one-off usage # 1) Plan only defi swap plan \ @@ -139,7 +140,7 @@ Execution command surface: - `approvals plan|run|submit|status` - `lend supply|withdraw|borrow|repay plan|run|submit|status` (protocol: `aave|morpho`) - `rewards claim|compound plan|run|submit|status` (protocol: `aave`) -- `actions list|status` +- `actions list|show` ## Command API Key Requirements @@ -178,7 +179,12 @@ If a keyed provider is used without a key, CLI exits with code `10`. Execution `run`/`submit` commands currently support a local key signer. -Key env inputs (in precedence order when `--key-source auto`): +Key input precedence: + +- `--private-key` (hex string, one-off override; less safe) +- env/file/keystore inputs below (when `--private-key` is not provided) + +Key env/file inputs (in precedence order when `--key-source auto` and `--private-key` is unset): - `DEFI_PRIVATE_KEY` (hex string, supported but less safe) - `DEFI_PRIVATE_KEY_FILE` (preferred explicit key-file path) @@ -234,7 +240,7 @@ providers: - `cache.max_stale` / `--max-stale` is only a temporary provider-failure fallback window (currently `unavailable` / `rate_limited`). - If fallback is disabled (`--no-stale` or `--max-stale 0s`) or stale data exceeds the budget, the CLI exits with code `14`. - Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. -- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|status`) bypass cache reads/writes. +- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|show`) bypass cache reads/writes. ## Caveats diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index ee55355..66326a4 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -23,7 +23,7 @@ Execution is integrated inside existing domain commands (for example `swap`, `br | Lend | `lend plan|run|submit|status` | `--protocol` required | `aave`, `morpho` execution (`morpho` requires `--market-id`) | | Rewards | `rewards plan|run|submit|status` | `--protocol` required | `aave` execution | | Approvals | `approvals plan|run|submit|status` | no provider selector | native ERC-20 approval execution | -| Action inspection | `actions list|status` | optional `--status` filter | persisted action inspection | +| Action inspection | `actions list|show` | optional `--status` filter | persisted action inspection | Notes: @@ -138,7 +138,7 @@ Tradeoff: - Domain `status` commands fetch one action. - `actions list` gives cross-domain recent actions. -- `actions status` fetches any action by ID. +- `actions show` fetches any action by ID. ## 5. Signing and Key Handling @@ -155,6 +155,7 @@ Supported backend today: Key sources: - `--key-source auto|env|file|keystore` +- `--private-key` (run/submit one-off override) - Environment variables: - `DEFI_PRIVATE_KEY` - `DEFI_PRIVATE_KEY_FILE` @@ -164,10 +165,11 @@ Key sources: `auto` precedence in current code: -1. `DEFI_PRIVATE_KEY` -2. `DEFI_PRIVATE_KEY_FILE` -3. `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` (default key-file fallback when present) -4. `DEFI_KEYSTORE_PATH` (+ password input) +1. `--private-key` (when provided) +2. `DEFI_PRIVATE_KEY` +3. `DEFI_PRIVATE_KEY_FILE` +4. `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` (default key-file fallback when present) +5. `DEFI_KEYSTORE_PATH` (+ password input) Security controls: diff --git a/internal/app/approvals_command.go b/internal/app/approvals_command.go index 3c4a84f..a8dfa57 100644 --- a/internal/app/approvals_command.go +++ b/internal/app/approvals_command.go @@ -90,7 +90,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("from-address") var run approvalArgs - var runSigner, runKeySource, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string var runAllowMaxApproval, runUnsafeProviderTx bool @@ -98,7 +98,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { Use: "run", Short: "Plan and execute an approval action", RunE: func(cmd *cobra.Command, _ []string) error { - txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, run.fromAddress) if err != nil { return err } @@ -148,6 +148,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { runCmd.Flags().StringVar(&run.rpcURL, "rpc-url", "", "RPC URL override for the selected chain") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -161,7 +162,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitPrivateKey, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string var submitAllowMaxApproval, submitUnsafeProviderTx bool @@ -183,7 +184,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { if action.IntentType != "approve" { return clierr.New(clierr.CodeUsage, "action is not an approval intent") } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitPrivateKey) if err != nil { return err } @@ -216,6 +217,7 @@ func (s *runtimeState) newApprovalsCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") diff --git a/internal/app/bridge_execution_commands.go b/internal/app/bridge_execution_commands.go index afbd0a4..686399c 100644 --- a/internal/app/bridge_execution_commands.go +++ b/internal/app/bridge_execution_commands.go @@ -127,7 +127,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { var runSlippageBps int64 var runSimulate bool var runRPCURL string - var runSigner, runKeySource, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string var runAllowMaxApproval, runUnsafeProviderTx bool @@ -139,7 +139,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } - txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runFromAddress) + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, runFromAddress) if err != nil { return err } @@ -209,6 +209,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { runCmd.Flags().StringVar(&runRPCURL, "rpc-url", "", "RPC URL override for source chain") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -223,7 +224,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitPrivateKey, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string var submitAllowMaxApproval, submitUnsafeProviderTx bool @@ -245,7 +246,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { if action.IntentType != "bridge" { return clierr.New(clierr.CodeUsage, "action is not a bridge intent") } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitPrivateKey) if err != nil { return err } @@ -278,6 +279,7 @@ func (s *runtimeState) addBridgeExecutionSubcommands(root *cobra.Command) { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go index c153017..2312304 100644 --- a/internal/app/lend_execution_commands.go +++ b/internal/app/lend_execution_commands.go @@ -124,7 +124,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh _ = planCmd.MarkFlagRequired("protocol") var run lendArgs - var runSigner, runKeySource, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string var runAllowMaxApproval, runUnsafeProviderTx bool @@ -132,7 +132,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh Use: "run", Short: "Plan and execute a lend action", RunE: func(cmd *cobra.Command, _ []string) error { - txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, run.fromAddress) if err != nil { return err } @@ -196,6 +196,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -209,7 +210,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitPrivateKey, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string var submitAllowMaxApproval, submitUnsafeProviderTx bool @@ -234,7 +235,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh if action.Status == execution.ActionStatusCompleted { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitPrivateKey) if err != nil { return err } @@ -267,6 +268,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") diff --git a/internal/app/rewards_command.go b/internal/app/rewards_command.go index 6d8deee..0905b1b 100644 --- a/internal/app/rewards_command.go +++ b/internal/app/rewards_command.go @@ -108,7 +108,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("protocol") var run claimArgs - var runSigner, runKeySource, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string var runAllowMaxApproval, runUnsafeProviderTx bool @@ -116,7 +116,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { Use: "run", Short: "Plan and execute a rewards-claim action", RunE: func(cmd *cobra.Command, _ []string) error { - txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, run.fromAddress) if err != nil { return err } @@ -173,6 +173,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -187,7 +188,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitPrivateKey, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string var submitAllowMaxApproval, submitUnsafeProviderTx bool @@ -212,7 +213,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { if action.Status == execution.ActionStatusCompleted { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitPrivateKey) if err != nil { return err } @@ -245,6 +246,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") @@ -379,7 +381,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("protocol") var run compoundArgs - var runSigner, runKeySource, runPollInterval, runStepTimeout string + var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string var runAllowMaxApproval, runUnsafeProviderTx bool @@ -387,7 +389,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { Use: "run", Short: "Plan and execute a rewards-compound action", RunE: func(cmd *cobra.Command, _ []string) error { - txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, run.fromAddress) + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, run.fromAddress) if err != nil { return err } @@ -446,6 +448,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { runCmd.Flags().StringVar(&run.poolAddressProvider, "pool-address-provider", "", "Aave pool address provider override") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -461,7 +464,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitFromAddress, submitPollInterval, submitStepTimeout string + var submitSigner, submitKeySource, submitPrivateKey, submitFromAddress, submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string var submitAllowMaxApproval, submitUnsafeProviderTx bool @@ -486,7 +489,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { if action.Status == execution.ActionStatusCompleted { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitPrivateKey) if err != nil { return err } @@ -519,6 +522,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") diff --git a/internal/app/runner.go b/internal/app/runner.go index 13ac5f0..70964c0 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -834,7 +834,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var runAmountBase, runAmountDecimal, runFromAddress, runRecipient string var runSlippageBps int64 var runSimulate bool - var runSigner, runKeySource string + var runSigner, runKeySource, runPrivateKey string var runPollInterval, runStepTimeout string var runGasMultiplier float64 var runMaxFeeGwei, runMaxPriorityFeeGwei string @@ -851,7 +851,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if err != nil { return err } - txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runFromAddress) + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, runFromAddress) if err != nil { return err } @@ -914,6 +914,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { runCmd.Flags().BoolVar(&runSimulate, "simulate", true, "Run preflight simulation before submission") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + runCmd.Flags().StringVar(&runPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") runCmd.Flags().StringVar(&runPollInterval, "poll-interval", "2s", "Receipt polling interval") runCmd.Flags().StringVar(&runStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") runCmd.Flags().Float64Var(&runGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") @@ -928,7 +929,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var submitActionID string var submitSimulate bool - var submitSigner, submitKeySource, submitFromAddress string + var submitSigner, submitKeySource, submitPrivateKey, submitFromAddress string var submitPollInterval, submitStepTimeout string var submitGasMultiplier float64 var submitMaxFeeGwei, submitMaxPriorityFeeGwei string @@ -955,7 +956,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, []string{"action already completed"}, cacheMetaBypass(), nil, false) } - txSigner, err := newExecutionSigner(submitSigner, submitKeySource) + txSigner, err := newExecutionSigner(submitSigner, submitKeySource, submitPrivateKey) if err != nil { return err } @@ -988,6 +989,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { submitCmd.Flags().BoolVar(&submitSimulate, "simulate", true, "Run preflight simulation before submission") submitCmd.Flags().StringVar(&submitSigner, "signer", "local", "Signer backend (local)") submitCmd.Flags().StringVar(&submitKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") + submitCmd.Flags().StringVar(&submitPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") submitCmd.Flags().StringVar(&submitFromAddress, "from-address", "", "Expected sender EOA address") submitCmd.Flags().StringVar(&submitPollInterval, "poll-interval", "2s", "Receipt polling interval") submitCmd.Flags().StringVar(&submitStepTimeout, "step-timeout", "2m", "Per-step receipt timeout") @@ -1027,7 +1029,16 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { } func (s *runtimeState) newActionsCommand() *cobra.Command { - root := &cobra.Command{Use: "actions", Short: "Execution action inspection commands"} + root := &cobra.Command{ + Use: "actions", + Short: "Execution action inspection commands", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + return clierr.New(clierr.CodeUsage, fmt.Sprintf("unknown actions subcommand %q", args[0])) + }, + } var listStatus string var listLimit int @@ -1048,29 +1059,33 @@ func (s *runtimeState) newActionsCommand() *cobra.Command { listCmd.Flags().StringVar(&listStatus, "status", "", "Optional action status filter") listCmd.Flags().IntVar(&listLimit, "limit", 20, "Maximum actions to return") - var statusActionID string - statusCmd := &cobra.Command{ - Use: "status", - Short: "Get action details by action id", - RunE: func(cmd *cobra.Command, args []string) error { - actionID, err := resolveActionID(statusActionID) - if err != nil { - return err - } - if err := s.ensureActionStore(); err != nil { - return err - } - item, err := s.actionStore.Get(actionID) - if err != nil { - return clierr.Wrap(clierr.CodeUsage, "load action", err) - } - return s.emitSuccess(trimRootPath(cmd.CommandPath()), item, nil, cacheMetaBypass(), nil, false) + lookupAction := func(cmd *cobra.Command, actionIDArg string) error { + actionID, err := resolveActionID(actionIDArg) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + item, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), item, nil, cacheMetaBypass(), nil, false) + } + + var showActionID string + showCmd := &cobra.Command{ + Use: "show", + Short: "Show action details by action id", + RunE: func(cmd *cobra.Command, _ []string) error { + return lookupAction(cmd, showActionID) }, } - statusCmd.Flags().StringVar(&statusActionID, "action-id", "", "Action identifier") + showCmd.Flags().StringVar(&showActionID, "action-id", "", "Action identifier") root.AddCommand(listCmd) - root.AddCommand(statusCmd) + root.AddCommand(showCmd) return root } @@ -1688,7 +1703,7 @@ func normalizeCommandPath(commandPath string) string { func isExecutionCommandPath(path string) bool { switch path { - case "actions", "actions list", "actions status": + case "actions", "actions list", "actions show": return true } parts := strings.Fields(path) @@ -1751,7 +1766,7 @@ func resolveActionID(actionID string) (string, error) { return actionID, nil } -func newExecutionSigner(signerBackend, keySource string) (execsigner.Signer, error) { +func newExecutionSigner(signerBackend, keySource, privateKey string) (execsigner.Signer, error) { signerBackend = strings.ToLower(strings.TrimSpace(signerBackend)) if signerBackend == "" { signerBackend = "local" @@ -1759,15 +1774,15 @@ func newExecutionSigner(signerBackend, keySource string) (execsigner.Signer, err if signerBackend != "local" { return nil, clierr.New(clierr.CodeUnsupported, "only local signer is supported") } - localSigner, err := execsigner.NewLocalSignerFromEnv(keySource) + localSigner, err := execsigner.NewLocalSignerFromInputs(keySource, privateKey) if err != nil { return nil, clierr.Wrap(clierr.CodeSigner, "initialize local signer", err) } return localSigner, nil } -func resolveRunSignerAndFromAddress(signerBackend, keySource, fromAddress string) (execsigner.Signer, string, error) { - txSigner, err := newExecutionSigner(signerBackend, keySource) +func resolveRunSignerAndFromAddress(signerBackend, keySource, privateKey, fromAddress string) (execsigner.Signer, string, error) { + txSigner, err := newExecutionSigner(signerBackend, keySource, privateKey) if err != nil { return nil, "", err } diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go index 015f2b5..688fb7f 100644 --- a/internal/app/runner_actions_test.go +++ b/internal/app/runner_actions_test.go @@ -28,7 +28,7 @@ func TestResolveActionID(t *testing.T) { func TestResolveRunSignerAndFromAddressDefaultsToSigner(t *testing.T) { t.Setenv(execsigner.EnvPrivateKey, runSignerTestPrivateKey) - txSigner, fromAddress, err := resolveRunSignerAndFromAddress("local", execsigner.KeySourceEnv, "") + txSigner, fromAddress, err := resolveRunSignerAndFromAddress("local", execsigner.KeySourceEnv, "", "") if err != nil { t.Fatalf("resolveRunSignerAndFromAddress failed: %v", err) } @@ -45,7 +45,7 @@ func TestResolveRunSignerAndFromAddressDefaultsToSigner(t *testing.T) { func TestResolveRunSignerAndFromAddressRejectsMismatch(t *testing.T) { t.Setenv(execsigner.EnvPrivateKey, runSignerTestPrivateKey) - _, _, err := resolveRunSignerAndFromAddress("local", execsigner.KeySourceEnv, "0x0000000000000000000000000000000000000001") + _, _, err := resolveRunSignerAndFromAddress("local", execsigner.KeySourceEnv, "", "0x0000000000000000000000000000000000000001") if err == nil { t.Fatal("expected mismatch error") } @@ -54,6 +54,23 @@ func TestResolveRunSignerAndFromAddressRejectsMismatch(t *testing.T) { } } +func TestResolveRunSignerAndFromAddressUsesPrivateKeyOverride(t *testing.T) { + t.Setenv(execsigner.EnvPrivateKey, "") + txSigner, fromAddress, err := resolveRunSignerAndFromAddress("local", execsigner.KeySourceAuto, runSignerTestPrivateKey, "") + if err != nil { + t.Fatalf("resolveRunSignerAndFromAddress failed with private key override: %v", err) + } + if txSigner == nil { + t.Fatal("expected non-nil signer") + } + if fromAddress == "" { + t.Fatal("expected non-empty from address") + } + if !strings.EqualFold(fromAddress, txSigner.Address().Hex()) { + t.Fatalf("expected from address %s to match signer %s", fromAddress, txSigner.Address().Hex()) + } +} + func TestParseExecuteOptionsRejectsGasMultiplierLTEOne(t *testing.T) { if _, err := parseExecuteOptions(true, "2s", "2m", 1, "", "", false, false); err == nil { t.Fatal("expected gas multiplier <= 1 to fail") @@ -95,6 +112,9 @@ func TestShouldOpenActionStore(t *testing.T) { if !shouldOpenActionStore("actions list") { t.Fatal("expected actions list to require action store") } + if !shouldOpenActionStore("actions show") { + t.Fatal("expected actions show to require action store") + } if shouldOpenActionStore("swap quote") { t.Fatal("did not expect swap quote to require action store") } @@ -103,6 +123,26 @@ func TestShouldOpenActionStore(t *testing.T) { } } +func TestActionsCommandHasNoStatusAlias(t *testing.T) { + state := &runtimeState{} + actionsCmd := state.newActionsCommand() + + names := map[string]struct{}{} + for _, cmd := range actionsCmd.Commands() { + names[cmd.Name()] = struct{}{} + } + + if _, ok := names["list"]; !ok { + t.Fatal("expected actions list command to be present") + } + if _, ok := names["show"]; !ok { + t.Fatal("expected actions show command to be present") + } + if _, ok := names["status"]; ok { + t.Fatal("did not expect deprecated actions status alias") + } +} + func TestShouldOpenCacheBypassesExecutionCommands(t *testing.T) { if shouldOpenCache("swap run") { t.Fatal("did not expect swap run to open cache") @@ -119,6 +159,9 @@ func TestShouldOpenCacheBypassesExecutionCommands(t *testing.T) { if shouldOpenCache("rewards compound run") { t.Fatal("did not expect rewards compound run to open cache") } + if shouldOpenCache("actions show") { + t.Fatal("did not expect actions show to open cache") + } if !shouldOpenCache("lend rates") { t.Fatal("expected lend rates to open cache") } @@ -211,6 +254,29 @@ func TestRunnerActionsListBypassesCacheOpen(t *testing.T) { } } +func TestRunnerActionsStatusRejected(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"actions", "status"}) + if code != 2 { + t.Fatalf("expected usage exit code 2, got %d stderr=%s", code, stderr.String()) + } + + var env map[string]any + if err := json.Unmarshal(stderr.Bytes(), &env); err != nil { + t.Fatalf("failed to parse error envelope: %v output=%s", err, stderr.String()) + } + errBody, ok := env["error"].(map[string]any) + if !ok { + t.Fatalf("expected error body, got %+v", env["error"]) + } + msg, _ := errBody["message"].(string) + if !strings.Contains(msg, "unknown actions subcommand") { + t.Fatalf("expected unknown actions subcommand message, got %q", msg) + } +} + func TestRunnerExecutionStatusBypassesCacheOpen(t *testing.T) { setUnopenableCacheEnv(t) diff --git a/internal/execution/signer/local.go b/internal/execution/signer/local.go index 5da50ed..6506c1e 100644 --- a/internal/execution/signer/local.go +++ b/internal/execution/signer/local.go @@ -48,6 +48,10 @@ func (s *LocalSigner) SignTx(chainID *big.Int, tx *types.Transaction) (*types.Tr } func NewLocalSignerFromEnv(source string) (*LocalSigner, error) { + return NewLocalSignerFromInputs(source, "") +} + +func NewLocalSignerFromInputs(source, privateKeyOverride string) (*LocalSigner, error) { source = strings.ToLower(strings.TrimSpace(source)) if source == "" { source = KeySourceAuto @@ -80,6 +84,13 @@ func NewLocalSignerFromEnv(source string) (*LocalSigner, error) { default: return nil, fmt.Errorf("unsupported key source %q (expected %s|%s|%s|%s)", source, KeySourceAuto, KeySourceEnv, KeySourceFile, KeySourceKeystore) } + if strings.TrimSpace(privateKeyOverride) != "" { + privateKeyHex = strings.TrimSpace(privateKeyOverride) + privateKeyFile = "" + keystorePath = "" + keystorePassword = "" + keystorePasswordFile = "" + } return NewLocalSigner(LocalSignerConfig{ PrivateKeyHex: privateKeyHex, diff --git a/internal/execution/signer/local_test.go b/internal/execution/signer/local_test.go index 0ffc003..71ea557 100644 --- a/internal/execution/signer/local_test.go +++ b/internal/execution/signer/local_test.go @@ -86,4 +86,29 @@ func TestNewLocalSignerFromEnvAutoUsesDefaultKeyFile(t *testing.T) { } } +func TestNewLocalSignerFromInputsPrivateKeyOverride(t *testing.T) { + t.Setenv(EnvPrivateKey, "") + t.Setenv(EnvPrivateKeyFile, "") + t.Setenv(EnvKeystorePath, "") + + s, err := NewLocalSignerFromInputs(KeySourceAuto, testPrivateKey) + if err != nil { + t.Fatalf("expected private key override to initialize signer: %v", err) + } + if s.Address() == (common.Address{}) { + t.Fatal("expected non-zero signer address") + } +} + +func TestNewLocalSignerFromInputsOverrideWinsOverFileSource(t *testing.T) { + t.Setenv(EnvPrivateKeyFile, "/tmp/does-not-exist") + s, err := NewLocalSignerFromInputs(KeySourceFile, testPrivateKey) + if err != nil { + t.Fatalf("expected private key override to win over file key-source: %v", err) + } + if s.Address() == (common.Address{}) { + t.Fatal("expected non-zero signer address") + } +} + func ptrAddress(v common.Address) *common.Address { return &v } From bc55243b334eb7c2fc8711aa93e65807337b23ff Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 09:18:13 -0400 Subject: [PATCH 09/18] signer: simplify default key-path UX and error hints --- AGENTS.md | 2 +- CHANGELOG.md | 1 + README.md | 2 +- docs/act-execution-design.md | 2 +- internal/execution/signer/local.go | 33 +++++++++++++++++++------ internal/execution/signer/local_test.go | 30 ++++++++++++++++++++++ 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7fb7e72..fc472d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ README.md # user-facing usage + caveats - Most commands do not require provider API keys. - Key-gated routes: `swap quote --provider 1inch` (`DEFI_1INCH_API_KEY`), `swap quote --provider uniswap` (`DEFI_UNISWAP_API_KEY`), `chains assets`, and `bridge list` / `bridge details` via DefiLlama (`DEFI_DEFILLAMA_API_KEY`). - Multi-provider command paths require explicit provider/protocol selection (`--provider` or `--protocol`); no implicit defaults. -- TaikoSwap quote/planning does not require an API key; execution uses local signer inputs (`--private-key` override, `DEFI_PRIVATE_KEY{,_FILE}`, or keystore envs) and also auto-discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. +- TaikoSwap quote/planning does not require an API key; execution uses local signer inputs (`--private-key` override, `DEFI_PRIVATE_KEY{,_FILE}`, or keystore envs) and also auto-discovers `~/.config/defi/key.hex` (or `$XDG_CONFIG_HOME/defi/key.hex`) when present. - Execution commands currently available: - `swap plan|run|submit|status` - `bridge plan|run|submit|status` (Across, LiFi) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd4d70c..21400f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Format: - Execution `run` commands now default sender to signer address when `--from-address` is omitted. - Execution `run`/`submit` commands now support `--private-key` as a one-off local signer override (highest precedence). - Local signer `--key-source auto` now discovers `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` when present. +- Missing local-signer key errors now include a simple default key-file hint (`~/.config/defi/key.hex`, with `XDG_CONFIG_HOME` override note). - Local signer key/keystore file loading no longer hard-fails on non-`0600` file permissions. - Execution endpoint defaults for Across/LiFi settlement polling and Morpho GraphQL planning are now centralized in `internal/registry`. - Execution pre-sign validation now enforces bounded ERC-20 approvals by default and validates TaikoSwap router/selector invariants before signing. diff --git a/README.md b/README.md index 062d159..6c52385 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Key env/file inputs (in precedence order when `--key-source auto` and `--private - `DEFI_PRIVATE_KEY` (hex string, supported but less safe) - `DEFI_PRIVATE_KEY_FILE` (preferred explicit key-file path) -- default key file: `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` +- default key file: `~/.config/defi/key.hex` (or `$XDG_CONFIG_HOME/defi/key.hex` when `XDG_CONFIG_HOME` is set) - `DEFI_KEYSTORE_PATH` + (`DEFI_KEYSTORE_PASSWORD` or `DEFI_KEYSTORE_PASSWORD_FILE`) You can force source selection with `--key-source env|file|keystore`. diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index 66326a4..803ddb3 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -168,7 +168,7 @@ Key sources: 1. `--private-key` (when provided) 2. `DEFI_PRIVATE_KEY` 3. `DEFI_PRIVATE_KEY_FILE` -4. `${XDG_CONFIG_HOME:-~/.config}/defi/key.hex` (default key-file fallback when present) +4. `~/.config/defi/key.hex` (or `$XDG_CONFIG_HOME/defi/key.hex` when `XDG_CONFIG_HOME` is set; fallback only when file is present) 5. `DEFI_KEYSTORE_PATH` (+ password input) Security controls: diff --git a/internal/execution/signer/local.go b/internal/execution/signer/local.go index 6506c1e..8e44b80 100644 --- a/internal/execution/signer/local.go +++ b/internal/execution/signer/local.go @@ -28,6 +28,7 @@ const ( KeySourceKeystore = "keystore" defaultPrivateKeyRelativePath = "defi/key.hex" + defaultPrivateKeyHintPath = "~/.config/defi/key.hex" ) type LocalSigner struct { @@ -155,7 +156,15 @@ func loadPrivateKey(cfg LocalSignerConfig) (*ecdsa.PrivateKey, error) { } return key.PrivateKey, nil } - return nil, fmt.Errorf("missing signing key: set %s or %s or %s", EnvPrivateKey, EnvPrivateKeyFile, EnvKeystorePath) + return nil, fmt.Errorf( + "missing signing key: pass --private-key, set %s, set %s, or put key at %s (XDG_CONFIG_HOME override); alternatively set %s (+ %s or %s)", + EnvPrivateKey, + EnvPrivateKeyFile, + defaultPrivateKeyHintPath, + EnvKeystorePath, + EnvKeystorePassword, + EnvKeystorePasswordFile, + ) } func parseHexKey(raw string) (*ecdsa.PrivateKey, error) { @@ -172,6 +181,21 @@ func parseHexKey(raw string) (*ecdsa.PrivateKey, error) { } func discoverDefaultPrivateKeyFile() string { + path := defaultPrivateKeyPath() + if path == "" { + return "" + } + info, err := os.Stat(path) + if err != nil { + return "" + } + if info.IsDir() { + return "" + } + return path +} + +func defaultPrivateKeyPath() string { base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")) if base == "" { home, err := os.UserHomeDir() @@ -184,12 +208,5 @@ func discoverDefaultPrivateKeyFile() string { if path == "" { return "" } - info, err := os.Stat(path) - if err != nil { - return "" - } - if info.IsDir() { - return "" - } return path } diff --git a/internal/execution/signer/local_test.go b/internal/execution/signer/local_test.go index 71ea557..4e1d5d8 100644 --- a/internal/execution/signer/local_test.go +++ b/internal/execution/signer/local_test.go @@ -4,6 +4,7 @@ import ( "math/big" "os" "path/filepath" + "strings" "testing" "github.com/ethereum/go-ethereum/common" @@ -111,4 +112,33 @@ func TestNewLocalSignerFromInputsOverrideWinsOverFileSource(t *testing.T) { } } +func TestDefaultPrivateKeyPathUsesXDGConfigHome(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/tmp/defi-config-home") + got := defaultPrivateKeyPath() + want := "/tmp/defi-config-home/defi/key.hex" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestNewLocalSignerFromInputsMissingKeyErrorIncludesSimplePathHint(t *testing.T) { + t.Setenv(EnvPrivateKey, "") + t.Setenv(EnvPrivateKeyFile, "") + t.Setenv(EnvKeystorePath, "") + t.Setenv(EnvKeystorePassword, "") + t.Setenv(EnvKeystorePasswordFile, "") + + _, err := NewLocalSignerFromInputs(KeySourceAuto, "") + if err == nil { + t.Fatal("expected missing key error") + } + msg := err.Error() + if !strings.Contains(msg, defaultPrivateKeyHintPath) { + t.Fatalf("expected missing key message to include %q, got: %s", defaultPrivateKeyHintPath, msg) + } + if !strings.Contains(msg, "--private-key") { + t.Fatalf("expected missing key message to include --private-key, got: %s", msg) + } +} + func ptrAddress(v common.Address) *common.Address { return &v } From 462d217e7e836c342b052f03af1a53a41d0e36dc Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 09:46:33 -0400 Subject: [PATCH 10/18] refactor rpc handling into shared registry and remove taiko-specific rpc config --- AGENTS.md | 10 +- CHANGELOG.md | 5 +- README.md | 20 ++- docs/act-execution-design.md | 14 +- internal/app/runner.go | 21 ++- internal/config/config.go | 18 --- internal/config/config_test.go | 15 -- internal/execution/planner/aave.go | 6 +- internal/execution/planner/approvals.go | 8 +- internal/execution/policy_basic.go | 12 +- internal/execution/policy_basic_test.go | 2 +- internal/providers/across/client.go | 2 +- internal/providers/lifi/client.go | 2 +- internal/providers/taikoswap/client.go | 57 +++---- internal/providers/taikoswap/client_test.go | 69 ++++++++- internal/providers/types.go | 2 + .../registry/{execution_data.go => abis.go} | 144 +----------------- internal/registry/contracts.go | 40 +++++ internal/registry/endpoints.go | 105 +++++++++++++ ...xecution_data_test.go => registry_test.go} | 48 +++++- internal/{execution => registry}/rpc.go | 14 +- 21 files changed, 354 insertions(+), 260 deletions(-) rename internal/registry/{execution_data.go => abis.go} (59%) create mode 100644 internal/registry/contracts.go create mode 100644 internal/registry/endpoints.go rename internal/registry/{execution_data_test.go => registry_test.go} (66%) rename internal/{execution => registry}/rpc.go (82%) diff --git a/AGENTS.md b/AGENTS.md index fc472d1..a855c74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,10 @@ internal/ aave/ morpho/ # direct GraphQL lending + yield defillama/ # market/yield normalization + fallback + bridge analytics across/ lifi/ # bridge quotes + lifi execution planning - oneinch/ uniswap/ taikoswap/ # swap quotes + taikoswap execution planning + oneinch/ uniswap/ taikoswap/ # swap quotes + uniswap-v3-compatible execution planning (taikoswap today) types.go # provider interfaces execution/ # action persistence + planner helpers + signer abstraction + tx execution - registry/ # canonical execution endpoints/contracts/ABI fragments + registry/ # canonical execution endpoints/contracts/ABI fragments + default chain RPC map config/ # defaults + file/env/flags precedence cache/ # sqlite cache + file lock id/ # CAIP parsing + amount normalization @@ -68,6 +68,12 @@ README.md # user-facing usage + caveats - Key-gated routes: `swap quote --provider 1inch` (`DEFI_1INCH_API_KEY`), `swap quote --provider uniswap` (`DEFI_UNISWAP_API_KEY`), `chains assets`, and `bridge list` / `bridge details` via DefiLlama (`DEFI_DEFILLAMA_API_KEY`). - Multi-provider command paths require explicit provider/protocol selection (`--provider` or `--protocol`); no implicit defaults. - TaikoSwap quote/planning does not require an API key; execution uses local signer inputs (`--private-key` override, `DEFI_PRIVATE_KEY{,_FILE}`, or keystore envs) and also auto-discovers `~/.config/defi/key.hex` (or `$XDG_CONFIG_HOME/defi/key.hex`) when present. +- `swap quote` (on-chain quote providers) and execution `plan`/`run` commands support optional `--rpc-url` overrides (`swap`, `bridge`, `approvals`, `lend`, `rewards`); `submit`/`status` use stored action step RPC URLs. +- Swap execution planning validates sender/recipient inputs as EVM hex addresses before building calldata. +- Metadata ownership is split by intent: + - `internal/registry`: canonical execution endpoints/contracts/ABIs and default chain RPC map (used when no `--rpc-url` is provided). + - `internal/providers/*/client.go`: provider quote/read API base URLs. + - `internal/id/id.go`: bootstrap token symbol/address registry for deterministic asset parsing. - Execution commands currently available: - `swap plan|run|submit|status` - `bridge plan|run|submit|status` (Across, LiFi) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21400f5..0ebe59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Format: - `providers list` now includes Across bridge execution capabilities (`bridge.plan`, `bridge.execute`). - `providers list` now includes Morpho lend execution capabilities (`lend.plan`, `lend.execute`). - Added execution-specific exit codes (`20`-`24`) for plan/simulation/policy/timeout/signer failures. -- Added execution config/env support for action store paths and Taiko RPC overrides. +- Added execution config/env support for action store paths. - Execution command cache/action-store policy now covers `swap|bridge|approvals|lend|rewards ... plan|run|submit|status`. - Removed implicit defaults for multi-provider command paths; `--provider`/`--protocol` must be set explicitly where applicable. - Added bridge gas-top-up request support via `--from-amount-for-gas` for LiFi quote/plan/run flows. @@ -47,8 +47,11 @@ Format: - Missing local-signer key errors now include a simple default key-file hint (`~/.config/defi/key.hex`, with `XDG_CONFIG_HOME` override note). - Local signer key/keystore file loading no longer hard-fails on non-`0600` file permissions. - Execution endpoint defaults for Across/LiFi settlement polling and Morpho GraphQL planning are now centralized in `internal/registry`. +- Default chain RPC metadata is now centralized in `internal/registry/rpc.go`; execution/quote flows use shared chain defaults when `--rpc-url` is not provided. - Execution pre-sign validation now enforces bounded ERC-20 approvals by default and validates TaikoSwap router/selector invariants before signing. - Execution `run`/`submit` commands now expose `--allow-max-approval` and `--unsafe-provider-tx` overrides for advanced/provider-specific flows. +- `swap quote` (on-chain providers) and `swap plan`/`swap run` now support `--rpc-url` to override chain default RPCs per invocation. +- Swap execution planning now validates sender/recipient fields as EVM addresses before route planning. ### Fixed - Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. diff --git a/README.md b/README.md index 6c52385..5e2ed0e 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ defi bridge details --bridge layerzero --results-only # Requires DEFI_DEFILLAMA_ defi bridge quote --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --results-only defi bridge quote --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-amount-for-gas 100000 --results-only defi swap quote --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --results-only -defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --from-address 0xYourEOA --results-only +defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --from-address 0xYourEOA --rpc-url https://rpc.mainnet.taiko.xyz --results-only defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --from-amount-for-gas 100000 --results-only defi bridge plan --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only defi lend supply plan --protocol aave --chain 1 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only @@ -133,6 +133,9 @@ defi swap run \ --results-only ``` +`swap quote` (on-chain quote providers) and execution `plan`/`run` commands support optional `--rpc-url` overrides (`swap`, `bridge`, `approvals`, `lend`, `rewards`). +For bridge flows, `--rpc-url` applies to the source-chain execution RPC. + Execution command surface: - `swap plan|run|submit|status` @@ -227,11 +230,19 @@ execution: actions_path: ~/.cache/defi/actions.db actions_lock_path: ~/.cache/defi/actions.lock providers: - taikoswap: - mainnet_rpc: https://rpc.mainnet.taiko.xyz - hoodi_rpc: https://rpc.hoodi.taiko.xyz + uniswap: + api_key_env: DEFI_UNISWAP_API_KEY ``` +Execution `plan`/`run` `--rpc-url` flags override chain default RPCs for that invocation. +`submit`/`status` commands use stored per-step RPC URLs from the persisted action. + +## Execution Metadata Locations (Implementers) + +- `internal/registry`: canonical execution endpoints/contracts/ABI fragments and default chain RPC map used when no `--rpc-url` is provided. +- `internal/providers/*/client.go`: provider quote/read API base URLs and external source URLs. +- `internal/id/id.go`: bootstrap token symbol/address registry used for deterministic symbol parsing. + ## Cache Policy - Command TTLs are fixed in code (`chains/protocols/chains assets`: `5m`, `lend markets`: `60s`, `lend rates`: `30s`, `yield`: `60s`, `bridge/swap quotes`: `15s`). @@ -260,6 +271,7 @@ providers: - Across bridge execution now waits for destination settlement status before marking the bridge step complete; adjust `--step-timeout` for slower routes. - LiFi bridge quote/plan/run support `--from-amount-for-gas` (source token base units reserved for destination native gas top-up). - Execution pre-sign checks enforce bounded ERC-20 approvals (`approve <= planned input amount`) by default; use `--allow-max-approval` when a route requires larger approvals. +- Swap execution validates `--from-address` and `--recipient` as EVM hex addresses before planning transactions. - Bridge execution pre-sign checks validate settlement provider metadata and known settlement endpoint URLs for Across/LiFi; use `--unsafe-provider-tx` to bypass these guardrails. - All `run` / `submit` execution commands will broadcast signed transactions. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index 803ddb3..f2b8b7c 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -186,18 +186,20 @@ Tradeoff: ## 6. Endpoint, Contract, and ABI Management -Canonical execution metadata currently lives in `internal/registry/execution_data.go`: +Canonical execution metadata is split under `internal/registry/`: -- Execution endpoint constants: +- `endpoints.go`: - LiFi quote/status endpoints - Across quote/status endpoints - Morpho GraphQL endpoint used by execution planners -- Contract address registries: - - TaikoSwap contracts by chain +- `rpc.go`: + - Default chain RPC map used by execution planners/providers when `--rpc-url` is not set +- `contracts.go`: + - Uniswap V3-compatible contracts by chain (used by TaikoSwap today) - Aave PoolAddressesProvider by chain -- ABI fragments: +- `abis.go`: - ERC-20 minimal - - TaikoSwap quoter/router + - Uniswap V3 quoter/router - Aave pool/rewards/provider - Morpho Blue diff --git a/internal/app/runner.go b/internal/app/runner.go index 70964c0..42abcc4 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -144,7 +144,7 @@ func (s *runtimeState) newRootCommand() *cobra.Command { morphoProvider := morpho.New(httpClient) kaminoProvider := kamino.New(httpClient) jupiterProvider := jupiter.New(httpClient, settings.JupiterAPIKey) - taikoSwapProvider := taikoswap.New(httpClient, settings.TaikoMainnetRPC, settings.TaikoHoodiRPC) + taikoSwapProvider := taikoswap.New() s.marketProvider = llama s.lendingProviders = map[string]providers.LendingProvider{ "aave": aaveProvider, @@ -695,7 +695,7 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { func (s *runtimeState) newSwapCommand() *cobra.Command { root := &cobra.Command{Use: "swap", Short: "Swap quote and execution commands"} - parseSwapRequest := func(chainArg, fromAssetArg, toAssetArg, amountBase, amountDecimal string) (providers.SwapQuoteRequest, error) { + parseSwapRequest := func(chainArg, fromAssetArg, toAssetArg, amountBase, amountDecimal, rpcURL string) (providers.SwapQuoteRequest, error) { chain, err := id.ParseChain(chainArg) if err != nil { return providers.SwapQuoteRequest{}, err @@ -722,11 +722,12 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { ToAsset: toAsset, AmountBaseUnits: base, AmountDecimal: decimal, + RPCURL: strings.TrimSpace(rpcURL), }, nil } var quoteProviderArg, quoteChainArg, quoteFromAssetArg, quoteToAssetArg string - var quoteAmountBase, quoteAmountDecimal string + var quoteAmountBase, quoteAmountDecimal, quoteRPCURL string quoteCmd := &cobra.Command{ Use: "quote", Short: "Get swap quote", @@ -739,7 +740,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if !ok { return clierr.New(clierr.CodeUnsupported, "unsupported swap provider") } - reqStruct, err := parseSwapRequest(quoteChainArg, quoteFromAssetArg, quoteToAssetArg, quoteAmountBase, quoteAmountDecimal) + reqStruct, err := parseSwapRequest(quoteChainArg, quoteFromAssetArg, quoteToAssetArg, quoteAmountBase, quoteAmountDecimal, quoteRPCURL) if err != nil { return err } @@ -750,6 +751,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { "from": reqStruct.FromAsset.AssetID, "to": reqStruct.ToAsset.AssetID, "amount": reqStruct.AmountBaseUnits, + "rpc_url": reqStruct.RPCURL, }) return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 15*time.Second, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { start := time.Now() @@ -765,6 +767,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { quoteCmd.Flags().StringVar("eToAssetArg, "to-asset", "", "Output asset") quoteCmd.Flags().StringVar("eAmountBase, "amount", "", "Amount in base units") quoteCmd.Flags().StringVar("eAmountDecimal, "amount-decimal", "", "Amount in decimal units") + quoteCmd.Flags().StringVar("eRPCURL, "rpc-url", "", "RPC URL override for on-chain quote providers") _ = quoteCmd.MarkFlagRequired("chain") _ = quoteCmd.MarkFlagRequired("from-asset") _ = quoteCmd.MarkFlagRequired("to-asset") @@ -774,6 +777,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var planAmountBase, planAmountDecimal, planFromAddress, planRecipient string var planSlippageBps int64 var planSimulate bool + var planRPCURL string planCmd := &cobra.Command{ Use: "plan", Short: "Create and persist a swap action plan", @@ -782,7 +786,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } - reqStruct, err := parseSwapRequest(planChainArg, planFromAssetArg, planToAssetArg, planAmountBase, planAmountDecimal) + reqStruct, err := parseSwapRequest(planChainArg, planFromAssetArg, planToAssetArg, planAmountBase, planAmountDecimal, "") if err != nil { return err } @@ -795,6 +799,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { Recipient: planRecipient, SlippageBps: planSlippageBps, Simulate: planSimulate, + RPCURL: planRPCURL, }) if strings.TrimSpace(providerInfoName) == "" { providerInfoName = providerName @@ -824,6 +829,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { planCmd.Flags().StringVar(&planRecipient, "recipient", "", "Recipient address (defaults to --from-address)") planCmd.Flags().Int64Var(&planSlippageBps, "slippage-bps", 50, "Max slippage in basis points") planCmd.Flags().BoolVar(&planSimulate, "simulate", true, "Include simulation checks during execution") + planCmd.Flags().StringVar(&planRPCURL, "rpc-url", "", "RPC URL override for the selected chain") _ = planCmd.MarkFlagRequired("chain") _ = planCmd.MarkFlagRequired("from-asset") _ = planCmd.MarkFlagRequired("to-asset") @@ -834,6 +840,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { var runAmountBase, runAmountDecimal, runFromAddress, runRecipient string var runSlippageBps int64 var runSimulate bool + var runRPCURL string var runSigner, runKeySource, runPrivateKey string var runPollInterval, runStepTimeout string var runGasMultiplier float64 @@ -847,7 +854,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if providerName == "" { return clierr.New(clierr.CodeUsage, "--provider is required") } - reqStruct, err := parseSwapRequest(runChainArg, runFromAssetArg, runToAssetArg, runAmountBase, runAmountDecimal) + reqStruct, err := parseSwapRequest(runChainArg, runFromAssetArg, runToAssetArg, runAmountBase, runAmountDecimal, "") if err != nil { return err } @@ -864,6 +871,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { Recipient: runRecipient, SlippageBps: runSlippageBps, Simulate: runSimulate, + RPCURL: runRPCURL, }) if strings.TrimSpace(providerInfoName) == "" { providerInfoName = providerName @@ -912,6 +920,7 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { runCmd.Flags().StringVar(&runRecipient, "recipient", "", "Recipient address (defaults to --from-address)") runCmd.Flags().Int64Var(&runSlippageBps, "slippage-bps", 50, "Max slippage in basis points") runCmd.Flags().BoolVar(&runSimulate, "simulate", true, "Run preflight simulation before submission") + runCmd.Flags().StringVar(&runRPCURL, "rpc-url", "", "RPC URL override for the selected chain") runCmd.Flags().StringVar(&runSigner, "signer", "local", "Signer backend (local)") runCmd.Flags().StringVar(&runKeySource, "key-source", execsigner.KeySourceAuto, "Key source (auto|env|file|keystore)") runCmd.Flags().StringVar(&runPrivateKey, "private-key", "", "Private key hex override for local signer (less safe)") diff --git a/internal/config/config.go b/internal/config/config.go index 23c29b2..2751fe5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,8 +45,6 @@ type Settings struct { DefiLlamaAPIKey string UniswapAPIKey string OneInchAPIKey string - TaikoMainnetRPC string - TaikoHoodiRPC string JupiterAPIKey string BungeeAPIKey string BungeeAffiliate string @@ -80,10 +78,6 @@ type fileConfig struct { APIKey string `yaml:"api_key"` APIKeyEnv string `yaml:"api_key_env"` } `yaml:"oneinch"` - TaikoSwap struct { - MainnetRPC string `yaml:"mainnet_rpc"` - HoodiRPC string `yaml:"hoodi_rpc"` - } `yaml:"taikoswap"` Jupiter struct { APIKey string `yaml:"api_key"` APIKeyEnv string `yaml:"api_key_env"` @@ -251,12 +245,6 @@ func applyFileConfig(path string, settings *Settings) error { if cfg.Providers.OneInch.APIKeyEnv != "" { settings.OneInchAPIKey = os.Getenv(cfg.Providers.OneInch.APIKeyEnv) } - if cfg.Providers.TaikoSwap.MainnetRPC != "" { - settings.TaikoMainnetRPC = cfg.Providers.TaikoSwap.MainnetRPC - } - if cfg.Providers.TaikoSwap.HoodiRPC != "" { - settings.TaikoHoodiRPC = cfg.Providers.TaikoSwap.HoodiRPC - } if cfg.Providers.Jupiter.APIKey != "" { settings.JupiterAPIKey = cfg.Providers.Jupiter.APIKey } @@ -334,12 +322,6 @@ func applyEnv(settings *Settings) { if v := os.Getenv("DEFI_1INCH_API_KEY"); v != "" { settings.OneInchAPIKey = v } - if v := os.Getenv("DEFI_TAIKO_MAINNET_RPC_URL"); v != "" { - settings.TaikoMainnetRPC = v - } - if v := os.Getenv("DEFI_TAIKO_HOODI_RPC_URL"); v != "" { - settings.TaikoHoodiRPC = v - } if v := os.Getenv("DEFI_JUPITER_API_KEY"); v != "" { settings.JupiterAPIKey = v } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9a89c8b..df7fe84 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -70,21 +70,6 @@ func TestLoadExecutionPathsFromEnv(t *testing.T) { } } -func TestLoadTaikoRPCFromEnv(t *testing.T) { - t.Setenv("DEFI_TAIKO_MAINNET_RPC_URL", "https://rpc.example.mainnet") - t.Setenv("DEFI_TAIKO_HOODI_RPC_URL", "https://rpc.example.hoodi") - settings, err := Load(GlobalFlags{}) - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if settings.TaikoMainnetRPC != "https://rpc.example.mainnet" { - t.Fatalf("unexpected mainnet rpc: %q", settings.TaikoMainnetRPC) - } - if settings.TaikoHoodiRPC != "https://rpc.example.hoodi" { - t.Fatalf("unexpected hoodi rpc: %q", settings.TaikoHoodiRPC) - } -} - func TestLoadJupiterAPIKeyFromEnv(t *testing.T) { t.Setenv("DEFI_JUPITER_API_KEY", "jup-key") settings, err := Load(GlobalFlags{}) diff --git a/internal/execution/planner/aave.go b/internal/execution/planner/aave.go index ac69e22..5c94423 100644 --- a/internal/execution/planner/aave.go +++ b/internal/execution/planner/aave.go @@ -215,7 +215,7 @@ func BuildAaveRewardsClaimAction(ctx context.Context, req AaveRewardsClaimReques return execution.Action{}, clierr.New(clierr.CodeUsage, "rewards claim requires at least one asset in --assets") } - rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + rpcURL, err := registry.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } @@ -290,7 +290,7 @@ func BuildAaveRewardsCompoundAction(ctx context.Context, req AaveRewardsCompound claimAction.IntentType = "compound_rewards" claimAction.Metadata["compound"] = true - rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + rpcURL, err := registry.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } @@ -363,7 +363,7 @@ func normalizeLendInputs(req AaveLendRequest) (common.Address, common.Address, c if !ok || amount.Sign() <= 0 { return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.New(clierr.CodeUsage, "lend amount must be a positive integer in base units") } - rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + rpcURL, err := registry.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) if err != nil { return common.Address{}, common.Address{}, common.Address{}, nil, "", common.Address{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } diff --git a/internal/execution/planner/approvals.go b/internal/execution/planner/approvals.go index afaa172..de1ed26 100644 --- a/internal/execution/planner/approvals.go +++ b/internal/execution/planner/approvals.go @@ -28,10 +28,16 @@ func BuildApprovalAction(req ApprovalRequest) (execution.Action, error) { if sender == "" { return execution.Action{}, clierr.New(clierr.CodeUsage, "approval requires sender address") } + if !common.IsHexAddress(sender) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "approval sender must be a valid EVM address") + } spender := strings.TrimSpace(req.Spender) if spender == "" { return execution.Action{}, clierr.New(clierr.CodeUsage, "approval requires spender address") } + if !common.IsHexAddress(spender) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "approval spender must be a valid EVM address") + } if !common.IsHexAddress(req.Asset.Address) { return execution.Action{}, clierr.New(clierr.CodeUsage, "approval requires ERC20 token address") } @@ -40,7 +46,7 @@ func BuildApprovalAction(req ApprovalRequest) (execution.Action, error) { return execution.Action{}, clierr.New(clierr.CodeUsage, "approval amount must be a positive integer in base units") } - rpcURL, err := execution.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) + rpcURL, err := registry.ResolveRPCURL(req.RPCURL, req.Chain.EVMChainID) if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } diff --git a/internal/execution/policy_basic.go b/internal/execution/policy_basic.go index 2ce778a..62d5adb 100644 --- a/internal/execution/policy_basic.go +++ b/internal/execution/policy_basic.go @@ -13,11 +13,11 @@ import ( ) var ( - policyERC20ABI = mustPolicyABI(registry.ERC20MinimalABI) - policyTaikoRouterABI = mustPolicyABI(registry.TaikoSwapRouterABI) + policyERC20ABI = mustPolicyABI(registry.ERC20MinimalABI) + policyUniswapV3RouterABI = mustPolicyABI(registry.UniswapV3RouterABI) - policyApproveSelector = policyERC20ABI.Methods["approve"].ID - policyTaikoSwapMethod = policyTaikoRouterABI.Methods["exactInputSingle"].ID + policyApproveSelector = policyERC20ABI.Methods["approve"].ID + policyUniswapV3SwapMethod = policyUniswapV3RouterABI.Methods["exactInputSingle"].ID ) func validateStepPolicy(action *Action, step *ActionStep, chainID int64, data []byte, opts ExecuteOptions) error { @@ -79,10 +79,10 @@ func validateSwapPolicy(action *Action, step *ActionStep, chainID int64, data [] if action == nil || !strings.EqualFold(strings.TrimSpace(action.Provider), "taikoswap") { return nil } - if len(data) < 4 || !bytes.Equal(data[:4], policyTaikoSwapMethod) { + if len(data) < 4 || !bytes.Equal(data[:4], policyUniswapV3SwapMethod) { return clierr.New(clierr.CodeActionPlan, "taikoswap swap step must call exactInputSingle") } - _, router, ok := registry.TaikoSwapContracts(chainID) + _, router, ok := registry.UniswapV3Contracts(chainID) if !ok { return clierr.New(clierr.CodeActionPlan, "taikoswap swap step has unsupported chain") } diff --git a/internal/execution/policy_basic_test.go b/internal/execution/policy_basic_test.go index ab6c8f6..9619a17 100644 --- a/internal/execution/policy_basic_test.go +++ b/internal/execution/policy_basic_test.go @@ -57,7 +57,7 @@ func TestValidateSwapPolicyTaikoRouter(t *testing.T) { Type: StepTypeSwap, Target: "0x00000000000000000000000000000000000000cd", } - err := validateStepPolicy(action, step, 167000, policyTaikoSwapMethod, ExecuteOptions{}) + err := validateStepPolicy(action, step, 167000, policyUniswapV3SwapMethod, ExecuteOptions{}) if err == nil { t.Fatal("expected taikoswap router mismatch to fail") } diff --git a/internal/providers/across/client.go b/internal/providers/across/client.go index 1465cd2..20df7f2 100644 --- a/internal/providers/across/client.go +++ b/internal/providers/across/client.go @@ -214,7 +214,7 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot return execution.Action{}, clierr.New(clierr.CodeActionPlan, "across swap transaction chain does not match source chain") } - rpcURL, err := execution.ResolveRPCURL(opts.RPCURL, req.FromChain.EVMChainID) + rpcURL, err := registry.ResolveRPCURL(opts.RPCURL, req.FromChain.EVMChainID) if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } diff --git a/internal/providers/lifi/client.go b/internal/providers/lifi/client.go index 81dc70a..266c8f3 100644 --- a/internal/providers/lifi/client.go +++ b/internal/providers/lifi/client.go @@ -241,7 +241,7 @@ func (c *Client) BuildBridgeAction(ctx context.Context, req providers.BridgeQuot return execution.Action{}, clierr.New(clierr.CodeActionPlan, "lifi transaction chain does not match source chain") } - rpcURL, err := execution.ResolveRPCURL(opts.RPCURL, req.FromChain.EVMChainID) + rpcURL, err := registry.ResolveRPCURL(opts.RPCURL, req.FromChain.EVMChainID) if err != nil { return execution.Action{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } diff --git a/internal/providers/taikoswap/client.go b/internal/providers/taikoswap/client.go index 5da2c15..e3dad17 100644 --- a/internal/providers/taikoswap/client.go +++ b/internal/providers/taikoswap/client.go @@ -13,41 +13,26 @@ import ( "github.com/ethereum/go-ethereum/ethclient" clierr "github.com/ggonzalez94/defi-cli/internal/errors" "github.com/ggonzalez94/defi-cli/internal/execution" - "github.com/ggonzalez94/defi-cli/internal/httpx" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" "github.com/ggonzalez94/defi-cli/internal/providers" "github.com/ggonzalez94/defi-cli/internal/registry" ) -const ( - defaultMainnetRPC = "https://rpc.mainnet.taiko.xyz" - defaultHoodiRPC = "https://rpc.hoodi.taiko.xyz" -) - var ( feeTiers = []uint32{100, 500, 3000, 10000} - quoterABI = mustABI(registry.TaikoSwapQuoterV2ABI) + quoterABI = mustABI(registry.UniswapV3QuoterV2ABI) erc20ABI = mustABI(registry.ERC20MinimalABI) - routerABI = mustABI(registry.TaikoSwapRouterABI) + routerABI = mustABI(registry.UniswapV3RouterABI) ) type Client struct { - http *httpx.Client - mainnetRPC string - hoodiRPC string - now func() time.Time + now func() time.Time } -func New(httpClient *httpx.Client, mainnetRPC, hoodiRPC string) *Client { - if strings.TrimSpace(mainnetRPC) == "" { - mainnetRPC = defaultMainnetRPC - } - if strings.TrimSpace(hoodiRPC) == "" { - hoodiRPC = defaultHoodiRPC - } - return &Client{http: httpClient, mainnetRPC: mainnetRPC, hoodiRPC: hoodiRPC, now: time.Now} +func New() *Client { + return &Client{now: time.Now} } func (c *Client) Info() model.ProviderInfo { @@ -82,7 +67,7 @@ type exactInputSingleParams struct { } func (c *Client) QuoteSwap(ctx context.Context, req providers.SwapQuoteRequest) (model.SwapQuote, error) { - rpcURL, quoter, _, err := c.chainConfig(req.Chain) + rpcURL, quoter, _, err := c.chainConfig(req.Chain, req.RPCURL) if err != nil { return model.SwapQuote{}, err } @@ -121,10 +106,14 @@ func (c *Client) QuoteSwap(ctx context.Context, req providers.SwapQuoteRequest) } func (c *Client) BuildSwapAction(ctx context.Context, req providers.SwapQuoteRequest, opts providers.SwapExecutionOptions) (execution.Action, error) { - if strings.TrimSpace(opts.Sender) == "" { + sender := strings.TrimSpace(opts.Sender) + if sender == "" { return execution.Action{}, clierr.New(clierr.CodeUsage, "swap execution requires sender address") } - rpcURL, quoter, router, err := c.chainConfig(req.Chain) + if !common.IsHexAddress(sender) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "swap execution sender must be a valid EVM address") + } + rpcURL, quoter, router, err := c.chainConfig(req.Chain, opts.RPCURL) if err != nil { return execution.Action{}, err } @@ -142,10 +131,13 @@ func (c *Client) BuildSwapAction(ctx context.Context, req providers.SwapQuoteReq toToken := common.HexToAddress(req.ToAsset.Address) recipient := strings.TrimSpace(opts.Recipient) if recipient == "" { - recipient = opts.Sender + recipient = sender + } + if !common.IsHexAddress(recipient) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "swap execution recipient must be a valid EVM address") } recipientAddr := common.HexToAddress(recipient) - senderAddr := common.HexToAddress(opts.Sender) + senderAddr := common.HexToAddress(sender) quotedOut, bestFee, _, err := quoteBestFee(ctx, client, quoter, fromToken, toToken, amountIn) if err != nil { @@ -238,19 +230,16 @@ func (c *Client) BuildSwapAction(ctx context.Context, req providers.SwapQuoteReq return action, nil } -func (c *Client) chainConfig(chain id.Chain) (rpc string, quoter common.Address, router common.Address, err error) { - quoterRaw, routerRaw, ok := registry.TaikoSwapContracts(chain.EVMChainID) +func (c *Client) chainConfig(chain id.Chain, rpcOverride string) (rpc string, quoter common.Address, router common.Address, err error) { + quoterRaw, routerRaw, ok := registry.UniswapV3Contracts(chain.EVMChainID) if !ok { return "", common.Address{}, common.Address{}, clierr.New(clierr.CodeUnsupported, "taikoswap only supports taiko mainnet/hoodi chains") } - switch chain.EVMChainID { - case 167000: - return c.mainnetRPC, common.HexToAddress(quoterRaw), common.HexToAddress(routerRaw), nil - case 167013: - return c.hoodiRPC, common.HexToAddress(quoterRaw), common.HexToAddress(routerRaw), nil - default: - return "", common.Address{}, common.Address{}, clierr.New(clierr.CodeUnsupported, "taikoswap only supports taiko mainnet/hoodi chains") + rpc, err = registry.ResolveRPCURL(rpcOverride, chain.EVMChainID) + if err != nil { + return "", common.Address{}, common.Address{}, clierr.Wrap(clierr.CodeUsage, "resolve rpc url", err) } + return rpc, common.HexToAddress(quoterRaw), common.HexToAddress(routerRaw), nil } func quoteBestFee(ctx context.Context, client *ethclient.Client, quoter, tokenIn, tokenOut common.Address, amountIn *big.Int) (*big.Int, uint32, *big.Int, error) { diff --git a/internal/providers/taikoswap/client_test.go b/internal/providers/taikoswap/client_test.go index 0baae50..8809972 100644 --- a/internal/providers/taikoswap/client_test.go +++ b/internal/providers/taikoswap/client_test.go @@ -11,9 +11,7 @@ import ( "strings" "sync" "testing" - "time" - "github.com/ggonzalez94/defi-cli/internal/httpx" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/providers" ) @@ -29,12 +27,12 @@ func TestQuoteSwapChoosesBestFeeRoute(t *testing.T) { server := newMockRPCServer(t, false) defer server.Close() - c := New(httpx.New(2*time.Second, 0), server.URL, "") + c := New() chain, _ := id.ParseChain("taiko") fromAsset, _ := id.ParseAsset("USDC", chain) toAsset, _ := id.ParseAsset("WETH", chain) quote, err := c.QuoteSwap(context.Background(), providers.SwapQuoteRequest{ - Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", + Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", RPCURL: server.URL, }) if err != nil { t.Fatalf("QuoteSwap failed: %v", err) @@ -54,7 +52,7 @@ func TestBuildSwapActionAddsApprovalWhenNeeded(t *testing.T) { server := newMockRPCServer(t, true) defer server.Close() - c := New(httpx.New(2*time.Second, 0), server.URL, "") + c := New() chain, _ := id.ParseChain("taiko") fromAsset, _ := id.ParseAsset("USDC", chain) toAsset, _ := id.ParseAsset("WETH", chain) @@ -65,6 +63,7 @@ func TestBuildSwapActionAddsApprovalWhenNeeded(t *testing.T) { Recipient: "0x00000000000000000000000000000000000000BB", SlippageBps: 100, Simulate: true, + RPCURL: server.URL, }) if err != nil { t.Fatalf("BuildSwapAction failed: %v", err) @@ -84,7 +83,7 @@ func TestBuildSwapActionAddsApprovalWhenNeeded(t *testing.T) { } func TestBuildSwapActionRequiresSender(t *testing.T) { - c := New(httpx.New(2*time.Second, 0), defaultMainnetRPC, "") + c := New() chain, _ := id.ParseChain("taiko") fromAsset, _ := id.ParseAsset("USDC", chain) toAsset, _ := id.ParseAsset("WETH", chain) @@ -96,6 +95,64 @@ func TestBuildSwapActionRequiresSender(t *testing.T) { } } +func TestBuildSwapActionRejectsInvalidSender(t *testing.T) { + c := New() + chain, _ := id.ParseChain("taiko") + fromAsset, _ := id.ParseAsset("USDC", chain) + toAsset, _ := id.ParseAsset("WETH", chain) + _, err := c.BuildSwapAction(context.Background(), providers.SwapQuoteRequest{ + Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", + }, providers.SwapExecutionOptions{Sender: "not-an-address"}) + if err == nil { + t.Fatal("expected invalid sender error") + } +} + +func TestBuildSwapActionRejectsInvalidRecipient(t *testing.T) { + c := New() + chain, _ := id.ParseChain("taiko") + fromAsset, _ := id.ParseAsset("USDC", chain) + toAsset, _ := id.ParseAsset("WETH", chain) + _, err := c.BuildSwapAction(context.Background(), providers.SwapQuoteRequest{ + Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", + }, providers.SwapExecutionOptions{ + Sender: "0x00000000000000000000000000000000000000AA", + Recipient: "not-an-address", + }) + if err == nil { + t.Fatal("expected invalid recipient error") + } +} + +func TestBuildSwapActionUsesRPCOverride(t *testing.T) { + server := newMockRPCServer(t, true) + defer server.Close() + + c := New() + chain, _ := id.ParseChain("taiko") + fromAsset, _ := id.ParseAsset("USDC", chain) + toAsset, _ := id.ParseAsset("WETH", chain) + action, err := c.BuildSwapAction(context.Background(), providers.SwapQuoteRequest{ + Chain: chain, FromAsset: fromAsset, ToAsset: toAsset, AmountBaseUnits: "1000000", AmountDecimal: "1", + }, providers.SwapExecutionOptions{ + Sender: "0x00000000000000000000000000000000000000AA", + SlippageBps: 100, + Simulate: true, + RPCURL: server.URL, + }) + if err != nil { + t.Fatalf("BuildSwapAction failed with rpc override: %v", err) + } + if len(action.Steps) == 0 { + t.Fatal("expected non-empty steps") + } + for i := range action.Steps { + if action.Steps[i].RPCURL != server.URL { + t.Fatalf("expected step %d rpc override %q, got %q", i, server.URL, action.Steps[i].RPCURL) + } + } +} + func newMockRPCServer(t *testing.T, includeAllowance bool) *httptest.Server { t.Helper() diff --git a/internal/providers/types.go b/internal/providers/types.go index 599b227..608d6c6 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -104,6 +104,7 @@ type SwapQuoteRequest struct { ToAsset id.Asset AmountBaseUnits string AmountDecimal string + RPCURL string } type SwapExecutionOptions struct { @@ -111,4 +112,5 @@ type SwapExecutionOptions struct { Recipient string SlippageBps int64 Simulate bool + RPCURL string } diff --git a/internal/registry/execution_data.go b/internal/registry/abis.go similarity index 59% rename from internal/registry/execution_data.go rename to internal/registry/abis.go index 0e43b22..3007192 100644 --- a/internal/registry/execution_data.go +++ b/internal/registry/abis.go @@ -1,145 +1,5 @@ package registry -import ( - "net" - "net/url" - "strings" -) - -const ( - // Execution provider endpoints. - LiFiBaseURL = "https://li.quest/v1" - LiFiSettlementURL = "https://li.quest/v1/status" - AcrossBaseURL = "https://app.across.to/api" - AcrossSettlementURL = "https://app.across.to/api/deposit/status" - MorphoGraphQLEndpoint = "https://api.morpho.org/graphql" -) - -func BridgeSettlementURL(provider string) (string, bool) { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "lifi": - return LiFiSettlementURL, true - case "across": - return AcrossSettlementURL, true - default: - return "", false - } -} - -func IsAllowedBridgeSettlementURL(provider, endpoint string) bool { - if strings.TrimSpace(endpoint) == "" { - return true - } - parsed, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil { - return false - } - if strings.TrimSpace(parsed.Hostname()) == "" { - return false - } - if isLoopbackHost(parsed.Hostname()) { - scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) - return scheme == "" || scheme == "http" || scheme == "https" - } - if !strings.EqualFold(strings.TrimSpace(parsed.Scheme), "https") { - return false - } - allowedRaw, ok := BridgeSettlementURL(provider) - if !ok { - return false - } - allowed, err := url.Parse(allowedRaw) - if err != nil { - return false - } - if !strings.EqualFold(parsed.Scheme, allowed.Scheme) { - return false - } - if !strings.EqualFold(parsed.Hostname(), allowed.Hostname()) { - return false - } - if normalizedURLPort(parsed) != normalizedURLPort(allowed) { - return false - } - return normalizedURLPath(parsed.Path) == normalizedURLPath(allowed.Path) -} - -func isLoopbackHost(host string) bool { - h := strings.TrimSpace(strings.ToLower(host)) - if h == "localhost" { - return true - } - ip := net.ParseIP(h) - return ip != nil && ip.IsLoopback() -} - -func normalizedURLPort(parsed *url.URL) string { - if parsed == nil { - return "" - } - if port := strings.TrimSpace(parsed.Port()); port != "" { - return port - } - switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { - case "http": - return "80" - case "https": - return "443" - default: - return "" - } -} - -func normalizedURLPath(path string) string { - p := strings.TrimSpace(path) - if p == "" { - return "/" - } - p = strings.TrimSuffix(p, "/") - if p == "" { - return "/" - } - return p -} - -// Canonical contracts used by TaikoSwap execution/quoting. -var taikoSwapContractsByChainID = map[int64]struct { - QuoterV2 string - Router string -}{ - 167000: { - QuoterV2: "0xcBa70D57be34aA26557B8E80135a9B7754680aDb", - Router: "0x1A0c3a0Cfd1791FAC7798FA2b05208B66aaadfeD", - }, - 167013: { - QuoterV2: "0xAC8D93657DCc5C0dE9d9AF2772aF9eA3A032a1C6", - Router: "0x482233e4DBD56853530fA1918157CE59B60dF230", - }, -} - -func TaikoSwapContracts(chainID int64) (quoterV2 string, router string, ok bool) { - contracts, ok := taikoSwapContractsByChainID[chainID] - if !ok { - return "", "", false - } - return contracts.QuoterV2, contracts.Router, true -} - -// Canonical Aave V3 PoolAddressesProvider contracts used by planners. -var aavePoolAddressProviderByChainID = map[int64]string{ - 1: "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e", // Ethereum - 10: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Optimism - 137: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Polygon - 8453: "0xe20fCBdBfFC4Dd138cE8b2E6FBb6CB49777ad64D", // Base - 42161: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Arbitrum - 43114: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Avalanche -} - -func AavePoolAddressProvider(chainID int64) (string, bool) { - value, ok := aavePoolAddressProviderByChainID[chainID] - return value, ok -} - // ABI fragments used across execution planners/providers. const ( ERC20MinimalABI = `[ @@ -147,11 +7,11 @@ const ( {"name":"approve","type":"function","stateMutability":"nonpayable","inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]} ]` - TaikoSwapQuoterV2ABI = `[ + UniswapV3QuoterV2ABI = `[ {"name":"quoteExactInputSingle","type":"function","stateMutability":"nonpayable","inputs":[{"name":"params","type":"tuple","components":[{"name":"tokenIn","type":"address"},{"name":"tokenOut","type":"address"},{"name":"amountIn","type":"uint256"},{"name":"fee","type":"uint24"},{"name":"sqrtPriceLimitX96","type":"uint160"}]}],"outputs":[{"name":"amountOut","type":"uint256"},{"name":"sqrtPriceX96After","type":"uint160"},{"name":"initializedTicksCrossed","type":"uint32"},{"name":"gasEstimate","type":"uint256"}]} ]` - TaikoSwapRouterABI = `[ + UniswapV3RouterABI = `[ {"name":"exactInputSingle","type":"function","stateMutability":"payable","inputs":[{"name":"params","type":"tuple","components":[{"name":"tokenIn","type":"address"},{"name":"tokenOut","type":"address"},{"name":"fee","type":"uint24"},{"name":"recipient","type":"address"},{"name":"amountIn","type":"uint256"},{"name":"amountOutMinimum","type":"uint256"},{"name":"sqrtPriceLimitX96","type":"uint160"}]}],"outputs":[{"name":"amountOut","type":"uint256"}]} ]` diff --git a/internal/registry/contracts.go b/internal/registry/contracts.go new file mode 100644 index 0000000..55a7758 --- /dev/null +++ b/internal/registry/contracts.go @@ -0,0 +1,40 @@ +package registry + +// Canonical Uniswap V3-compatible contracts used by swap execution/quoting. +// Today this map includes Taiko deployments and can be extended chain-by-chain. +var uniswapV3ContractsByChainID = map[int64]struct { + QuoterV2 string + Router string +}{ + 167000: { + QuoterV2: "0xcBa70D57be34aA26557B8E80135a9B7754680aDb", + Router: "0x1A0c3a0Cfd1791FAC7798FA2b05208B66aaadfeD", + }, + 167013: { + QuoterV2: "0xAC8D93657DCc5C0dE9d9AF2772aF9eA3A032a1C6", + Router: "0x482233e4DBD56853530fA1918157CE59B60dF230", + }, +} + +func UniswapV3Contracts(chainID int64) (quoterV2 string, router string, ok bool) { + contracts, ok := uniswapV3ContractsByChainID[chainID] + if !ok { + return "", "", false + } + return contracts.QuoterV2, contracts.Router, true +} + +// Canonical Aave V3 PoolAddressesProvider contracts used by planners. +var aavePoolAddressProviderByChainID = map[int64]string{ + 1: "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e", // Ethereum + 10: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Optimism + 137: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Polygon + 8453: "0xe20fCBdBfFC4Dd138cE8b2E6FBb6CB49777ad64D", // Base + 42161: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Arbitrum + 43114: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", // Avalanche +} + +func AavePoolAddressProvider(chainID int64) (string, bool) { + value, ok := aavePoolAddressProviderByChainID[chainID] + return value, ok +} diff --git a/internal/registry/endpoints.go b/internal/registry/endpoints.go new file mode 100644 index 0000000..422f6fa --- /dev/null +++ b/internal/registry/endpoints.go @@ -0,0 +1,105 @@ +package registry + +import ( + "net" + "net/url" + "strings" +) + +const ( + // Execution provider endpoints. + LiFiBaseURL = "https://li.quest/v1" + LiFiSettlementURL = "https://li.quest/v1/status" + AcrossBaseURL = "https://app.across.to/api" + AcrossSettlementURL = "https://app.across.to/api/deposit/status" + + // Shared GraphQL endpoint used by Morpho adapter and execution planner. + MorphoGraphQLEndpoint = "https://api.morpho.org/graphql" +) + +func BridgeSettlementURL(provider string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "lifi": + return LiFiSettlementURL, true + case "across": + return AcrossSettlementURL, true + default: + return "", false + } +} + +func IsAllowedBridgeSettlementURL(provider, endpoint string) bool { + if strings.TrimSpace(endpoint) == "" { + return true + } + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return false + } + if strings.TrimSpace(parsed.Hostname()) == "" { + return false + } + if isLoopbackHost(parsed.Hostname()) { + scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) + return scheme == "" || scheme == "http" || scheme == "https" + } + if !strings.EqualFold(strings.TrimSpace(parsed.Scheme), "https") { + return false + } + allowedRaw, ok := BridgeSettlementURL(provider) + if !ok { + return false + } + allowed, err := url.Parse(allowedRaw) + if err != nil { + return false + } + if !strings.EqualFold(parsed.Scheme, allowed.Scheme) { + return false + } + if !strings.EqualFold(parsed.Hostname(), allowed.Hostname()) { + return false + } + if normalizedURLPort(parsed) != normalizedURLPort(allowed) { + return false + } + return normalizedURLPath(parsed.Path) == normalizedURLPath(allowed.Path) +} + +func isLoopbackHost(host string) bool { + h := strings.TrimSpace(strings.ToLower(host)) + if h == "localhost" { + return true + } + ip := net.ParseIP(h) + return ip != nil && ip.IsLoopback() +} + +func normalizedURLPort(parsed *url.URL) string { + if parsed == nil { + return "" + } + if port := strings.TrimSpace(parsed.Port()); port != "" { + return port + } + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + +func normalizedURLPath(path string) string { + p := strings.TrimSpace(path) + if p == "" { + return "/" + } + p = strings.TrimSuffix(p, "/") + if p == "" { + return "/" + } + return p +} diff --git a/internal/registry/execution_data_test.go b/internal/registry/registry_test.go similarity index 66% rename from internal/registry/execution_data_test.go rename to internal/registry/registry_test.go index f1ffc02..a232878 100644 --- a/internal/registry/execution_data_test.go +++ b/internal/registry/registry_test.go @@ -7,17 +7,17 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" ) -func TestTaikoSwapContracts(t *testing.T) { - quoter, router, ok := TaikoSwapContracts(167000) +func TestUniswapV3Contracts(t *testing.T) { + quoter, router, ok := UniswapV3Contracts(167000) if !ok { t.Fatal("expected taiko mainnet contracts to exist") } if quoter == "" || router == "" { - t.Fatalf("unexpected empty taikoswap contract values: quoter=%q router=%q", quoter, router) + t.Fatalf("unexpected empty uniswap-v3 contract values: quoter=%q router=%q", quoter, router) } - if _, _, ok := TaikoSwapContracts(1); ok { - t.Fatal("did not expect taikoswap contracts for unsupported chain") + if _, _, ok := UniswapV3Contracts(1); ok { + t.Fatal("did not expect uniswap-v3 contracts for unsupported chain") } } @@ -37,8 +37,8 @@ func TestAavePoolAddressProvider(t *testing.T) { func TestExecutionABIConstantsParse(t *testing.T) { abis := []string{ ERC20MinimalABI, - TaikoSwapQuoterV2ABI, - TaikoSwapRouterABI, + UniswapV3QuoterV2ABI, + UniswapV3RouterABI, AavePoolAddressProviderABI, AavePoolABI, AaveRewardsABI, @@ -51,6 +51,40 @@ func TestExecutionABIConstantsParse(t *testing.T) { } } +func TestDefaultRPCURL(t *testing.T) { + if rpc, ok := DefaultRPCURL(167000); !ok || rpc == "" { + t.Fatalf("expected taiko mainnet rpc default, got ok=%v rpc=%q", ok, rpc) + } + if rpc, ok := DefaultRPCURL(8453); !ok || rpc == "" { + t.Fatalf("expected base rpc default, got ok=%v rpc=%q", ok, rpc) + } + if _, ok := DefaultRPCURL(999999); ok { + t.Fatal("did not expect rpc default for unsupported chain") + } +} + +func TestResolveRPCURL(t *testing.T) { + override, err := ResolveRPCURL(" https://rpc.example.test ", 1) + if err != nil { + t.Fatalf("resolve with override: %v", err) + } + if override != "https://rpc.example.test" { + t.Fatalf("unexpected override value: %q", override) + } + + defaultRPC, err := ResolveRPCURL("", 1) + if err != nil { + t.Fatalf("resolve with default: %v", err) + } + if defaultRPC == "" { + t.Fatal("expected non-empty default rpc") + } + + if _, err := ResolveRPCURL("", 999999); err == nil { + t.Fatal("expected missing chain default rpc error") + } +} + func TestBridgeSettlementURL(t *testing.T) { got, ok := BridgeSettlementURL("lifi") if !ok || got != LiFiSettlementURL { diff --git a/internal/execution/rpc.go b/internal/registry/rpc.go similarity index 82% rename from internal/execution/rpc.go rename to internal/registry/rpc.go index 1fc1e9c..bda1ffb 100644 --- a/internal/execution/rpc.go +++ b/internal/registry/rpc.go @@ -1,19 +1,21 @@ -package execution +package registry import ( "fmt" "strings" ) +// Canonical default EVM RPC endpoints by chain ID. +// These values are used whenever a command does not pass --rpc-url. var defaultRPCByChainID = map[int64]string{ 1: "https://eth.llamarpc.com", 10: "https://mainnet.optimism.io", 56: "https://bsc-dataseed.binance.org", 100: "https://rpc.gnosischain.com", 137: "https://polygon-rpc.com", - 324: "https://mainnet.era.zksync.io", 146: "https://rpc.soniclabs.com", 252: "https://rpc.frax.com", + 324: "https://mainnet.era.zksync.io", 480: "https://worldchain-mainnet.g.alchemy.com/public", 5000: "https://rpc.mantle.xyz", 8453: "https://mainnet.base.org", @@ -30,16 +32,16 @@ var defaultRPCByChainID = map[int64]string{ } func DefaultRPCURL(chainID int64) (string, bool) { - v, ok := defaultRPCByChainID[chainID] - return v, ok + value, ok := defaultRPCByChainID[chainID] + return value, ok } func ResolveRPCURL(override string, chainID int64) (string, error) { if strings.TrimSpace(override) != "" { return strings.TrimSpace(override), nil } - if v, ok := DefaultRPCURL(chainID); ok { - return v, nil + if value, ok := DefaultRPCURL(chainID); ok { + return value, nil } return "", fmt.Errorf("no default rpc configured for chain id %d; provide --rpc-url", chainID) } From 925fbf9697f4938722a2a957501887870e00d37b Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 09:47:41 -0400 Subject: [PATCH 11/18] docs: clarify rpc override scope and keep swap examples neutral --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5e2ed0e..55f5e94 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ defi bridge details --bridge layerzero --results-only # Requires DEFI_DEFILLAMA_ defi bridge quote --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --results-only defi bridge quote --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-amount-for-gas 100000 --results-only defi swap quote --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --results-only -defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --from-address 0xYourEOA --rpc-url https://rpc.mainnet.taiko.xyz --results-only +defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --from-address 0xYourEOA --results-only defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --from-amount-for-gas 100000 --results-only defi bridge plan --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only defi lend supply plan --protocol aave --chain 1 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only @@ -234,7 +234,7 @@ providers: api_key_env: DEFI_UNISWAP_API_KEY ``` -Execution `plan`/`run` `--rpc-url` flags override chain default RPCs for that invocation. +`swap quote` (on-chain quote providers) and execution `plan`/`run` `--rpc-url` flags override chain default RPCs for that invocation. `submit`/`status` commands use stored per-step RPC URLs from the persisted action. ## Execution Metadata Locations (Implementers) From 3e77c0046956e986d099b4f764e90803e3ed16fe Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 10:56:13 -0400 Subject: [PATCH 12/18] feat!: standardize lend/rewards selector on --provider --- AGENTS.md | 6 +-- CHANGELOG.md | 3 +- README.md | 16 +++--- docs/act-execution-design.md | 6 +-- internal/app/lend_execution_commands.go | 20 ++++---- internal/app/provider_selection_test.go | 8 +-- internal/app/rewards_command.go | 24 ++++----- internal/app/runner.go | 40 +++++++-------- internal/app/runner_actions_test.go | 2 +- internal/execution/actionbuilder/registry.go | 49 +++++++++++-------- .../execution/actionbuilder/registry_test.go | 24 ++++++--- internal/providers/aave/client.go | 12 ++--- internal/providers/kamino/client.go | 12 ++--- internal/providers/morpho/client.go | 12 ++--- internal/providers/types.go | 4 +- scripts/nightly_execution_smoke.sh | 6 +-- 16 files changed, 133 insertions(+), 111 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a855c74..6c93ef4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ go test -race ./... go vet ./... ./defi providers list --results-only -./defi lend markets --protocol aave --chain 1 --asset USDC --results-only +./defi lend markets --provider aave --chain 1 --asset USDC --results-only ./defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit 5 --results-only ``` @@ -63,10 +63,10 @@ README.md # user-facing usage + caveats - Error output always returns a full envelope, even with `--results-only` or `--select`. - Config precedence is `flags > env > config file > defaults`. - `yield --providers` expects provider names (`defillama,aave,morpho`), not protocol categories. -- Lending routes by `--protocol` to direct adapters when available, then may fallback to DefiLlama on selected failures. +- Lending routes by `--provider` to direct adapters when available, then may fallback to DefiLlama on selected failures. - Most commands do not require provider API keys. - Key-gated routes: `swap quote --provider 1inch` (`DEFI_1INCH_API_KEY`), `swap quote --provider uniswap` (`DEFI_UNISWAP_API_KEY`), `chains assets`, and `bridge list` / `bridge details` via DefiLlama (`DEFI_DEFILLAMA_API_KEY`). -- Multi-provider command paths require explicit provider/protocol selection (`--provider` or `--protocol`); no implicit defaults. +- Multi-provider command paths require explicit selector choice via `--provider`; no implicit defaults. - TaikoSwap quote/planning does not require an API key; execution uses local signer inputs (`--private-key` override, `DEFI_PRIVATE_KEY{,_FILE}`, or keystore envs) and also auto-discovers `~/.config/defi/key.hex` (or `$XDG_CONFIG_HOME/defi/key.hex`) when present. - `swap quote` (on-chain quote providers) and execution `plan`/`run` commands support optional `--rpc-url` overrides (`swap`, `bridge`, `approvals`, `lend`, `rewards`); `submit`/`status` use stored action step RPC URLs. - Swap execution planning validates sender/recipient inputs as EVM hex addresses before building calldata. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ebe59a..4b02deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Format: - Added nightly execution-planning smoke workflow (`nightly-execution-smoke.yml`) and script. ### Changed +- BREAKING: Lend and rewards commands now use `--provider` as the selector flag; `--protocol` has been removed. - `providers list` now includes TaikoSwap execution capabilities (`swap.plan`, `swap.execute`) alongside quote metadata. - `providers list` now includes LiFi bridge execution capabilities (`bridge.plan`, `bridge.execute`). - `providers list` now includes Across bridge execution capabilities (`bridge.plan`, `bridge.execute`). @@ -31,7 +32,7 @@ Format: - Added execution-specific exit codes (`20`-`24`) for plan/simulation/policy/timeout/signer failures. - Added execution config/env support for action store paths. - Execution command cache/action-store policy now covers `swap|bridge|approvals|lend|rewards ... plan|run|submit|status`. -- Removed implicit defaults for multi-provider command paths; `--provider`/`--protocol` must be set explicitly where applicable. +- Removed implicit defaults for multi-provider command paths; `--provider` must be set explicitly where applicable. - Added bridge gas-top-up request support via `--from-amount-for-gas` for LiFi quote/plan/run flows. - Bridge execution now tracks LiFi destination settlement status before finalizing bridge steps. - Bridge execution now tracks Across destination settlement status before finalizing bridge steps. diff --git a/README.md b/README.md index 55f5e94..32cfa8f 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ defi providers list --results-only defi chains top --limit 10 --results-only --select rank,chain,tvl_usd defi chains assets --chain 1 --asset USDC --results-only # Requires DEFI_DEFILLAMA_API_KEY defi assets resolve --chain base --symbol USDC --results-only -defi lend markets --protocol aave --chain 1 --asset USDC --results-only -defi lend rates --protocol morpho --chain 1 --asset USDC --results-only +defi lend markets --provider aave --chain 1 --asset USDC --results-only +defi lend rates --provider morpho --chain 1 --asset USDC --results-only defi yield opportunities --chain base --asset USDC --limit 20 --results-only defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit 10 --results-only defi bridge list --limit 10 --results-only # Requires DEFI_DEFILLAMA_API_KEY @@ -79,9 +79,9 @@ defi swap quote --provider taikoswap --chain taiko --from-asset USDC --to-asset defi swap plan --provider taikoswap --chain taiko --from-asset USDC --to-asset WETH --amount 1000000 --from-address 0xYourEOA --results-only defi bridge plan --provider lifi --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --from-amount-for-gas 100000 --results-only defi bridge plan --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only -defi lend supply plan --protocol aave --chain 1 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only -defi lend supply plan --protocol morpho --chain 1 --asset USDC --market-id 0x... --amount 1000000 --from-address 0xYourEOA --results-only -defi rewards claim plan --protocol aave --chain 1 --from-address 0xYourEOA --assets 0x... --reward-token 0x... --results-only +defi lend supply plan --provider aave --chain 1 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only +defi lend supply plan --provider morpho --chain 1 --asset USDC --market-id 0x... --amount 1000000 --from-address 0xYourEOA --results-only +defi rewards claim plan --provider aave --chain 1 --from-address 0xYourEOA --assets 0x... --reward-token 0x... --results-only defi approvals plan --chain taiko --asset USDC --spender 0xSpender --amount 1000000 --from-address 0xYourEOA --results-only defi swap status --action-id --results-only defi actions list --results-only @@ -141,8 +141,8 @@ Execution command surface: - `swap plan|run|submit|status` - `bridge plan|run|submit|status` (provider: `across|lifi`) - `approvals plan|run|submit|status` -- `lend supply|withdraw|borrow|repay plan|run|submit|status` (protocol: `aave|morpho`) -- `rewards claim|compound plan|run|submit|status` (protocol: `aave`) +- `lend supply|withdraw|borrow|repay plan|run|submit|status` (provider: `aave|morpho`) +- `rewards claim|compound plan|run|submit|status` (provider: `aave`) - `actions list|show` ## Command API Key Requirements @@ -275,7 +275,7 @@ providers: - Bridge execution pre-sign checks validate settlement provider metadata and known settlement endpoint URLs for Across/LiFi; use `--unsafe-provider-tx` to bypass these guardrails. - All `run` / `submit` execution commands will broadcast signed transactions. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. -- Provider/protocol selection is explicit for multi-provider flows; pass `--provider` or `--protocol` (no implicit defaults). +- Selector choice is explicit for multi-provider flows; pass `--provider` (no implicit defaults). ## Exit Codes diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index f2b8b7c..7b8ec4e 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -20,14 +20,14 @@ Execution is integrated inside existing domain commands (for example `swap`, `br |---|---|---|---| | Swap | `swap plan|run|submit|status` | `--provider` required | `taikoswap` execution today | | Bridge | `bridge plan|run|submit|status` | `--provider` required | `across`, `lifi` execution | -| Lend | `lend plan|run|submit|status` | `--protocol` required | `aave`, `morpho` execution (`morpho` requires `--market-id`) | -| Rewards | `rewards plan|run|submit|status` | `--protocol` required | `aave` execution | +| Lend | `lend plan|run|submit|status` | `--provider` required | `aave`, `morpho` execution (`morpho` requires `--market-id`) | +| Rewards | `rewards plan|run|submit|status` | `--provider` required | `aave` execution | | Approvals | `approvals plan|run|submit|status` | no provider selector | native ERC-20 approval execution | | Action inspection | `actions list|show` | optional `--status` filter | persisted action inspection | Notes: -- Multi-provider commands do not have implicit defaults. Users must pass `--provider` or `--protocol`. +- Multi-provider commands do not have implicit defaults. Users must pass `--provider`. ## 3. Architecture Overview diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go index 2312304..ac68848 100644 --- a/internal/app/lend_execution_commands.go +++ b/internal/app/lend_execution_commands.go @@ -30,7 +30,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh expectedIntent := "lend_" + string(verb) type lendArgs struct { - protocol string + provider string chainArg string assetArg string marketID string @@ -59,7 +59,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh return execution.Action{}, err } return s.actionBuilderRegistry().BuildLendAction(ctx, actionbuilder.LendRequest{ - Protocol: args.protocol, + Provider: args.provider, Verb: verb, Chain: chain, Asset: asset, @@ -85,7 +85,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh defer cancel() start := time.Now() action, err := buildAction(ctx, plan) - providerName := normalizeLendingProtocol(plan.protocol) + providerName := normalizeLendingProvider(plan.provider) if providerName == "" { providerName = "lend" } @@ -104,10 +104,10 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Lending protocol (aave|morpho)") + planCmd.Flags().StringVar(&plan.provider, "provider", "", "Lending provider (aave|morpho)") planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") planCmd.Flags().StringVar(&plan.assetArg, "asset", "", "Asset symbol/address/CAIP-19") - planCmd.Flags().StringVar(&plan.marketID, "market-id", "", "Morpho market unique key (required for --protocol morpho)") + planCmd.Flags().StringVar(&plan.marketID, "market-id", "", "Morpho market unique key (required for --provider morpho)") planCmd.Flags().StringVar(&plan.amountBase, "amount", "", "Amount in base units") planCmd.Flags().StringVar(&plan.amountDecimal, "amount-decimal", "", "Amount in decimal units") planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") @@ -121,7 +121,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh _ = planCmd.MarkFlagRequired("chain") _ = planCmd.MarkFlagRequired("asset") _ = planCmd.MarkFlagRequired("from-address") - _ = planCmd.MarkFlagRequired("protocol") + _ = planCmd.MarkFlagRequired("provider") var run lendArgs var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string @@ -143,7 +143,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh defer cancel() start := time.Now() action, err := buildAction(ctx, runArgs) - providerName := normalizeLendingProtocol(run.protocol) + providerName := normalizeLendingProvider(run.provider) if providerName == "" { providerName = "lend" } @@ -180,10 +180,10 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Lending protocol (aave|morpho)") + runCmd.Flags().StringVar(&run.provider, "provider", "", "Lending provider (aave|morpho)") runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") runCmd.Flags().StringVar(&run.assetArg, "asset", "", "Asset symbol/address/CAIP-19") - runCmd.Flags().StringVar(&run.marketID, "market-id", "", "Morpho market unique key (required for --protocol morpho)") + runCmd.Flags().StringVar(&run.marketID, "market-id", "", "Morpho market unique key (required for --provider morpho)") runCmd.Flags().StringVar(&run.amountBase, "amount", "", "Amount in base units") runCmd.Flags().StringVar(&run.amountDecimal, "amount-decimal", "", "Amount in decimal units") runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") @@ -206,7 +206,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh runCmd.Flags().BoolVar(&runUnsafeProviderTx, "unsafe-provider-tx", false, "Bypass provider transaction guardrails for bridge/aggregator payloads") _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("asset") - _ = runCmd.MarkFlagRequired("protocol") + _ = runCmd.MarkFlagRequired("provider") var submitActionID string var submitSimulate bool diff --git a/internal/app/provider_selection_test.go b/internal/app/provider_selection_test.go index b1ea45b..7b62a03 100644 --- a/internal/app/provider_selection_test.go +++ b/internal/app/provider_selection_test.go @@ -7,14 +7,14 @@ import ( "github.com/ggonzalez94/defi-cli/internal/providers" ) -func TestNormalizeLendingProtocol(t *testing.T) { - if got := normalizeLendingProtocol("AAVE-V3"); got != "aave" { +func TestNormalizeLendingProvider(t *testing.T) { + if got := normalizeLendingProvider("AAVE-V3"); got != "aave" { t.Fatalf("expected aave, got %s", got) } - if got := normalizeLendingProtocol("morpho-blue"); got != "morpho" { + if got := normalizeLendingProvider("morpho-blue"); got != "morpho" { t.Fatalf("expected morpho, got %s", got) } - if got := normalizeLendingProtocol("kamino-finance"); got != "kamino" { + if got := normalizeLendingProvider("kamino-finance"); got != "kamino" { t.Fatalf("expected kamino, got %s", got) } } diff --git a/internal/app/rewards_command.go b/internal/app/rewards_command.go index 0905b1b..97c8661 100644 --- a/internal/app/rewards_command.go +++ b/internal/app/rewards_command.go @@ -26,7 +26,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { const expectedIntent = "claim_rewards" type claimArgs struct { - protocol string + provider string chainArg string fromAddress string recipient string @@ -52,7 +52,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { amount = "max" } return s.actionBuilderRegistry().BuildRewardsClaimAction(ctx, actionbuilder.RewardsClaimRequest{ - Protocol: args.protocol, + Provider: args.provider, Chain: chain, Sender: args.fromAddress, Recipient: args.recipient, @@ -90,7 +90,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Rewards protocol (aave)") + planCmd.Flags().StringVar(&plan.provider, "provider", "", "Rewards provider (aave)") planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") planCmd.Flags().StringVar(&plan.recipient, "recipient", "", "Recipient address (defaults to --from-address)") @@ -105,7 +105,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("from-address") _ = planCmd.MarkFlagRequired("assets") _ = planCmd.MarkFlagRequired("reward-token") - _ = planCmd.MarkFlagRequired("protocol") + _ = planCmd.MarkFlagRequired("provider") var run claimArgs var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string @@ -160,7 +160,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Rewards protocol (aave)") + runCmd.Flags().StringVar(&run.provider, "provider", "", "Rewards provider (aave)") runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") @@ -184,7 +184,7 @@ func (s *runtimeState) newRewardsClaimCommand() *cobra.Command { _ = runCmd.MarkFlagRequired("chain") _ = runCmd.MarkFlagRequired("assets") _ = runCmd.MarkFlagRequired("reward-token") - _ = runCmd.MarkFlagRequired("protocol") + _ = runCmd.MarkFlagRequired("provider") var submitActionID string var submitSimulate bool @@ -292,7 +292,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { const expectedIntent = "compound_rewards" type compoundArgs struct { - protocol string + provider string chainArg string fromAddress string recipient string @@ -320,7 +320,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { return execution.Action{}, clierr.New(clierr.CodeUsage, "--amount is required") } return s.actionBuilderRegistry().BuildRewardsCompoundAction(ctx, actionbuilder.RewardsCompoundRequest{ - Protocol: args.protocol, + Provider: args.provider, Chain: chain, Sender: args.fromAddress, Recipient: args.recipient, @@ -360,7 +360,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - planCmd.Flags().StringVar(&plan.protocol, "protocol", "", "Rewards protocol (aave)") + planCmd.Flags().StringVar(&plan.provider, "provider", "", "Rewards provider (aave)") planCmd.Flags().StringVar(&plan.chainArg, "chain", "", "Chain identifier") planCmd.Flags().StringVar(&plan.fromAddress, "from-address", "", "Sender EOA address") planCmd.Flags().StringVar(&plan.recipient, "recipient", "", "Recipient address (defaults to --from-address)") @@ -378,7 +378,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { _ = planCmd.MarkFlagRequired("assets") _ = planCmd.MarkFlagRequired("reward-token") _ = planCmd.MarkFlagRequired("amount") - _ = planCmd.MarkFlagRequired("protocol") + _ = planCmd.MarkFlagRequired("provider") var run compoundArgs var runSigner, runKeySource, runPrivateKey, runPollInterval, runStepTimeout string @@ -433,7 +433,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - runCmd.Flags().StringVar(&run.protocol, "protocol", "", "Rewards protocol (aave)") + runCmd.Flags().StringVar(&run.provider, "provider", "", "Rewards provider (aave)") runCmd.Flags().StringVar(&run.chainArg, "chain", "", "Chain identifier") runCmd.Flags().StringVar(&run.fromAddress, "from-address", "", "Sender EOA address (defaults to signer address)") runCmd.Flags().StringVar(&run.recipient, "recipient", "", "Recipient address (defaults to --from-address)") @@ -460,7 +460,7 @@ func (s *runtimeState) newRewardsCompoundCommand() *cobra.Command { _ = runCmd.MarkFlagRequired("assets") _ = runCmd.MarkFlagRequired("reward-token") _ = runCmd.MarkFlagRequired("amount") - _ = runCmd.MarkFlagRequired("protocol") + _ = runCmd.MarkFlagRequired("provider") var submitActionID string var submitSimulate bool diff --git a/internal/app/runner.go b/internal/app/runner.go index 42abcc4..d1e6266 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -447,7 +447,7 @@ func (s *runtimeState) newAssetsCommand() *cobra.Command { func (s *runtimeState) newLendCommand() *cobra.Command { root := &cobra.Command{Use: "lend", Short: "Lending data"} - var protocolArg string + var providerArg string var chainArg string var assetArg string var marketsLimit int @@ -456,24 +456,24 @@ func (s *runtimeState) newLendCommand() *cobra.Command { Use: "markets", Short: "List lending markets", RunE: func(cmd *cobra.Command, args []string) error { - protocol := normalizeLendingProtocol(protocolArg) - if protocol == "" { - return clierr.New(clierr.CodeUsage, "--protocol is required") + providerName := normalizeLendingProvider(providerArg) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") } chain, asset, err := parseChainAsset(chainArg, assetArg) if err != nil { return err } - req := map[string]any{"protocol": protocol, "chain": chain.CAIP2, "asset": asset.AssetID, "limit": marketsLimit} + req := map[string]any{"provider": providerName, "chain": chain.CAIP2, "asset": asset.AssetID, "limit": marketsLimit} key := cacheKey(trimRootPath(cmd.CommandPath()), req) return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 60*time.Second, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { - provider, err := s.selectLendingProvider(protocol) + provider, err := s.selectLendingProvider(providerName) if err != nil { return nil, nil, nil, false, err } start := time.Now() - data, err := provider.LendMarkets(ctx, protocol, chain, asset) + data, err := provider.LendMarkets(ctx, providerName, chain, asset) statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { return nil, statuses, nil, false, err @@ -483,35 +483,35 @@ func (s *runtimeState) newLendCommand() *cobra.Command { }) }, } - marketsCmd.Flags().StringVar(&protocolArg, "protocol", "", "Lending protocol (aave, morpho, kamino)") + marketsCmd.Flags().StringVar(&providerArg, "provider", "", "Lending provider (aave, morpho, kamino)") marketsCmd.Flags().StringVar(&chainArg, "chain", "", "Chain identifier") marketsCmd.Flags().StringVar(&assetArg, "asset", "", "Asset (symbol/address/CAIP-19)") marketsCmd.Flags().IntVar(&marketsLimit, "limit", 20, "Maximum lending markets to return") - var ratesProtocol, ratesChain, ratesAsset string + var ratesProvider, ratesChain, ratesAsset string var ratesLimit int ratesCmd := &cobra.Command{ Use: "rates", Short: "List lending rates", RunE: func(cmd *cobra.Command, args []string) error { - protocol := normalizeLendingProtocol(ratesProtocol) - if protocol == "" { - return clierr.New(clierr.CodeUsage, "--protocol is required") + providerName := normalizeLendingProvider(ratesProvider) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") } chain, asset, err := parseChainAsset(ratesChain, ratesAsset) if err != nil { return err } - req := map[string]any{"protocol": protocol, "chain": chain.CAIP2, "asset": asset.AssetID, "limit": ratesLimit} + req := map[string]any{"provider": providerName, "chain": chain.CAIP2, "asset": asset.AssetID, "limit": ratesLimit} key := cacheKey(trimRootPath(cmd.CommandPath()), req) return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 30*time.Second, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { - provider, err := s.selectLendingProvider(protocol) + provider, err := s.selectLendingProvider(providerName) if err != nil { return nil, nil, nil, false, err } start := time.Now() - data, err := provider.LendRates(ctx, protocol, chain, asset) + data, err := provider.LendRates(ctx, providerName, chain, asset) statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} if err != nil { return nil, statuses, nil, false, err @@ -521,7 +521,7 @@ func (s *runtimeState) newLendCommand() *cobra.Command { }) }, } - ratesCmd.Flags().StringVar(&ratesProtocol, "protocol", "", "Lending protocol (aave, morpho, kamino)") + ratesCmd.Flags().StringVar(&ratesProvider, "provider", "", "Lending provider (aave, morpho, kamino)") ratesCmd.Flags().StringVar(&ratesChain, "chain", "", "Chain identifier") ratesCmd.Flags().StringVar(&ratesAsset, "asset", "", "Asset (symbol/address/CAIP-19)") ratesCmd.Flags().IntVar(&ratesLimit, "limit", 20, "Maximum lending rates to return") @@ -1372,7 +1372,7 @@ func (s *runtimeState) renderError(commandPath string, err error, warnings []str _ = out.Render(s.runner.stderr, env, settings) } -func normalizeLendingProtocol(input string) string { +func normalizeLendingProvider(input string) string { switch strings.ToLower(strings.TrimSpace(input)) { case "aave", "aave-v2", "aave-v3": return "aave" @@ -1385,10 +1385,10 @@ func normalizeLendingProtocol(input string) string { } } -func (s *runtimeState) selectLendingProvider(protocol string) (providers.LendingProvider, error) { - primary, ok := s.lendingProviders[protocol] +func (s *runtimeState) selectLendingProvider(providerName string) (providers.LendingProvider, error) { + primary, ok := s.lendingProviders[providerName] if !ok { - return nil, clierr.New(clierr.CodeUnsupported, fmt.Sprintf("unsupported lending protocol: %s", protocol)) + return nil, clierr.New(clierr.CodeUnsupported, fmt.Sprintf("unsupported lending provider: %s", providerName)) } return primary, nil } diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go index 688fb7f..f4b6893 100644 --- a/internal/app/runner_actions_test.go +++ b/internal/app/runner_actions_test.go @@ -223,7 +223,7 @@ func TestRunnerMorphoLendPlanRequiresMarketID(t *testing.T) { r := NewRunnerWithWriters(&stdout, &stderr) code := r.Run([]string{ "lend", "supply", "plan", - "--protocol", "morpho", + "--provider", "morpho", "--chain", "1", "--asset", "USDC", "--amount", "1000000", diff --git a/internal/execution/actionbuilder/registry.go b/internal/execution/actionbuilder/registry.go index 693cd1f..568d21c 100644 --- a/internal/execution/actionbuilder/registry.go +++ b/internal/execution/actionbuilder/registry.go @@ -84,7 +84,7 @@ func (r *Registry) BridgeExecutionProviderNames() []string { } type LendRequest struct { - Protocol string + Provider string Verb planner.AaveLendVerb Chain id.Chain Asset id.Asset @@ -101,11 +101,11 @@ type LendRequest struct { } func (r *Registry) BuildLendAction(ctx context.Context, req LendRequest) (execution.Action, error) { - protocol := normalizeLendingProtocol(req.Protocol) - if protocol == "" { - return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + providerName := normalizeLendingProvider(req.Provider) + if providerName == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--provider is required") } - switch protocol { + switch providerName { case "aave": return planner.BuildAaveLendAction(ctx, planner.AaveLendRequest{ Verb: req.Verb, @@ -135,12 +135,12 @@ func (r *Registry) BuildLendAction(ctx context.Context, req LendRequest) (execut RPCURL: req.RPCURL, }) default: - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports protocol=aave|morpho") + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports provider=aave|morpho") } } type RewardsClaimRequest struct { - Protocol string + Provider string Chain id.Chain Sender string Recipient string @@ -154,12 +154,12 @@ type RewardsClaimRequest struct { } func (r *Registry) BuildRewardsClaimAction(ctx context.Context, req RewardsClaimRequest) (execution.Action, error) { - protocol := normalizeLendingProtocol(req.Protocol) - if protocol == "" { - return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + providerName := normalizeLendingProvider(req.Provider) + if providerName == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--provider is required") } - if protocol != "aave" { - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") + if providerName != "aave" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only provider=aave") } return planner.BuildAaveRewardsClaimAction(ctx, planner.AaveRewardsClaimRequest{ Chain: req.Chain, @@ -176,7 +176,7 @@ func (r *Registry) BuildRewardsClaimAction(ctx context.Context, req RewardsClaim } type RewardsCompoundRequest struct { - Protocol string + Provider string Chain id.Chain Sender string Recipient string @@ -192,12 +192,12 @@ type RewardsCompoundRequest struct { } func (r *Registry) BuildRewardsCompoundAction(ctx context.Context, req RewardsCompoundRequest) (execution.Action, error) { - protocol := normalizeLendingProtocol(req.Protocol) - if protocol == "" { - return execution.Action{}, clierr.New(clierr.CodeUsage, "--protocol is required") + providerName := normalizeLendingProvider(req.Provider) + if providerName == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--provider is required") } - if protocol != "aave" { - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only protocol=aave") + if providerName != "aave" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "rewards execution currently supports only provider=aave") } return planner.BuildAaveRewardsCompoundAction(ctx, planner.AaveRewardsCompoundRequest{ Chain: req.Chain, @@ -219,6 +219,15 @@ func (r *Registry) BuildApprovalAction(req planner.ApprovalRequest) (execution.A return planner.BuildApprovalAction(req) } -func normalizeLendingProtocol(v string) string { - return strings.ToLower(strings.TrimSpace(v)) +func normalizeLendingProvider(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "aave", "aave-v2", "aave-v3": + return "aave" + case "morpho", "morpho-blue": + return "morpho" + case "kamino", "kamino-lend", "kamino-finance": + return "kamino" + default: + return strings.ToLower(strings.TrimSpace(v)) + } } diff --git a/internal/execution/actionbuilder/registry_test.go b/internal/execution/actionbuilder/registry_test.go index 7f448d6..c62866b 100644 --- a/internal/execution/actionbuilder/registry_test.go +++ b/internal/execution/actionbuilder/registry_test.go @@ -40,11 +40,11 @@ func TestBuildBridgeActionRejectsQuoteOnlyProvider(t *testing.T) { } } -func TestBuildLendActionRejectsUnsupportedProtocol(t *testing.T) { +func TestBuildLendActionRejectsUnsupportedProvider(t *testing.T) { reg := New(nil, nil) - _, err := reg.BuildLendAction(context.Background(), LendRequest{Protocol: "kamino"}) + _, err := reg.BuildLendAction(context.Background(), LendRequest{Provider: "kamino"}) if err == nil { - t.Fatal("expected unsupported protocol error") + t.Fatal("expected unsupported provider error") } cErr, ok := clierr.As(err) if !ok || cErr.Code != clierr.CodeUnsupported { @@ -52,11 +52,11 @@ func TestBuildLendActionRejectsUnsupportedProtocol(t *testing.T) { } } -func TestBuildRewardsClaimActionRejectsUnsupportedProtocol(t *testing.T) { +func TestBuildRewardsClaimActionRejectsUnsupportedProvider(t *testing.T) { reg := New(nil, nil) - _, err := reg.BuildRewardsClaimAction(context.Background(), RewardsClaimRequest{Protocol: "morpho"}) + _, err := reg.BuildRewardsClaimAction(context.Background(), RewardsClaimRequest{Provider: "morpho"}) if err == nil { - t.Fatal("expected unsupported protocol error") + t.Fatal("expected unsupported provider error") } cErr, ok := clierr.As(err) if !ok || cErr.Code != clierr.CodeUnsupported { @@ -64,6 +64,18 @@ func TestBuildRewardsClaimActionRejectsUnsupportedProtocol(t *testing.T) { } } +func TestNormalizeLendingProviderAliases(t *testing.T) { + if got := normalizeLendingProvider("AAVE-V3"); got != "aave" { + t.Fatalf("expected aave, got %s", got) + } + if got := normalizeLendingProvider("morpho-blue"); got != "morpho" { + t.Fatalf("expected morpho, got %s", got) + } + if got := normalizeLendingProvider("kamino-finance"); got != "kamino" { + t.Fatalf("expected kamino, got %s", got) + } +} + func TestBuildApprovalActionRoutesToPlanner(t *testing.T) { reg := New(nil, nil) chain, err := id.ParseChain("1") diff --git a/internal/providers/aave/client.go b/internal/providers/aave/client.go index 0ec7054..df7a14d 100644 --- a/internal/providers/aave/client.go +++ b/internal/providers/aave/client.go @@ -117,9 +117,9 @@ type aaveReserve struct { } `json:"borrowInfo"` } -func (c *Client) LendMarkets(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { - if !strings.EqualFold(protocol, "aave") { - return nil, clierr.New(clierr.CodeUnsupported, "aave adapter supports only protocol=aave") +func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { + if !strings.EqualFold(provider, "aave") { + return nil, clierr.New(clierr.CodeUnsupported, "aave adapter supports only provider=aave") } markets, err := c.fetchMarkets(ctx, chain) if err != nil { @@ -171,9 +171,9 @@ func (c *Client) LendMarkets(ctx context.Context, protocol string, chain id.Chai return out, nil } -func (c *Client) LendRates(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) { - if !strings.EqualFold(protocol, "aave") { - return nil, clierr.New(clierr.CodeUnsupported, "aave adapter supports only protocol=aave") +func (c *Client) LendRates(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) { + if !strings.EqualFold(provider, "aave") { + return nil, clierr.New(clierr.CodeUnsupported, "aave adapter supports only provider=aave") } markets, err := c.fetchMarkets(ctx, chain) if err != nil { diff --git a/internal/providers/kamino/client.go b/internal/providers/kamino/client.go index 20adf8a..8478334 100644 --- a/internal/providers/kamino/client.go +++ b/internal/providers/kamino/client.go @@ -72,9 +72,9 @@ type reserveWithMarket struct { Reserve reserveMetric } -func (c *Client) LendMarkets(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { - if !strings.EqualFold(strings.TrimSpace(protocol), "kamino") { - return nil, clierr.New(clierr.CodeUnsupported, "kamino adapter supports only protocol=kamino") +func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { + if !strings.EqualFold(strings.TrimSpace(provider), "kamino") { + return nil, clierr.New(clierr.CodeUnsupported, "kamino adapter supports only provider=kamino") } reserves, err := c.fetchReserves(ctx, chain) if err != nil { @@ -126,9 +126,9 @@ func (c *Client) LendMarkets(ctx context.Context, protocol string, chain id.Chai return out, nil } -func (c *Client) LendRates(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) { - if !strings.EqualFold(strings.TrimSpace(protocol), "kamino") { - return nil, clierr.New(clierr.CodeUnsupported, "kamino adapter supports only protocol=kamino") +func (c *Client) LendRates(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) { + if !strings.EqualFold(strings.TrimSpace(provider), "kamino") { + return nil, clierr.New(clierr.CodeUnsupported, "kamino adapter supports only provider=kamino") } reserves, err := c.fetchReserves(ctx, chain) if err != nil { diff --git a/internal/providers/morpho/client.go b/internal/providers/morpho/client.go index 40c06fb..4d22a0e 100644 --- a/internal/providers/morpho/client.go +++ b/internal/providers/morpho/client.go @@ -98,9 +98,9 @@ type morphoMarket struct { } `json:"state"` } -func (c *Client) LendMarkets(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { - if !strings.EqualFold(protocol, "morpho") { - return nil, clierr.New(clierr.CodeUnsupported, "morpho adapter supports only protocol=morpho") +func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { + if !strings.EqualFold(provider, "morpho") { + return nil, clierr.New(clierr.CodeUnsupported, "morpho adapter supports only provider=morpho") } markets, err := c.fetchMarkets(ctx, chain, asset) if err != nil { @@ -143,9 +143,9 @@ func (c *Client) LendMarkets(ctx context.Context, protocol string, chain id.Chai return out, nil } -func (c *Client) LendRates(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) { - if !strings.EqualFold(protocol, "morpho") { - return nil, clierr.New(clierr.CodeUnsupported, "morpho adapter supports only protocol=morpho") +func (c *Client) LendRates(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) { + if !strings.EqualFold(provider, "morpho") { + return nil, clierr.New(clierr.CodeUnsupported, "morpho adapter supports only provider=morpho") } markets, err := c.fetchMarkets(ctx, chain, asset) if err != nil { diff --git a/internal/providers/types.go b/internal/providers/types.go index 608d6c6..e5cdb6c 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -22,8 +22,8 @@ type MarketDataProvider interface { type LendingProvider interface { Provider - LendMarkets(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) - LendRates(ctx context.Context, protocol string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) + LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) + LendRates(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) } type YieldProvider interface { diff --git a/scripts/nightly_execution_smoke.sh b/scripts/nightly_execution_smoke.sh index 3ceadc8..696d7d4 100644 --- a/scripts/nightly_execution_smoke.sh +++ b/scripts/nightly_execution_smoke.sh @@ -42,7 +42,7 @@ go build -o defi ./cmd/defi --results-only >/dev/null ./defi lend supply plan \ - --protocol aave \ + --provider aave \ --chain 1 \ --asset USDC \ --amount 1000000 \ @@ -50,7 +50,7 @@ go build -o defi ./cmd/defi --results-only >/dev/null ./defi rewards claim plan \ - --protocol aave \ + --provider aave \ --chain 1 \ --from-address 0x00000000000000000000000000000000000000aa \ --assets 0x00000000000000000000000000000000000000d1 \ @@ -58,7 +58,7 @@ go build -o defi ./cmd/defi --results-only >/dev/null ./defi rewards compound plan \ - --protocol aave \ + --provider aave \ --chain 1 \ --from-address 0x00000000000000000000000000000000000000aa \ --assets 0x00000000000000000000000000000000000000d1 \ From e47723dbec7c79abcb8e037f0c290ed9a16ea569 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Wed, 25 Feb 2026 16:23:47 -0400 Subject: [PATCH 13/18] docs: add taiko hoodi alias notes after main merge --- AGENTS.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8c45d4..3e04d72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ README.md # user-facing usage + caveats - Morpho lend execution requires `--market-id` (Morpho market unique key bytes32). - Key requirements are command + provider specific; `providers list` is metadata only and should remain callable without provider keys. - Prefer env vars for provider keys in docs/examples; keep config file usage optional and focused on non-secret defaults. -- `--chain` supports CAIP-2, numeric chain IDs, and aliases; aliases include `mantle`, `megaeth`/`mega eth`/`mega-eth`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, `zksync`, `hyperevm`/`hyper evm`/`hyper-evm`, `monad`, and `citrea`. +- `--chain` supports CAIP-2, numeric chain IDs, and aliases; aliases include `mantle`, `megaeth`/`mega eth`/`mega-eth`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, `taiko hoodi`/`hoodi`, `zksync`, `hyperevm`/`hyper evm`/`hyper-evm`, `monad`, and `citrea`. - Bungee Auto quote calls use deterministic placeholder sender/receiver addresses for quote-only mode (`0x000...001`). - Swap quote type defaults to `exact-input`; `exact-output` currently routes through Uniswap only (`--type exact-output` with `--amount-out` or `--amount-out-decimal`). - Uniswap quote calls require a real `swapper` address via `swap quote --from-address` and default to provider auto slippage unless `swap quote --slippage-pct` is provided. diff --git a/README.md b/README.md index 1fb603a..98a28e5 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ providers: - `chains assets` requires `DEFI_DEFILLAMA_API_KEY` because DefiLlama chain asset TVL is key-gated. - `bridge list` and `bridge details` require `DEFI_DEFILLAMA_API_KEY`; quote providers (`across`, `lifi`) do not. - Category rankings from `protocols categories` are deterministic and sorted by `tvl_usd`, then protocol count, then name. -- `--chain` normalization supports additional aliases/IDs including `mantle`, `megaeth`/`mega eth`/`mega-eth`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, `zksync`, `hyperevm`/`hyper evm`/`hyper-evm`, `monad`, and `citrea`. +- `--chain` normalization supports additional aliases/IDs including `mantle`, `megaeth`/`mega eth`/`mega-eth`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, `taiko hoodi`/`hoodi`, `zksync`, `hyperevm`/`hyper evm`/`hyper-evm`, `monad`, and `citrea`. - Bungee Auto-mode quote coverage is chain+token dependent; unsupported pairs return provider errors even when chain normalization succeeds. - Bungee quote requests use deterministic placeholder sender/receiver addresses for quote-only resolution (`0x000...001`). - Bungee dedicated backend routing only activates when both `DEFI_BUNGEE_API_KEY` and `DEFI_BUNGEE_AFFILIATE` are set; if either is missing, requests use the public backend. From cd6df555735a831acd41131709eb5391583da665 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Thu, 26 Feb 2026 12:51:09 -0400 Subject: [PATCH 14/18] add lend positions by address with typed filters --- AGENTS.md | 7 +- CHANGELOG.md | 3 + README.md | 6 +- docs/concepts/providers-and-auth.mdx | 3 +- docs/guides/lending.mdx | 16 +- docs/quickstart.mdx | 7 +- docs/reference/commands-overview.mdx | 2 +- docs/reference/lending-and-yield-commands.mdx | 26 +- internal/app/provider_selection_test.go | 35 ++ internal/app/runner.go | 111 ++++++ internal/app/runner_test.go | 251 ++++++++++++++ internal/model/types.go | 16 + internal/providers/aave/client.go | 324 ++++++++++++++++++ internal/providers/aave/client_test.go | 108 ++++++ internal/providers/morpho/client.go | 296 ++++++++++++++++ internal/providers/morpho/client_test.go | 108 ++++++ internal/providers/types.go | 22 ++ 17 files changed, 1319 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e04d72..4280f43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ go vet ./... ./defi providers list --results-only ./defi lend markets --provider aave --chain 1 --asset USDC --results-only +./defi lend positions --provider aave --chain 1 --address 0x000000000000000000000000000000000000dEaD --type all --limit 3 --results-only ./defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit 5 --results-only ``` @@ -66,7 +67,9 @@ README.md # user-facing usage + caveats - Error output always returns a full envelope, even with `--results-only` or `--select`. - Config precedence is `flags > env > config file > defaults`. - `yield --providers` expects provider names (`defillama,aave,morpho`), not protocol categories. -- Lending routes by `--provider` to direct adapters when available, then may fallback to DefiLlama on selected failures. +- Lending routes by `--provider` use direct protocol adapters (`aave`, `morpho`, `kamino`). +- `lend positions` currently supports `--provider aave|morpho`; `kamino` does not expose positions yet. +- `lend positions --type all` intentionally returns non-overlapping intents (`supply`, `borrow`, `collateral`) for automation-friendly filtering. - Most commands do not require provider API keys. - Key-gated routes: `swap quote --provider 1inch` (`DEFI_1INCH_API_KEY`), `swap quote --provider uniswap` (`DEFI_UNISWAP_API_KEY`), `chains assets`, and `bridge list` / `bridge details` via DefiLlama (`DEFI_DEFILLAMA_API_KEY`). - Multi-provider command paths require explicit selector choice via `--provider`; no implicit defaults. @@ -108,7 +111,7 @@ README.md # user-facing usage + caveats - Fresh cache hits (`age <= ttl`) skip provider calls; once TTL expires, the CLI re-fetches providers and only serves stale data within `max_stale` on temporary provider failures. - Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. - Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|show`) bypass cache initialization. -- For `lend`/`yield`, unresolved asset symbols skip DefiLlama symbol matching and fallback/provider selection where symbol-based matching would be unsafe. +- For `lend`/`yield`, unresolved symbols are treated as symbol filters; on chains without bootstrap token entries, prefer token address or CAIP-19 for deterministic matching. - Amounts used for swaps/bridges are base units; keep both base and decimal forms consistent. - Release artifacts are built on `v*` tags via `.github/workflows/release.yml` and `.goreleaser.yml`. - Mintlify production docs should use the `docs-live` branch; the release workflow force-syncs `docs-live` to each `v*` tag. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dec72d..6d9586e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Format: - Added planner unit tests for approvals, Aave lend/rewards flows, and LiFi bridge action building. - Added centralized execution registry data in `internal/registry` for endpoint, contract, and ABI references. - Added nightly execution-planning smoke workflow (`nightly-execution-smoke.yml`) and script. +- Added `lend positions` to query account-level lending positions by address for Aave and Morpho with `--type all|supply|borrow|collateral`. ### Changed - BREAKING: Lend and rewards commands now use `--provider` as the selector flag; `--protocol` has been removed. @@ -54,6 +55,7 @@ Format: - `swap quote` (on-chain providers) and `swap plan`/`swap run` now support `--rpc-url` to override chain default RPCs per invocation. - Swap execution planning now validates sender/recipient fields as EVM addresses before route planning. - Uniswap `swap quote` now requires a real `--from-address` swapper input instead of using a deterministic placeholder address. +- `lend positions` now emits non-overlapping type rows for automation: `supply` (non-collateral), `collateral` (posted collateral), and `borrow` (debt). ### Fixed - Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. @@ -63,6 +65,7 @@ Format: - Updated `AGENTS.md` with expanded execution command coverage and caveats. - Updated `docs/act-execution-design.md` implementation status to reflect the shipped Phase 2 surface. - Clarified execution builder architecture split (provider-backed route builders for swap/bridge vs internal planners for lend/rewards/approvals) in `AGENTS.md` and execution design docs. +- Added `lend positions` usage and caveats to `README.md`, `AGENTS.md`, and Mintlify lending command reference. ### Security - None yet. diff --git a/README.md b/README.md index 98a28e5..15eec0d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Built for AI agents and scripts. Stable JSON output, canonical identifiers (CAIP ## Features -- **Lending** — query markets and rates from Aave, Morpho, and more (with DefiLlama fallback), plus execute Aave lend actions. +- **Lending** — query markets/rates from Aave/Morpho/Kamino and account positions from Aave/Morpho, plus execute Aave/Morpho lend actions. - **Yield** — compare opportunities across protocols and chains, filter by TVL and APY. - **Bridging** — get cross-chain quotes (Across, LiFi), bridge analytics (volume, chain breakdown), and execute LiFi bridge plans. - **Swapping** — get swap quotes (1inch, Uniswap, TaikoSwap) and execute TaikoSwap plans on-chain. @@ -91,6 +91,7 @@ defi chains assets --chain 1 --asset USDC --results-only # Requires DEFI_DEFILLA defi assets resolve --chain base --symbol USDC --results-only defi lend markets --provider aave --chain 1 --asset USDC --results-only defi lend rates --provider morpho --chain 1 --asset USDC --results-only +defi lend positions --provider aave --chain 1 --address 0xYourEOA --type all --limit 20 --results-only defi yield opportunities --chain base --asset USDC --limit 20 --results-only defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit 10 --results-only defi bridge list --limit 10 --results-only # Requires DEFI_DEFILLAMA_API_KEY @@ -273,7 +274,7 @@ providers: ## Cache Policy -- Command TTLs are fixed in code (`chains/protocols/chains assets`: `5m`, `lend markets`: `60s`, `lend rates`: `30s`, `yield`: `60s`, `bridge/swap quotes`: `15s`). +- Command TTLs are fixed in code (`chains/protocols/chains assets`: `5m`, `lend markets`: `60s`, `lend rates`: `30s`, `lend positions`: `30s`, `yield`: `60s`, `bridge/swap quotes`: `15s`). - Cache entries are served directly only while fresh (`age <= ttl`). - After TTL expiry, the CLI fetches provider data immediately. - `cache.max_stale` / `--max-stale` is only a temporary provider-failure fallback window (currently `unavailable` / `rate_limited`). @@ -299,6 +300,7 @@ providers: - `fibrous` swap quotes are currently limited to `base`, `hyperevm`, and `citrea` (`monad` temporarily disabled due unstable route responses). - For chains without bootstrap symbol entries, pass token address or CAIP-19 via `--asset`/`--from-asset`/`--to-asset` for deterministic resolution. - For `lend`/`yield`, unresolved asset symbols skip DefiLlama-based symbol matching and may disable fallback/provider selection to avoid unsafe broad matches. +- `lend positions --type all` returns disjoint rows by intent: `supply` (non-collateralized supplied balance), `collateral` (posted collateral), and `borrow` (debt). - Swap execution currently supports TaikoSwap only. - Bridge execution currently supports Across and LiFi. - Lend execution supports Aave and Morpho (`--market-id` required for Morpho). diff --git a/docs/concepts/providers-and-auth.mdx b/docs/concepts/providers-and-auth.mdx index 1c0e350..2e8b432 100644 --- a/docs/concepts/providers-and-auth.mdx +++ b/docs/concepts/providers-and-auth.mdx @@ -37,7 +37,8 @@ If either is missing, bungee quotes use public backend. ## Routing and fallback -- Lending routes by `--protocol` (`aave`, `morpho`, `kamino`) using direct adapters only. +- Lending routes by `--provider` (`aave`, `morpho`, `kamino`) using direct adapters only. +- `lend positions` currently supports `--provider aave|morpho`. - `yield opportunities` aggregates direct providers and accepts `--providers aave,morpho,kamino`. - `swap quote` defaults by chain family: `1inch` for EVM chains, `jupiter` for Solana. - Provider/chain-family mismatches fail fast with `unsupported` errors (for example `--provider jupiter` on EVM chains). diff --git a/docs/guides/lending.mdx b/docs/guides/lending.mdx index fa68829..fb074db 100644 --- a/docs/guides/lending.mdx +++ b/docs/guides/lending.mdx @@ -6,27 +6,27 @@ description: Query lending markets and rates across direct protocol adapters. ## Markets ```bash -defi lend markets --protocol aave --chain 1 --asset USDC --limit 10 --results-only -defi lend markets --protocol kamino --chain solana --asset USDC --limit 10 --results-only +defi lend markets --provider aave --chain 1 --asset USDC --limit 10 --results-only +defi lend markets --provider kamino --chain solana --asset USDC --limit 10 --results-only ``` ## Rates ```bash -defi lend rates --protocol morpho --chain 1 --asset USDC --limit 10 --results-only -defi lend rates --protocol kamino --chain solana --asset USDC --limit 10 --results-only +defi lend rates --provider morpho --chain 1 --asset USDC --limit 10 --results-only +defi lend rates --provider kamino --chain solana --asset USDC --limit 10 --results-only ``` ## Protocol routing -- `--protocol aave` -> Aave adapter -- `--protocol morpho` -> Morpho adapter -- `--protocol kamino` -> Kamino adapter (Solana mainnet only) +- `--provider aave` -> Aave adapter +- `--provider morpho` -> Morpho adapter +- `--provider kamino` -> Kamino adapter (Solana mainnet only) ## Suggested filters and reliability defaults ```bash -defi lend rates --protocol morpho --chain 1 --asset USDC --limit 20 --timeout 12s --retries 2 --max-stale 5m --results-only +defi lend rates --provider morpho --chain 1 --asset USDC --limit 20 --timeout 12s --retries 2 --max-stale 5m --results-only ``` ## Output fields diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 75adf12..d3a12bc 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -26,9 +26,10 @@ defi assets resolve --chain solana --asset USDC --results-only ## 4. Query lending markets and rates ```bash -defi lend markets --protocol aave --chain 1 --asset USDC --limit 5 --results-only -defi lend rates --protocol morpho --chain 1 --asset USDC --limit 5 --results-only -defi lend markets --protocol kamino --chain solana --asset USDC --limit 5 --results-only +defi lend markets --provider aave --chain 1 --asset USDC --limit 5 --results-only +defi lend rates --provider morpho --chain 1 --asset USDC --limit 5 --results-only +defi lend markets --provider kamino --chain solana --asset USDC --limit 5 --results-only +defi lend positions --provider aave --chain 1 --address 0xYourEOA --type all --limit 5 --results-only ``` ## 5. Rank yield opportunities diff --git a/docs/reference/commands-overview.mdx b/docs/reference/commands-overview.mdx index c9ed29a..158a1af 100644 --- a/docs/reference/commands-overview.mdx +++ b/docs/reference/commands-overview.mdx @@ -43,7 +43,7 @@ defi [flags] [global flags] Examples: ```bash -defi lend markets --protocol aave --chain 1 --asset USDC --results-only +defi lend markets --provider aave --chain 1 --asset USDC --results-only defi bridge quote --from 1 --to 8453 --asset USDC --amount 1000000 --timeout 12s --retries 2 --results-only defi swap quote --chain solana --from-asset USDC --to-asset SOL --amount 1000000 --results-only ``` diff --git a/docs/reference/lending-and-yield-commands.mdx b/docs/reference/lending-and-yield-commands.mdx index b23e82f..ad1f0ac 100644 --- a/docs/reference/lending-and-yield-commands.mdx +++ b/docs/reference/lending-and-yield-commands.mdx @@ -6,13 +6,13 @@ description: Full reference for lend and yield command flags and examples. ## `lend markets` ```bash -defi lend markets --protocol aave --chain 1 --asset USDC --limit 20 --results-only -defi lend markets --protocol kamino --chain solana --asset USDC --limit 20 --results-only +defi lend markets --provider aave --chain 1 --asset USDC --limit 20 --results-only +defi lend markets --provider kamino --chain solana --asset USDC --limit 20 --results-only ``` Flags: -- `--protocol string` (`aave`, `morpho`, `kamino`) required +- `--provider string` (`aave`, `morpho`, `kamino`) required - `--chain string` required - `--asset string` required - `--limit int` (default `20`) @@ -20,12 +20,28 @@ Flags: ## `lend rates` ```bash -defi lend rates --protocol morpho --chain 1 --asset USDC --limit 20 --results-only -defi lend rates --protocol kamino --chain solana --asset USDC --limit 20 --results-only +defi lend rates --provider morpho --chain 1 --asset USDC --limit 20 --results-only +defi lend rates --provider kamino --chain solana --asset USDC --limit 20 --results-only ``` Flags are the same as `lend markets`. +## `lend positions` + +```bash +defi lend positions --provider aave --chain 1 --address 0xYourEOA --type all --limit 20 --results-only +defi lend positions --provider morpho --chain 1 --address 0xYourEOA --type borrow --asset USDC --results-only +``` + +Flags: + +- `--provider string` (`aave`, `morpho`) required +- `--chain string` required +- `--address string` required +- `--asset string` optional filter (`symbol`/address/CAIP-19) +- `--type string` (`all|supply|borrow|collateral`, default `all`) +- `--limit int` (default `20`) + ## `yield opportunities` ```bash diff --git a/internal/app/provider_selection_test.go b/internal/app/provider_selection_test.go index 7b62a03..4ece051 100644 --- a/internal/app/provider_selection_test.go +++ b/internal/app/provider_selection_test.go @@ -19,6 +19,41 @@ func TestNormalizeLendingProvider(t *testing.T) { } } +func TestParseLendPositionType(t *testing.T) { + tests := []struct { + name string + input string + want providers.LendPositionType + wantErr bool + }{ + {name: "default", input: "", want: providers.LendPositionTypeAll}, + {name: "all", input: "all", want: providers.LendPositionTypeAll}, + {name: "supply", input: "supply", want: providers.LendPositionTypeSupply}, + {name: "borrow", input: "borrow", want: providers.LendPositionTypeBorrow}, + {name: "collateral", input: "collateral", want: providers.LendPositionTypeCollateral}, + {name: "invalid", input: "debt", wantErr: true}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got, err := parseLendPositionType(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for input %q", tc.input) + } + return + } + if err != nil { + t.Fatalf("parseLendPositionType failed: %v", err) + } + if got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + func TestSelectYieldProviders(t *testing.T) { s := &runtimeState{yieldProviders: map[string]providers.YieldProvider{}} // Use nil implementations via map key presence for selection behavior. diff --git a/internal/app/runner.go b/internal/app/runner.go index 95d4df5..0e1b061 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -527,8 +527,83 @@ func (s *runtimeState) newLendCommand() *cobra.Command { ratesCmd.Flags().StringVar(&ratesAsset, "asset", "", "Asset (symbol/address/CAIP-19)") ratesCmd.Flags().IntVar(&ratesLimit, "limit", 20, "Maximum lending rates to return") + var positionsProvider, positionsChain, positionsAddress, positionsAsset, positionsType string + var positionsLimit int + positionsCmd := &cobra.Command{ + Use: "positions", + Short: "List lending positions for an account address", + RunE: func(cmd *cobra.Command, args []string) error { + providerName := normalizeLendingProvider(positionsProvider) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") + } + chain, err := id.ParseChain(positionsChain) + if err != nil { + return err + } + account := strings.TrimSpace(positionsAddress) + if account == "" { + return clierr.New(clierr.CodeUsage, "--address is required") + } + if chain.IsEVM() && !common.IsHexAddress(account) { + return clierr.New(clierr.CodeUsage, "--address must be a valid EVM hex address") + } + + asset, err := parseOptionalChainAsset(chain, positionsAsset) + if err != nil { + return err + } + positionType, err := parseLendPositionType(positionsType) + if err != nil { + return err + } + + cacheAccount := account + if chain.IsEVM() { + cacheAccount = strings.ToLower(account) + } + req := map[string]any{ + "provider": providerName, + "chain": chain.CAIP2, + "address": cacheAccount, + "asset": chainAssetFilterCacheValue(asset, positionsAsset), + "type": string(positionType), + "limit": positionsLimit, + } + key := cacheKey(trimRootPath(cmd.CommandPath()), req) + return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 30*time.Second, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { + provider, err := s.selectLendingProvider(providerName) + if err != nil { + return nil, nil, nil, false, err + } + positionProvider, ok := provider.(providers.LendingPositionsProvider) + if !ok { + return nil, nil, nil, false, clierr.New(clierr.CodeUnsupported, fmt.Sprintf("lending provider %s does not support positions", providerName)) + } + + start := time.Now() + data, err := positionProvider.LendPositions(ctx, providers.LendPositionsRequest{ + Chain: chain, + Account: account, + Asset: asset, + PositionType: positionType, + Limit: positionsLimit, + }) + statuses := []model.ProviderStatus{{Name: provider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + return data, statuses, nil, false, err + }) + }, + } + positionsCmd.Flags().StringVar(&positionsProvider, "provider", "", "Lending provider (aave, morpho)") + positionsCmd.Flags().StringVar(&positionsChain, "chain", "", "Chain identifier") + positionsCmd.Flags().StringVar(&positionsAddress, "address", "", "Position owner address") + positionsCmd.Flags().StringVar(&positionsAsset, "asset", "", "Optional asset filter (symbol/address/CAIP-19)") + positionsCmd.Flags().StringVar(&positionsType, "type", string(providers.LendPositionTypeAll), "Position type filter (all|supply|borrow|collateral)") + positionsCmd.Flags().IntVar(&positionsLimit, "limit", 20, "Maximum positions to return") + root.AddCommand(marketsCmd) root.AddCommand(ratesCmd) + root.AddCommand(positionsCmd) s.addLendExecutionSubcommands(root) return root } @@ -1481,6 +1556,21 @@ func normalizeLendingProvider(input string) string { } } +func parseLendPositionType(input string) (providers.LendPositionType, error) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "", string(providers.LendPositionTypeAll): + return providers.LendPositionTypeAll, nil + case string(providers.LendPositionTypeSupply): + return providers.LendPositionTypeSupply, nil + case string(providers.LendPositionTypeBorrow): + return providers.LendPositionTypeBorrow, nil + case string(providers.LendPositionTypeCollateral): + return providers.LendPositionTypeCollateral, nil + default: + return "", clierr.New(clierr.CodeUsage, "--type must be one of: all,supply,borrow,collateral") + } +} + func (s *runtimeState) selectLendingProvider(providerName string) (providers.LendingProvider, error) { primary, ok := s.lendingProviders[providerName] if !ok { @@ -1615,6 +1705,27 @@ func parseChainAsset(chainArg, assetArg string) (id.Chain, id.Asset, error) { return chain, asset, nil } +func parseOptionalChainAsset(chain id.Chain, assetArg string) (id.Asset, error) { + assetArg = strings.TrimSpace(assetArg) + if assetArg == "" { + return id.Asset{}, nil + } + + asset, err := id.ParseAsset(assetArg, chain) + if err == nil { + return asset, nil + } + + if looksLikeAddressOrCAIP(assetArg) || !looksLikeSymbolFilter(assetArg) { + return id.Asset{}, err + } + + return id.Asset{ + ChainID: chain.CAIP2, + Symbol: strings.ToUpper(assetArg), + }, nil +} + func parseChainAssetFilter(chain id.Chain, assetArg string) (id.Asset, error) { assetArg = strings.TrimSpace(assetArg) if assetArg == "" { diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 3875eee..9757f89 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -474,6 +474,202 @@ func TestRunnerChainsAssetsAllowsUnknownSymbolFilter(t *testing.T) { } } +func TestRunnerLendPositionsCallsProvider(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + aaveProvider := &fakeLendingProvider{ + name: "aave", + positions: []model.LendPosition{ + { + Provider: "aave", + ChainID: "eip155:1", + AccountAddress: "0x000000000000000000000000000000000000dead", + PositionType: "collateral", + AssetID: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }, + }, + } + state := &runtimeState{ + runner: &Runner{ + stdout: &stdout, + stderr: &stderr, + now: time.Now, + }, + settings: config.Settings{ + OutputMode: "json", + Timeout: 2 * time.Second, + CacheEnabled: false, + }, + lendingProviders: map[string]providers.LendingProvider{ + "aave": aaveProvider, + }, + } + + root := &cobra.Command{Use: "defi"} + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(&stdout) + root.SetErr(&stderr) + root.AddCommand(state.newLendCommand()) + root.SetArgs([]string{ + "lend", "positions", + "--provider", "aave", + "--chain", "1", + "--address", "0x000000000000000000000000000000000000dEaD", + "--asset", "USDC", + "--type", "collateral", + "--limit", "5", + }) + + if err := root.Execute(); err != nil { + t.Fatalf("lend positions command failed: %v stderr=%s", err, stderr.String()) + } + if aaveProvider.calls != 1 { + t.Fatalf("expected provider call once, got %d", aaveProvider.calls) + } + if aaveProvider.lastReq.PositionType != providers.LendPositionTypeCollateral { + t.Fatalf("expected collateral request type, got %s", aaveProvider.lastReq.PositionType) + } + if !strings.EqualFold(aaveProvider.lastReq.Account, "0x000000000000000000000000000000000000dead") { + t.Fatalf("unexpected account passed to provider: %s", aaveProvider.lastReq.Account) + } + if !strings.EqualFold(aaveProvider.lastReq.Asset.Symbol, "USDC") { + t.Fatalf("expected USDC asset filter, got %+v", aaveProvider.lastReq.Asset) + } + + var env map[string]any + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("failed to parse output json: %v output=%s", err, stdout.String()) + } + if env["success"] != true { + t.Fatalf("expected success=true, got %v", env["success"]) + } +} + +func TestRunnerLendPositionsRejectsInvalidType(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + aaveProvider := &fakeLendingProvider{name: "aave"} + state := &runtimeState{ + runner: &Runner{ + stdout: &stdout, + stderr: &stderr, + now: time.Now, + }, + settings: config.Settings{ + OutputMode: "json", + Timeout: 2 * time.Second, + CacheEnabled: false, + }, + lendingProviders: map[string]providers.LendingProvider{ + "aave": aaveProvider, + }, + } + + root := &cobra.Command{Use: "defi"} + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(&stdout) + root.SetErr(&stderr) + root.AddCommand(state.newLendCommand()) + root.SetArgs([]string{ + "lend", "positions", + "--provider", "aave", + "--chain", "1", + "--address", "0x000000000000000000000000000000000000dEaD", + "--type", "debt", + }) + + if err := root.Execute(); err == nil { + t.Fatalf("expected invalid type error, stderr=%s", stderr.String()) + } + if aaveProvider.calls != 0 { + t.Fatalf("expected provider not to be called, got %d calls", aaveProvider.calls) + } +} + +func TestRunnerLendPositionsRejectsInvalidEVMAddress(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + aaveProvider := &fakeLendingProvider{name: "aave"} + state := &runtimeState{ + runner: &Runner{ + stdout: &stdout, + stderr: &stderr, + now: time.Now, + }, + settings: config.Settings{ + OutputMode: "json", + Timeout: 2 * time.Second, + CacheEnabled: false, + }, + lendingProviders: map[string]providers.LendingProvider{ + "aave": aaveProvider, + }, + } + + root := &cobra.Command{Use: "defi"} + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(&stdout) + root.SetErr(&stderr) + root.AddCommand(state.newLendCommand()) + root.SetArgs([]string{ + "lend", "positions", + "--provider", "aave", + "--chain", "1", + "--address", "not-an-address", + }) + + if err := root.Execute(); err == nil { + t.Fatalf("expected invalid address error, stderr=%s", stderr.String()) + } + if aaveProvider.calls != 0 { + t.Fatalf("expected provider not to be called, got %d calls", aaveProvider.calls) + } +} + +func TestRunnerLendPositionsRequiresProviderCapability(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + state := &runtimeState{ + runner: &Runner{ + stdout: &stdout, + stderr: &stderr, + now: time.Now, + }, + settings: config.Settings{ + OutputMode: "json", + Timeout: 2 * time.Second, + CacheEnabled: false, + }, + lendingProviders: map[string]providers.LendingProvider{ + "kamino": &fakeLendingProviderNoPositions{name: "kamino"}, + }, + } + + root := &cobra.Command{Use: "defi"} + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(&stdout) + root.SetErr(&stderr) + root.AddCommand(state.newLendCommand()) + root.SetArgs([]string{ + "lend", "positions", + "--provider", "kamino", + "--chain", "solana", + "--address", "6dM4QgP1VnRfx6TVV1t5hBf3ytA5Qn2ATqNnSboP8qz5", + }) + + err := root.Execute() + if err == nil { + t.Fatalf("expected unsupported capability error, stderr=%s", stderr.String()) + } + if !strings.Contains(strings.ToLower(err.Error()), "does not support positions") { + t.Fatalf("expected capability error message, got: %v", err) + } +} + func TestRunnerBridgeListRejectsProviderFlag(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -1056,6 +1252,61 @@ func (f *fakeSwapProvider) QuoteSwap(_ context.Context, req providers.SwapQuoteR }, nil } +type fakeLendingProvider struct { + name string + positions []model.LendPosition + err error + calls int + lastReq providers.LendPositionsRequest +} + +func (f *fakeLendingProvider) Info() model.ProviderInfo { + return model.ProviderInfo{ + Name: f.name, + Type: "lending", + RequiresKey: false, + Capabilities: []string{"lend.markets", "lend.rates", "lend.positions"}, + } +} + +func (f *fakeLendingProvider) LendMarkets(context.Context, string, id.Chain, id.Asset) ([]model.LendMarket, error) { + return nil, nil +} + +func (f *fakeLendingProvider) LendRates(context.Context, string, id.Chain, id.Asset) ([]model.LendRate, error) { + return nil, nil +} + +func (f *fakeLendingProvider) LendPositions(_ context.Context, req providers.LendPositionsRequest) ([]model.LendPosition, error) { + f.calls++ + f.lastReq = req + if f.err != nil { + return nil, f.err + } + return f.positions, nil +} + +type fakeLendingProviderNoPositions struct { + name string +} + +func (f *fakeLendingProviderNoPositions) Info() model.ProviderInfo { + return model.ProviderInfo{ + Name: f.name, + Type: "lending", + RequiresKey: false, + Capabilities: []string{"lend.markets", "lend.rates"}, + } +} + +func (f *fakeLendingProviderNoPositions) LendMarkets(context.Context, string, id.Chain, id.Asset) ([]model.LendMarket, error) { + return nil, nil +} + +func (f *fakeLendingProviderNoPositions) LendRates(context.Context, string, id.Chain, id.Asset) ([]model.LendRate, error) { + return nil, nil +} + func setUnopenableCacheEnv(t *testing.T) { t.Helper() t.Setenv("DEFI_CACHE_PATH", "/dev/null/cache.db") diff --git a/internal/model/types.go b/internal/model/types.go index d2649df..caef570 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -130,6 +130,22 @@ type LendRate struct { FetchedAt string `json:"fetched_at"` } +type LendPosition struct { + Protocol string `json:"protocol"` + Provider string `json:"provider"` + ChainID string `json:"chain_id"` + AccountAddress string `json:"account_address"` + PositionType string `json:"position_type"` + AssetID string `json:"asset_id"` + ProviderNativeID string `json:"provider_native_id,omitempty"` + ProviderNativeIDKind string `json:"provider_native_id_kind,omitempty"` + Amount AmountInfo `json:"amount"` + AmountUSD float64 `json:"amount_usd"` + APY float64 `json:"apy"` + SourceURL string `json:"source_url,omitempty"` + FetchedAt string `json:"fetched_at"` +} + type AmountInfo struct { AmountBaseUnits string `json:"amount_base_units"` AmountDecimal string `json:"amount_decimal"` diff --git a/internal/providers/aave/client.go b/internal/providers/aave/client.go index df7a14d..66fccd6 100644 --- a/internal/providers/aave/client.go +++ b/internal/providers/aave/client.go @@ -41,6 +41,7 @@ func (c *Client) Info() model.ProviderInfo { Capabilities: []string{ "lend.markets", "lend.rates", + "lend.positions", "yield.opportunities", "lend.plan", "lend.execute", @@ -65,6 +66,29 @@ const marketsQuery = `query Markets($request: MarketsRequest!) { } }` +const marketAddressesQuery = `query MarketAddresses($request: MarketsRequest!) { + markets(request: $request) { + address + } +}` + +const positionsQuery = `query Positions($suppliesRequest: UserSuppliesRequest!, $borrowsRequest: UserBorrowsRequest!) { + userSupplies(request: $suppliesRequest) { + market { address } + currency { address symbol decimals } + balance { amount { raw decimals value } usd } + apy { value } + isCollateral + canBeCollateral + } + userBorrows(request: $borrowsRequest) { + market { address } + currency { address symbol decimals } + debt { amount { raw decimals value } usd } + apy { value } + } +}` + type marketsResponse struct { Data struct { Markets []aaveMarket `json:"markets"` @@ -74,6 +98,27 @@ type marketsResponse struct { } `json:"errors"` } +type marketAddressesResponse struct { + Data struct { + Markets []struct { + Address string `json:"address"` + } `json:"markets"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type positionsResponse struct { + Data struct { + UserSupplies []aaveUserSupply `json:"userSupplies"` + UserBorrows []aaveUserBorrow `json:"userBorrows"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + type aaveMarket struct { Name string `json:"name"` Address string `json:"address"` @@ -117,6 +162,52 @@ type aaveReserve struct { } `json:"borrowInfo"` } +type aaveUserSupply struct { + Market struct { + Address string `json:"address"` + } `json:"market"` + Currency struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + } `json:"currency"` + Balance struct { + Amount struct { + Raw string `json:"raw"` + Decimals int `json:"decimals"` + Value string `json:"value"` + } `json:"amount"` + USD string `json:"usd"` + } `json:"balance"` + APY struct { + Value string `json:"value"` + } `json:"apy"` + IsCollateral bool `json:"isCollateral"` + CanBeCollateral bool `json:"canBeCollateral"` +} + +type aaveUserBorrow struct { + Market struct { + Address string `json:"address"` + } `json:"market"` + Currency struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + } `json:"currency"` + Debt struct { + Amount struct { + Raw string `json:"raw"` + Decimals int `json:"decimals"` + Value string `json:"value"` + } `json:"amount"` + USD string `json:"usd"` + } `json:"debt"` + APY struct { + Value string `json:"value"` + } `json:"apy"` +} + func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { if !strings.EqualFold(provider, "aave") { return nil, clierr.New(clierr.CodeUnsupported, "aave adapter supports only provider=aave") @@ -221,6 +312,135 @@ func (c *Client) LendRates(ctx context.Context, provider string, chain id.Chain, return out, nil } +func (c *Client) LendPositions(ctx context.Context, req providers.LendPositionsRequest) ([]model.LendPosition, error) { + if !req.Chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "aave supports only EVM chains") + } + account := normalizeEVMAddress(req.Account) + if account == "" { + return nil, clierr.New(clierr.CodeUsage, "aave positions requires a valid EVM account address") + } + + marketAddresses, err := c.fetchMarketAddresses(ctx, req.Chain) + if err != nil { + return nil, err + } + markets := make([]map[string]any, 0, len(marketAddresses)) + for _, address := range marketAddresses { + markets = append(markets, map[string]any{ + "address": address, + "chainId": req.Chain.EVMChainID, + }) + } + + body, err := json.Marshal(map[string]any{ + "query": positionsQuery, + "variables": map[string]any{ + "suppliesRequest": map[string]any{ + "markets": markets, + "user": account, + "collateralsOnly": false, + "orderBy": map[string]any{ + "balance": "DESC", + }, + }, + "borrowsRequest": map[string]any{ + "markets": markets, + "user": account, + "orderBy": map[string]any{ + "debt": "DESC", + }, + }, + }, + }) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "marshal aave positions query", err) + } + + var resp positionsResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("aave graphql error: %s", resp.Errors[0].Message)) + } + + filterType := req.PositionType + if filterType == "" { + filterType = providers.LendPositionTypeAll + } + out := make([]model.LendPosition, 0, len(resp.Data.UserSupplies)+len(resp.Data.UserBorrows)) + for _, supply := range resp.Data.UserSupplies { + positionType := providers.LendPositionTypeSupply + if supply.IsCollateral { + positionType = providers.LendPositionTypeCollateral + } + if !matchesPositionType(filterType, positionType) { + continue + } + if !matchesPositionAsset(supply.Currency.Address, supply.Currency.Symbol, req.Asset) { + continue + } + + assetID := canonicalAssetIDForChain(req.Chain.CAIP2, supply.Currency.Address) + if assetID == "" { + continue + } + amount := amountInfoFromRaw(supply.Balance.Amount.Raw, supply.Currency.Decimals) + out = append(out, model.LendPosition{ + Protocol: "aave", + Provider: "aave", + ChainID: req.Chain.CAIP2, + AccountAddress: account, + PositionType: string(positionType), + AssetID: assetID, + ProviderNativeID: providerNativeID("aave", req.Chain.CAIP2, supply.Market.Address, supply.Currency.Address), + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + Amount: amount, + AmountUSD: parseFloat(supply.Balance.USD), + APY: parseFloat(supply.APY.Value) * 100, + SourceURL: "https://app.aave.com", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + + for _, borrow := range resp.Data.UserBorrows { + if !matchesPositionType(filterType, providers.LendPositionTypeBorrow) { + continue + } + if !matchesPositionAsset(borrow.Currency.Address, borrow.Currency.Symbol, req.Asset) { + continue + } + + assetID := canonicalAssetIDForChain(req.Chain.CAIP2, borrow.Currency.Address) + if assetID == "" { + continue + } + amount := amountInfoFromRaw(borrow.Debt.Amount.Raw, borrow.Currency.Decimals) + out = append(out, model.LendPosition{ + Protocol: "aave", + Provider: "aave", + ChainID: req.Chain.CAIP2, + AccountAddress: account, + PositionType: string(providers.LendPositionTypeBorrow), + AssetID: assetID, + ProviderNativeID: providerNativeID("aave", req.Chain.CAIP2, borrow.Market.Address, borrow.Currency.Address), + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + Amount: amount, + AmountUSD: parseFloat(borrow.Debt.USD), + APY: parseFloat(borrow.APY.Value) * 100, + SourceURL: "https://app.aave.com", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + + sortLendPositions(out) + if req.Limit > 0 && len(out) > req.Limit { + out = out[:req.Limit] + } + return out, nil +} + func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequest) ([]model.YieldOpportunity, error) { markets, err := c.fetchMarkets(ctx, req.Chain) if err != nil { @@ -324,6 +544,45 @@ func (c *Client) fetchMarkets(ctx context.Context, chain id.Chain) ([]aaveMarket return resp.Data.Markets, nil } +func (c *Client) fetchMarketAddresses(ctx context.Context, chain id.Chain) ([]string, error) { + if !chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "aave supports only EVM chains") + } + body, err := json.Marshal(map[string]any{ + "query": marketAddressesQuery, + "variables": map[string]any{ + "request": map[string]any{ + "chainIds": []int64{chain.EVMChainID}, + }, + }, + }) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "marshal aave market-address query", err) + } + + var resp marketAddressesResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("aave graphql error: %s", resp.Errors[0].Message)) + } + if len(resp.Data.Markets) == 0 { + return nil, clierr.New(clierr.CodeUnsupported, "aave has no market for requested chain") + } + out := make([]string, 0, len(resp.Data.Markets)) + for _, market := range resp.Data.Markets { + address := normalizeEVMAddress(market.Address) + if address != "" { + out = append(out, address) + } + } + if len(out) == 0 { + return nil, clierr.New(clierr.CodeUnavailable, "aave market list returned no valid addresses") + } + return out, nil +} + func matchesReserveAsset(r aaveReserve, asset id.Asset) bool { assetAddress := strings.TrimSpace(asset.Address) if assetAddress != "" { @@ -340,6 +599,14 @@ func canonicalAssetID(asset id.Asset, address string) string { return fmt.Sprintf("%s/erc20:%s", asset.ChainID, addr) } +func canonicalAssetIDForChain(chainID, address string) string { + addr := normalizeEVMAddress(address) + if chainID == "" || addr == "" { + return "" + } + return fmt.Sprintf("%s/erc20:%s", chainID, addr) +} + func normalizeEVMAddress(address string) string { addr := strings.ToLower(strings.TrimSpace(address)) if len(addr) != 42 || !strings.HasPrefix(addr, "0x") { @@ -352,6 +619,63 @@ func providerNativeID(provider, chainID, marketAddress, underlyingAddress string return fmt.Sprintf("%s:%s:%s:%s", provider, chainID, normalizeEVMAddress(marketAddress), normalizeEVMAddress(underlyingAddress)) } +func matchesPositionType(filter, position providers.LendPositionType) bool { + if filter == "" || filter == providers.LendPositionTypeAll { + return true + } + return filter == position +} + +func matchesPositionAsset(address, symbol string, asset id.Asset) bool { + if strings.TrimSpace(asset.Address) != "" { + return strings.EqualFold(strings.TrimSpace(address), strings.TrimSpace(asset.Address)) + } + if strings.TrimSpace(asset.Symbol) != "" { + return strings.EqualFold(strings.TrimSpace(symbol), strings.TrimSpace(asset.Symbol)) + } + return true +} + +func amountInfoFromRaw(raw string, decimals int) model.AmountInfo { + if decimals < 0 { + decimals = 0 + } + base := normalizeBaseUnits(raw) + return model.AmountInfo{ + AmountBaseUnits: base, + AmountDecimal: id.FormatDecimalCompat(base, decimals), + Decimals: decimals, + } +} + +func normalizeBaseUnits(v string) string { + clean := strings.TrimSpace(v) + if clean == "" { + return "0" + } + for _, r := range clean { + if r < '0' || r > '9' { + return "0" + } + } + return clean +} + +func sortLendPositions(items []model.LendPosition) { + sort.Slice(items, func(i, j int) bool { + if items[i].AmountUSD != items[j].AmountUSD { + return items[i].AmountUSD > items[j].AmountUSD + } + if items[i].PositionType != items[j].PositionType { + return items[i].PositionType < items[j].PositionType + } + if items[i].AssetID != items[j].AssetID { + return items[i].AssetID < items[j].AssetID + } + return items[i].ProviderNativeID < items[j].ProviderNativeID + }) +} + func parseFloat(v string) float64 { f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err != nil { diff --git a/internal/providers/aave/client_test.go b/internal/providers/aave/client_test.go index 87c17df..541744b 100644 --- a/internal/providers/aave/client_test.go +++ b/internal/providers/aave/client_test.go @@ -2,8 +2,10 @@ package aave import ( "context" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -107,3 +109,109 @@ func TestLendMarketsPrefersAddressMatchOverSymbol(t *testing.T) { t.Fatal("expected no market match due address mismatch") } } + +func TestLendPositionsTypeSplit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(string(body), "MarketAddresses"): + _, _ = w.Write([]byte(`{ + "data": { + "markets": [ + {"address": "0x1111111111111111111111111111111111111111"} + ] + } + }`)) + case strings.Contains(string(body), "Positions"): + _, _ = w.Write([]byte(`{ + "data": { + "userSupplies": [ + { + "market": {"address": "0x1111111111111111111111111111111111111111"}, + "currency": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC", "decimals": 6}, + "balance": {"amount": {"raw": "1000000", "decimals": 6, "value": "1"}, "usd": "1"}, + "apy": {"value": "0.03"}, + "isCollateral": false, + "canBeCollateral": true + }, + { + "market": {"address": "0x1111111111111111111111111111111111111111"}, + "currency": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC", "decimals": 6}, + "balance": {"amount": {"raw": "2000000", "decimals": 6, "value": "2"}, "usd": "2"}, + "apy": {"value": "0.03"}, + "isCollateral": true, + "canBeCollateral": true + } + ], + "userBorrows": [ + { + "market": {"address": "0x1111111111111111111111111111111111111111"}, + "currency": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC", "decimals": 6}, + "debt": {"amount": {"raw": "500000", "decimals": 6, "value": "0.5"}, "usd": "0.5"}, + "apy": {"value": "0.05"} + } + ] + } + }`)) + default: + _, _ = w.Write([]byte(`{"errors":[{"message":"unexpected query"}]}`)) + } + })) + defer srv.Close() + + client := New(httpx.New(2*time.Second, 0)) + client.endpoint = srv.URL + chain, _ := id.ParseChain("ethereum") + account := "0x000000000000000000000000000000000000dEaD" + + all, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, + Account: account, + PositionType: providers.LendPositionTypeAll, + }) + if err != nil { + t.Fatalf("LendPositions(all) failed: %v", err) + } + if len(all) != 3 { + t.Fatalf("expected 3 positions, got %d", len(all)) + } + counts := map[string]int{} + for _, item := range all { + counts[item.PositionType]++ + } + if counts[string(providers.LendPositionTypeSupply)] != 1 { + t.Fatalf("expected one supply position, got %+v", counts) + } + if counts[string(providers.LendPositionTypeCollateral)] != 1 { + t.Fatalf("expected one collateral position, got %+v", counts) + } + if counts[string(providers.LendPositionTypeBorrow)] != 1 { + t.Fatalf("expected one borrow position, got %+v", counts) + } + + supplyOnly, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, + Account: account, + PositionType: providers.LendPositionTypeSupply, + }) + if err != nil { + t.Fatalf("LendPositions(supply) failed: %v", err) + } + if len(supplyOnly) != 1 || supplyOnly[0].PositionType != string(providers.LendPositionTypeSupply) { + t.Fatalf("expected non-collateral supply-only row, got %+v", supplyOnly) + } + + collateralOnly, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, + Account: account, + PositionType: providers.LendPositionTypeCollateral, + }) + if err != nil { + t.Fatalf("LendPositions(collateral) failed: %v", err) + } + if len(collateralOnly) != 1 || collateralOnly[0].PositionType != string(providers.LendPositionTypeCollateral) { + t.Fatalf("expected collateral-only row, got %+v", collateralOnly) + } +} diff --git a/internal/providers/morpho/client.go b/internal/providers/morpho/client.go index 4d22a0e..400a1c3 100644 --- a/internal/providers/morpho/client.go +++ b/internal/providers/morpho/client.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math/big" "net/http" "sort" "strings" @@ -40,6 +41,7 @@ func (c *Client) Info() model.ProviderInfo { Capabilities: []string{ "lend.markets", "lend.rates", + "lend.positions", "yield.opportunities", "lend.plan", "lend.execute", @@ -60,6 +62,28 @@ const marketsQuery = `query Markets($first:Int,$where:MarketFilters,$orderBy:Mar } }` +const positionsQuery = `query Positions($first:Int,$where:MarketPositionFilters,$orderBy:MarketPositionOrderBy,$orderDirection:OrderDirection){ + marketPositions(first:$first, where:$where, orderBy:$orderBy, orderDirection:$orderDirection){ + items{ + id + market{ + uniqueKey + loanAsset{ address symbol decimals chain{ id network } } + collateralAsset{ address symbol decimals } + state{ supplyApy borrowApy } + } + state{ + supplyAssets + supplyAssetsUsd + borrowAssets + borrowAssetsUsd + collateral + collateralUsd + } + } + } +}` + type marketsResponse struct { Data struct { Markets struct { @@ -71,6 +95,17 @@ type marketsResponse struct { } `json:"errors"` } +type positionsResponse struct { + Data struct { + MarketPositions struct { + Items []morphoMarketPosition `json:"items"` + } `json:"marketPositions"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + type morphoMarket struct { ID string `json:"id"` UniqueKey string `json:"uniqueKey"` @@ -98,6 +133,39 @@ type morphoMarket struct { } `json:"state"` } +type morphoMarketPosition struct { + ID string `json:"id"` + Market struct { + UniqueKey string `json:"uniqueKey"` + LoanAsset struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + Chain struct { + ID int64 `json:"id"` + Network string `json:"network"` + } `json:"chain"` + } `json:"loanAsset"` + CollateralAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + } `json:"collateralAsset"` + State *struct { + SupplyAPY float64 `json:"supplyApy"` + BorrowAPY float64 `json:"borrowApy"` + } `json:"state"` + } `json:"market"` + State *struct { + SupplyAssets bigintString `json:"supplyAssets"` + SupplyAssetsUSD float64 `json:"supplyAssetsUsd"` + BorrowAssets bigintString `json:"borrowAssets"` + BorrowAssetsUSD float64 `json:"borrowAssetsUsd"` + Collateral bigintString `json:"collateral"` + CollateralUSD float64 `json:"collateralUsd"` + } `json:"state"` +} + func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { if !strings.EqualFold(provider, "morpho") { return nil, clierr.New(clierr.CodeUnsupported, "morpho adapter supports only provider=morpho") @@ -181,6 +249,143 @@ func (c *Client) LendRates(ctx context.Context, provider string, chain id.Chain, return out, nil } +func (c *Client) LendPositions(ctx context.Context, req providers.LendPositionsRequest) ([]model.LendPosition, error) { + if !req.Chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "morpho supports only EVM chains") + } + account := normalizeEVMAddress(req.Account) + if account == "" { + return nil, clierr.New(clierr.CodeUsage, "morpho positions requires a valid EVM account address") + } + filterType := req.PositionType + if filterType == "" { + filterType = providers.LendPositionTypeAll + } + + first := req.Limit + if first <= 0 { + first = 200 + } else if first < 50 { + first = 50 + } + body, err := json.Marshal(map[string]any{ + "query": positionsQuery, + "variables": map[string]any{ + "first": first, + "orderBy": "SupplyShares", + "orderDirection": "Desc", + "where": map[string]any{ + "userAddress_in": []string{account}, + "chainId_in": []int64{req.Chain.EVMChainID}, + "marketListed": true, + }, + }, + }) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "marshal morpho positions query", err) + } + + var resp positionsResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("morpho graphql error: %s", resp.Errors[0].Message)) + } + + out := make([]model.LendPosition, 0, len(resp.Data.MarketPositions.Items)*2) + for _, item := range resp.Data.MarketPositions.Items { + if item.State == nil { + continue + } + + loanAssetID := canonicalAssetIDForChain(req.Chain.CAIP2, item.Market.LoanAsset.Address) + if loanAssetID != "" { + if matchesPositionType(filterType, providers.LendPositionTypeSupply) && + matchesPositionAsset(item.Market.LoanAsset.Address, item.Market.LoanAsset.Symbol, req.Asset) { + base := item.State.SupplyAssets.normalized() + if base != "0" { + supplyAPY := 0.0 + if item.Market.State != nil { + supplyAPY = item.Market.State.SupplyAPY * 100 + } + out = append(out, model.LendPosition{ + Protocol: "morpho", + Provider: "morpho", + ChainID: req.Chain.CAIP2, + AccountAddress: account, + PositionType: string(providers.LendPositionTypeSupply), + AssetID: loanAssetID, + ProviderNativeID: strings.TrimSpace(item.Market.UniqueKey), + ProviderNativeIDKind: model.NativeIDKindMarketID, + Amount: amountInfoFromBase(base, item.Market.LoanAsset.Decimals), + AmountUSD: item.State.SupplyAssetsUSD, + APY: supplyAPY, + SourceURL: "https://app.morpho.org", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + + if matchesPositionType(filterType, providers.LendPositionTypeBorrow) && + matchesPositionAsset(item.Market.LoanAsset.Address, item.Market.LoanAsset.Symbol, req.Asset) { + base := item.State.BorrowAssets.normalized() + if base != "0" { + borrowAPY := 0.0 + if item.Market.State != nil { + borrowAPY = item.Market.State.BorrowAPY * 100 + } + out = append(out, model.LendPosition{ + Protocol: "morpho", + Provider: "morpho", + ChainID: req.Chain.CAIP2, + AccountAddress: account, + PositionType: string(providers.LendPositionTypeBorrow), + AssetID: loanAssetID, + ProviderNativeID: strings.TrimSpace(item.Market.UniqueKey), + ProviderNativeIDKind: model.NativeIDKindMarketID, + Amount: amountInfoFromBase(base, item.Market.LoanAsset.Decimals), + AmountUSD: item.State.BorrowAssetsUSD, + APY: borrowAPY, + SourceURL: "https://app.morpho.org", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + } + + if item.Market.CollateralAsset != nil && + matchesPositionType(filterType, providers.LendPositionTypeCollateral) && + matchesPositionAsset(item.Market.CollateralAsset.Address, item.Market.CollateralAsset.Symbol, req.Asset) { + base := item.State.Collateral.normalized() + collateralAssetID := canonicalAssetIDForChain(req.Chain.CAIP2, item.Market.CollateralAsset.Address) + if base != "0" && collateralAssetID != "" { + out = append(out, model.LendPosition{ + Protocol: "morpho", + Provider: "morpho", + ChainID: req.Chain.CAIP2, + AccountAddress: account, + PositionType: string(providers.LendPositionTypeCollateral), + AssetID: collateralAssetID, + ProviderNativeID: strings.TrimSpace(item.Market.UniqueKey), + ProviderNativeIDKind: model.NativeIDKindMarketID, + Amount: amountInfoFromBase(base, item.Market.CollateralAsset.Decimals), + AmountUSD: item.State.CollateralUSD, + APY: 0, + SourceURL: "https://app.morpho.org", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + } + + sortLendPositions(out) + if req.Limit > 0 && len(out) > req.Limit { + out = out[:req.Limit] + } + return out, nil +} + func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequest) ([]model.YieldOpportunity, error) { markets, err := c.fetchMarkets(ctx, req.Chain, req.Asset) if err != nil { @@ -287,6 +492,14 @@ func canonicalAssetID(asset id.Asset, address string) string { return fmt.Sprintf("%s/erc20:%s", asset.ChainID, addr) } +func canonicalAssetIDForChain(chainID, address string) string { + addr := normalizeEVMAddress(address) + if chainID == "" || addr == "" { + return "" + } + return fmt.Sprintf("%s/erc20:%s", chainID, addr) +} + func riskFromCollateral(collateral *struct { Address string `json:"address"` Symbol string `json:"symbol"` @@ -308,3 +521,86 @@ func hashOpportunity(provider, chainID, marketID, assetID string) string { h := sha1.Sum([]byte(seed)) return hex.EncodeToString(h[:]) } + +type bigintString string + +func (b *bigintString) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *b = "0" + return nil + } + if strings.HasPrefix(raw, "\"") { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *b = bigintString(strings.TrimSpace(s)) + return nil + } + *b = bigintString(raw) + return nil +} + +func (b bigintString) normalized() string { + raw := strings.TrimSpace(string(b)) + if raw == "" { + return "0" + } + n, ok := new(big.Int).SetString(raw, 10) + if !ok || n.Sign() <= 0 { + return "0" + } + return n.String() +} + +func normalizeEVMAddress(address string) string { + addr := strings.ToLower(strings.TrimSpace(address)) + if len(addr) != 42 || !strings.HasPrefix(addr, "0x") { + return "" + } + return addr +} + +func matchesPositionType(filter, position providers.LendPositionType) bool { + if filter == "" || filter == providers.LendPositionTypeAll { + return true + } + return filter == position +} + +func matchesPositionAsset(address, symbol string, asset id.Asset) bool { + if strings.TrimSpace(asset.Address) != "" { + return strings.EqualFold(strings.TrimSpace(address), strings.TrimSpace(asset.Address)) + } + if strings.TrimSpace(asset.Symbol) != "" { + return strings.EqualFold(strings.TrimSpace(symbol), strings.TrimSpace(asset.Symbol)) + } + return true +} + +func amountInfoFromBase(base string, decimals int) model.AmountInfo { + if decimals < 0 { + decimals = 0 + } + return model.AmountInfo{ + AmountBaseUnits: base, + AmountDecimal: id.FormatDecimalCompat(base, decimals), + Decimals: decimals, + } +} + +func sortLendPositions(items []model.LendPosition) { + sort.Slice(items, func(i, j int) bool { + if items[i].AmountUSD != items[j].AmountUSD { + return items[i].AmountUSD > items[j].AmountUSD + } + if items[i].PositionType != items[j].PositionType { + return items[i].PositionType < items[j].PositionType + } + if items[i].AssetID != items[j].AssetID { + return items[i].AssetID < items[j].AssetID + } + return items[i].ProviderNativeID < items[j].ProviderNativeID + }) +} diff --git a/internal/providers/morpho/client_test.go b/internal/providers/morpho/client_test.go index f5ce941..57256e8 100644 --- a/internal/providers/morpho/client_test.go +++ b/internal/providers/morpho/client_test.go @@ -2,8 +2,10 @@ package morpho import ( "context" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -71,3 +73,109 @@ func TestLendRatesAndYield(t *testing.T) { t.Fatalf("expected market_id kind on yield opportunity, got %+v", opps[0]) } } + +func TestLendPositionsTypeSplit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + + if !strings.Contains(string(body), "marketPositions") { + _, _ = w.Write([]byte(`{"errors":[{"message":"unexpected query"}]}`)) + return + } + + _, _ = w.Write([]byte(`{ + "data": { + "marketPositions": { + "items": [ + { + "id": "position-1", + "market": { + "uniqueKey": "market-1", + "loanAsset": { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "symbol": "USDC", + "decimals": 6, + "chain": {"id": 1, "network": "ethereum"} + }, + "collateralAsset": { + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": 18 + }, + "state": {"supplyApy": 0.02, "borrowApy": 0.03} + }, + "state": { + "supplyAssets": "1500000", + "supplyAssetsUsd": 1.5, + "borrowAssets": "500000", + "borrowAssetsUsd": 0.5, + "collateral": "1000000000000000000", + "collateralUsd": 2000 + } + } + ] + } + } + }`)) + })) + defer srv.Close() + + client := New(httpx.New(2*time.Second, 0)) + client.endpoint = srv.URL + chain, _ := id.ParseChain("ethereum") + account := "0x000000000000000000000000000000000000dEaD" + + all, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, + Account: account, + PositionType: providers.LendPositionTypeAll, + }) + if err != nil { + t.Fatalf("LendPositions(all) failed: %v", err) + } + if len(all) != 3 { + t.Fatalf("expected 3 distinct positions, got %d", len(all)) + } + counts := map[string]int{} + for _, item := range all { + counts[item.PositionType]++ + } + if counts[string(providers.LendPositionTypeSupply)] != 1 { + t.Fatalf("expected one supply row, got %+v", counts) + } + if counts[string(providers.LendPositionTypeBorrow)] != 1 { + t.Fatalf("expected one borrow row, got %+v", counts) + } + if counts[string(providers.LendPositionTypeCollateral)] != 1 { + t.Fatalf("expected one collateral row, got %+v", counts) + } + + supplyOnly, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, + Account: account, + PositionType: providers.LendPositionTypeSupply, + }) + if err != nil { + t.Fatalf("LendPositions(supply) failed: %v", err) + } + if len(supplyOnly) != 1 || supplyOnly[0].PositionType != string(providers.LendPositionTypeSupply) { + t.Fatalf("expected supply-only row, got %+v", supplyOnly) + } + + usdcOnly, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, + Account: account, + PositionType: providers.LendPositionTypeAll, + Asset: id.Asset{ + ChainID: chain.CAIP2, + Symbol: "USDC", + }, + }) + if err != nil { + t.Fatalf("LendPositions(asset=USDC) failed: %v", err) + } + if len(usdcOnly) != 2 { + t.Fatalf("expected supply+borrow rows for USDC filter, got %+v", usdcOnly) + } +} diff --git a/internal/providers/types.go b/internal/providers/types.go index 937525d..8ad5431 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -26,6 +26,28 @@ type LendingProvider interface { LendRates(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) } +type LendPositionType string + +const ( + LendPositionTypeAll LendPositionType = "all" + LendPositionTypeSupply LendPositionType = "supply" + LendPositionTypeBorrow LendPositionType = "borrow" + LendPositionTypeCollateral LendPositionType = "collateral" +) + +type LendPositionsRequest struct { + Chain id.Chain + Account string + Asset id.Asset + PositionType LendPositionType + Limit int +} + +type LendingPositionsProvider interface { + Provider + LendPositions(ctx context.Context, req LendPositionsRequest) ([]model.LendPosition, error) +} + type YieldProvider interface { Provider YieldOpportunities(ctx context.Context, req YieldRequest) ([]model.YieldOpportunity, error) From 9954c1872bfc12edc3cdd1e1e5f3f58492b09bdc Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Thu, 26 Feb 2026 13:52:14 -0400 Subject: [PATCH 15/18] breaking: switch morpho yield opportunities to vaults --- CHANGELOG.md | 1 + internal/model/types.go | 1 + internal/providers/morpho/client.go | 465 +++++++++++++++++++++-- internal/providers/morpho/client_test.go | 213 ++++++++++- 4 files changed, 641 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9586e..84d93af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Format: - Added `lend positions` to query account-level lending positions by address for Aave and Morpho with `--type all|supply|borrow|collateral`. ### Changed +- BREAKING: Morpho `yield opportunities` now returns vault-level opportunities (`provider_native_id_kind=vault_address`) sourced from Morpho vault/vault-v2 data instead of Morpho market IDs. - BREAKING: Lend and rewards commands now use `--provider` as the selector flag; `--protocol` has been removed. - `providers list` now includes TaikoSwap execution capabilities (`swap.plan`, `swap.execute`) alongside quote metadata. - `providers list` now includes LiFi bridge execution capabilities (`bridge.plan`, `bridge.execute`). diff --git a/internal/model/types.go b/internal/model/types.go index caef570..0f713b7 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -7,6 +7,7 @@ const EnvelopeVersion = "v1" const ( NativeIDKindCompositeMarketAsset = "composite_market_asset" NativeIDKindMarketID = "market_id" + NativeIDKindVaultAddress = "vault_address" NativeIDKindPoolID = "pool_id" ) diff --git a/internal/providers/morpho/client.go b/internal/providers/morpho/client.go index 400a1c3..3cbfb72 100644 --- a/internal/providers/morpho/client.go +++ b/internal/providers/morpho/client.go @@ -84,6 +84,67 @@ const positionsQuery = `query Positions($first:Int,$where:MarketPositionFilters, } }` +const vaultsYieldQuery = `query Vaults($first:Int,$skip:Int,$where:VaultFilters,$orderBy:VaultOrderBy,$orderDirection:OrderDirection){ + vaults(first:$first, skip:$skip, where:$where, orderBy:$orderBy, orderDirection:$orderDirection){ + items{ + address + name + symbol + asset{ address symbol } + state{ + netApy + totalAssetsUsd + allocation{ + supplyAssetsUsd + market{ + collateralAsset{ address symbol } + } + } + } + liquidity{ usd } + } + } +}` + +const vaultV2sYieldQuery = `query VaultV2s($first:Int,$skip:Int,$where:VaultV2sFilters,$orderBy:VaultV2OrderBy,$orderDirection:OrderDirection){ + vaultV2s(first:$first, skip:$skip, where:$where, orderBy:$orderBy, orderDirection:$orderDirection){ + items{ + address + name + symbol + asset{ address symbol } + netApy + totalAssetsUsd + liquidityUsd + liquidityData{ + __typename + ... on MarketV1LiquidityData { + market{ + collateralAsset{ address symbol } + } + } + ... on MetaMorphoLiquidityData { + metaMorpho{ + state{ + allocation{ + supplyAssetsUsd + market{ + collateralAsset{ address symbol } + } + } + } + } + } + } + } + } +}` + +const ( + yieldVaultPageSize = 200 + yieldVaultMaxPages = 20 +) + type marketsResponse struct { Data struct { Markets struct { @@ -106,6 +167,28 @@ type positionsResponse struct { } `json:"errors"` } +type vaultsResponse struct { + Data struct { + Vaults struct { + Items []morphoVault `json:"items"` + } `json:"vaults"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type vaultV2sResponse struct { + Data struct { + VaultV2s struct { + Items []morphoVaultV2 `json:"items"` + } `json:"vaultV2s"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + type morphoMarket struct { ID string `json:"id"` UniqueKey string `json:"uniqueKey"` @@ -166,6 +249,75 @@ type morphoMarketPosition struct { } `json:"state"` } +type morphoVault struct { + Address string `json:"address"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Asset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"asset"` + State *struct { + NetAPY float64 `json:"netApy"` + TotalAssetsUSD float64 `json:"totalAssetsUsd"` + Allocation []marketAllocation `json:"allocation"` + } `json:"state"` + Liquidity *struct { + USD float64 `json:"usd"` + } `json:"liquidity"` +} + +type morphoVaultV2 struct { + Address string `json:"address"` + Name string `json:"name"` + Symbol string `json:"symbol"` + NetAPY float64 `json:"netApy"` + TotalAssets float64 `json:"totalAssetsUsd"` + LiquidityUSD float64 `json:"liquidityUsd"` + Asset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"asset"` + LiquidityData *struct { + TypeName string `json:"__typename"` + Market *struct { + CollateralAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"collateralAsset"` + } `json:"market"` + MetaMorpho *struct { + State *struct { + Allocation []marketAllocation `json:"allocation"` + } `json:"state"` + } `json:"metaMorpho"` + } `json:"liquidityData"` +} + +type marketAllocation struct { + SupplyAssetsUSD float64 `json:"supplyAssetsUsd"` + Market *struct { + CollateralAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"collateralAsset"` + } `json:"market"` +} + +type vaultYieldCandidate struct { + Address string + AssetAddress string + NetAPYPercent float64 + TotalAssetsUSD float64 + LiquidityUSD float64 + CollateralShares []collateralShare +} + +type collateralShare struct { + Symbol string + USD float64 +} + func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { if !strings.EqualFold(provider, "morpho") { return nil, clierr.New(clierr.CodeUnsupported, "morpho adapter supports only provider=morpho") @@ -387,7 +539,7 @@ func (c *Client) LendPositions(ctx context.Context, req providers.LendPositionsR } func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequest) ([]model.YieldOpportunity, error) { - markets, err := c.fetchMarkets(ctx, req.Chain, req.Asset) + vaults, err := c.fetchYieldVaultCandidates(ctx, req.Chain, req.Asset) if err != nil { return nil, err } @@ -396,10 +548,10 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ maxRisk = yieldutil.RiskOrder("high") } - out := make([]model.YieldOpportunity, 0, len(markets)) - for _, m := range markets { - apy := m.State.SupplyAPY * 100 - tvl := yieldutil.PositiveFirst(m.State.SupplyAssetsUSD, m.State.TotalLiquidityUSD, m.State.LiquidityAssetsUSD) + out := make([]model.YieldOpportunity, 0, len(vaults)) + for _, vault := range vaults { + apy := vault.NetAPYPercent + tvl := vault.TotalAssetsUSD if (apy == 0 || tvl == 0) && !req.IncludeIncomplete { continue } @@ -407,20 +559,24 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ continue } - riskLevel, reasons := riskFromCollateral(m.CollateralAsset) + riskLevel, reasons := riskFromCollateralShares(vault.CollateralShares) if yieldutil.RiskOrder(riskLevel) > maxRisk { continue } - liq := yieldutil.PositiveFirst(m.State.LiquidityAssetsUSD, m.State.TotalLiquidityUSD, tvl) - assetID := canonicalAssetID(req.Asset, m.LoanAsset.Address) + liq := yieldutil.PositiveFirst(vault.LiquidityUSD, tvl) + assetID := canonicalAssetID(req.Asset, vault.AssetAddress) + vaultAddress := normalizeEVMAddress(vault.Address) + if vaultAddress == "" { + continue + } out = append(out, model.YieldOpportunity{ - OpportunityID: hashOpportunity("morpho", req.Chain.CAIP2, m.UniqueKey, assetID), + OpportunityID: hashOpportunity("morpho", req.Chain.CAIP2, vaultAddress, assetID), Provider: "morpho", Protocol: "morpho", ChainID: req.Chain.CAIP2, AssetID: assetID, - ProviderNativeID: strings.TrimSpace(m.UniqueKey), - ProviderNativeIDKind: model.NativeIDKindMarketID, + ProviderNativeID: vaultAddress, + ProviderNativeIDKind: model.NativeIDKindVaultAddress, Type: "lend", APYBase: apy, APYReward: 0, @@ -432,7 +588,7 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ RiskLevel: riskLevel, RiskReasons: reasons, Score: yieldutil.ScoreOpportunity(apy, tvl, liq, riskLevel), - SourceURL: "https://app.morpho.org", + SourceURL: sourceURLForVault(vaultAddress), FetchedAt: c.now().UTC().Format(time.RFC3339), }) } @@ -447,6 +603,75 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ return out[:req.Limit], nil } +func (c *Client) fetchYieldVaultCandidates(ctx context.Context, chain id.Chain, asset id.Asset) ([]vaultYieldCandidate, error) { + if !chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "morpho supports only EVM chains") + } + + vaults, err := c.fetchVaults(ctx, chain, asset) + if err != nil { + return nil, err + } + vaultV2s, err := c.fetchVaultV2s(ctx, chain) + if err != nil { + return nil, err + } + + out := make([]vaultYieldCandidate, 0, len(vaults)+len(vaultV2s)) + for _, vault := range vaults { + assetAddress := "" + assetSymbol := "" + if vault.Asset != nil { + assetAddress = vault.Asset.Address + assetSymbol = vault.Asset.Symbol + } + if !matchesVaultAsset(assetAddress, assetSymbol, asset) { + continue + } + netAPY := 0.0 + tvl := 0.0 + if vault.State != nil { + netAPY = vault.State.NetAPY * 100 + tvl = vault.State.TotalAssetsUSD + } + liquidity := 0.0 + if vault.Liquidity != nil { + liquidity = vault.Liquidity.USD + } + out = append(out, vaultYieldCandidate{ + Address: vault.Address, + AssetAddress: assetAddress, + NetAPYPercent: netAPY, + TotalAssetsUSD: tvl, + LiquidityUSD: liquidity, + CollateralShares: collateralSharesFromAllocation(0, nil, allocationFromVault(vault)), + }) + } + for _, vault := range vaultV2s { + assetAddress := "" + assetSymbol := "" + if vault.Asset != nil { + assetAddress = vault.Asset.Address + assetSymbol = vault.Asset.Symbol + } + if !matchesVaultAsset(assetAddress, assetSymbol, asset) { + continue + } + out = append(out, vaultYieldCandidate{ + Address: vault.Address, + AssetAddress: assetAddress, + NetAPYPercent: vault.NetAPY * 100, + TotalAssetsUSD: vault.TotalAssets, + LiquidityUSD: vault.LiquidityUSD, + CollateralShares: collateralSharesFromVaultV2(vault), + }) + } + if len(out) == 0 { + return nil, clierr.New(clierr.CodeUnsupported, "morpho has no yield vault for requested chain/asset") + } + return out, nil +} + func (c *Client) fetchMarkets(ctx context.Context, chain id.Chain, asset id.Asset) ([]morphoMarket, error) { if !chain.IsEVM() { return nil, clierr.New(clierr.CodeUnsupported, "morpho supports only EVM chains") @@ -484,6 +709,206 @@ func (c *Client) fetchMarkets(ctx context.Context, chain id.Chain, asset id.Asse return resp.Data.Markets.Items, nil } +func (c *Client) fetchVaults(ctx context.Context, chain id.Chain, asset id.Asset) ([]morphoVault, error) { + where := map[string]any{ + "chainId_in": []int64{chain.EVMChainID}, + "listed": true, + } + if addr := normalizeEVMAddress(asset.Address); addr != "" { + where["assetAddress_in"] = []string{addr} + } else if symbol := strings.TrimSpace(asset.Symbol); symbol != "" { + where["assetSymbol_in"] = []string{symbol} + } + + out := make([]morphoVault, 0, yieldVaultPageSize) + for page := 0; page < yieldVaultMaxPages; page++ { + body, err := json.Marshal(map[string]any{ + "query": vaultsYieldQuery, + "variables": map[string]any{ + "first": yieldVaultPageSize, + "skip": page * yieldVaultPageSize, + "where": where, + }, + }) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "marshal morpho vault query", err) + } + + var resp vaultsResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("morpho graphql error: %s", resp.Errors[0].Message)) + } + out = append(out, resp.Data.Vaults.Items...) + if len(resp.Data.Vaults.Items) < yieldVaultPageSize { + break + } + } + + return out, nil +} + +func (c *Client) fetchVaultV2s(ctx context.Context, chain id.Chain) ([]morphoVaultV2, error) { + where := map[string]any{ + "chainId_in": []int64{chain.EVMChainID}, + "listed": true, + } + + out := make([]morphoVaultV2, 0, yieldVaultPageSize) + for page := 0; page < yieldVaultMaxPages; page++ { + body, err := json.Marshal(map[string]any{ + "query": vaultV2sYieldQuery, + "variables": map[string]any{ + "first": yieldVaultPageSize, + "skip": page * yieldVaultPageSize, + "where": where, + }, + }) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "marshal morpho vault-v2 query", err) + } + + var resp vaultV2sResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("morpho graphql error: %s", resp.Errors[0].Message)) + } + out = append(out, resp.Data.VaultV2s.Items...) + if len(resp.Data.VaultV2s.Items) < yieldVaultPageSize { + break + } + } + + return out, nil +} + +func matchesVaultAsset(vaultAssetAddress, vaultAssetSymbol string, asset id.Asset) bool { + if addr := normalizeEVMAddress(asset.Address); addr != "" { + return strings.EqualFold(normalizeEVMAddress(vaultAssetAddress), addr) + } + if symbol := strings.TrimSpace(asset.Symbol); symbol != "" { + return strings.EqualFold(strings.TrimSpace(vaultAssetSymbol), symbol) + } + return true +} + +func allocationFromVault(vault morphoVault) []marketAllocation { + if vault.State == nil { + return nil + } + return vault.State.Allocation +} + +func collateralSharesFromVaultV2(vault morphoVaultV2) []collateralShare { + if vault.LiquidityData == nil { + if usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD); usd > 0 { + return []collateralShare{{USD: usd}} + } + return nil + } + + switch vault.LiquidityData.TypeName { + case "MarketV1LiquidityData": + symbol := "" + if vault.LiquidityData.Market != nil && vault.LiquidityData.Market.CollateralAsset != nil { + symbol = vault.LiquidityData.Market.CollateralAsset.Symbol + } + usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD) + if usd <= 0 { + return nil + } + return []collateralShare{{Symbol: symbol, USD: usd}} + case "MetaMorphoLiquidityData": + if vault.LiquidityData.MetaMorpho != nil && vault.LiquidityData.MetaMorpho.State != nil { + shares := collateralSharesFromAllocation(vault.TotalAssets, nil, vault.LiquidityData.MetaMorpho.State.Allocation) + if len(shares) > 0 { + return shares + } + } + } + + if usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD); usd > 0 { + return []collateralShare{{USD: usd}} + } + return nil +} + +func collateralSharesFromAllocation(totalOverride float64, shares []collateralShare, allocation []marketAllocation) []collateralShare { + total := 0.0 + for _, item := range allocation { + if item.SupplyAssetsUSD > 0 { + total += item.SupplyAssetsUSD + } + } + for _, item := range allocation { + if item.SupplyAssetsUSD <= 0 { + continue + } + usd := item.SupplyAssetsUSD + if totalOverride > 0 && total > 0 { + usd = totalOverride * item.SupplyAssetsUSD / total + } + symbol := "" + if item.Market != nil && item.Market.CollateralAsset != nil { + symbol = item.Market.CollateralAsset.Symbol + } + shares = append(shares, collateralShare{Symbol: symbol, USD: usd}) + } + return shares +} + +var stableCollateralSymbols = map[string]struct{}{ + "USDC": {}, + "USDT": {}, + "DAI": {}, + "USDE": {}, +} + +func riskFromCollateralShares(shares []collateralShare) (string, []string) { + hasNonStable := false + hasMissing := false + hasKnownStable := false + + for _, share := range shares { + if share.USD <= 0 { + continue + } + symbol := strings.ToUpper(strings.TrimSpace(share.Symbol)) + if symbol == "" { + hasMissing = true + continue + } + if _, ok := stableCollateralSymbols[symbol]; ok { + hasKnownStable = true + continue + } + hasNonStable = true + } + + switch { + case hasNonStable: + return "medium", []string{"non-stable collateral"} + case hasMissing: + return "medium", []string{"missing collateral metadata"} + case hasKnownStable: + return "low", []string{"stable collateral"} + default: + return "medium", []string{"missing collateral metadata"} + } +} + +func sourceURLForVault(address string) string { + addr := normalizeEVMAddress(address) + if addr == "" { + return "https://app.morpho.org" + } + return fmt.Sprintf("https://app.morpho.org/vault/%s", addr) +} + func canonicalAssetID(asset id.Asset, address string) string { addr := strings.ToLower(strings.TrimSpace(address)) if addr == "" { @@ -500,22 +925,6 @@ func canonicalAssetIDForChain(chainID, address string) string { return fmt.Sprintf("%s/erc20:%s", chainID, addr) } -func riskFromCollateral(collateral *struct { - Address string `json:"address"` - Symbol string `json:"symbol"` -}) (string, []string) { - if collateral == nil { - return "medium", []string{"missing collateral metadata"} - } - s := strings.ToUpper(strings.TrimSpace(collateral.Symbol)) - switch s { - case "USDC", "USDT", "DAI", "USDE": - return "low", []string{"stable collateral"} - default: - return "medium", []string{"non-stable collateral"} - } -} - func hashOpportunity(provider, chainID, marketID, assetID string) string { seed := strings.Join([]string{provider, chainID, marketID, assetID}, "|") h := sha1.Sum([]byte(seed)) diff --git a/internal/providers/morpho/client_test.go b/internal/providers/morpho/client_test.go index 57256e8..96e2281 100644 --- a/internal/providers/morpho/client_test.go +++ b/internal/providers/morpho/client_test.go @@ -17,11 +17,16 @@ import ( func TestLendRatesAndYield(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "data": { - "markets": { - "items": [ + query := string(body) + + switch { + case strings.Contains(query, "query Markets("): + _, _ = w.Write([]byte(`{ + "data": { + "markets": { + "items": [ { "id": "4f598145-0188-44dc-9e18-38a2817020a1", "uniqueKey": "m1", @@ -31,9 +36,79 @@ func TestLendRatesAndYield(t *testing.T) { "state": {"supplyApy": 0.02, "borrowApy": 0.03, "utilization": 0.5, "supplyAssetsUsd": 2000000, "liquidityAssetsUsd": 1000000, "totalLiquidityUsd": 1200000} } ] + } } - } - }`)) + }`)) + case strings.Contains(query, "query Vaults("): + _, _ = w.Write([]byte(`{ + "data": { + "vaults": { + "items": [ + { + "address": "0x1111111111111111111111111111111111111111", + "name": "Morpho USDC Vault", + "symbol": "vUSDC", + "asset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, + "state": { + "netApy": 0.05, + "totalAssetsUsd": 1000000, + "allocation": [ + { + "supplyAssetsUsd": 1000000, + "market": {"collateralAsset": {"address": "0x111", "symbol": "WETH"}} + } + ] + }, + "liquidity": {"usd": 500000} + } + ] + } + } + }`)) + case strings.Contains(query, "query VaultV2s("): + _, _ = w.Write([]byte(`{ + "data": { + "vaultV2s": { + "items": [ + { + "address": "0x2222222222222222222222222222222222222222", + "name": "Morpho USDC V2 Vault", + "symbol": "v2USDC", + "asset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, + "netApy": 0.03, + "totalAssetsUsd": 2000000, + "liquidityUsd": 1500000, + "liquidityData": { + "__typename": "MetaMorphoLiquidityData", + "metaMorpho": { + "state": { + "allocation": [ + { + "supplyAssetsUsd": 2000000, + "market": {"collateralAsset": {"address": "0x6b175474e89094c44da98b954eedeac495271d0f", "symbol": "DAI"}} + } + ] + } + } + } + }, + { + "address": "0x3333333333333333333333333333333333333333", + "name": "Morpho USDT V2 Vault", + "symbol": "v2USDT", + "asset": {"address": "0xdac17f958d2ee523a2206206994597c13d831ec7", "symbol": "USDT"}, + "netApy": 0.09, + "totalAssetsUsd": 3000000, + "liquidityUsd": 2500000, + "liquidityData": {"__typename": "MetaMorphoLiquidityData"} + } + ] + } + } + }`)) + default: + _, _ = w.Write([]byte(`{"errors":[{"message":"unexpected query"}]}`)) + } })) defer srv.Close() @@ -63,14 +138,130 @@ func TestLendRatesAndYield(t *testing.T) { if err != nil { t.Fatalf("YieldOpportunities failed: %v", err) } - if len(opps) != 1 || opps[0].Provider != "morpho" { + if len(opps) != 2 { t.Fatalf("unexpected opportunities: %+v", opps) } - if opps[0].ProviderNativeID != "m1" { - t.Fatalf("expected provider native id on yield opportunity, got %+v", opps[0]) + + byID := map[string]model.YieldOpportunity{} + for _, opp := range opps { + if opp.Provider != "morpho" { + t.Fatalf("expected morpho provider, got %+v", opp) + } + byID[opp.ProviderNativeID] = opp + } + + vaultOne, ok := byID["0x1111111111111111111111111111111111111111"] + if !ok { + t.Fatalf("expected first vault id in output, got %+v", byID) + } + if vaultOne.ProviderNativeIDKind != model.NativeIDKindVaultAddress { + t.Fatalf("expected vault_address kind on first vault, got %+v", vaultOne) + } + if vaultOne.RiskLevel != "medium" || len(vaultOne.RiskReasons) == 0 || vaultOne.RiskReasons[0] != "non-stable collateral" { + t.Fatalf("expected medium/non-stable risk on first vault, got %+v", vaultOne) + } + + vaultTwo, ok := byID["0x2222222222222222222222222222222222222222"] + if !ok { + t.Fatalf("expected second vault id in output, got %+v", byID) + } + if vaultTwo.ProviderNativeIDKind != model.NativeIDKindVaultAddress { + t.Fatalf("expected vault_address kind on second vault, got %+v", vaultTwo) + } + if vaultTwo.RiskLevel != "low" || len(vaultTwo.RiskReasons) == 0 || vaultTwo.RiskReasons[0] != "stable collateral" { + t.Fatalf("expected low/stable risk on second vault, got %+v", vaultTwo) + } + if _, ok := byID["0x3333333333333333333333333333333333333333"]; ok { + t.Fatalf("expected USDT vault to be filtered out for USDC request, got %+v", byID) + } +} + +func TestYieldOpportunitiesVaultMaxRiskFilter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + query := string(body) + switch { + case strings.Contains(query, "query Vaults("): + _, _ = w.Write([]byte(`{ + "data": { + "vaults": { + "items": [ + { + "address": "0x1111111111111111111111111111111111111111", + "name": "Morpho USDC Vault", + "symbol": "vUSDC", + "asset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, + "state": { + "netApy": 0.06, + "totalAssetsUsd": 1000000, + "allocation": [ + { + "supplyAssetsUsd": 1000000, + "market": {"collateralAsset": {"address": "0x111", "symbol": "WETH"}} + } + ] + }, + "liquidity": {"usd": 700000} + } + ] + } + } + }`)) + case strings.Contains(query, "query VaultV2s("): + _, _ = w.Write([]byte(`{ + "data": { + "vaultV2s": { + "items": [ + { + "address": "0x2222222222222222222222222222222222222222", + "name": "Morpho USDC V2 Vault", + "symbol": "v2USDC", + "asset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, + "netApy": 0.03, + "totalAssetsUsd": 2000000, + "liquidityUsd": 1800000, + "liquidityData": { + "__typename": "MetaMorphoLiquidityData", + "metaMorpho": { + "state": { + "allocation": [ + { + "supplyAssetsUsd": 2000000, + "market": {"collateralAsset": {"address": "0x6b175474e89094c44da98b954eedeac495271d0f", "symbol": "DAI"}} + } + ] + } + } + } + } + ] + } + } + }`)) + default: + _, _ = w.Write([]byte(`{"data":{"markets":{"items":[]}}}`)) + } + })) + defer srv.Close() + + client := New(httpx.New(2*time.Second, 0)) + client.endpoint = srv.URL + chain, _ := id.ParseChain("ethereum") + asset, _ := id.ParseAsset("USDC", chain) + + opps, err := client.YieldOpportunities(context.Background(), providers.YieldRequest{Chain: chain, Asset: asset, Limit: 10, MaxRisk: "low"}) + if err != nil { + t.Fatalf("YieldOpportunities failed: %v", err) + } + if len(opps) != 1 { + t.Fatalf("expected one low-risk vault after max-risk filter, got %+v", opps) + } + if opps[0].ProviderNativeID != "0x2222222222222222222222222222222222222222" { + t.Fatalf("expected low-risk vault id, got %+v", opps[0]) } - if opps[0].ProviderNativeIDKind != model.NativeIDKindMarketID { - t.Fatalf("expected market_id kind on yield opportunity, got %+v", opps[0]) + if opps[0].RiskLevel != "low" { + t.Fatalf("expected low risk, got %+v", opps[0]) } } From 43c935a461de53ba6d95f7a1180b45c2ec02849b Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Thu, 26 Feb 2026 16:30:09 -0400 Subject: [PATCH 16/18] feat(yield): add provider-backed yield history command --- CHANGELOG.md | 3 + README.md | 9 +- docs/concepts/providers-and-auth.mdx | 1 + docs/guides/yield.mdx | 16 + docs/index.mdx | 4 +- docs/quickstart.mdx | 1 + docs/reference/lending-and-yield-commands.mdx | 27 ++ internal/app/runner.go | 377 ++++++++++++++++-- internal/app/runner_test.go | 200 ++++++++++ internal/model/types.go | 22 + internal/providers/aave/client.go | 228 +++++++++++ internal/providers/aave/client_test.go | 85 ++++ internal/providers/kamino/client.go | 248 ++++++++++++ internal/providers/kamino/client_test.go | 124 ++++++ internal/providers/morpho/client.go | 249 ++++++++++++ internal/providers/morpho/client_test.go | 138 +++++++ internal/providers/types.go | 28 ++ 17 files changed, 1728 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84d93af..3d88fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Format: - Added centralized execution registry data in `internal/registry` for endpoint, contract, and ABI references. - Added nightly execution-planning smoke workflow (`nightly-execution-smoke.yml`) and script. - Added `lend positions` to query account-level lending positions by address for Aave and Morpho with `--type all|supply|borrow|collateral`. +- Added `yield history` to query historical yield-provider series with `--metrics apy_total,tvl_usd`, `--interval hour|day`, `--window`/`--from`/`--to`, and optional `--opportunity-ids`. ### Changed - BREAKING: Morpho `yield opportunities` now returns vault-level opportunities (`provider_native_id_kind=vault_address`) sourced from Morpho vault/vault-v2 data instead of Morpho market IDs. @@ -57,6 +58,7 @@ Format: - Swap execution planning now validates sender/recipient fields as EVM addresses before route planning. - Uniswap `swap quote` now requires a real `--from-address` swapper input instead of using a deterministic placeholder address. - `lend positions` now emits non-overlapping type rows for automation: `supply` (non-collateral), `collateral` (posted collateral), and `borrow` (debt). +- `providers list` for Aave/Morpho/Kamino now advertises `yield.history` capability metadata. ### Fixed - Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. @@ -67,6 +69,7 @@ Format: - Updated `docs/act-execution-design.md` implementation status to reflect the shipped Phase 2 surface. - Clarified execution builder architecture split (provider-backed route builders for swap/bridge vs internal planners for lend/rewards/approvals) in `AGENTS.md` and execution design docs. - Added `lend positions` usage and caveats to `README.md`, `AGENTS.md`, and Mintlify lending command reference. +- Documented `yield history` usage, flags, and provider caveats across README and Mintlify yield/lending references. ### Security - None yet. diff --git a/README.md b/README.md index 15eec0d..a9493ba 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Built for AI agents and scripts. Stable JSON output, canonical identifiers (CAIP ## Features - **Lending** — query markets/rates from Aave/Morpho/Kamino and account positions from Aave/Morpho, plus execute Aave/Morpho lend actions. -- **Yield** — compare opportunities across protocols and chains, filter by TVL and APY. +- **Yield** — compare opportunities and query historical yield/TVL series across Aave, Morpho, and Kamino. - **Bridging** — get cross-chain quotes (Across, LiFi), bridge analytics (volume, chain breakdown), and execute LiFi bridge plans. - **Swapping** — get swap quotes (1inch, Uniswap, TaikoSwap) and execute TaikoSwap plans on-chain. - **Approvals & rewards** — create and execute ERC-20 approvals, Aave rewards claims, and compound flows. @@ -94,6 +94,7 @@ defi lend rates --provider morpho --chain 1 --asset USDC --results-only defi lend positions --provider aave --chain 1 --address 0xYourEOA --type all --limit 20 --results-only defi yield opportunities --chain base --asset USDC --limit 20 --results-only defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit 10 --results-only +defi yield history --chain 1 --asset USDC --providers aave,morpho --metrics apy_total,tvl_usd --interval day --window 7d --limit 1 --results-only defi bridge list --limit 10 --results-only # Requires DEFI_DEFILLAMA_API_KEY defi bridge details --bridge layerzero --results-only # Requires DEFI_DEFILLAMA_API_KEY defi bridge quote --provider across --from 1 --to 8453 --asset USDC --amount 1000000 --results-only @@ -110,7 +111,7 @@ defi swap status --action-id --results-only defi actions list --results-only ``` -`yield opportunities --providers` accepts provider names from `defi providers list` (e.g. `defillama,aave,morpho`). +`yield opportunities --providers` and `yield history --providers` accept provider names from `defi providers list` (for example `aave,morpho,kamino`). Bridge quote examples: @@ -274,7 +275,7 @@ providers: ## Cache Policy -- Command TTLs are fixed in code (`chains/protocols/chains assets`: `5m`, `lend markets`: `60s`, `lend rates`: `30s`, `lend positions`: `30s`, `yield`: `60s`, `bridge/swap quotes`: `15s`). +- Command TTLs are fixed in code (`chains/protocols/chains assets`: `5m`, `lend markets`: `60s`, `lend rates`: `30s`, `lend positions`: `30s`, `yield opportunities`: `60s`, `yield history`: `5m`, `bridge/swap quotes`: `15s`). - Cache entries are served directly only while fresh (`age <= ttl`). - After TTL expiry, the CLI fetches provider data immediately. - `cache.max_stale` / `--max-stale` is only a temporary provider-failure fallback window (currently `unavailable` / `rate_limited`). @@ -285,6 +286,8 @@ providers: ## Caveats - Morpho can surface extreme APY values on very small markets. Prefer `--min-tvl-usd` when ranking yield. +- `yield history --metrics` supports `apy_total` and `tvl_usd`; Aave currently supports `apy_total` only. +- Aave historical windows are lookback-based and effectively end near current time; use `--window` for Aave-friendly history requests. - `chains assets` requires `DEFI_DEFILLAMA_API_KEY` because DefiLlama chain asset TVL is key-gated. - `bridge list` and `bridge details` require `DEFI_DEFILLAMA_API_KEY`; quote providers (`across`, `lifi`) do not. - Category rankings from `protocols categories` are deterministic and sorted by `tvl_usd`, then protocol count, then name. diff --git a/docs/concepts/providers-and-auth.mdx b/docs/concepts/providers-and-auth.mdx index 2e8b432..e6ebd7d 100644 --- a/docs/concepts/providers-and-auth.mdx +++ b/docs/concepts/providers-and-auth.mdx @@ -40,6 +40,7 @@ If either is missing, bungee quotes use public backend. - Lending routes by `--provider` (`aave`, `morpho`, `kamino`) using direct adapters only. - `lend positions` currently supports `--provider aave|morpho`. - `yield opportunities` aggregates direct providers and accepts `--providers aave,morpho,kamino`. +- `yield history` uses the same direct providers and accepts `--providers aave,morpho,kamino`. - `swap quote` defaults by chain family: `1inch` for EVM chains, `jupiter` for Solana. - Provider/chain-family mismatches fail fast with `unsupported` errors (for example `--provider jupiter` on EVM chains). diff --git a/docs/guides/yield.mdx b/docs/guides/yield.mdx index 3d56533..a2b75b9 100644 --- a/docs/guides/yield.mdx +++ b/docs/guides/yield.mdx @@ -41,8 +41,24 @@ defi yield opportunities --chain 1 --asset USDC --include-incomplete --results-o When enabled, warnings may indicate missing APY/TVL and combined-provider counts. +## Historical series + +```bash +defi yield history --chain 1 --asset USDC --providers aave,morpho --metrics apy_total --interval day --window 7d --limit 1 --results-only +defi yield history --chain 1 --asset USDC --providers morpho --metrics apy_total,tvl_usd --interval day --window 30d --limit 1 --results-only +defi yield history --chain solana --asset USDC --providers kamino --metrics apy_total,tvl_usd --interval day --window 7d --limit 1 --results-only +``` + +Optional time filters: + +```bash +defi yield history --chain 1 --asset USDC --providers aave --metrics apy_total --from 2026-02-20T00:00:00Z --to 2026-02-26T00:00:00Z --interval hour --limit 1 --results-only +``` + ## Important caveats - APY values are percentage points (`2.3` = `2.3%`). - Morpho may produce extreme APY for very small markets; use `--min-tvl-usd`. - Kamino yield routes currently support Solana mainnet only. +- `yield history --metrics` supports `apy_total` and `tvl_usd`; Aave currently supports `apy_total` only. +- Aave history is lookback-window based and effectively ends near current time. diff --git a/docs/index.mdx b/docs/index.mdx index 06fde25..eb478b9 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -36,7 +36,7 @@ defi providers list --results-only "name": "kamino", "type": "lending+yield", "requires_key": false, - "capabilities": ["lend.markets", "lend.rates", "yield.opportunities"] + "capabilities": ["lend.markets", "lend.rates", "yield.opportunities", "yield.history"] }, { "name": "jupiter", @@ -65,7 +65,7 @@ Bungee quotes are keyless by default. Dedicated backend mode requires both `DEFI ## Lending and yield routing -`lend markets`, `lend rates`, and `yield opportunities` use direct protocol adapters: +`lend markets`, `lend rates`, `yield opportunities`, and `yield history` use direct protocol adapters: - `aave` - `morpho` diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index d3a12bc..03f1698 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -38,6 +38,7 @@ defi lend positions --provider aave --chain 1 --address 0xYourEOA --type all --l defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --limit 10 --results-only defi yield opportunities --chain solana --asset USDC --providers kamino --limit 10 --results-only defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --min-tvl-usd 1000000 --limit 10 --results-only +defi yield history --chain 1 --asset USDC --providers aave,morpho --metrics apy_total,tvl_usd --interval day --window 7d --limit 1 --results-only ``` `--providers` expects provider names from `providers list` (`aave,morpho,kamino`), not protocol categories. diff --git a/docs/reference/lending-and-yield-commands.mdx b/docs/reference/lending-and-yield-commands.mdx index ad1f0ac..7da129c 100644 --- a/docs/reference/lending-and-yield-commands.mdx +++ b/docs/reference/lending-and-yield-commands.mdx @@ -68,6 +68,33 @@ Flags: - `--sort string` (`score|apy_total|tvl_usd|liquidity_usd`, default `score`) - `--include-incomplete` bool (default `false`) +## `yield history` + +```bash +defi yield history \ + --chain 1 \ + --asset USDC \ + --providers aave,morpho \ + --metrics apy_total,tvl_usd \ + --interval day \ + --window 7d \ + --limit 1 \ + --results-only +``` + +Flags: + +- `--chain string` required +- `--asset string` required +- `--providers string` (`aave,morpho,kamino`) +- `--metrics string` (`apy_total,tvl_usd`, default `apy_total`) +- `--interval string` (`hour|day`, default `day`) +- `--window string` lookback duration (for example `24h`, `7d`, `30d`) +- `--from string` optional RFC3339 start time (`--window` is ignored when set) +- `--to string` optional RFC3339 end time (default `now`) +- `--opportunity-ids string` optional CSV filter from `yield opportunities` +- `--limit int` max opportunities per provider (default `20`) + ## Provider-selection note `--providers` accepts provider names, not protocol categories. diff --git a/internal/app/runner.go b/internal/app/runner.go index 0e1b061..9a4552a 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -10,6 +10,7 @@ import ( "io" "os" "sort" + "strconv" "strings" "time" @@ -1271,28 +1272,29 @@ func (s *runtimeState) newActionsCommand() *cobra.Command { func (s *runtimeState) newYieldCommand() *cobra.Command { root := &cobra.Command{Use: "yield", Short: "Yield opportunity commands"} - var chainArg, assetArg, providersArg, sortArg, maxRisk string - var limit int - var minTVL, minAPY float64 - var includeIncomplete bool - cmd := &cobra.Command{ + + var opportunitiesChainArg, opportunitiesAssetArg, opportunitiesProvidersArg, opportunitiesSortArg, opportunitiesMaxRisk string + var opportunitiesLimit int + var opportunitiesMinTVL, opportunitiesMinAPY float64 + var opportunitiesIncludeIncomplete bool + opportunitiesCmd := &cobra.Command{ Use: "opportunities", Short: "Rank yield opportunities", RunE: func(cmd *cobra.Command, args []string) error { - chain, asset, err := parseChainAsset(chainArg, assetArg) + chain, asset, err := parseChainAsset(opportunitiesChainArg, opportunitiesAssetArg) if err != nil { return err } req := providers.YieldRequest{ Chain: chain, Asset: asset, - Limit: limit, - MinTVLUSD: minTVL, - MinAPY: minAPY, - MaxRisk: maxRisk, - Providers: splitCSV(providersArg), - SortBy: sortArg, - IncludeIncomplete: includeIncomplete, + Limit: opportunitiesLimit, + MinTVLUSD: opportunitiesMinTVL, + MinAPY: opportunitiesMinAPY, + MaxRisk: opportunitiesMaxRisk, + Providers: splitCSV(opportunitiesProvidersArg), + SortBy: opportunitiesSortArg, + IncludeIncomplete: opportunitiesIncludeIncomplete, } key := cacheKey(trimRootPath(cmd.CommandPath()), map[string]any{ "chain": req.Chain.CAIP2, @@ -1334,7 +1336,7 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { combined = append(combined, items...) } - if includeIncomplete { + if opportunitiesIncludeIncomplete { warnings = append(warnings, "include_incomplete enabled: opportunities with missing APY/TVL may be present") } @@ -1350,25 +1352,192 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { if req.Limit > 0 && len(combined) > req.Limit { combined = combined[:req.Limit] } - if includeIncomplete { + if opportunitiesIncludeIncomplete { warnings = append(warnings, fmt.Sprintf("returned %d combined opportunities across %d provider(s)", len(combined), len(selectedProviders))) } return combined, statuses, warnings, partial, nil }) }, } - cmd.Flags().StringVar(&chainArg, "chain", "", "Chain identifier") - cmd.Flags().StringVar(&assetArg, "asset", "", "Asset symbol/address/CAIP-19") - cmd.Flags().IntVar(&limit, "limit", 20, "Maximum opportunities to return") - cmd.Flags().Float64Var(&minTVL, "min-tvl-usd", 0, "Minimum TVL in USD") - cmd.Flags().StringVar(&maxRisk, "max-risk", "high", "Maximum risk level (low|medium|high|unknown)") - cmd.Flags().Float64Var(&minAPY, "min-apy", 0, "Minimum total APY percent") - cmd.Flags().StringVar(&providersArg, "providers", "", "Filter by provider names (aave,morpho,kamino)") - cmd.Flags().StringVar(&sortArg, "sort", "score", "Sort key (score|apy_total|tvl_usd|liquidity_usd)") - cmd.Flags().BoolVar(&includeIncomplete, "include-incomplete", false, "Include opportunities missing APY/TVL") - _ = cmd.MarkFlagRequired("chain") - _ = cmd.MarkFlagRequired("asset") - root.AddCommand(cmd) + opportunitiesCmd.Flags().StringVar(&opportunitiesChainArg, "chain", "", "Chain identifier") + opportunitiesCmd.Flags().StringVar(&opportunitiesAssetArg, "asset", "", "Asset symbol/address/CAIP-19") + opportunitiesCmd.Flags().IntVar(&opportunitiesLimit, "limit", 20, "Maximum opportunities to return") + opportunitiesCmd.Flags().Float64Var(&opportunitiesMinTVL, "min-tvl-usd", 0, "Minimum TVL in USD") + opportunitiesCmd.Flags().StringVar(&opportunitiesMaxRisk, "max-risk", "high", "Maximum risk level (low|medium|high|unknown)") + opportunitiesCmd.Flags().Float64Var(&opportunitiesMinAPY, "min-apy", 0, "Minimum total APY percent") + opportunitiesCmd.Flags().StringVar(&opportunitiesProvidersArg, "providers", "", "Filter by provider names (aave,morpho,kamino)") + opportunitiesCmd.Flags().StringVar(&opportunitiesSortArg, "sort", "score", "Sort key (score|apy_total|tvl_usd|liquidity_usd)") + opportunitiesCmd.Flags().BoolVar(&opportunitiesIncludeIncomplete, "include-incomplete", false, "Include opportunities missing APY/TVL") + _ = opportunitiesCmd.MarkFlagRequired("chain") + _ = opportunitiesCmd.MarkFlagRequired("asset") + root.AddCommand(opportunitiesCmd) + + var historyChainArg, historyAssetArg, historyProvidersArg, historyMetricsArg string + var historyIntervalArg, historyWindowArg, historyFromArg, historyToArg, historyOpportunityIDsArg string + var historyLimit int + historyCmd := &cobra.Command{ + Use: "history", + Short: "Get yield history for provider opportunities", + RunE: func(cmd *cobra.Command, args []string) error { + chain, asset, err := parseChainAsset(historyChainArg, historyAssetArg) + if err != nil { + return err + } + metrics, err := parseYieldHistoryMetrics(historyMetricsArg) + if err != nil { + return err + } + interval, err := parseYieldHistoryInterval(historyIntervalArg) + if err != nil { + return err + } + startTime, endTime, err := resolveYieldHistoryRange(historyFromArg, historyToArg, historyWindowArg, s.runner.now().UTC()) + if err != nil { + return err + } + opportunityIDs := splitCSV(historyOpportunityIDsArg) + opportunityIDSet := make(map[string]struct{}, len(opportunityIDs)) + for _, item := range opportunityIDs { + opportunityIDSet[item] = struct{}{} + } + providerFilter := splitCSV(historyProvidersArg) + + key := cacheKey(trimRootPath(cmd.CommandPath()), map[string]any{ + "chain": chain.CAIP2, + "asset": asset.AssetID, + "providers": providerFilter, + "metrics": metrics, + "interval": interval, + "start_time": startTime.UTC().Format(time.RFC3339), + "end_time": endTime.UTC().Format(time.RFC3339), + "opportunity_ids": opportunityIDs, + "opportunity_limit": historyLimit, + }) + return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 5*time.Minute, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { + selectedProviders, err := s.selectYieldProviders(providerFilter, chain) + if err != nil { + return nil, nil, nil, false, err + } + + statuses := make([]model.ProviderStatus, 0, len(selectedProviders)) + warnings := []string{} + combined := make([]model.YieldHistorySeries, 0) + partial := false + var firstErr error + + for _, providerName := range selectedProviders { + provider := s.yieldProviders[providerName] + historyProvider, ok := provider.(providers.YieldHistoryProvider) + providerStart := time.Now() + if !ok { + providerErr := clierr.New(clierr.CodeUnsupported, fmt.Sprintf("yield provider %s does not support history", providerName)) + statuses = append(statuses, model.ProviderStatus{Name: provider.Info().Name, Status: statusFromErr(providerErr), LatencyMS: time.Since(providerStart).Milliseconds()}) + warnings = append(warnings, fmt.Sprintf("provider %s does not support yield history", provider.Info().Name)) + partial = true + if firstErr == nil { + firstErr = providerErr + } + continue + } + + discoveryReq := providers.YieldRequest{ + Chain: chain, + Asset: asset, + Limit: historyLimit, + MinTVLUSD: 0, + MinAPY: 0, + MaxRisk: "high", + SortBy: "score", + IncludeIncomplete: true, + } + if len(opportunityIDSet) > 0 { + discoveryReq.Limit = 0 + } + opportunities, providerErr := provider.YieldOpportunities(ctx, discoveryReq) + if providerErr != nil { + statuses = append(statuses, model.ProviderStatus{Name: provider.Info().Name, Status: statusFromErr(providerErr), LatencyMS: time.Since(providerStart).Milliseconds()}) + warnings = append(warnings, fmt.Sprintf("provider %s failed during opportunity lookup: %v", provider.Info().Name, providerErr)) + partial = true + if firstErr == nil { + firstErr = providerErr + } + continue + } + if len(opportunityIDSet) > 0 { + opportunities = filterYieldOpportunitiesByID(opportunities, opportunityIDSet) + } + if historyLimit > 0 && len(opportunities) > historyLimit { + opportunities = opportunities[:historyLimit] + } + if len(opportunities) == 0 { + providerErr = clierr.New(clierr.CodeUnavailable, fmt.Sprintf("provider %s returned no matching opportunities", providerName)) + statuses = append(statuses, model.ProviderStatus{Name: provider.Info().Name, Status: statusFromErr(providerErr), LatencyMS: time.Since(providerStart).Milliseconds()}) + warnings = append(warnings, fmt.Sprintf("provider %s returned no matching opportunities", provider.Info().Name)) + partial = true + if firstErr == nil { + firstErr = providerErr + } + continue + } + + providerSeries := make([]model.YieldHistorySeries, 0, len(opportunities)*len(metrics)) + var providerHistoryErr error + for _, opportunity := range opportunities { + series, err := historyProvider.YieldHistory(ctx, providers.YieldHistoryRequest{ + Opportunity: opportunity, + StartTime: startTime, + EndTime: endTime, + Interval: interval, + Metrics: metrics, + }) + if err != nil { + partial = true + warnings = append(warnings, fmt.Sprintf("provider %s failed history for opportunity %s: %v", provider.Info().Name, opportunity.OpportunityID, err)) + if providerHistoryErr == nil { + providerHistoryErr = err + } + continue + } + providerSeries = append(providerSeries, series...) + } + + statusErr := providerHistoryErr + if len(providerSeries) == 0 && statusErr == nil { + statusErr = clierr.New(clierr.CodeUnavailable, fmt.Sprintf("provider %s returned no historical points", providerName)) + } + statuses = append(statuses, model.ProviderStatus{Name: provider.Info().Name, Status: statusFromErr(statusErr), LatencyMS: time.Since(providerStart).Milliseconds()}) + if statusErr != nil && firstErr == nil { + firstErr = statusErr + } + combined = append(combined, providerSeries...) + } + + if len(combined) == 0 { + if firstErr != nil { + return nil, statuses, warnings, partial, firstErr + } + return nil, statuses, warnings, partial, clierr.New(clierr.CodeUnavailable, "no yield history returned by selected providers") + } + + sortYieldHistorySeries(combined) + return combined, statuses, warnings, partial, nil + }) + }, + } + historyCmd.Flags().StringVar(&historyChainArg, "chain", "", "Chain identifier") + historyCmd.Flags().StringVar(&historyAssetArg, "asset", "", "Asset symbol/address/CAIP-19") + historyCmd.Flags().StringVar(&historyProvidersArg, "providers", "", "Filter by provider names (aave,morpho,kamino)") + historyCmd.Flags().StringVar(&historyMetricsArg, "metrics", "apy_total", "History metrics (apy_total,tvl_usd)") + historyCmd.Flags().StringVar(&historyIntervalArg, "interval", "day", "Point interval (hour|day)") + historyCmd.Flags().StringVar(&historyWindowArg, "window", "7d", "Lookback window (for example 24h,7d,30d)") + historyCmd.Flags().StringVar(&historyFromArg, "from", "", "Start time (RFC3339). Overrides --window when set") + historyCmd.Flags().StringVar(&historyToArg, "to", "", "End time (RFC3339). Defaults to now") + historyCmd.Flags().StringVar(&historyOpportunityIDsArg, "opportunity-ids", "", "Optional comma-separated opportunity IDs from yield opportunities") + historyCmd.Flags().IntVar(&historyLimit, "limit", 20, "Maximum opportunities per provider to fetch history for") + _ = historyCmd.MarkFlagRequired("chain") + _ = historyCmd.MarkFlagRequired("asset") + root.AddCommand(historyCmd) + return root } @@ -1673,6 +1842,160 @@ func sortYieldOpportunities(items []model.YieldOpportunity, sortBy string) { }) } +func filterYieldOpportunitiesByID(items []model.YieldOpportunity, ids map[string]struct{}) []model.YieldOpportunity { + if len(ids) == 0 { + return items + } + out := make([]model.YieldOpportunity, 0, len(items)) + for _, item := range items { + if _, ok := ids[strings.ToLower(strings.TrimSpace(item.OpportunityID))]; ok { + out = append(out, item) + } + } + return out +} + +func sortYieldHistorySeries(items []model.YieldHistorySeries) { + for i := range items { + sort.Slice(items[i].Points, func(a, b int) bool { + return strings.Compare(items[i].Points[a].Timestamp, items[i].Points[b].Timestamp) < 0 + }) + } + sort.Slice(items, func(i, j int) bool { + a, b := items[i], items[j] + if a.Provider != b.Provider { + return a.Provider < b.Provider + } + if a.OpportunityID != b.OpportunityID { + return a.OpportunityID < b.OpportunityID + } + if a.Metric != b.Metric { + return a.Metric < b.Metric + } + if a.Interval != b.Interval { + return a.Interval < b.Interval + } + return strings.Compare(a.StartTime, b.StartTime) < 0 + }) +} + +func parseYieldHistoryMetrics(input string) ([]providers.YieldHistoryMetric, error) { + parts := splitCSV(input) + if len(parts) == 0 { + parts = []string{string(providers.YieldHistoryMetricAPYTotal)} + } + out := make([]providers.YieldHistoryMetric, 0, len(parts)) + seen := map[providers.YieldHistoryMetric]struct{}{} + for _, part := range parts { + var metric providers.YieldHistoryMetric + switch strings.ToLower(strings.TrimSpace(part)) { + case string(providers.YieldHistoryMetricAPYTotal): + metric = providers.YieldHistoryMetricAPYTotal + case string(providers.YieldHistoryMetricTVLUSD): + metric = providers.YieldHistoryMetricTVLUSD + default: + return nil, clierr.New(clierr.CodeUsage, "--metrics must be one or more of: apy_total,tvl_usd") + } + if _, ok := seen[metric]; ok { + continue + } + seen[metric] = struct{}{} + out = append(out, metric) + } + return out, nil +} + +func parseYieldHistoryInterval(input string) (providers.YieldHistoryInterval, error) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "", "day", "daily", "1d": + return providers.YieldHistoryIntervalDay, nil + case "hour", "hourly", "1h": + return providers.YieldHistoryIntervalHour, nil + default: + return "", clierr.New(clierr.CodeUsage, "--interval must be one of: hour,day") + } +} + +func resolveYieldHistoryRange(fromArg, toArg, windowArg string, now time.Time) (time.Time, time.Time, error) { + endTime := now.UTC() + if strings.TrimSpace(toArg) != "" { + parsed, err := parseRFC3339(toArg) + if err != nil { + return time.Time{}, time.Time{}, clierr.Wrap(clierr.CodeUsage, "parse --to", err) + } + endTime = parsed.UTC() + } + if endTime.After(now.Add(5 * time.Minute)) { + return time.Time{}, time.Time{}, clierr.New(clierr.CodeUsage, "--to cannot be in the future") + } + + var startTime time.Time + if strings.TrimSpace(fromArg) != "" { + parsed, err := parseRFC3339(fromArg) + if err != nil { + return time.Time{}, time.Time{}, clierr.Wrap(clierr.CodeUsage, "parse --from", err) + } + startTime = parsed.UTC() + } else { + window, err := parseLookbackWindow(windowArg) + if err != nil { + return time.Time{}, time.Time{}, clierr.Wrap(clierr.CodeUsage, "parse --window", err) + } + startTime = endTime.Add(-window) + } + + if !startTime.Before(endTime) { + return time.Time{}, time.Time{}, clierr.New(clierr.CodeUsage, "history range must have --from before --to") + } + if endTime.Sub(startTime) > 366*24*time.Hour { + return time.Time{}, time.Time{}, clierr.New(clierr.CodeUsage, "history range cannot exceed 366d") + } + return startTime, endTime, nil +} + +func parseRFC3339(raw string) (time.Time, error) { + value := strings.TrimSpace(raw) + if value == "" { + return time.Time{}, fmt.Errorf("empty timestamp") + } + ts, err := time.Parse(time.RFC3339, value) + if err == nil { + return ts, nil + } + ts, err = time.Parse(time.RFC3339Nano, value) + if err == nil { + return ts, nil + } + return time.Time{}, fmt.Errorf("expected RFC3339 timestamp") +} + +func parseLookbackWindow(raw string) (time.Duration, error) { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + value = "7d" + } + switch { + case strings.HasSuffix(value, "d"): + n, err := strconv.Atoi(strings.TrimSuffix(value, "d")) + if err != nil || n <= 0 { + return 0, fmt.Errorf("invalid day window") + } + return time.Duration(n) * 24 * time.Hour, nil + case strings.HasSuffix(value, "w"): + n, err := strconv.Atoi(strings.TrimSuffix(value, "w")) + if err != nil || n <= 0 { + return 0, fmt.Errorf("invalid week window") + } + return time.Duration(n) * 7 * 24 * time.Hour, nil + default: + d, err := time.ParseDuration(value) + if err != nil || d <= 0 { + return 0, fmt.Errorf("invalid duration window") + } + return d, nil + } +} + func applyLendMarketLimit(items []model.LendMarket, limit int) []model.LendMarket { if limit <= 0 || len(items) <= limit { return items diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 9757f89..c7b5fe8 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -105,6 +105,151 @@ func TestSelectYieldProvidersExplicitFilterBypassesChainDefaults(t *testing.T) { } } +func TestParseYieldHistoryMetricsDedupesAndValidates(t *testing.T) { + metrics, err := parseYieldHistoryMetrics("apy_total,tvl_usd,apy_total") + if err != nil { + t.Fatalf("parseYieldHistoryMetrics failed: %v", err) + } + if len(metrics) != 2 { + t.Fatalf("expected 2 metrics, got %+v", metrics) + } + if metrics[0] != providers.YieldHistoryMetricAPYTotal || metrics[1] != providers.YieldHistoryMetricTVLUSD { + t.Fatalf("unexpected metric order: %+v", metrics) + } + + if _, err := parseYieldHistoryMetrics("foo"); err == nil { + t.Fatal("expected invalid metric error") + } +} + +func TestYieldHistoryCommandCallsProvider(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + fixedNow := time.Date(2026, 2, 26, 20, 0, 0, 0, time.UTC) + fakeProvider := &fakeYieldHistoryProvider{ + name: "aave", + opportunities: []model.YieldOpportunity{ + { + OpportunityID: "opp-1", + Provider: "aave", + Protocol: "aave", + ChainID: "eip155:1", + AssetID: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ProviderNativeID: "aave:eip155:1:0x1111111111111111111111111111111111111111:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + SourceURL: "https://app.aave.com", + }, + }, + series: []model.YieldHistorySeries{ + { + OpportunityID: "opp-1", + Provider: "aave", + Metric: "apy_total", + Interval: "hour", + Points: []model.YieldHistoryPoint{ + {Timestamp: "2026-02-26T19:00:00Z", Value: 3.1}, + }, + }, + }, + } + state := &runtimeState{ + runner: &Runner{ + stdout: &stdout, + stderr: &stderr, + now: func() time.Time { return fixedNow }, + }, + settings: config.Settings{ + OutputMode: "json", + ResultsOnly: true, + Timeout: 2 * time.Second, + CacheEnabled: false, + }, + yieldProviders: map[string]providers.YieldProvider{ + "aave": fakeProvider, + }, + } + + root := &cobra.Command{Use: "defi"} + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(&stdout) + root.SetErr(&stderr) + root.AddCommand(state.newYieldCommand()) + root.SetArgs([]string{ + "yield", "history", + "--chain", "1", + "--asset", "USDC", + "--providers", "aave", + "--metrics", "apy_total", + "--interval", "hour", + "--window", "24h", + "--limit", "1", + }) + if err := root.Execute(); err != nil { + t.Fatalf("yield history command failed: %v stderr=%s", err, stderr.String()) + } + + if fakeProvider.historyCalls != 1 { + t.Fatalf("expected one history call, got %d", fakeProvider.historyCalls) + } + if fakeProvider.lastHistoryReq.Interval != providers.YieldHistoryIntervalHour { + t.Fatalf("expected hour interval, got %+v", fakeProvider.lastHistoryReq.Interval) + } + if got := fakeProvider.lastHistoryReq.EndTime.UTC(); !got.Equal(fixedNow) { + t.Fatalf("expected end time %s, got %s", fixedNow, got) + } + if got := fakeProvider.lastHistoryReq.StartTime.UTC(); !got.Equal(fixedNow.Add(-24 * time.Hour)) { + t.Fatalf("expected start time %s, got %s", fixedNow.Add(-24*time.Hour), got) + } + + var out []map[string]any + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("failed parsing output json: %v output=%s", err, stdout.String()) + } + if len(out) != 1 { + t.Fatalf("expected one series row, got %+v", out) + } + if out[0]["metric"] != "apy_total" { + t.Fatalf("expected metric apy_total, got %+v", out[0]) + } +} + +func TestYieldHistoryCommandFailsWhenProviderHasNoHistorySupport(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + state := &runtimeState{ + runner: &Runner{ + stdout: &stdout, + stderr: &stderr, + now: time.Now, + }, + settings: config.Settings{ + OutputMode: "json", + Timeout: 2 * time.Second, + CacheEnabled: false, + }, + yieldProviders: map[string]providers.YieldProvider{ + "aave": &fakeYieldProviderNoHistory{name: "aave"}, + }, + } + + root := &cobra.Command{Use: "defi"} + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(&stdout) + root.SetErr(&stderr) + root.AddCommand(state.newYieldCommand()) + root.SetArgs([]string{ + "yield", "history", + "--chain", "1", + "--asset", "USDC", + "--providers", "aave", + }) + if err := root.Execute(); err == nil { + t.Fatalf("expected yield history to fail without history provider support; stderr=%s", stderr.String()) + } +} + func TestParseChainAssetFilterAllowsUnknownSymbol(t *testing.T) { chain, err := id.ParseChain("ethereum") if err != nil { @@ -1307,6 +1452,61 @@ func (f *fakeLendingProviderNoPositions) LendRates(context.Context, string, id.C return nil, nil } +type fakeYieldHistoryProvider struct { + name string + opportunities []model.YieldOpportunity + series []model.YieldHistorySeries + err error + calls int + historyCalls int + lastYieldReq providers.YieldRequest + lastHistoryReq providers.YieldHistoryRequest +} + +func (f *fakeYieldHistoryProvider) Info() model.ProviderInfo { + return model.ProviderInfo{ + Name: f.name, + Type: "yield", + RequiresKey: false, + Capabilities: []string{"yield.opportunities", "yield.history"}, + } +} + +func (f *fakeYieldHistoryProvider) YieldOpportunities(_ context.Context, req providers.YieldRequest) ([]model.YieldOpportunity, error) { + f.calls++ + f.lastYieldReq = req + if f.err != nil { + return nil, f.err + } + return f.opportunities, nil +} + +func (f *fakeYieldHistoryProvider) YieldHistory(_ context.Context, req providers.YieldHistoryRequest) ([]model.YieldHistorySeries, error) { + f.historyCalls++ + f.lastHistoryReq = req + if f.err != nil { + return nil, f.err + } + return f.series, nil +} + +type fakeYieldProviderNoHistory struct { + name string +} + +func (f *fakeYieldProviderNoHistory) Info() model.ProviderInfo { + return model.ProviderInfo{ + Name: f.name, + Type: "yield", + RequiresKey: false, + Capabilities: []string{"yield.opportunities"}, + } +} + +func (f *fakeYieldProviderNoHistory) YieldOpportunities(context.Context, providers.YieldRequest) ([]model.YieldOpportunity, error) { + return nil, nil +} + func setUnopenableCacheEnv(t *testing.T) { t.Helper() t.Setenv("DEFI_CACHE_PATH", "/dev/null/cache.db") diff --git a/internal/model/types.go b/internal/model/types.go index 0f713b7..69006e7 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -280,3 +280,25 @@ type YieldOpportunity struct { SourceURL string `json:"source_url,omitempty"` FetchedAt string `json:"fetched_at"` } + +type YieldHistoryPoint struct { + Timestamp string `json:"timestamp"` + Value float64 `json:"value"` +} + +type YieldHistorySeries struct { + OpportunityID string `json:"opportunity_id"` + Provider string `json:"provider"` + Protocol string `json:"protocol"` + ChainID string `json:"chain_id"` + AssetID string `json:"asset_id"` + ProviderNativeID string `json:"provider_native_id,omitempty"` + ProviderNativeIDKind string `json:"provider_native_id_kind,omitempty"` + Metric string `json:"metric"` + Interval string `json:"interval"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Points []YieldHistoryPoint `json:"points"` + SourceURL string `json:"source_url,omitempty"` + FetchedAt string `json:"fetched_at"` +} diff --git a/internal/providers/aave/client.go b/internal/providers/aave/client.go index 66fccd6..ef7d43a 100644 --- a/internal/providers/aave/client.go +++ b/internal/providers/aave/client.go @@ -43,6 +43,7 @@ func (c *Client) Info() model.ProviderInfo { "lend.rates", "lend.positions", "yield.opportunities", + "yield.history", "lend.plan", "lend.execute", "rewards.plan", @@ -89,6 +90,13 @@ const positionsQuery = `query Positions($suppliesRequest: UserSuppliesRequest!, } }` +const supplyAPYHistoryQuery = `query SupplyAPYHistory($request: SupplyAPYHistoryRequest!) { + supplyAPYHistory(request: $request) { + date + avgRate { value } + } +}` + type marketsResponse struct { Data struct { Markets []aaveMarket `json:"markets"` @@ -119,6 +127,20 @@ type positionsResponse struct { } `json:"errors"` } +type supplyAPYHistoryResponse struct { + Data struct { + SupplyAPYHistory []struct { + Date string `json:"date"` + AvgRate struct { + Value string `json:"value"` + } `json:"avgRate"` + } `json:"supplyAPYHistory"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + type aaveMarket struct { Name string `json:"name"` Address string `json:"address"` @@ -515,6 +537,107 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ return out[:req.Limit], nil } +func (c *Client) YieldHistory(ctx context.Context, req providers.YieldHistoryRequest) ([]model.YieldHistorySeries, error) { + if !strings.EqualFold(strings.TrimSpace(req.Opportunity.Provider), "aave") { + return nil, clierr.New(clierr.CodeUnsupported, "aave history supports only aave opportunities") + } + if !req.StartTime.Before(req.EndTime) { + return nil, clierr.New(clierr.CodeUsage, "history start time must be before end time") + } + metricSet := make(map[providers.YieldHistoryMetric]struct{}, len(req.Metrics)) + for _, metric := range req.Metrics { + metricSet[metric] = struct{}{} + } + for metric := range metricSet { + if metric != providers.YieldHistoryMetricAPYTotal { + return nil, clierr.New(clierr.CodeUnsupported, "aave history supports only metric=apy_total") + } + } + + chain, err := id.ParseChain(req.Opportunity.ChainID) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUsage, "parse aave opportunity chain", err) + } + if !chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "aave supports only EVM chains") + } + + marketAddress, underlyingAddress, err := parseOpportunityNativeID(req.Opportunity) + if err != nil { + return nil, err + } + window, err := historyWindow(req.StartTime, req.EndTime, c.now().UTC()) + if err != nil { + return nil, err + } + + body, err := json.Marshal(map[string]any{ + "query": supplyAPYHistoryQuery, + "variables": map[string]any{ + "request": map[string]any{ + "market": marketAddress, + "underlyingToken": underlyingAddress, + "window": window, + "chainId": chain.EVMChainID, + }, + }, + }) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "marshal aave history query", err) + } + + var resp supplyAPYHistoryResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("aave graphql error: %s", resp.Errors[0].Message)) + } + + points := make([]model.YieldHistoryPoint, 0, len(resp.Data.SupplyAPYHistory)) + for _, sample := range resp.Data.SupplyAPYHistory { + ts, ok := parseAPITime(sample.Date) + if !ok { + continue + } + if ts.Before(req.StartTime) || ts.After(req.EndTime) { + continue + } + points = append(points, model.YieldHistoryPoint{ + Timestamp: ts.UTC().Format(time.RFC3339), + Value: parseFloat(sample.AvgRate.Value) * 100, + }) + } + if req.Interval == providers.YieldHistoryIntervalDay { + points = averagePointsByDay(points) + } else { + sortHistoryPoints(points) + } + if len(points) == 0 { + return nil, clierr.New(clierr.CodeUnavailable, "no aave historical points for requested range") + } + + series := []model.YieldHistorySeries{ + { + OpportunityID: req.Opportunity.OpportunityID, + Provider: "aave", + Protocol: req.Opportunity.Protocol, + ChainID: req.Opportunity.ChainID, + AssetID: req.Opportunity.AssetID, + ProviderNativeID: req.Opportunity.ProviderNativeID, + ProviderNativeIDKind: req.Opportunity.ProviderNativeIDKind, + Metric: string(providers.YieldHistoryMetricAPYTotal), + Interval: string(req.Interval), + StartTime: req.StartTime.UTC().Format(time.RFC3339), + EndTime: req.EndTime.UTC().Format(time.RFC3339), + Points: points, + SourceURL: req.Opportunity.SourceURL, + FetchedAt: c.now().UTC().Format(time.RFC3339), + }, + } + return series, nil +} + func (c *Client) fetchMarkets(ctx context.Context, chain id.Chain) ([]aaveMarket, error) { if !chain.IsEVM() { return nil, clierr.New(clierr.CodeUnsupported, "aave supports only EVM chains") @@ -619,6 +742,111 @@ func providerNativeID(provider, chainID, marketAddress, underlyingAddress string return fmt.Sprintf("%s:%s:%s:%s", provider, chainID, normalizeEVMAddress(marketAddress), normalizeEVMAddress(underlyingAddress)) } +func parseOpportunityNativeID(op model.YieldOpportunity) (string, string, error) { + nativeID := strings.TrimSpace(op.ProviderNativeID) + if nativeID == "" { + return "", "", clierr.New(clierr.CodeUsage, "aave opportunity missing provider_native_id") + } + prefix := fmt.Sprintf("aave:%s:", strings.TrimSpace(op.ChainID)) + if !strings.HasPrefix(strings.ToLower(nativeID), strings.ToLower(prefix)) { + return "", "", clierr.New(clierr.CodeUsage, "invalid aave provider_native_id format") + } + suffix := nativeID[len(prefix):] + parts := strings.SplitN(suffix, ":", 2) + if len(parts) != 2 { + return "", "", clierr.New(clierr.CodeUsage, "invalid aave provider_native_id format") + } + marketAddress := normalizeEVMAddress(parts[0]) + underlyingAddress := normalizeEVMAddress(parts[1]) + if marketAddress == "" || underlyingAddress == "" { + return "", "", clierr.New(clierr.CodeUsage, "invalid aave provider_native_id addresses") + } + return marketAddress, underlyingAddress, nil +} + +func historyWindow(start, end, now time.Time) (string, error) { + if end.Before(now.Add(-2 * time.Hour)) { + return "", clierr.New(clierr.CodeUnsupported, "aave history supports lookback windows ending near now") + } + span := end.Sub(start) + switch { + case span <= 24*time.Hour: + return "LAST_DAY", nil + case span <= 7*24*time.Hour: + return "LAST_WEEK", nil + case span <= 31*24*time.Hour: + return "LAST_MONTH", nil + case span <= 183*24*time.Hour: + return "LAST_SIX_MONTHS", nil + case span <= 366*24*time.Hour: + return "LAST_YEAR", nil + default: + return "", clierr.New(clierr.CodeUnsupported, "aave history supports windows up to 1 year") + } +} + +func parseAPITime(v string) (time.Time, bool) { + raw := strings.TrimSpace(v) + if raw == "" { + return time.Time{}, false + } + ts, err := time.Parse(time.RFC3339, raw) + if err == nil { + return ts.UTC(), true + } + ts, err = time.Parse(time.RFC3339Nano, raw) + if err == nil { + return ts.UTC(), true + } + return time.Time{}, false +} + +func sortHistoryPoints(points []model.YieldHistoryPoint) { + sort.Slice(points, func(i, j int) bool { + return strings.Compare(points[i].Timestamp, points[j].Timestamp) < 0 + }) +} + +func averagePointsByDay(points []model.YieldHistoryPoint) []model.YieldHistoryPoint { + if len(points) == 0 { + return nil + } + sortHistoryPoints(points) + type bucket struct { + sum float64 + count int + } + byDay := map[string]bucket{} + for _, point := range points { + ts, err := time.Parse(time.RFC3339, point.Timestamp) + if err != nil { + continue + } + day := ts.UTC().Format("2006-01-02") + entry := byDay[day] + entry.sum += point.Value + entry.count++ + byDay[day] = entry + } + days := make([]string, 0, len(byDay)) + for day := range byDay { + days = append(days, day) + } + sort.Strings(days) + out := make([]model.YieldHistoryPoint, 0, len(days)) + for _, day := range days { + entry := byDay[day] + if entry.count == 0 { + continue + } + out = append(out, model.YieldHistoryPoint{ + Timestamp: day + "T00:00:00Z", + Value: entry.sum / float64(entry.count), + }) + } + return out +} + func matchesPositionType(filter, position providers.LendPositionType) bool { if filter == "" || filter == providers.LendPositionTypeAll { return true diff --git a/internal/providers/aave/client_test.go b/internal/providers/aave/client_test.go index 541744b..4b68d93 100644 --- a/internal/providers/aave/client_test.go +++ b/internal/providers/aave/client_test.go @@ -2,6 +2,7 @@ package aave import ( "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -215,3 +216,87 @@ func TestLendPositionsTypeSplit(t *testing.T) { t.Fatalf("expected collateral-only row, got %+v", collateralOnly) } } + +func TestYieldHistoryAPY(t *testing.T) { + fixedNow := time.Date(2026, 2, 26, 20, 0, 0, 0, time.UTC) + start := fixedNow.Add(-6 * time.Hour) + market := "0x1111111111111111111111111111111111111111" + underlying := "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "SupplyAPYHistory") { + t.Fatalf("expected SupplyAPYHistory query, got %s", string(body)) + } + if !strings.Contains(string(body), "\"window\":\"LAST_DAY\"") { + t.Fatalf("expected LAST_DAY window, got %s", string(body)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(fmt.Sprintf(`{ + "data": { + "supplyAPYHistory": [ + {"date": %q, "avgRate": {"value": "0.02"}}, + {"date": %q, "avgRate": {"value": "0.018"}} + ] + } + }`, fixedNow.Add(-5*time.Hour).Format(time.RFC3339), fixedNow.Add(-3*time.Hour).Format(time.RFC3339)))) + })) + defer srv.Close() + + client := New(httpx.New(2*time.Second, 0)) + client.endpoint = srv.URL + client.now = func() time.Time { return fixedNow } + + series, err := client.YieldHistory(context.Background(), providers.YieldHistoryRequest{ + Opportunity: model.YieldOpportunity{ + OpportunityID: "opp-1", + Provider: "aave", + Protocol: "aave", + ChainID: "eip155:1", + AssetID: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ProviderNativeID: "aave:eip155:1:" + market + ":" + underlying, + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + SourceURL: "https://app.aave.com", + }, + StartTime: start, + EndTime: fixedNow, + Interval: providers.YieldHistoryIntervalHour, + Metrics: []providers.YieldHistoryMetric{providers.YieldHistoryMetricAPYTotal}, + }) + if err != nil { + t.Fatalf("YieldHistory failed: %v", err) + } + if len(series) != 1 { + t.Fatalf("expected one series, got %+v", series) + } + if series[0].Metric != string(providers.YieldHistoryMetricAPYTotal) { + t.Fatalf("unexpected metric: %+v", series[0]) + } + if len(series[0].Points) != 2 { + t.Fatalf("expected two points, got %+v", series[0].Points) + } + if series[0].Points[0].Value != 2 { + t.Fatalf("expected first point value 2, got %+v", series[0].Points[0]) + } +} + +func TestYieldHistoryRejectsUnsupportedMetric(t *testing.T) { + client := New(httpx.New(2*time.Second, 0)) + client.now = func() time.Time { return time.Date(2026, 2, 26, 20, 0, 0, 0, time.UTC) } + + _, err := client.YieldHistory(context.Background(), providers.YieldHistoryRequest{ + Opportunity: model.YieldOpportunity{ + Provider: "aave", + Protocol: "aave", + ChainID: "eip155:1", + ProviderNativeID: "aave:eip155:1:0x1111111111111111111111111111111111111111:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }, + StartTime: client.now().UTC().Add(-time.Hour), + EndTime: client.now().UTC(), + Interval: providers.YieldHistoryIntervalHour, + Metrics: []providers.YieldHistoryMetric{providers.YieldHistoryMetricTVLUSD}, + }) + if err == nil { + t.Fatal("expected unsupported metric error") + } +} diff --git a/internal/providers/kamino/client.go b/internal/providers/kamino/client.go index 8478334..1224baa 100644 --- a/internal/providers/kamino/client.go +++ b/internal/providers/kamino/client.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "net/http" + "net/url" "sort" "strconv" "strings" @@ -46,6 +47,7 @@ func (c *Client) Info() model.ProviderInfo { "lend.markets", "lend.rates", "yield.opportunities", + "yield.history", }, } } @@ -72,6 +74,16 @@ type reserveWithMarket struct { Reserve reserveMetric } +type reserveMetricsHistoryResponse struct { + Reserve string `json:"reserve"` + History []reserveMetricsHistoryItem `json:"history"` +} + +type reserveMetricsHistoryItem struct { + Timestamp string `json:"timestamp"` + Metrics map[string]any `json:"metrics"` +} + func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { if !strings.EqualFold(strings.TrimSpace(provider), "kamino") { return nil, clierr.New(clierr.CodeUnsupported, "kamino adapter supports only provider=kamino") @@ -258,6 +270,138 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ return out[:req.Limit], nil } +func (c *Client) YieldHistory(ctx context.Context, req providers.YieldHistoryRequest) ([]model.YieldHistorySeries, error) { + if !strings.EqualFold(strings.TrimSpace(req.Opportunity.Provider), "kamino") { + return nil, clierr.New(clierr.CodeUnsupported, "kamino history supports only kamino opportunities") + } + if !req.StartTime.Before(req.EndTime) { + return nil, clierr.New(clierr.CodeUsage, "history start time must be before end time") + } + + chain, err := id.ParseChain(req.Opportunity.ChainID) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUsage, "parse kamino opportunity chain", err) + } + if !chain.IsSolana() || chain.CAIP2 != solanaMainnetCAIP2 { + return nil, clierr.New(clierr.CodeUnsupported, "kamino history supports only Solana mainnet") + } + + reserve := strings.TrimSpace(req.Opportunity.ProviderNativeID) + if reserve == "" { + return nil, clierr.New(clierr.CodeUsage, "kamino opportunity requires provider_native_id reserve") + } + + market := marketFromSourceURL(req.Opportunity.SourceURL) + if market == "" { + market, err = c.resolveMarketForReserve(ctx, chain, reserve) + if err != nil { + return nil, err + } + } + frequency, err := kaminoHistoryFrequency(req.Interval) + if err != nil { + return nil, err + } + + history, err := c.fetchReserveMetricsHistory(ctx, market, reserve, req.StartTime, req.EndTime, frequency) + if err != nil { + return nil, err + } + if len(history.History) == 0 { + return nil, clierr.New(clierr.CodeUnavailable, "no kamino historical points for requested range") + } + + metricSet := make(map[providers.YieldHistoryMetric]struct{}, len(req.Metrics)) + for _, metric := range req.Metrics { + metricSet[metric] = struct{}{} + } + for metric := range metricSet { + switch metric { + case providers.YieldHistoryMetricAPYTotal, providers.YieldHistoryMetricTVLUSD: + default: + return nil, clierr.New(clierr.CodeUnsupported, "kamino history supports metrics apy_total,tvl_usd") + } + } + + series := make([]model.YieldHistorySeries, 0, len(metricSet)) + if _, ok := metricSet[providers.YieldHistoryMetricAPYTotal]; ok { + points := make([]model.YieldHistoryPoint, 0, len(history.History)) + for _, sample := range history.History { + ts, err := time.Parse(time.RFC3339, strings.TrimSpace(sample.Timestamp)) + if err != nil { + continue + } + value, ok := parseHistoryMetric(sample.Metrics, "supplyInterestAPY") + if !ok { + continue + } + points = append(points, model.YieldHistoryPoint{ + Timestamp: ts.UTC().Format(time.RFC3339), + Value: value * 100, + }) + } + sortHistoryPoints(points) + if len(points) > 0 { + series = append(series, model.YieldHistorySeries{ + OpportunityID: req.Opportunity.OpportunityID, + Provider: "kamino", + Protocol: req.Opportunity.Protocol, + ChainID: req.Opportunity.ChainID, + AssetID: req.Opportunity.AssetID, + ProviderNativeID: req.Opportunity.ProviderNativeID, + ProviderNativeIDKind: req.Opportunity.ProviderNativeIDKind, + Metric: string(providers.YieldHistoryMetricAPYTotal), + Interval: string(req.Interval), + StartTime: req.StartTime.UTC().Format(time.RFC3339), + EndTime: req.EndTime.UTC().Format(time.RFC3339), + Points: points, + SourceURL: req.Opportunity.SourceURL, + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + if _, ok := metricSet[providers.YieldHistoryMetricTVLUSD]; ok { + points := make([]model.YieldHistoryPoint, 0, len(history.History)) + for _, sample := range history.History { + ts, err := time.Parse(time.RFC3339, strings.TrimSpace(sample.Timestamp)) + if err != nil { + continue + } + value, ok := parseHistoryMetric(sample.Metrics, "depositTvl") + if !ok { + continue + } + points = append(points, model.YieldHistoryPoint{ + Timestamp: ts.UTC().Format(time.RFC3339), + Value: value, + }) + } + sortHistoryPoints(points) + if len(points) > 0 { + series = append(series, model.YieldHistorySeries{ + OpportunityID: req.Opportunity.OpportunityID, + Provider: "kamino", + Protocol: req.Opportunity.Protocol, + ChainID: req.Opportunity.ChainID, + AssetID: req.Opportunity.AssetID, + ProviderNativeID: req.Opportunity.ProviderNativeID, + ProviderNativeIDKind: req.Opportunity.ProviderNativeIDKind, + Metric: string(providers.YieldHistoryMetricTVLUSD), + Interval: string(req.Interval), + StartTime: req.StartTime.UTC().Format(time.RFC3339), + EndTime: req.EndTime.UTC().Format(time.RFC3339), + Points: points, + SourceURL: req.Opportunity.SourceURL, + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + if len(series) == 0 { + return nil, clierr.New(clierr.CodeUnavailable, "no kamino historical points for requested range") + } + return series, nil +} + func (c *Client) fetchReserves(ctx context.Context, chain id.Chain) ([]reserveWithMarket, error) { if !chain.IsSolana() { return nil, clierr.New(clierr.CodeUnsupported, "kamino supports only Solana chains") @@ -371,6 +515,110 @@ func (c *Client) fetchMarketReserves(ctx context.Context, marketPubkey string) ( return reserves, nil } +func (c *Client) fetchReserveMetricsHistory( + ctx context.Context, + marketPubkey string, + reserve string, + start time.Time, + end time.Time, + frequency string, +) (reserveMetricsHistoryResponse, error) { + endpoint := fmt.Sprintf( + "%s/kamino-market/%s/reserves/%s/metrics/history?env=mainnet-beta&start=%s&end=%s&frequency=%s", + strings.TrimRight(c.baseURL, "/"), + strings.TrimSpace(marketPubkey), + strings.TrimSpace(reserve), + url.QueryEscape(start.UTC().Format(time.RFC3339)), + url.QueryEscape(end.UTC().Format(time.RFC3339)), + url.QueryEscape(strings.TrimSpace(frequency)), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return reserveMetricsHistoryResponse{}, clierr.Wrap(clierr.CodeInternal, "build kamino reserve history request", err) + } + var resp reserveMetricsHistoryResponse + if _, err := c.http.DoJSON(ctx, req, &resp); err != nil { + return reserveMetricsHistoryResponse{}, err + } + return resp, nil +} + +func (c *Client) resolveMarketForReserve(ctx context.Context, chain id.Chain, reserve string) (string, error) { + reserve = strings.TrimSpace(reserve) + if reserve == "" { + return "", clierr.New(clierr.CodeUsage, "reserve id is required") + } + reserves, err := c.fetchReserves(ctx, chain) + if err != nil { + return "", err + } + for _, item := range reserves { + if strings.EqualFold(strings.TrimSpace(item.Reserve.Reserve), reserve) { + return strings.TrimSpace(item.Market.LendingMarket), nil + } + } + return "", clierr.New(clierr.CodeUnavailable, "kamino market not found for reserve") +} + +func marketFromSourceURL(source string) string { + raw := strings.TrimSpace(source) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + parts := strings.Split(strings.Trim(strings.TrimSpace(parsed.Path), "/"), "/") + if len(parts) < 2 || !strings.EqualFold(parts[0], "lending") { + return "" + } + return strings.TrimSpace(parts[1]) +} + +func kaminoHistoryFrequency(interval providers.YieldHistoryInterval) (string, error) { + switch interval { + case providers.YieldHistoryIntervalHour: + return "hour", nil + case providers.YieldHistoryIntervalDay: + return "day", nil + default: + return "", clierr.New(clierr.CodeUsage, "kamino history interval must be hour or day") + } +} + +func parseHistoryMetric(metrics map[string]any, key string) (float64, bool) { + value, ok := metrics[strings.TrimSpace(key)] + if !ok { + return 0, false + } + switch v := value.(type) { + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0, false + } + return v, true + case int: + return float64(v), true + case int64: + return float64(v), true + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false + } + return f, true + default: + return 0, false + } +} + +func sortHistoryPoints(points []model.YieldHistoryPoint) { + sort.Slice(points, func(i, j int) bool { + return strings.Compare(points[i].Timestamp, points[j].Timestamp) < 0 + }) +} + func reserveAssetID(chainID, fallbackAssetID, mint string) string { mint = strings.TrimSpace(mint) if mint == "" { diff --git a/internal/providers/kamino/client_test.go b/internal/providers/kamino/client_test.go index 097bb8c..508e0f6 100644 --- a/internal/providers/kamino/client_test.go +++ b/internal/providers/kamino/client_test.go @@ -244,3 +244,127 @@ func TestLendMarketsFailsWhenAnyMarketReserveFetchFails(t *testing.T) { t.Fatal("expected reserve fetch failure to fail command") } } + +func TestYieldHistoryFromSourceMarket(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/kamino-market/market-primary/reserves/reserve-1/metrics/history", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("frequency"); got != "hour" { + t.Fatalf("expected frequency=hour, got %q", got) + } + _, _ = w.Write([]byte(`{ + "reserve":"reserve-1", + "history":[ + { + "timestamp":"2026-02-25T00:00:00Z", + "metrics":{"supplyInterestAPY":0.03,"depositTvl":"1000000"} + }, + { + "timestamp":"2026-02-25T01:00:00Z", + "metrics":{"supplyInterestAPY":0.031,"depositTvl":"1100000"} + } + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0)) + c.baseURL = srv.URL + c.now = func() time.Time { return time.Date(2026, 2, 26, 20, 0, 0, 0, time.UTC) } + + series, err := c.YieldHistory(context.Background(), providers.YieldHistoryRequest{ + Opportunity: model.YieldOpportunity{ + OpportunityID: "opp-1", + Provider: "kamino", + Protocol: "kamino", + ChainID: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + AssetID: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ProviderNativeID: "reserve-1", + ProviderNativeIDKind: model.NativeIDKindPoolID, + SourceURL: "https://app.kamino.finance/lending/market-primary", + }, + StartTime: time.Date(2026, 2, 25, 0, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 25, 2, 0, 0, 0, time.UTC), + Interval: providers.YieldHistoryIntervalHour, + Metrics: []providers.YieldHistoryMetric{ + providers.YieldHistoryMetricAPYTotal, + providers.YieldHistoryMetricTVLUSD, + }, + }) + if err != nil { + t.Fatalf("YieldHistory failed: %v", err) + } + if len(series) != 2 { + t.Fatalf("expected two series, got %+v", series) + } + byMetric := map[string]model.YieldHistorySeries{} + for _, item := range series { + byMetric[item.Metric] = item + } + apy := byMetric[string(providers.YieldHistoryMetricAPYTotal)] + if len(apy.Points) != 2 || apy.Points[0].Value != 3 { + t.Fatalf("unexpected apy points: %+v", apy.Points) + } + tvl := byMetric[string(providers.YieldHistoryMetricTVLUSD)] + if len(tvl.Points) != 2 || tvl.Points[1].Value != 1100000 { + t.Fatalf("unexpected tvl points: %+v", tvl.Points) + } +} + +func TestYieldHistoryResolvesMarketFromReserve(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v2/kamino-market", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[ + {"lendingMarket":"market-primary","name":"Main Market","isPrimary":true,"isCurated":false} + ]`)) + }) + mux.HandleFunc("/kamino-market/market-primary/reserves/metrics", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[ + { + "reserve":"reserve-1", + "liquidityToken":"USDC", + "liquidityTokenMint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "borrowApy":"0.03", + "supplyApy":"0.04", + "totalSupplyUsd":"1000000", + "totalBorrowUsd":"400000" + } + ]`)) + }) + mux.HandleFunc("/kamino-market/market-primary/reserves/reserve-1/metrics/history", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "reserve":"reserve-1", + "history":[ + {"timestamp":"2026-02-25T00:00:00Z","metrics":{"supplyInterestAPY":0.03,"depositTvl":"1000000"}} + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0)) + c.baseURL = srv.URL + c.now = func() time.Time { return time.Date(2026, 2, 26, 20, 0, 0, 0, time.UTC) } + + series, err := c.YieldHistory(context.Background(), providers.YieldHistoryRequest{ + Opportunity: model.YieldOpportunity{ + OpportunityID: "opp-1", + Provider: "kamino", + Protocol: "kamino", + ChainID: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + AssetID: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ProviderNativeID: "reserve-1", + ProviderNativeIDKind: model.NativeIDKindPoolID, + }, + StartTime: time.Date(2026, 2, 25, 0, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 25, 2, 0, 0, 0, time.UTC), + Interval: providers.YieldHistoryIntervalDay, + Metrics: []providers.YieldHistoryMetric{providers.YieldHistoryMetricAPYTotal}, + }) + if err != nil { + t.Fatalf("YieldHistory failed: %v", err) + } + if len(series) != 1 || len(series[0].Points) != 1 { + t.Fatalf("unexpected series: %+v", series) + } +} diff --git a/internal/providers/morpho/client.go b/internal/providers/morpho/client.go index 3cbfb72..aa10176 100644 --- a/internal/providers/morpho/client.go +++ b/internal/providers/morpho/client.go @@ -43,6 +43,7 @@ func (c *Client) Info() model.ProviderInfo { "lend.rates", "lend.positions", "yield.opportunities", + "yield.history", "lend.plan", "lend.execute", }, @@ -140,6 +141,26 @@ const vaultV2sYieldQuery = `query VaultV2s($first:Int,$skip:Int,$where:VaultV2sF } }` +const vaultHistoryQuery = `query VaultHistory($address:String!,$chainId:Int!,$start:Int!,$end:Int!,$interval:TimeseriesInterval!){ + vaultByAddress(address:$address, chainId:$chainId){ + address + historicalState{ + netApy(options:{startTimestamp:$start, endTimestamp:$end, interval:$interval}){ x y } + totalAssetsUsd(options:{startTimestamp:$start, endTimestamp:$end, interval:$interval}){ x y } + } + } +}` + +const vaultV2HistoryQuery = `query VaultV2History($address:String!,$chainId:Int!,$start:Int!,$end:Int!,$interval:TimeseriesInterval!){ + vaultV2ByAddress(address:$address, chainId:$chainId){ + address + historicalState{ + avgNetApy(options:{startTimestamp:$start, endTimestamp:$end, interval:$interval}){ x y } + totalAssetsUsd(options:{startTimestamp:$start, endTimestamp:$end, interval:$interval}){ x y } + } + } +}` + const ( yieldVaultPageSize = 200 yieldVaultMaxPages = 20 @@ -189,6 +210,41 @@ type vaultV2sResponse struct { } `json:"errors"` } +type vaultHistoryResponse struct { + Data struct { + VaultByAddress *struct { + Address string `json:"address"` + HistoricalState *struct { + NetAPY []morphoFloatDataPoint `json:"netApy"` + TVLUSD []morphoFloatDataPoint `json:"totalAssetsUsd"` + } `json:"historicalState"` + } `json:"vaultByAddress"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type vaultV2HistoryResponse struct { + Data struct { + VaultV2ByAddress *struct { + Address string `json:"address"` + HistoricalState *struct { + AvgNetAPY []morphoFloatDataPoint `json:"avgNetApy"` + TVLUSD []morphoFloatDataPoint `json:"totalAssetsUsd"` + } `json:"historicalState"` + } `json:"vaultV2ByAddress"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type morphoFloatDataPoint struct { + X float64 `json:"x"` + Y *float64 `json:"y"` +} + type morphoMarket struct { ID string `json:"id"` UniqueKey string `json:"uniqueKey"` @@ -603,6 +659,99 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ return out[:req.Limit], nil } +func (c *Client) YieldHistory(ctx context.Context, req providers.YieldHistoryRequest) ([]model.YieldHistorySeries, error) { + if !strings.EqualFold(strings.TrimSpace(req.Opportunity.Provider), "morpho") { + return nil, clierr.New(clierr.CodeUnsupported, "morpho history supports only morpho opportunities") + } + if !req.StartTime.Before(req.EndTime) { + return nil, clierr.New(clierr.CodeUsage, "history start time must be before end time") + } + + chain, err := id.ParseChain(req.Opportunity.ChainID) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUsage, "parse morpho opportunity chain", err) + } + if !chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "morpho supports only EVM chains") + } + vaultAddress := normalizeEVMAddress(req.Opportunity.ProviderNativeID) + if vaultAddress == "" { + return nil, clierr.New(clierr.CodeUsage, "morpho opportunity requires a vault address provider_native_id") + } + + interval, err := morphoTimeseriesInterval(req.Interval) + if err != nil { + return nil, err + } + start := int(req.StartTime.UTC().Unix()) + end := int(req.EndTime.UTC().Unix()) + + metricSet := make(map[providers.YieldHistoryMetric]struct{}, len(req.Metrics)) + for _, metric := range req.Metrics { + metricSet[metric] = struct{}{} + } + for metric := range metricSet { + switch metric { + case providers.YieldHistoryMetricAPYTotal, providers.YieldHistoryMetricTVLUSD: + default: + return nil, clierr.New(clierr.CodeUnsupported, "morpho history supports metrics apy_total,tvl_usd") + } + } + + apys, tvl, sourceURL, err := c.fetchVaultHistory(ctx, vaultAddress, chain.EVMChainID, start, end, interval) + if err != nil { + return nil, err + } + + series := make([]model.YieldHistorySeries, 0, len(metricSet)) + if _, ok := metricSet[providers.YieldHistoryMetricAPYTotal]; ok { + points := convertMorphoPoints(apys, true) + if len(points) > 0 { + series = append(series, model.YieldHistorySeries{ + OpportunityID: req.Opportunity.OpportunityID, + Provider: "morpho", + Protocol: req.Opportunity.Protocol, + ChainID: req.Opportunity.ChainID, + AssetID: req.Opportunity.AssetID, + ProviderNativeID: req.Opportunity.ProviderNativeID, + ProviderNativeIDKind: req.Opportunity.ProviderNativeIDKind, + Metric: string(providers.YieldHistoryMetricAPYTotal), + Interval: string(req.Interval), + StartTime: req.StartTime.UTC().Format(time.RFC3339), + EndTime: req.EndTime.UTC().Format(time.RFC3339), + Points: points, + SourceURL: sourceURL, + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + if _, ok := metricSet[providers.YieldHistoryMetricTVLUSD]; ok { + points := convertMorphoPoints(tvl, false) + if len(points) > 0 { + series = append(series, model.YieldHistorySeries{ + OpportunityID: req.Opportunity.OpportunityID, + Provider: "morpho", + Protocol: req.Opportunity.Protocol, + ChainID: req.Opportunity.ChainID, + AssetID: req.Opportunity.AssetID, + ProviderNativeID: req.Opportunity.ProviderNativeID, + ProviderNativeIDKind: req.Opportunity.ProviderNativeIDKind, + Metric: string(providers.YieldHistoryMetricTVLUSD), + Interval: string(req.Interval), + StartTime: req.StartTime.UTC().Format(time.RFC3339), + EndTime: req.EndTime.UTC().Format(time.RFC3339), + Points: points, + SourceURL: sourceURL, + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + if len(series) == 0 { + return nil, clierr.New(clierr.CodeUnavailable, "no morpho historical points for requested range") + } + return series, nil +} + func (c *Client) fetchYieldVaultCandidates(ctx context.Context, chain id.Chain, asset id.Asset) ([]vaultYieldCandidate, error) { if !chain.IsEVM() { return nil, clierr.New(clierr.CodeUnsupported, "morpho supports only EVM chains") @@ -786,6 +935,106 @@ func (c *Client) fetchVaultV2s(ctx context.Context, chain id.Chain) ([]morphoVau return out, nil } +func (c *Client) fetchVaultHistory( + ctx context.Context, + address string, + chainID int64, + start int, + end int, + interval string, +) ([]morphoFloatDataPoint, []morphoFloatDataPoint, string, error) { + body, err := json.Marshal(map[string]any{ + "query": vaultHistoryQuery, + "variables": map[string]any{ + "address": address, + "chainId": chainID, + "start": start, + "end": end, + "interval": interval, + }, + }) + if err != nil { + return nil, nil, "", clierr.Wrap(clierr.CodeInternal, "marshal morpho vault history query", err) + } + + var resp vaultHistoryResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &resp); err != nil { + return nil, nil, "", err + } + if len(resp.Errors) > 0 { + if !isMorphoNoResultsError(resp.Errors[0].Message) { + return nil, nil, "", clierr.New(clierr.CodeUnavailable, fmt.Sprintf("morpho graphql error: %s", resp.Errors[0].Message)) + } + } + if resp.Data.VaultByAddress != nil && resp.Data.VaultByAddress.HistoricalState != nil { + return resp.Data.VaultByAddress.HistoricalState.NetAPY, resp.Data.VaultByAddress.HistoricalState.TVLUSD, sourceURLForVault(address), nil + } + + body, err = json.Marshal(map[string]any{ + "query": vaultV2HistoryQuery, + "variables": map[string]any{ + "address": address, + "chainId": chainID, + "start": start, + "end": end, + "interval": interval, + }, + }) + if err != nil { + return nil, nil, "", clierr.Wrap(clierr.CodeInternal, "marshal morpho vault-v2 history query", err) + } + + var respV2 vaultV2HistoryResponse + if _, err := httpx.DoBodyJSON(ctx, c.http, http.MethodPost, c.endpoint, body, nil, &respV2); err != nil { + return nil, nil, "", err + } + if len(respV2.Errors) > 0 { + return nil, nil, "", clierr.New(clierr.CodeUnavailable, fmt.Sprintf("morpho graphql error: %s", respV2.Errors[0].Message)) + } + if respV2.Data.VaultV2ByAddress == nil || respV2.Data.VaultV2ByAddress.HistoricalState == nil { + return nil, nil, "", clierr.New(clierr.CodeUnavailable, "morpho returned no vault history for requested opportunity") + } + return respV2.Data.VaultV2ByAddress.HistoricalState.AvgNetAPY, respV2.Data.VaultV2ByAddress.HistoricalState.TVLUSD, sourceURLForVault(address), nil +} + +func isMorphoNoResultsError(message string) bool { + msg := strings.ToLower(strings.TrimSpace(message)) + return strings.Contains(msg, "no results matching given parameters") +} + +func morphoTimeseriesInterval(interval providers.YieldHistoryInterval) (string, error) { + switch interval { + case providers.YieldHistoryIntervalHour: + return "HOUR", nil + case providers.YieldHistoryIntervalDay: + return "DAY", nil + default: + return "", clierr.New(clierr.CodeUsage, "morpho history interval must be hour or day") + } +} + +func convertMorphoPoints(points []morphoFloatDataPoint, percent bool) []model.YieldHistoryPoint { + out := make([]model.YieldHistoryPoint, 0, len(points)) + for _, point := range points { + if point.Y == nil { + continue + } + ts := time.Unix(int64(point.X), 0).UTC() + val := *point.Y + if percent { + val *= 100 + } + out = append(out, model.YieldHistoryPoint{ + Timestamp: ts.Format(time.RFC3339), + Value: val, + }) + } + sort.Slice(out, func(i, j int) bool { + return strings.Compare(out[i].Timestamp, out[j].Timestamp) < 0 + }) + return out +} + func matchesVaultAsset(vaultAssetAddress, vaultAssetSymbol string, asset id.Asset) bool { if addr := normalizeEVMAddress(asset.Address); addr != "" { return strings.EqualFold(normalizeEVMAddress(vaultAssetAddress), addr) diff --git a/internal/providers/morpho/client_test.go b/internal/providers/morpho/client_test.go index 96e2281..4fd2708 100644 --- a/internal/providers/morpho/client_test.go +++ b/internal/providers/morpho/client_test.go @@ -370,3 +370,141 @@ func TestLendPositionsTypeSplit(t *testing.T) { t.Fatalf("expected supply+borrow rows for USDC filter, got %+v", usdcOnly) } } + +func TestYieldHistoryFromVault(t *testing.T) { + fixedNow := time.Date(2026, 2, 26, 20, 0, 0, 0, time.UTC) + start := fixedNow.Add(-48 * time.Hour) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + query := string(body) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(query, "query VaultHistory(") { + _, _ = w.Write([]byte(`{ + "data": { + "vaultByAddress": { + "address": "0x1111111111111111111111111111111111111111", + "historicalState": { + "netApy": [ + {"x": 1771981200, "y": 0.03}, + {"x": 1772067600, "y": 0.031} + ], + "totalAssetsUsd": [ + {"x": 1771981200, "y": 1000000}, + {"x": 1772067600, "y": 1100000} + ] + } + } + } + }`)) + return + } + t.Fatalf("unexpected query: %s", query) + })) + defer srv.Close() + + client := New(httpx.New(2*time.Second, 0)) + client.endpoint = srv.URL + client.now = func() time.Time { return fixedNow } + + series, err := client.YieldHistory(context.Background(), providers.YieldHistoryRequest{ + Opportunity: model.YieldOpportunity{ + OpportunityID: "opp-1", + Provider: "morpho", + Protocol: "morpho", + ChainID: "eip155:1", + AssetID: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ProviderNativeID: "0x1111111111111111111111111111111111111111", + ProviderNativeIDKind: model.NativeIDKindVaultAddress, + }, + StartTime: start, + EndTime: fixedNow, + Interval: providers.YieldHistoryIntervalDay, + Metrics: []providers.YieldHistoryMetric{ + providers.YieldHistoryMetricAPYTotal, + providers.YieldHistoryMetricTVLUSD, + }, + }) + if err != nil { + t.Fatalf("YieldHistory failed: %v", err) + } + if len(series) != 2 { + t.Fatalf("expected 2 series, got %+v", series) + } + byMetric := map[string]model.YieldHistorySeries{} + for _, item := range series { + byMetric[item.Metric] = item + } + apy := byMetric[string(providers.YieldHistoryMetricAPYTotal)] + if len(apy.Points) != 2 { + t.Fatalf("unexpected apy points: %+v", apy.Points) + } + if apy.Points[0].Value != 3 { + t.Fatalf("expected apy value 3, got %+v", apy.Points[0]) + } + tvl := byMetric[string(providers.YieldHistoryMetricTVLUSD)] + if len(tvl.Points) != 2 || tvl.Points[0].Value != 1000000 { + t.Fatalf("unexpected tvl points: %+v", tvl.Points) + } +} + +func TestYieldHistoryFallsBackToVaultV2(t *testing.T) { + fixedNow := time.Date(2026, 2, 26, 20, 0, 0, 0, time.UTC) + start := fixedNow.Add(-48 * time.Hour) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + query := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(query, "query VaultHistory("): + _, _ = w.Write([]byte(`{"data":{"vaultByAddress":null},"errors":[{"message":"No results matching given parameters"}]}`)) + case strings.Contains(query, "query VaultV2History("): + _, _ = w.Write([]byte(`{ + "data": { + "vaultV2ByAddress": { + "address": "0x2222222222222222222222222222222222222222", + "historicalState": { + "avgNetApy": [ + {"x": 1771981200, "y": 0.04} + ], + "totalAssetsUsd": [ + {"x": 1771981200, "y": 2000000} + ] + } + } + } + }`)) + default: + t.Fatalf("unexpected query: %s", query) + } + })) + defer srv.Close() + + client := New(httpx.New(2*time.Second, 0)) + client.endpoint = srv.URL + client.now = func() time.Time { return fixedNow } + + series, err := client.YieldHistory(context.Background(), providers.YieldHistoryRequest{ + Opportunity: model.YieldOpportunity{ + OpportunityID: "opp-2", + Provider: "morpho", + Protocol: "morpho", + ChainID: "eip155:1", + AssetID: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ProviderNativeID: "0x2222222222222222222222222222222222222222", + ProviderNativeIDKind: model.NativeIDKindVaultAddress, + }, + StartTime: start, + EndTime: fixedNow, + Interval: providers.YieldHistoryIntervalDay, + Metrics: []providers.YieldHistoryMetric{providers.YieldHistoryMetricAPYTotal}, + }) + if err != nil { + t.Fatalf("YieldHistory failed: %v", err) + } + if len(series) != 1 || len(series[0].Points) != 1 { + t.Fatalf("unexpected series: %+v", series) + } + if series[0].Points[0].Value != 4 { + t.Fatalf("expected v2 apy value 4, got %+v", series[0].Points[0]) + } +} diff --git a/internal/providers/types.go b/internal/providers/types.go index 8ad5431..59c1c3f 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -2,6 +2,7 @@ package providers import ( "context" + "time" "github.com/ggonzalez94/defi-cli/internal/execution" "github.com/ggonzalez94/defi-cli/internal/id" @@ -53,6 +54,33 @@ type YieldProvider interface { YieldOpportunities(ctx context.Context, req YieldRequest) ([]model.YieldOpportunity, error) } +type YieldHistoryMetric string + +const ( + YieldHistoryMetricAPYTotal YieldHistoryMetric = "apy_total" + YieldHistoryMetricTVLUSD YieldHistoryMetric = "tvl_usd" +) + +type YieldHistoryInterval string + +const ( + YieldHistoryIntervalHour YieldHistoryInterval = "hour" + YieldHistoryIntervalDay YieldHistoryInterval = "day" +) + +type YieldHistoryRequest struct { + Opportunity model.YieldOpportunity + StartTime time.Time + EndTime time.Time + Interval YieldHistoryInterval + Metrics []YieldHistoryMetric +} + +type YieldHistoryProvider interface { + Provider + YieldHistory(ctx context.Context, req YieldHistoryRequest) ([]model.YieldHistorySeries, error) +} + type YieldRequest struct { Chain id.Chain Asset id.Asset From c0a2e572dde80c0264f16afd21b91e6526bd9476 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Thu, 26 Feb 2026 18:46:38 -0400 Subject: [PATCH 17/18] refactor yield opportunities to objective metrics and backing assets --- CHANGELOG.md | 5 + README.md | 2 + docs/guides/yield.mdx | 13 +- docs/reference/lending-and-yield-commands.mdx | 11 +- internal/app/runner.go | 78 ++++---- internal/model/types.go | 44 +++-- internal/providers/aave/client.go | 39 ++-- internal/providers/aave/client_test.go | 32 +-- internal/providers/defillama/client_test.go | 13 +- internal/providers/kamino/client.go | 32 +-- internal/providers/kamino/client_test.go | 9 +- internal/providers/morpho/client.go | 185 ++++++++++++------ internal/providers/morpho/client_test.go | 40 ++-- internal/providers/types.go | 1 - internal/providers/yieldutil/yieldutil.go | 53 +---- .../providers/yieldutil/yieldutil_test.go | 26 +-- 16 files changed, 300 insertions(+), 283 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d88fac..35b02de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ Format: ### Changed - BREAKING: Morpho `yield opportunities` now returns vault-level opportunities (`provider_native_id_kind=vault_address`) sourced from Morpho vault/vault-v2 data instead of Morpho market IDs. +- BREAKING: `yield opportunities` removed subjective fields (`risk_level`, `risk_reasons`, `score`) and removed the `--max-risk` flag. +- BREAKING: `yield opportunities --sort` now supports only objective keys (`apy_total|tvl_usd|liquidity_usd`) and defaults to `apy_total`. +- `yield opportunities` now returns `backing_assets` with full per-opportunity backing composition. +- Yield liquidity/TVL sourcing is now provider-native and consistent: Aave (`size.usd`, `borrowInfo.availableLiquidity.usd`), Morpho vaults (`totalAssetsUsd`, vault liquidity fields), and Kamino (`totalSupplyUsd`, `max(totalSupplyUsd-totalBorrowUsd,0)`). - BREAKING: Lend and rewards commands now use `--provider` as the selector flag; `--protocol` has been removed. - `providers list` now includes TaikoSwap execution capabilities (`swap.plan`, `swap.execute`) alongside quote metadata. - `providers list` now includes LiFi bridge execution capabilities (`bridge.plan`, `bridge.execute`). @@ -70,6 +74,7 @@ Format: - Clarified execution builder architecture split (provider-backed route builders for swap/bridge vs internal planners for lend/rewards/approvals) in `AGENTS.md` and execution design docs. - Added `lend positions` usage and caveats to `README.md`, `AGENTS.md`, and Mintlify lending command reference. - Documented `yield history` usage, flags, and provider caveats across README and Mintlify yield/lending references. +- Updated yield docs/reference examples to remove risk-based flags and document `backing_assets` plus objective `tvl_usd`/`liquidity_usd` semantics. ### Security - None yet. diff --git a/README.md b/README.md index a9493ba..26731ac 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,8 @@ providers: ## Caveats - Morpho can surface extreme APY values on very small markets. Prefer `--min-tvl-usd` when ranking yield. +- `yield opportunities` returns objective metrics and composition data: `apy_total`, `tvl_usd`, `liquidity_usd`, and full `backing_assets` (subjective `risk_*`/`score` fields were removed). +- `liquidity_usd` is provider-sourced available liquidity and is intentionally distinct from `tvl_usd` (total supplied/managed value). - `yield history --metrics` supports `apy_total` and `tvl_usd`; Aave currently supports `apy_total` only. - Aave historical windows are lookback-based and effectively end near current time; use `--window` for Aave-friendly history requests. - `chains assets` requires `DEFI_DEFILLAMA_API_KEY` because DefiLlama chain asset TVL is key-gated. diff --git a/docs/guides/yield.mdx b/docs/guides/yield.mdx index a2b75b9..abc1150 100644 --- a/docs/guides/yield.mdx +++ b/docs/guides/yield.mdx @@ -18,7 +18,7 @@ defi yield opportunities --chain solana --asset USDC --providers kamino --limit `--providers` expects provider names from `defi providers list`. -## Control risk and quality +## Filter with objective metrics ```bash defi yield opportunities \ @@ -27,12 +27,18 @@ defi yield opportunities \ --providers aave,morpho \ --min-tvl-usd 1000000 \ --min-apy 1 \ - --max-risk medium \ - --sort score \ + --sort liquidity_usd \ --limit 20 \ --results-only ``` +## Key opportunity fields + +- `apy_total`: total APY percentage points (`2.3` means `2.3%`). +- `tvl_usd`: total supplied/managed USD reported by the provider. +- `liquidity_usd`: currently available withdrawable/borrowable USD from the provider. +- `backing_assets`: full backing composition list with per-asset `share_pct`. + ## Incomplete opportunities ```bash @@ -60,5 +66,6 @@ defi yield history --chain 1 --asset USDC --providers aave --metrics apy_total - - APY values are percentage points (`2.3` = `2.3%`). - Morpho may produce extreme APY for very small markets; use `--min-tvl-usd`. - Kamino yield routes currently support Solana mainnet only. +- `yield opportunities` no longer includes subjective risk/score fields. - `yield history --metrics` supports `apy_total` and `tvl_usd`; Aave currently supports `apy_total` only. - Aave history is lookback-window based and effectively ends near current time. diff --git a/docs/reference/lending-and-yield-commands.mdx b/docs/reference/lending-and-yield-commands.mdx index 7da129c..08a4000 100644 --- a/docs/reference/lending-and-yield-commands.mdx +++ b/docs/reference/lending-and-yield-commands.mdx @@ -50,8 +50,7 @@ defi yield opportunities \ --asset USDC \ --providers aave,morpho \ --min-tvl-usd 1000000 \ - --max-risk medium \ - --sort score \ + --sort liquidity_usd \ --limit 20 \ --results-only ``` @@ -63,11 +62,15 @@ Flags: - `--limit int` (default `20`) - `--min-tvl-usd float` (default `0`) - `--min-apy float` (default `0`) -- `--max-risk string` (`low|medium|high|unknown`, default `high`) - `--providers string` (`aave,morpho,kamino`) -- `--sort string` (`score|apy_total|tvl_usd|liquidity_usd`, default `score`) +- `--sort string` (`apy_total|tvl_usd|liquidity_usd`, default `apy_total`) - `--include-incomplete` bool (default `false`) +Output notes: + +- `backing_assets` includes the full reported backing composition for each opportunity. +- `tvl_usd` and `liquidity_usd` are provider-sourced objective metrics (not inferred risk labels). + ## `yield history` ```bash diff --git a/internal/app/runner.go b/internal/app/runner.go index 9a4552a..7a342fa 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -1273,7 +1273,7 @@ func (s *runtimeState) newActionsCommand() *cobra.Command { func (s *runtimeState) newYieldCommand() *cobra.Command { root := &cobra.Command{Use: "yield", Short: "Yield opportunity commands"} - var opportunitiesChainArg, opportunitiesAssetArg, opportunitiesProvidersArg, opportunitiesSortArg, opportunitiesMaxRisk string + var opportunitiesChainArg, opportunitiesAssetArg, opportunitiesProvidersArg, opportunitiesSortArg string var opportunitiesLimit int var opportunitiesMinTVL, opportunitiesMinAPY float64 var opportunitiesIncludeIncomplete bool @@ -1291,7 +1291,6 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { Limit: opportunitiesLimit, MinTVLUSD: opportunitiesMinTVL, MinAPY: opportunitiesMinAPY, - MaxRisk: opportunitiesMaxRisk, Providers: splitCSV(opportunitiesProvidersArg), SortBy: opportunitiesSortArg, IncludeIncomplete: opportunitiesIncludeIncomplete, @@ -1302,7 +1301,6 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { "limit": req.Limit, "min_tvl_usd": req.MinTVLUSD, "min_apy": req.MinAPY, - "max_risk": req.MaxRisk, "providers": req.Providers, "sort": req.SortBy, "include_incomplete": req.IncludeIncomplete, @@ -1363,10 +1361,9 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { opportunitiesCmd.Flags().StringVar(&opportunitiesAssetArg, "asset", "", "Asset symbol/address/CAIP-19") opportunitiesCmd.Flags().IntVar(&opportunitiesLimit, "limit", 20, "Maximum opportunities to return") opportunitiesCmd.Flags().Float64Var(&opportunitiesMinTVL, "min-tvl-usd", 0, "Minimum TVL in USD") - opportunitiesCmd.Flags().StringVar(&opportunitiesMaxRisk, "max-risk", "high", "Maximum risk level (low|medium|high|unknown)") opportunitiesCmd.Flags().Float64Var(&opportunitiesMinAPY, "min-apy", 0, "Minimum total APY percent") opportunitiesCmd.Flags().StringVar(&opportunitiesProvidersArg, "providers", "", "Filter by provider names (aave,morpho,kamino)") - opportunitiesCmd.Flags().StringVar(&opportunitiesSortArg, "sort", "score", "Sort key (score|apy_total|tvl_usd|liquidity_usd)") + opportunitiesCmd.Flags().StringVar(&opportunitiesSortArg, "sort", "apy_total", "Sort key (apy_total|tvl_usd|liquidity_usd)") opportunitiesCmd.Flags().BoolVar(&opportunitiesIncludeIncomplete, "include-incomplete", false, "Include opportunities missing APY/TVL") _ = opportunitiesCmd.MarkFlagRequired("chain") _ = opportunitiesCmd.MarkFlagRequired("asset") @@ -1440,16 +1437,15 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { continue } - discoveryReq := providers.YieldRequest{ - Chain: chain, - Asset: asset, - Limit: historyLimit, - MinTVLUSD: 0, - MinAPY: 0, - MaxRisk: "high", - SortBy: "score", - IncludeIncomplete: true, - } + discoveryReq := providers.YieldRequest{ + Chain: chain, + Asset: asset, + Limit: historyLimit, + MinTVLUSD: 0, + MinAPY: 0, + SortBy: "apy_total", + IncludeIncomplete: true, + } if len(opportunityIDSet) > 0 { discoveryReq.Limit = 0 } @@ -1796,7 +1792,7 @@ func dedupeYieldByOpportunityID(items []model.YieldOpportunity) []model.YieldOpp byID := make(map[string]model.YieldOpportunity, len(items)) for _, item := range items { existing, ok := byID[item.OpportunityID] - if !ok || item.Score > existing.Score { + if !ok || compareYieldOpportunities(item, existing, "apy_total") { byID[item.OpportunityID] = item } } @@ -1810,36 +1806,38 @@ func dedupeYieldByOpportunityID(items []model.YieldOpportunity) []model.YieldOpp func sortYieldOpportunities(items []model.YieldOpportunity, sortBy string) { sortBy = strings.ToLower(strings.TrimSpace(sortBy)) if sortBy == "" { - sortBy = "score" + sortBy = "apy_total" } sort.Slice(items, func(i, j int) bool { - a, b := items[i], items[j] - switch sortBy { - case "apy_total": - if a.APYTotal != b.APYTotal { - return a.APYTotal > b.APYTotal - } - case "tvl_usd": - if a.TVLUSD != b.TVLUSD { - return a.TVLUSD > b.TVLUSD - } - case "liquidity_usd": - if a.LiquidityUSD != b.LiquidityUSD { - return a.LiquidityUSD > b.LiquidityUSD - } - default: - if a.Score != b.Score { - return a.Score > b.Score - } + return compareYieldOpportunities(items[i], items[j], sortBy) + }) +} + +func compareYieldOpportunities(a, b model.YieldOpportunity, sortBy string) bool { + switch sortBy { + case "tvl_usd": + if a.TVLUSD != b.TVLUSD { + return a.TVLUSD > b.TVLUSD + } + case "liquidity_usd": + if a.LiquidityUSD != b.LiquidityUSD { + return a.LiquidityUSD > b.LiquidityUSD } + default: if a.APYTotal != b.APYTotal { return a.APYTotal > b.APYTotal } - if a.TVLUSD != b.TVLUSD { - return a.TVLUSD > b.TVLUSD - } - return strings.Compare(a.OpportunityID, b.OpportunityID) < 0 - }) + } + if a.APYTotal != b.APYTotal { + return a.APYTotal > b.APYTotal + } + if a.TVLUSD != b.TVLUSD { + return a.TVLUSD > b.TVLUSD + } + if a.LiquidityUSD != b.LiquidityUSD { + return a.LiquidityUSD > b.LiquidityUSD + } + return strings.Compare(a.OpportunityID, b.OpportunityID) < 0 } func filterYieldOpportunitiesByID(items []model.YieldOpportunity, ids map[string]struct{}) []model.YieldOpportunity { diff --git a/internal/model/types.go b/internal/model/types.go index 69006e7..11716ac 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -258,27 +258,31 @@ type SwapQuote struct { FetchedAt string `json:"fetched_at"` } +type YieldBackingAsset struct { + AssetID string `json:"asset_id"` + Symbol string `json:"symbol"` + SharePct float64 `json:"share_pct"` +} + type YieldOpportunity struct { - OpportunityID string `json:"opportunity_id"` - Provider string `json:"provider"` - Protocol string `json:"protocol"` - ChainID string `json:"chain_id"` - AssetID string `json:"asset_id"` - ProviderNativeID string `json:"provider_native_id,omitempty"` - ProviderNativeIDKind string `json:"provider_native_id_kind,omitempty"` - Type string `json:"type"` - APYBase float64 `json:"apy_base"` - APYReward float64 `json:"apy_reward"` - APYTotal float64 `json:"apy_total"` - TVLUSD float64 `json:"tvl_usd"` - LiquidityUSD float64 `json:"liquidity_usd"` - LockupDays float64 `json:"lockup_days"` - WithdrawalTerms string `json:"withdrawal_terms"` - RiskLevel string `json:"risk_level"` - RiskReasons []string `json:"risk_reasons,omitempty"` - Score float64 `json:"score"` - SourceURL string `json:"source_url,omitempty"` - FetchedAt string `json:"fetched_at"` + OpportunityID string `json:"opportunity_id"` + Provider string `json:"provider"` + Protocol string `json:"protocol"` + ChainID string `json:"chain_id"` + AssetID string `json:"asset_id"` + ProviderNativeID string `json:"provider_native_id,omitempty"` + ProviderNativeIDKind string `json:"provider_native_id_kind,omitempty"` + Type string `json:"type"` + APYBase float64 `json:"apy_base"` + APYReward float64 `json:"apy_reward"` + APYTotal float64 `json:"apy_total"` + TVLUSD float64 `json:"tvl_usd"` + LiquidityUSD float64 `json:"liquidity_usd"` + LockupDays float64 `json:"lockup_days"` + WithdrawalTerms string `json:"withdrawal_terms"` + BackingAssets []YieldBackingAsset `json:"backing_assets"` + SourceURL string `json:"source_url,omitempty"` + FetchedAt string `json:"fetched_at"` } type YieldHistoryPoint struct { diff --git a/internal/providers/aave/client.go b/internal/providers/aave/client.go index ef7d43a..77c90d3 100644 --- a/internal/providers/aave/client.go +++ b/internal/providers/aave/client.go @@ -62,7 +62,7 @@ const marketsQuery = `query Markets($request: MarketsRequest!) { aToken { address } size { usd } supplyInfo { apy { value } total { value } } - borrowInfo { apy { value } total { usd } utilizationRate { value } } + borrowInfo { apy { value } total { usd } utilizationRate { value } availableLiquidity { usd } } } } }` @@ -181,6 +181,9 @@ type aaveReserve struct { UtilizationRate struct { Value string `json:"value"` } `json:"utilizationRate"` + AvailableLiquidity struct { + USD string `json:"usd"` + } `json:"availableLiquidity"` } `json:"borrowInfo"` } @@ -469,11 +472,6 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ return nil, err } - maxRisk := yieldutil.RiskOrder(req.MaxRisk) - if maxRisk == 0 { - maxRisk = yieldutil.RiskOrder("high") - } - out := make([]model.YieldOpportunity, 0) for _, m := range markets { for _, r := range m.Reserves { @@ -492,12 +490,11 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ continue } - riskLevel, reasons := riskFromSymbol(r.UnderlyingToken.Symbol) - if yieldutil.RiskOrder(riskLevel) > maxRisk { - continue - } - assetID := canonicalAssetID(req.Asset, r.UnderlyingToken.Address) + liquidityUSD := tvl + if r.BorrowInfo != nil { + liquidityUSD = parseFloat(r.BorrowInfo.AvailableLiquidity.USD) + } normalizedMarket := normalizeEVMAddress(m.Address) normalizedUnderlying := normalizeEVMAddress(r.UnderlyingToken.Address) nativeID := providerNativeID("aave", req.Chain.CAIP2, normalizedMarket, normalizedUnderlying) @@ -515,12 +512,14 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ APYReward: 0, APYTotal: apy, TVLUSD: tvl, - LiquidityUSD: tvl, + LiquidityUSD: liquidityUSD, LockupDays: 0, WithdrawalTerms: "variable", - RiskLevel: riskLevel, - RiskReasons: reasons, - Score: yieldutil.ScoreOpportunity(apy, tvl, tvl, riskLevel), + BackingAssets: []model.YieldBackingAsset{{ + AssetID: assetID, + Symbol: strings.TrimSpace(r.UnderlyingToken.Symbol), + SharePct: 100, + }}, SourceURL: "https://app.aave.com", FetchedAt: c.now().UTC().Format(time.RFC3339), }) @@ -915,16 +914,6 @@ func parseFloat(v string) float64 { return f } -func riskFromSymbol(symbol string) (string, []string) { - s := strings.ToUpper(strings.TrimSpace(symbol)) - switch s { - case "USDC", "USDT", "DAI", "GHO": - return "low", []string{"stablecoin asset"} - default: - return "medium", []string{"variable asset exposure"} - } -} - func hashOpportunity(provider, chainID, marketID, assetID string) string { seed := strings.Join([]string{provider, chainID, marketID, assetID}, "|") h := sha1.Sum([]byte(seed)) diff --git a/internal/providers/aave/client_test.go b/internal/providers/aave/client_test.go index 4b68d93..21b4264 100644 --- a/internal/providers/aave/client_test.go +++ b/internal/providers/aave/client_test.go @@ -27,18 +27,18 @@ func TestLendMarketsAndYield(t *testing.T) { "address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", "chain": {"chainId": 1, "name": "Ethereum"}, "reserves": [ - { - "underlyingToken": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC", "decimals": 6}, - "aToken": {"address": "0x71Aef7b30728b9BB371578f36c5A1f1502a5723e"}, - "size": {"usd": "1000000"}, - "supplyInfo": {"apy": {"value": "0.03"}, "total": {"value": "1000000"}}, - "borrowInfo": {"apy": {"value": "0.05"}, "total": {"usd": "500000"}, "utilizationRate": {"value": "0.4"}} - } - ] - } - ] - } - }`)) + { + "underlyingToken": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC", "decimals": 6}, + "aToken": {"address": "0x71Aef7b30728b9BB371578f36c5A1f1502a5723e"}, + "size": {"usd": "1000000"}, + "supplyInfo": {"apy": {"value": "0.03"}, "total": {"value": "1000000"}}, + "borrowInfo": {"apy": {"value": "0.05"}, "total": {"usd": "500000"}, "utilizationRate": {"value": "0.4"}, "availableLiquidity": {"usd": "600000"}} + } + ] + } + ] + } + }`)) })) defer srv.Close() @@ -64,7 +64,7 @@ func TestLendMarketsAndYield(t *testing.T) { t.Fatalf("expected provider/native id kind metadata, got %+v", markets[0]) } - opps, err := client.YieldOpportunities(context.Background(), providers.YieldRequest{Chain: chain, Asset: asset, Limit: 10, MaxRisk: "high"}) + opps, err := client.YieldOpportunities(context.Background(), providers.YieldRequest{Chain: chain, Asset: asset, Limit: 10}) if err != nil { t.Fatalf("YieldOpportunities failed: %v", err) } @@ -74,6 +74,12 @@ func TestLendMarketsAndYield(t *testing.T) { if opps[0].ProviderNativeID == "" || opps[0].ProviderNativeIDKind != model.NativeIDKindCompositeMarketAsset { t.Fatalf("expected yield provider native id metadata, got %+v", opps[0]) } + if opps[0].LiquidityUSD != 600000 { + t.Fatalf("expected liquidity 600000 from borrowInfo.availableLiquidity, got %+v", opps[0]) + } + if len(opps[0].BackingAssets) != 1 || opps[0].BackingAssets[0].SharePct != 100 { + t.Fatalf("expected single backing asset at 100%%, got %+v", opps[0].BackingAssets) + } } func TestLendMarketsPrefersAddressMatchOverSymbol(t *testing.T) { diff --git a/internal/providers/defillama/client_test.go b/internal/providers/defillama/client_test.go index cd26b55..5555d8e 100644 --- a/internal/providers/defillama/client_test.go +++ b/internal/providers/defillama/client_test.go @@ -129,20 +129,15 @@ func TestChainsAssetsFiltersByAsset(t *testing.T) { } } -func TestYieldScoreAndSortDeterministic(t *testing.T) { +func TestYieldSortDeterministic(t *testing.T) { opps := []model.YieldOpportunity{ - {OpportunityID: "b", Score: 50, APYTotal: 10, TVLUSD: 100}, - {OpportunityID: "a", Score: 50, APYTotal: 10, TVLUSD: 100}, + {OpportunityID: "b", APYTotal: 10, TVLUSD: 100, LiquidityUSD: 50}, + {OpportunityID: "a", APYTotal: 10, TVLUSD: 100, LiquidityUSD: 50}, } - yieldutil.Sort(opps, "score") + yieldutil.Sort(opps, "apy_total") if opps[0].OpportunityID != "a" { t.Fatalf("expected lexicographic tie-break, got %+v", opps) } - - score := yieldutil.ScoreOpportunity(20, 1_000_000, 700_000, "low") - if score <= 0 || score > 100 { - t.Fatalf("score out of range: %f", score) - } } func TestProtocolsCategoriesAggregation(t *testing.T) { diff --git a/internal/providers/kamino/client.go b/internal/providers/kamino/client.go index 1224baa..3da9d51 100644 --- a/internal/providers/kamino/client.go +++ b/internal/providers/kamino/client.go @@ -193,11 +193,6 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ return nil, err } - maxRisk := yieldutil.RiskOrder(req.MaxRisk) - if maxRisk == 0 { - maxRisk = yieldutil.RiskOrder("high") - } - out := make([]model.YieldOpportunity, 0, len(reserves)) fetchedAt := c.now().UTC().Format(time.RFC3339) for _, item := range reserves { @@ -217,16 +212,8 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ continue } - riskLevel, reasons := riskFromSymbol(item.Reserve.LiquidityToken) - if yieldutil.RiskOrder(riskLevel) > maxRisk { - continue - } - borrowUSD := parseNonNegative(item.Reserve.TotalBorrowUSD) - liquidityUSD := tvl - borrowUSD - if liquidityUSD <= 0 { - liquidityUSD = tvl - } + liquidityUSD := math.Max(tvl-borrowUSD, 0) assetID := reserveAssetID(req.Chain.CAIP2, req.Asset.AssetID, item.Reserve.LiquidityTokenMint) seed := strings.Join([]string{ @@ -252,9 +239,11 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ LiquidityUSD: liquidityUSD, LockupDays: 0, WithdrawalTerms: "variable", - RiskLevel: riskLevel, - RiskReasons: reasons, - Score: yieldutil.ScoreOpportunity(apy, tvl, liquidityUSD, riskLevel), + BackingAssets: []model.YieldBackingAsset{{ + AssetID: assetID, + Symbol: strings.TrimSpace(item.Reserve.LiquidityToken), + SharePct: 100, + }}, SourceURL: marketURL(item.Market.LendingMarket), FetchedAt: fetchedAt, }) @@ -652,12 +641,3 @@ func hashOpportunity(seed string) string { sum := sha1.Sum([]byte(seed)) return hex.EncodeToString(sum[:]) } - -func riskFromSymbol(symbol string) (string, []string) { - switch strings.ToUpper(strings.TrimSpace(symbol)) { - case "USDC", "USDT", "DAI", "USDE", "PYUSD": - return "low", []string{"stablecoin collateral and borrow profile"} - default: - return "medium", []string{"non-stable asset volatility"} - } -} diff --git a/internal/providers/kamino/client_test.go b/internal/providers/kamino/client_test.go index 508e0f6..cea2e7c 100644 --- a/internal/providers/kamino/client_test.go +++ b/internal/providers/kamino/client_test.go @@ -152,8 +152,7 @@ func TestYieldOpportunitiesFiltersByAPYAndTVL(t *testing.T) { Limit: 10, MinTVLUSD: 50000, MinAPY: 1, - MaxRisk: "high", - SortBy: "score", + SortBy: "apy_total", }) if err != nil { t.Fatalf("YieldOpportunities failed: %v", err) @@ -170,6 +169,12 @@ func TestYieldOpportunitiesFiltersByAPYAndTVL(t *testing.T) { if opps[0].APYTotal != 4 { t.Fatalf("expected APY total 4, got %+v", opps[0]) } + if opps[0].LiquidityUSD != 600000 { + t.Fatalf("expected liquidity_usd = totalSupplyUsd-totalBorrowUsd (600000), got %+v", opps[0]) + } + if len(opps[0].BackingAssets) != 1 || opps[0].BackingAssets[0].SharePct != 100 { + t.Fatalf("expected single backing asset at 100%%, got %+v", opps[0].BackingAssets) + } } func TestLendMarketsPrefersMintMatchOverSymbol(t *testing.T) { diff --git a/internal/providers/morpho/client.go b/internal/providers/morpho/client.go index aa10176..a00e458 100644 --- a/internal/providers/morpho/client.go +++ b/internal/providers/morpho/client.go @@ -98,6 +98,7 @@ const vaultsYieldQuery = `query Vaults($first:Int,$skip:Int,$where:VaultFilters, allocation{ supplyAssetsUsd market{ + loanAsset{ address symbol } collateralAsset{ address symbol } } } @@ -130,6 +131,7 @@ const vaultV2sYieldQuery = `query VaultV2s($first:Int,$skip:Int,$where:VaultV2sF allocation{ supplyAssetsUsd market{ + loanAsset{ address symbol } collateralAsset{ address symbol } } } @@ -337,6 +339,10 @@ type morphoVaultV2 struct { LiquidityData *struct { TypeName string `json:"__typename"` Market *struct { + LoanAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"loanAsset"` CollateralAsset *struct { Address string `json:"address"` Symbol string `json:"symbol"` @@ -353,6 +359,10 @@ type morphoVaultV2 struct { type marketAllocation struct { SupplyAssetsUSD float64 `json:"supplyAssetsUsd"` Market *struct { + LoanAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"loanAsset"` CollateralAsset *struct { Address string `json:"address"` Symbol string `json:"symbol"` @@ -363,13 +373,15 @@ type marketAllocation struct { type vaultYieldCandidate struct { Address string AssetAddress string + AssetSymbol string NetAPYPercent float64 TotalAssetsUSD float64 LiquidityUSD float64 - CollateralShares []collateralShare + BackingShares []collateralShare } type collateralShare struct { + Address string Symbol string USD float64 } @@ -599,10 +611,6 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ if err != nil { return nil, err } - maxRisk := yieldutil.RiskOrder(req.MaxRisk) - if maxRisk == 0 { - maxRisk = yieldutil.RiskOrder("high") - } out := make([]model.YieldOpportunity, 0, len(vaults)) for _, vault := range vaults { @@ -614,12 +622,8 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ if apy < req.MinAPY || tvl < req.MinTVLUSD { continue } - - riskLevel, reasons := riskFromCollateralShares(vault.CollateralShares) - if yieldutil.RiskOrder(riskLevel) > maxRisk { - continue - } - liq := yieldutil.PositiveFirst(vault.LiquidityUSD, tvl) + backingAssets := backingAssetsFromShares(vault.BackingShares, req.Chain.CAIP2, vault.AssetAddress, vault.AssetSymbol, req.Asset.AssetID) + liq := vault.LiquidityUSD assetID := canonicalAssetID(req.Asset, vault.AssetAddress) vaultAddress := normalizeEVMAddress(vault.Address) if vaultAddress == "" { @@ -641,9 +645,7 @@ func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequ LiquidityUSD: liq, LockupDays: 0, WithdrawalTerms: "variable", - RiskLevel: riskLevel, - RiskReasons: reasons, - Score: yieldutil.ScoreOpportunity(apy, tvl, liq, riskLevel), + BackingAssets: backingAssets, SourceURL: sourceURLForVault(vaultAddress), FetchedAt: c.now().UTC().Format(time.RFC3339), }) @@ -790,10 +792,11 @@ func (c *Client) fetchYieldVaultCandidates(ctx context.Context, chain id.Chain, out = append(out, vaultYieldCandidate{ Address: vault.Address, AssetAddress: assetAddress, + AssetSymbol: assetSymbol, NetAPYPercent: netAPY, TotalAssetsUSD: tvl, LiquidityUSD: liquidity, - CollateralShares: collateralSharesFromAllocation(0, nil, allocationFromVault(vault)), + BackingShares: collateralSharesFromAllocation(0, allocationFromVault(vault), assetAddress, assetSymbol), }) } for _, vault := range vaultV2s { @@ -809,10 +812,11 @@ func (c *Client) fetchYieldVaultCandidates(ctx context.Context, chain id.Chain, out = append(out, vaultYieldCandidate{ Address: vault.Address, AssetAddress: assetAddress, + AssetSymbol: assetSymbol, NetAPYPercent: vault.NetAPY * 100, TotalAssetsUSD: vault.TotalAssets, LiquidityUSD: vault.LiquidityUSD, - CollateralShares: collateralSharesFromVaultV2(vault), + BackingShares: collateralSharesFromVaultV2(vault, assetAddress, assetSymbol), }) } if len(out) == 0 { @@ -1052,28 +1056,44 @@ func allocationFromVault(vault morphoVault) []marketAllocation { return vault.State.Allocation } -func collateralSharesFromVaultV2(vault morphoVaultV2) []collateralShare { +func collateralSharesFromVaultV2(vault morphoVaultV2, fallbackAddress, fallbackSymbol string) []collateralShare { if vault.LiquidityData == nil { if usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD); usd > 0 { - return []collateralShare{{USD: usd}} + return []collateralShare{{ + Address: fallbackAddress, + Symbol: fallbackSymbol, + USD: usd, + }} } return nil } switch vault.LiquidityData.TypeName { case "MarketV1LiquidityData": + address := fallbackAddress symbol := "" if vault.LiquidityData.Market != nil && vault.LiquidityData.Market.CollateralAsset != nil { + address = vault.LiquidityData.Market.CollateralAsset.Address symbol = vault.LiquidityData.Market.CollateralAsset.Symbol + } else if vault.LiquidityData.Market != nil && vault.LiquidityData.Market.LoanAsset != nil { + address = vault.LiquidityData.Market.LoanAsset.Address + symbol = vault.LiquidityData.Market.LoanAsset.Symbol + } + if strings.TrimSpace(symbol) == "" { + symbol = fallbackSymbol } usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD) if usd <= 0 { return nil } - return []collateralShare{{Symbol: symbol, USD: usd}} + return []collateralShare{{ + Address: address, + Symbol: symbol, + USD: usd, + }} case "MetaMorphoLiquidityData": if vault.LiquidityData.MetaMorpho != nil && vault.LiquidityData.MetaMorpho.State != nil { - shares := collateralSharesFromAllocation(vault.TotalAssets, nil, vault.LiquidityData.MetaMorpho.State.Allocation) + shares := collateralSharesFromAllocation(vault.TotalAssets, vault.LiquidityData.MetaMorpho.State.Allocation, fallbackAddress, fallbackSymbol) if len(shares) > 0 { return shares } @@ -1081,12 +1101,17 @@ func collateralSharesFromVaultV2(vault morphoVaultV2) []collateralShare { } if usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD); usd > 0 { - return []collateralShare{{USD: usd}} + return []collateralShare{{ + Address: fallbackAddress, + Symbol: fallbackSymbol, + USD: usd, + }} } return nil } -func collateralSharesFromAllocation(totalOverride float64, shares []collateralShare, allocation []marketAllocation) []collateralShare { +func collateralSharesFromAllocation(totalOverride float64, allocation []marketAllocation, fallbackAddress, fallbackSymbol string) []collateralShare { + shares := make([]collateralShare, 0, len(allocation)) total := 0.0 for _, item := range allocation { if item.SupplyAssetsUSD > 0 { @@ -1101,53 +1126,101 @@ func collateralSharesFromAllocation(totalOverride float64, shares []collateralSh if totalOverride > 0 && total > 0 { usd = totalOverride * item.SupplyAssetsUSD / total } - symbol := "" - if item.Market != nil && item.Market.CollateralAsset != nil { - symbol = item.Market.CollateralAsset.Symbol + address := fallbackAddress + symbol := fallbackSymbol + if item.Market != nil { + if item.Market.CollateralAsset != nil { + address = item.Market.CollateralAsset.Address + symbol = item.Market.CollateralAsset.Symbol + } else if item.Market.LoanAsset != nil { + address = item.Market.LoanAsset.Address + symbol = item.Market.LoanAsset.Symbol + } + } + if strings.TrimSpace(address) == "" { + address = fallbackAddress + } + if strings.TrimSpace(symbol) == "" { + symbol = fallbackSymbol } - shares = append(shares, collateralShare{Symbol: symbol, USD: usd}) + shares = append(shares, collateralShare{Address: address, Symbol: symbol, USD: usd}) } return shares } -var stableCollateralSymbols = map[string]struct{}{ - "USDC": {}, - "USDT": {}, - "DAI": {}, - "USDE": {}, -} - -func riskFromCollateralShares(shares []collateralShare) (string, []string) { - hasNonStable := false - hasMissing := false - hasKnownStable := false - +func backingAssetsFromShares( + shares []collateralShare, + chainID string, + fallbackAddress string, + fallbackSymbol string, + fallbackAssetID string, +) []model.YieldBackingAsset { + type aggregate struct { + Symbol string + USD float64 + } + byAsset := map[string]aggregate{} + total := 0.0 for _, share := range shares { if share.USD <= 0 { continue } - symbol := strings.ToUpper(strings.TrimSpace(share.Symbol)) - if symbol == "" { - hasMissing = true - continue + assetID := canonicalAssetIDForChain(chainID, share.Address) + symbol := strings.TrimSpace(share.Symbol) + if assetID == "" { + assetID = canonicalAssetIDForChain(chainID, fallbackAddress) + } + if assetID == "" { + assetID = strings.TrimSpace(fallbackAssetID) } - if _, ok := stableCollateralSymbols[symbol]; ok { - hasKnownStable = true + if assetID == "" { continue } - hasNonStable = true - } - - switch { - case hasNonStable: - return "medium", []string{"non-stable collateral"} - case hasMissing: - return "medium", []string{"missing collateral metadata"} - case hasKnownStable: - return "low", []string{"stable collateral"} - default: - return "medium", []string{"missing collateral metadata"} + if symbol == "" { + symbol = strings.TrimSpace(fallbackSymbol) + } + item := byAsset[assetID] + if item.Symbol == "" { + item.Symbol = symbol + } + item.USD += share.USD + byAsset[assetID] = item + total += share.USD + } + if len(byAsset) == 0 { + assetID := canonicalAssetIDForChain(chainID, fallbackAddress) + if assetID == "" { + assetID = strings.TrimSpace(fallbackAssetID) + } + if assetID == "" { + return nil + } + return []model.YieldBackingAsset{{ + AssetID: assetID, + Symbol: strings.TrimSpace(fallbackSymbol), + SharePct: 100, + }} + } + + out := make([]model.YieldBackingAsset, 0, len(byAsset)) + for assetID, item := range byAsset { + sharePct := 0.0 + if total > 0 { + sharePct = (item.USD / total) * 100 + } + out = append(out, model.YieldBackingAsset{ + AssetID: assetID, + Symbol: strings.TrimSpace(item.Symbol), + SharePct: sharePct, + }) } + sort.Slice(out, func(i, j int) bool { + if out[i].SharePct != out[j].SharePct { + return out[i].SharePct > out[j].SharePct + } + return strings.Compare(out[i].AssetID, out[j].AssetID) < 0 + }) + return out } func sourceURLForVault(address string) string { diff --git a/internal/providers/morpho/client_test.go b/internal/providers/morpho/client_test.go index 4fd2708..e29462d 100644 --- a/internal/providers/morpho/client_test.go +++ b/internal/providers/morpho/client_test.go @@ -55,7 +55,7 @@ func TestLendRatesAndYield(t *testing.T) { "allocation": [ { "supplyAssetsUsd": 1000000, - "market": {"collateralAsset": {"address": "0x111", "symbol": "WETH"}} + "market": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "collateralAsset": {"address": "0x4200000000000000000000000000000000000006", "symbol": "WETH"}} } ] }, @@ -85,7 +85,7 @@ func TestLendRatesAndYield(t *testing.T) { "allocation": [ { "supplyAssetsUsd": 2000000, - "market": {"collateralAsset": {"address": "0x6b175474e89094c44da98b954eedeac495271d0f", "symbol": "DAI"}} + "market": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "collateralAsset": {"address": "0x6b175474e89094c44da98b954eedeac495271d0f", "symbol": "DAI"}} } ] } @@ -134,7 +134,7 @@ func TestLendRatesAndYield(t *testing.T) { t.Fatalf("expected morpho provider id metadata, got %+v", rates[0]) } - opps, err := client.YieldOpportunities(context.Background(), providers.YieldRequest{Chain: chain, Asset: asset, Limit: 10, MaxRisk: "high"}) + opps, err := client.YieldOpportunities(context.Background(), providers.YieldRequest{Chain: chain, Asset: asset, Limit: 10}) if err != nil { t.Fatalf("YieldOpportunities failed: %v", err) } @@ -157,8 +157,11 @@ func TestLendRatesAndYield(t *testing.T) { if vaultOne.ProviderNativeIDKind != model.NativeIDKindVaultAddress { t.Fatalf("expected vault_address kind on first vault, got %+v", vaultOne) } - if vaultOne.RiskLevel != "medium" || len(vaultOne.RiskReasons) == 0 || vaultOne.RiskReasons[0] != "non-stable collateral" { - t.Fatalf("expected medium/non-stable risk on first vault, got %+v", vaultOne) + if vaultOne.LiquidityUSD != 500000 { + t.Fatalf("expected first vault liquidity to come from vault liquidity USD, got %+v", vaultOne) + } + if len(vaultOne.BackingAssets) != 1 || vaultOne.BackingAssets[0].Symbol != "WETH" || vaultOne.BackingAssets[0].SharePct != 100 { + t.Fatalf("expected first vault backing assets to expose full WETH share, got %+v", vaultOne.BackingAssets) } vaultTwo, ok := byID["0x2222222222222222222222222222222222222222"] @@ -168,15 +171,18 @@ func TestLendRatesAndYield(t *testing.T) { if vaultTwo.ProviderNativeIDKind != model.NativeIDKindVaultAddress { t.Fatalf("expected vault_address kind on second vault, got %+v", vaultTwo) } - if vaultTwo.RiskLevel != "low" || len(vaultTwo.RiskReasons) == 0 || vaultTwo.RiskReasons[0] != "stable collateral" { - t.Fatalf("expected low/stable risk on second vault, got %+v", vaultTwo) + if vaultTwo.LiquidityUSD != 1500000 { + t.Fatalf("expected second vault liquidity to come from vaultV2 liquidityUsd, got %+v", vaultTwo) + } + if len(vaultTwo.BackingAssets) != 1 || vaultTwo.BackingAssets[0].Symbol != "DAI" || vaultTwo.BackingAssets[0].SharePct != 100 { + t.Fatalf("expected second vault backing assets to expose full DAI share, got %+v", vaultTwo.BackingAssets) } if _, ok := byID["0x3333333333333333333333333333333333333333"]; ok { t.Fatalf("expected USDT vault to be filtered out for USDC request, got %+v", byID) } } -func TestYieldOpportunitiesVaultMaxRiskFilter(t *testing.T) { +func TestYieldOpportunitiesVaultSortAndLimit(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) w.Header().Set("Content-Type", "application/json") @@ -198,7 +204,7 @@ func TestYieldOpportunitiesVaultMaxRiskFilter(t *testing.T) { "allocation": [ { "supplyAssetsUsd": 1000000, - "market": {"collateralAsset": {"address": "0x111", "symbol": "WETH"}} + "market": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "collateralAsset": {"address": "0x4200000000000000000000000000000000000006", "symbol": "WETH"}} } ] }, @@ -228,7 +234,7 @@ func TestYieldOpportunitiesVaultMaxRiskFilter(t *testing.T) { "allocation": [ { "supplyAssetsUsd": 2000000, - "market": {"collateralAsset": {"address": "0x6b175474e89094c44da98b954eedeac495271d0f", "symbol": "DAI"}} + "market": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "collateralAsset": {"address": "0x6b175474e89094c44da98b954eedeac495271d0f", "symbol": "DAI"}} } ] } @@ -250,18 +256,20 @@ func TestYieldOpportunitiesVaultMaxRiskFilter(t *testing.T) { chain, _ := id.ParseChain("ethereum") asset, _ := id.ParseAsset("USDC", chain) - opps, err := client.YieldOpportunities(context.Background(), providers.YieldRequest{Chain: chain, Asset: asset, Limit: 10, MaxRisk: "low"}) + opps, err := client.YieldOpportunities(context.Background(), providers.YieldRequest{ + Chain: chain, + Asset: asset, + Limit: 1, + SortBy: "tvl_usd", + }) if err != nil { t.Fatalf("YieldOpportunities failed: %v", err) } if len(opps) != 1 { - t.Fatalf("expected one low-risk vault after max-risk filter, got %+v", opps) + t.Fatalf("expected one opportunity after limit, got %+v", opps) } if opps[0].ProviderNativeID != "0x2222222222222222222222222222222222222222" { - t.Fatalf("expected low-risk vault id, got %+v", opps[0]) - } - if opps[0].RiskLevel != "low" { - t.Fatalf("expected low risk, got %+v", opps[0]) + t.Fatalf("expected highest-tvl vault first, got %+v", opps[0]) } } diff --git a/internal/providers/types.go b/internal/providers/types.go index 59c1c3f..63d0370 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -87,7 +87,6 @@ type YieldRequest struct { Limit int MinTVLUSD float64 MinAPY float64 - MaxRisk string Providers []string SortBy string IncludeIncomplete bool diff --git a/internal/providers/yieldutil/yieldutil.go b/internal/providers/yieldutil/yieldutil.go index 6d9bf24..2434b04 100644 --- a/internal/providers/yieldutil/yieldutil.go +++ b/internal/providers/yieldutil/yieldutil.go @@ -17,44 +17,10 @@ func PositiveFirst(values ...float64) float64 { return 0 } -func RiskOrder(v string) int { - switch strings.ToLower(strings.TrimSpace(v)) { - case "low": - return 1 - case "medium": - return 2 - case "high": - return 3 - case "unknown": - return 4 - default: - return 0 - } -} - -func ScoreOpportunity(apyTotal, tvlUSD, liquidityUSD float64, riskLevel string) float64 { - apyNorm := clamp(apyTotal, 0, 100) / 100 - tvlNorm := clamp(math.Log10(tvlUSD+1)/10, 0, 1) - liqNorm := 0.0 - if tvlUSD > 0 { - liqNorm = clamp(liquidityUSD/math.Max(tvlUSD, 1), 0, 1) - } - - riskPenalty := map[string]float64{ - "low": 0.10, - "medium": 0.30, - "high": 0.60, - "unknown": 0.45, - }[strings.ToLower(strings.TrimSpace(riskLevel))] - - scoreRaw := 0.45*apyNorm + 0.30*tvlNorm + 0.20*liqNorm - 0.25*riskPenalty - return math.Round(clamp(scoreRaw, 0, 1)*100*100) / 100 -} - func Sort(items []model.YieldOpportunity, sortBy string) { sortBy = strings.ToLower(strings.TrimSpace(sortBy)) if sortBy == "" { - sortBy = "score" + sortBy = "apy_total" } sort.Slice(items, func(i, j int) bool { @@ -73,8 +39,8 @@ func Sort(items []model.YieldOpportunity, sortBy string) { return a.LiquidityUSD > b.LiquidityUSD } default: - if a.Score != b.Score { - return a.Score > b.Score + if a.APYTotal != b.APYTotal { + return a.APYTotal > b.APYTotal } } if a.APYTotal != b.APYTotal { @@ -83,16 +49,9 @@ func Sort(items []model.YieldOpportunity, sortBy string) { if a.TVLUSD != b.TVLUSD { return a.TVLUSD > b.TVLUSD } + if a.LiquidityUSD != b.LiquidityUSD { + return a.LiquidityUSD > b.LiquidityUSD + } return strings.Compare(a.OpportunityID, b.OpportunityID) < 0 }) } - -func clamp(v, min, max float64) float64 { - if v < min { - return min - } - if v > max { - return max - } - return v -} diff --git a/internal/providers/yieldutil/yieldutil_test.go b/internal/providers/yieldutil/yieldutil_test.go index 36f4199..85a80af 100644 --- a/internal/providers/yieldutil/yieldutil_test.go +++ b/internal/providers/yieldutil/yieldutil_test.go @@ -14,30 +14,14 @@ func TestPositiveFirst(t *testing.T) { } } -func TestRiskOrder(t *testing.T) { - if RiskOrder("low") != 1 || RiskOrder("medium") != 2 || RiskOrder("high") != 3 || RiskOrder("unknown") != 4 { - t.Fatalf("unexpected risk order mapping") - } - if RiskOrder("n/a") != 0 { - t.Fatalf("expected unknown mapping to be 0") - } -} - -func TestScoreOpportunity(t *testing.T) { - score := ScoreOpportunity(10, 1_000_000, 250_000, "medium") - if score != 20 { - t.Fatalf("unexpected score: %v", score) - } -} - func TestSort(t *testing.T) { items := []model.YieldOpportunity{ - {OpportunityID: "b", Score: 10, APYTotal: 8, TVLUSD: 100, LiquidityUSD: 40}, - {OpportunityID: "a", Score: 10, APYTotal: 8, TVLUSD: 100, LiquidityUSD: 30}, - {OpportunityID: "c", Score: 20, APYTotal: 4, TVLUSD: 90, LiquidityUSD: 20}, + {OpportunityID: "b", APYTotal: 8, TVLUSD: 100, LiquidityUSD: 40}, + {OpportunityID: "a", APYTotal: 8, TVLUSD: 100, LiquidityUSD: 30}, + {OpportunityID: "c", APYTotal: 4, TVLUSD: 90, LiquidityUSD: 20}, } - Sort(items, "score") - if items[0].OpportunityID != "c" || items[1].OpportunityID != "a" || items[2].OpportunityID != "b" { + Sort(items, "apy_total") + if items[0].OpportunityID != "b" || items[1].OpportunityID != "a" || items[2].OpportunityID != "c" { t.Fatalf("unexpected sort order: %#v", items) } } From 508992d5771e78e81e467d049bedde5260e0101a Mon Sep 17 00:00:00 2001 From: Gustavo Gonzalez Date: Fri, 27 Feb 2026 17:05:20 -0400 Subject: [PATCH 18/18] add actions gas estimator command and docs --- AGENTS.md | 4 +- CHANGELOG.md | 1 + README.md | 6 +- docs/act-execution-design.md | 7 +- docs/reference/commands-overview.mdx | 1 + internal/app/runner.go | 88 ++++++- internal/app/runner_actions_test.go | 21 ++ internal/execution/estimate.go | 353 +++++++++++++++++++++++++++ internal/execution/estimate_test.go | 271 ++++++++++++++++++++ 9 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 internal/execution/estimate.go create mode 100644 internal/execution/estimate_test.go diff --git a/AGENTS.md b/AGENTS.md index 4280f43..c4530d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ README.md # user-facing usage + caveats - `approvals plan|run|submit|status` - `lend supply|withdraw|borrow|repay plan|run|submit|status` (Aave, Morpho) - `rewards claim|compound plan|run|submit|status` (Aave) - - `actions list|show` + - `actions list|show|estimate` - Execution builder architecture is intentionally split: - `swap`/`bridge` action construction is provider capability based (`BuildSwapAction` / `BuildBridgeAction`) because route payloads are provider-specific. - `lend`/`rewards`/`approvals` action construction uses internal planners for deterministic contract-call composition. @@ -110,7 +110,7 @@ README.md # user-facing usage + caveats - Morpho can emit extreme APYs in tiny markets; use `--min-tvl-usd` in ranking/filters. - Fresh cache hits (`age <= ttl`) skip provider calls; once TTL expires, the CLI re-fetches providers and only serves stale data within `max_stale` on temporary provider failures. - Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. -- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|show`) bypass cache initialization. +- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|show|estimate`) bypass cache initialization. - For `lend`/`yield`, unresolved symbols are treated as symbol filters; on chains without bootstrap token entries, prefer token address or CAIP-19 for deterministic matching. - Amounts used for swaps/bridges are base units; keep both base and decimal forms consistent. - Release artifacts are built on `v*` tags via `.github/workflows/release.yml` and `.goreleaser.yml`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 35b02de..14a6192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Format: - Added nightly execution-planning smoke workflow (`nightly-execution-smoke.yml`) and script. - Added `lend positions` to query account-level lending positions by address for Aave and Morpho with `--type all|supply|borrow|collateral`. - Added `yield history` to query historical yield-provider series with `--metrics apy_total,tvl_usd`, `--interval hour|day`, `--window`/`--from`/`--to`, and optional `--opportunity-ids`. +- Added `actions estimate` to compute per-step gas projections for persisted actions using `eth_estimateGas` and EIP-1559 fee cap/tip resolution. ### Changed - BREAKING: Morpho `yield opportunities` now returns vault-level opportunities (`provider_native_id_kind=vault_address`) sourced from Morpho vault/vault-v2 data instead of Morpho market IDs. diff --git a/README.md b/README.md index 26731ac..21f42b0 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ defi rewards claim plan --provider aave --chain 1 --from-address 0xYourEOA --ass defi approvals plan --chain taiko --asset USDC --spender 0xSpender --amount 1000000 --from-address 0xYourEOA --results-only defi swap status --action-id --results-only defi actions list --results-only +defi actions estimate --action-id --results-only ``` `yield opportunities --providers` and `yield history --providers` accept provider names from `defi providers list` (for example `aave,morpho,kamino`). @@ -173,7 +174,7 @@ Execution command surface: - `approvals plan|run|submit|status` - `lend supply|withdraw|borrow|repay plan|run|submit|status` (provider: `aave|morpho`) - `rewards claim|compound plan|run|submit|status` (provider: `aave`) -- `actions list|show` +- `actions list|show|estimate` ## Command API Key Requirements @@ -281,13 +282,14 @@ providers: - `cache.max_stale` / `--max-stale` is only a temporary provider-failure fallback window (currently `unavailable` / `rate_limited`). - If fallback is disabled (`--no-stale` or `--max-stale 0s`) or stale data exceeds the budget, the CLI exits with code `14`. - Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. -- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|show`) bypass cache reads/writes. +- Execution commands (`swap|bridge|approvals|lend|rewards ... plan|run|submit|status`, `actions list|show|estimate`) bypass cache reads/writes. ## Caveats - Morpho can surface extreme APY values on very small markets. Prefer `--min-tvl-usd` when ranking yield. - `yield opportunities` returns objective metrics and composition data: `apy_total`, `tvl_usd`, `liquidity_usd`, and full `backing_assets` (subjective `risk_*`/`score` fields were removed). - `liquidity_usd` is provider-sourced available liquidity and is intentionally distinct from `tvl_usd` (total supplied/managed value). +- `actions estimate` reports source-chain EVM step gas/fee projections from planned calldata (`eth_estimateGas` + EIP-1559); it does not add destination settlement gas unless that transaction is an explicit action step. - `yield history --metrics` supports `apy_total` and `tvl_usd`; Aave currently supports `apy_total` only. - Aave historical windows are lookback-based and effectively end near current time; use `--window` for Aave-friendly history requests. - `chains assets` requires `DEFI_DEFILLAMA_API_KEY` because DefiLlama chain asset TVL is key-gated. diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index 7b8ec4e..12868ee 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -20,10 +20,10 @@ Execution is integrated inside existing domain commands (for example `swap`, `br |---|---|---|---| | Swap | `swap plan|run|submit|status` | `--provider` required | `taikoswap` execution today | | Bridge | `bridge plan|run|submit|status` | `--provider` required | `across`, `lifi` execution | -| Lend | `lend plan|run|submit|status` | `--provider` required | `aave`, `morpho` execution (`morpho` requires `--market-id`) | -| Rewards | `rewards plan|run|submit|status` | `--provider` required | `aave` execution | +| Lend | `lend (supply|withdraw|borrow|repay) plan|run|submit|status` | `--provider` required | `aave`, `morpho` execution (`morpho` requires `--market-id`) | +| Rewards | `rewards (claim|compound) plan|run|submit|status` | `--provider` required | `aave` execution | | Approvals | `approvals plan|run|submit|status` | no provider selector | native ERC-20 approval execution | -| Action inspection | `actions list|show` | optional `--status` filter | persisted action inspection | +| Action inspection | `actions list|show|estimate` | optional `--status` / `--action-id` filters | persisted action inspection + gas/fee estimation | Notes: @@ -139,6 +139,7 @@ Tradeoff: - Domain `status` commands fetch one action. - `actions list` gives cross-domain recent actions. - `actions show` fetches any action by ID. +- `actions estimate` computes per-step `eth_estimateGas` and EIP-1559 likely/worst-case fee projections for a persisted action. ## 5. Signing and Key Handling diff --git a/docs/reference/commands-overview.mdx b/docs/reference/commands-overview.mdx index 158a1af..c25b73e 100644 --- a/docs/reference/commands-overview.mdx +++ b/docs/reference/commands-overview.mdx @@ -46,4 +46,5 @@ Examples: defi lend markets --provider aave --chain 1 --asset USDC --results-only defi bridge quote --from 1 --to 8453 --asset USDC --amount 1000000 --timeout 12s --retries 2 --results-only defi swap quote --chain solana --from-asset USDC --to-asset SOL --amount 1000000 --results-only +defi actions estimate --action-id --results-only ``` diff --git a/internal/app/runner.go b/internal/app/runner.go index 7a342fa..6f014d3 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -1265,8 +1265,52 @@ func (s *runtimeState) newActionsCommand() *cobra.Command { } showCmd.Flags().StringVar(&showActionID, "action-id", "", "Action identifier") + var estimateActionID, estimateStepIDs, estimateMaxFeeGwei, estimateMaxPriorityFeeGwei, estimateBlockTag string + var estimateGasMultiplier float64 + estimateCmd := &cobra.Command{ + Use: "estimate", + Short: "Estimate gas and EIP-1559 fees for a planned action", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(estimateActionID) + if err != nil { + return err + } + if err := s.ensureActionStore(); err != nil { + return err + } + action, err := s.actionStore.Get(actionID) + if err != nil { + return clierr.Wrap(clierr.CodeUsage, "load action", err) + } + opts, err := parseActionEstimateOptions( + estimateStepIDs, + estimateGasMultiplier, + estimateMaxFeeGwei, + estimateMaxPriorityFeeGwei, + estimateBlockTag, + ) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + estimate, err := execution.EstimateActionGas(ctx, action, opts) + if err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), estimate, nil, cacheMetaBypass(), nil, false) + }, + } + estimateCmd.Flags().StringVar(&estimateActionID, "action-id", "", "Action identifier") + estimateCmd.Flags().StringVar(&estimateStepIDs, "step-ids", "", "Optional comma-separated step_id filter") + estimateCmd.Flags().Float64Var(&estimateGasMultiplier, "gas-multiplier", 1.2, "Gas estimate safety multiplier") + estimateCmd.Flags().StringVar(&estimateMaxFeeGwei, "max-fee-gwei", "", "Optional EIP-1559 max fee (gwei)") + estimateCmd.Flags().StringVar(&estimateMaxPriorityFeeGwei, "max-priority-fee-gwei", "", "Optional EIP-1559 max priority fee (gwei)") + estimateCmd.Flags().StringVar(&estimateBlockTag, "block-tag", "pending", "Block tag used for estimation (pending|latest)") + root.AddCommand(listCmd) root.AddCommand(showCmd) + root.AddCommand(estimateCmd) return root } @@ -1437,15 +1481,15 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { continue } - discoveryReq := providers.YieldRequest{ - Chain: chain, - Asset: asset, - Limit: historyLimit, - MinTVLUSD: 0, - MinAPY: 0, - SortBy: "apy_total", - IncludeIncomplete: true, - } + discoveryReq := providers.YieldRequest{ + Chain: chain, + Asset: asset, + Limit: historyLimit, + MinTVLUSD: 0, + MinAPY: 0, + SortBy: "apy_total", + IncludeIncomplete: true, + } if len(opportunityIDSet) > 0 { discoveryReq.Limit = 0 } @@ -2240,7 +2284,7 @@ func normalizeCommandPath(commandPath string) string { func isExecutionCommandPath(path string) bool { switch path { - case "actions", "actions list", "actions show": + case "actions", "actions list", "actions show", "actions estimate": return true } parts := strings.Fields(path) @@ -2371,6 +2415,30 @@ func parseExecuteOptions( return opts, nil } +func parseActionEstimateOptions( + stepIDsCSV string, + gasMultiplier float64, + maxFeeGwei, maxPriorityFeeGwei, blockTag string, +) (execution.EstimateOptions, error) { + opts := execution.DefaultEstimateOptions() + opts.StepIDs = splitCSV(stepIDsCSV) + if gasMultiplier <= 1 { + return execution.EstimateOptions{}, clierr.New(clierr.CodeUsage, "--gas-multiplier must be > 1") + } + opts.GasMultiplier = gasMultiplier + opts.MaxFeeGwei = strings.TrimSpace(maxFeeGwei) + opts.MaxPriorityFeeGwei = strings.TrimSpace(maxPriorityFeeGwei) + switch strings.ToLower(strings.TrimSpace(blockTag)) { + case "", string(execution.EstimateBlockTagPending): + opts.BlockTag = execution.EstimateBlockTagPending + case string(execution.EstimateBlockTagLatest): + opts.BlockTag = execution.EstimateBlockTagLatest + default: + return execution.EstimateOptions{}, clierr.New(clierr.CodeUsage, "--block-tag must be one of: pending,latest") + } + return opts, nil +} + func (s *runtimeState) resetCommandDiagnostics() { s.lastWarnings = nil s.lastProviders = nil diff --git a/internal/app/runner_actions_test.go b/internal/app/runner_actions_test.go index f4b6893..9fe524c 100644 --- a/internal/app/runner_actions_test.go +++ b/internal/app/runner_actions_test.go @@ -115,6 +115,9 @@ func TestShouldOpenActionStore(t *testing.T) { if !shouldOpenActionStore("actions show") { t.Fatal("expected actions show to require action store") } + if !shouldOpenActionStore("actions estimate") { + t.Fatal("expected actions estimate to require action store") + } if shouldOpenActionStore("swap quote") { t.Fatal("did not expect swap quote to require action store") } @@ -138,6 +141,9 @@ func TestActionsCommandHasNoStatusAlias(t *testing.T) { if _, ok := names["show"]; !ok { t.Fatal("expected actions show command to be present") } + if _, ok := names["estimate"]; !ok { + t.Fatal("expected actions estimate command to be present") + } if _, ok := names["status"]; ok { t.Fatal("did not expect deprecated actions status alias") } @@ -162,6 +168,9 @@ func TestShouldOpenCacheBypassesExecutionCommands(t *testing.T) { if shouldOpenCache("actions show") { t.Fatal("did not expect actions show to open cache") } + if shouldOpenCache("actions estimate") { + t.Fatal("did not expect actions estimate to open cache") + } if !shouldOpenCache("lend rates") { t.Fatal("expected lend rates to open cache") } @@ -288,3 +297,15 @@ func TestRunnerExecutionStatusBypassesCacheOpen(t *testing.T) { t.Fatalf("expected usage exit code 2, got %d stderr=%s", code, stderr.String()) } } + +func TestParseActionEstimateOptionsRejectsGasMultiplierLTEOne(t *testing.T) { + if _, err := parseActionEstimateOptions("", 1, "", "", "pending"); err == nil { + t.Fatal("expected gas multiplier <= 1 to fail") + } +} + +func TestParseActionEstimateOptionsRejectsUnknownBlockTag(t *testing.T) { + if _, err := parseActionEstimateOptions("", 1.2, "", "", "safe"); err == nil { + t.Fatal("expected unknown block tag to fail") + } +} diff --git a/internal/execution/estimate.go b/internal/execution/estimate.go new file mode 100644 index 0000000..a317578 --- /dev/null +++ b/internal/execution/estimate.go @@ -0,0 +1,353 @@ +package execution + +import ( + "context" + "fmt" + "math/big" + "sort" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + clierr "github.com/ggonzalez94/defi-cli/internal/errors" +) + +type EstimateBlockTag string + +const ( + EstimateBlockTagLatest EstimateBlockTag = "latest" + EstimateBlockTagPending EstimateBlockTag = "pending" +) + +type EstimateOptions struct { + StepIDs []string + GasMultiplier float64 + MaxFeeGwei string + MaxPriorityFeeGwei string + BlockTag EstimateBlockTag +} + +type ActionGasEstimate struct { + ActionID string `json:"action_id"` + EstimatedAt string `json:"estimated_at"` + BlockTag string `json:"block_tag"` + Steps []ActionGasEstimateStep `json:"steps"` + TotalsByChain []ActionGasEstimateChainTotal `json:"totals_by_chain"` +} + +type ActionGasEstimateStep struct { + StepID string `json:"step_id"` + Type StepType `json:"type"` + Status StepStatus `json:"status"` + ChainID string `json:"chain_id"` + GasEstimateRaw string `json:"gas_estimate_raw"` + GasLimit string `json:"gas_limit"` + BaseFeePerGasWei string `json:"base_fee_per_gas_wei"` + MaxPriorityFeePerGasWei string `json:"max_priority_fee_per_gas_wei"` + MaxFeePerGasWei string `json:"max_fee_per_gas_wei"` + EffectiveGasPriceWei string `json:"effective_gas_price_wei"` + LikelyFeeWei string `json:"likely_fee_wei"` + WorstCaseFeeWei string `json:"worst_case_fee_wei"` +} + +type ActionGasEstimateChainTotal struct { + ChainID string `json:"chain_id"` + LikelyFeeWei string `json:"likely_fee_wei"` + WorstCaseFeeWei string `json:"worst_case_fee_wei"` +} + +func DefaultEstimateOptions() EstimateOptions { + return EstimateOptions{ + GasMultiplier: 1.2, + BlockTag: EstimateBlockTagPending, + } +} + +func EstimateActionGas(ctx context.Context, action Action, opts EstimateOptions) (ActionGasEstimate, error) { + if strings.TrimSpace(action.ActionID) == "" { + return ActionGasEstimate{}, clierr.New(clierr.CodeUsage, "missing action id") + } + if len(action.Steps) == 0 { + return ActionGasEstimate{}, clierr.New(clierr.CodeUsage, "action has no executable steps") + } + if opts.GasMultiplier <= 1 { + return ActionGasEstimate{}, clierr.New(clierr.CodeUsage, "--gas-multiplier must be > 1") + } + blockTag, err := normalizeEstimateBlockTag(opts.BlockTag) + if err != nil { + return ActionGasEstimate{}, err + } + + fromAddress := common.Address{} + if strings.TrimSpace(action.FromAddress) != "" { + if !common.IsHexAddress(strings.TrimSpace(action.FromAddress)) { + return ActionGasEstimate{}, clierr.New(clierr.CodeUsage, "action has invalid from_address") + } + fromAddress = common.HexToAddress(strings.TrimSpace(action.FromAddress)) + } + + stepFilter := buildStepFilter(opts.StepIDs) + selected := make([]ActionStep, 0, len(action.Steps)) + for _, step := range action.Steps { + if !matchesStepFilter(stepFilter, step.StepID) { + continue + } + selected = append(selected, step) + } + if len(selected) == 0 { + return ActionGasEstimate{}, clierr.New(clierr.CodeUsage, "no action steps matched the requested --step-ids filter") + } + + byChainLikely := map[string]*big.Int{} + byChainWorst := map[string]*big.Int{} + estimatedSteps := make([]ActionGasEstimateStep, 0, len(selected)) + + for _, step := range selected { + if strings.TrimSpace(step.RPCURL) == "" { + return ActionGasEstimate{}, clierr.New(clierr.CodeUsage, fmt.Sprintf("step %s is missing rpc_url", step.StepID)) + } + if strings.TrimSpace(step.Target) == "" || !common.IsHexAddress(strings.TrimSpace(step.Target)) { + return ActionGasEstimate{}, clierr.New(clierr.CodeUsage, fmt.Sprintf("step %s has invalid target address", step.StepID)) + } + + client, err := ethclient.DialContext(ctx, strings.TrimSpace(step.RPCURL)) + if err != nil { + return ActionGasEstimate{}, clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + + msg, err := actionStepCallMsg(step, fromAddress) + if err != nil { + client.Close() + return ActionGasEstimate{}, err + } + + chainID, err := client.ChainID(ctx) + if err != nil { + client.Close() + return ActionGasEstimate{}, clierr.Wrap(clierr.CodeUnavailable, "read chain id", err) + } + chainKey := fmt.Sprintf("eip155:%d", chainID.Int64()) + if strings.TrimSpace(step.ChainID) != "" { + if !strings.EqualFold(strings.TrimSpace(step.ChainID), chainKey) { + client.Close() + return ActionGasEstimate{}, clierr.New(clierr.CodeActionPlan, fmt.Sprintf("step chain mismatch: expected %s, got %s", chainKey, step.ChainID)) + } + } + + rawGas, err := estimateGasWithBlockTag(ctx, client, msg, blockTag) + if err != nil { + client.Close() + return ActionGasEstimate{}, wrapEVMExecutionError(clierr.CodeActionSim, "estimate gas", err) + } + gasLimit := uint64(float64(rawGas) * opts.GasMultiplier) + if gasLimit == 0 { + client.Close() + return ActionGasEstimate{}, clierr.New(clierr.CodeActionSim, "estimate gas returned zero") + } + + tipCap, err := resolveTipCap(ctx, client, opts.MaxPriorityFeeGwei) + if err != nil { + client.Close() + return ActionGasEstimate{}, err + } + baseFee, err := baseFeeAtBlockTag(ctx, client, blockTag) + if err != nil { + client.Close() + return ActionGasEstimate{}, err + } + feeCap, err := resolveFeeCap(baseFee, tipCap, opts.MaxFeeGwei) + if err != nil { + client.Close() + return ActionGasEstimate{}, err + } + client.Close() + + effectiveGasPrice := new(big.Int).Add(new(big.Int).Set(baseFee), tipCap) + if effectiveGasPrice.Cmp(feeCap) > 0 { + effectiveGasPrice = new(big.Int).Set(feeCap) + } + + gasLimitBI := new(big.Int).SetUint64(gasLimit) + likelyFee := new(big.Int).Mul(new(big.Int).Set(gasLimitBI), effectiveGasPrice) + worstFee := new(big.Int).Mul(new(big.Int).Set(gasLimitBI), feeCap) + + estimatedSteps = append(estimatedSteps, ActionGasEstimateStep{ + StepID: step.StepID, + Type: step.Type, + Status: step.Status, + ChainID: chainKey, + GasEstimateRaw: strconvUint64(rawGas), + GasLimit: strconvUint64(gasLimit), + BaseFeePerGasWei: baseFee.String(), + MaxPriorityFeePerGasWei: tipCap.String(), + MaxFeePerGasWei: feeCap.String(), + EffectiveGasPriceWei: effectiveGasPrice.String(), + LikelyFeeWei: likelyFee.String(), + WorstCaseFeeWei: worstFee.String(), + }) + + if _, ok := byChainLikely[chainKey]; !ok { + byChainLikely[chainKey] = big.NewInt(0) + } + if _, ok := byChainWorst[chainKey]; !ok { + byChainWorst[chainKey] = big.NewInt(0) + } + byChainLikely[chainKey].Add(byChainLikely[chainKey], likelyFee) + byChainWorst[chainKey].Add(byChainWorst[chainKey], worstFee) + } + + totals := make([]ActionGasEstimateChainTotal, 0, len(byChainLikely)) + chainIDs := make([]string, 0, len(byChainLikely)) + for chainID := range byChainLikely { + chainIDs = append(chainIDs, chainID) + } + sort.Strings(chainIDs) + for _, chainID := range chainIDs { + totals = append(totals, ActionGasEstimateChainTotal{ + ChainID: chainID, + LikelyFeeWei: byChainLikely[chainID].String(), + WorstCaseFeeWei: byChainWorst[chainID].String(), + }) + } + + return ActionGasEstimate{ + ActionID: action.ActionID, + EstimatedAt: time.Now().UTC().Format(time.RFC3339), + BlockTag: string(blockTag), + Steps: estimatedSteps, + TotalsByChain: totals, + }, nil +} + +func actionStepCallMsg(step ActionStep, from common.Address) (ethereum.CallMsg, error) { + target := common.HexToAddress(strings.TrimSpace(step.Target)) + data, err := decodeHex(step.Data) + if err != nil { + return ethereum.CallMsg{}, clierr.Wrap(clierr.CodeUsage, "decode step calldata", err) + } + value, err := parseNonNegativeBaseUnits(step.Value) + if err != nil { + return ethereum.CallMsg{}, clierr.Wrap(clierr.CodeUsage, "parse step value", err) + } + return ethereum.CallMsg{ + From: from, + To: &target, + Value: value, + Data: data, + }, nil +} + +func parseNonNegativeBaseUnits(raw string) (*big.Int, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return big.NewInt(0), nil + } + value, ok := new(big.Int).SetString(clean, 10) + if !ok { + return nil, fmt.Errorf("invalid base-units integer") + } + if value.Sign() < 0 { + return nil, fmt.Errorf("value must be non-negative") + } + return value, nil +} + +func normalizeEstimateBlockTag(input EstimateBlockTag) (EstimateBlockTag, error) { + switch strings.ToLower(strings.TrimSpace(string(input))) { + case "", string(EstimateBlockTagPending): + return EstimateBlockTagPending, nil + case string(EstimateBlockTagLatest): + return EstimateBlockTagLatest, nil + default: + return "", clierr.New(clierr.CodeUsage, "--block-tag must be one of: pending,latest") + } +} + +func buildStepFilter(stepIDs []string) map[string]struct{} { + if len(stepIDs) == 0 { + return nil + } + out := make(map[string]struct{}, len(stepIDs)) + for _, stepID := range stepIDs { + if normalized := strings.ToLower(strings.TrimSpace(stepID)); normalized != "" { + out[normalized] = struct{}{} + } + } + if len(out) == 0 { + return nil + } + return out +} + +func matchesStepFilter(filter map[string]struct{}, stepID string) bool { + if len(filter) == 0 { + return true + } + _, ok := filter[strings.ToLower(strings.TrimSpace(stepID))] + return ok +} + +func estimateGasWithBlockTag(ctx context.Context, client *ethclient.Client, msg ethereum.CallMsg, blockTag EstimateBlockTag) (uint64, error) { + arg := map[string]any{ + "from": msg.From.Hex(), + } + if msg.To != nil { + arg["to"] = msg.To.Hex() + } + if len(msg.Data) > 0 { + arg["data"] = hexutil.Bytes(msg.Data) + } + if msg.Value != nil { + arg["value"] = (*hexutil.Big)(msg.Value) + } + + var estimated hexutil.Uint64 + if err := client.Client().CallContext(ctx, &estimated, "eth_estimateGas", arg, string(blockTag)); err != nil { + if blockTag == EstimateBlockTagPending { + if retryErr := client.Client().CallContext(ctx, &estimated, "eth_estimateGas", arg, string(EstimateBlockTagLatest)); retryErr == nil { + return uint64(estimated), nil + } + } + fallback, fallbackErr := client.EstimateGas(ctx, msg) + if fallbackErr == nil { + return fallback, nil + } + return 0, err + } + return uint64(estimated), nil +} + +func baseFeeAtBlockTag(ctx context.Context, client *ethclient.Client, blockTag EstimateBlockTag) (*big.Int, error) { + var block struct { + BaseFeePerGas *hexutil.Big `json:"baseFeePerGas"` + } + if err := client.Client().CallContext(ctx, &block, "eth_getBlockByNumber", string(blockTag), false); err != nil { + if blockTag == EstimateBlockTagPending { + if retryErr := client.Client().CallContext(ctx, &block, "eth_getBlockByNumber", string(EstimateBlockTagLatest), false); retryErr == nil { + if block.BaseFeePerGas == nil { + return big.NewInt(1_000_000_000), nil + } + return new(big.Int).Set((*big.Int)(block.BaseFeePerGas)), nil + } + } + header, headerErr := client.HeaderByNumber(ctx, nil) + if headerErr == nil { + if header.BaseFee == nil { + return big.NewInt(1_000_000_000), nil + } + return new(big.Int).Set(header.BaseFee), nil + } + return nil, clierr.Wrap(clierr.CodeUnavailable, "fetch latest header", err) + } + if block.BaseFeePerGas == nil { + return big.NewInt(1_000_000_000), nil + } + return new(big.Int).Set((*big.Int)(block.BaseFeePerGas)), nil +} + +func strconvUint64(v uint64) string { + return new(big.Int).SetUint64(v).String() +} diff --git a/internal/execution/estimate_test.go b/internal/execution/estimate_test.go new file mode 100644 index 0000000..d725b1c --- /dev/null +++ b/internal/execution/estimate_test.go @@ -0,0 +1,271 @@ +package execution + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +type estimateRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +func TestEstimateActionGasSingleStep(t *testing.T) { + rpc := newEstimateRPCServer(t) + defer rpc.Close() + + action := Action{ + ActionID: "act_test", + FromAddress: "0x00000000000000000000000000000000000000aa", + Steps: []ActionStep{{ + StepID: "swap-step", + Type: StepTypeSwap, + Status: StepStatusPending, + ChainID: "eip155:1", + RPCURL: rpc.URL, + Target: "0x00000000000000000000000000000000000000bb", + Data: "0x", + Value: "0", + }}, + } + + estimate, err := EstimateActionGas(context.Background(), action, DefaultEstimateOptions()) + if err != nil { + t.Fatalf("EstimateActionGas failed: %v", err) + } + if estimate.ActionID != "act_test" { + t.Fatalf("unexpected action id: %s", estimate.ActionID) + } + if estimate.BlockTag != string(EstimateBlockTagPending) { + t.Fatalf("expected block tag pending, got %s", estimate.BlockTag) + } + if len(estimate.Steps) != 1 { + t.Fatalf("expected one estimated step, got %d", len(estimate.Steps)) + } + step := estimate.Steps[0] + if step.StepID != "swap-step" { + t.Fatalf("unexpected step id: %s", step.StepID) + } + if step.GasEstimateRaw != "21000" { + t.Fatalf("expected raw gas 21000, got %s", step.GasEstimateRaw) + } + if step.GasLimit != "25200" { + t.Fatalf("expected gas limit 25200, got %s", step.GasLimit) + } + if step.BaseFeePerGasWei != "1000000000" { + t.Fatalf("expected base fee 1 gwei, got %s", step.BaseFeePerGasWei) + } + if step.MaxPriorityFeePerGasWei != "2000000000" { + t.Fatalf("expected tip cap 2 gwei, got %s", step.MaxPriorityFeePerGasWei) + } + if step.MaxFeePerGasWei != "4000000000" { + t.Fatalf("expected fee cap 4 gwei, got %s", step.MaxFeePerGasWei) + } + if step.EffectiveGasPriceWei != "3000000000" { + t.Fatalf("expected effective gas price 3 gwei, got %s", step.EffectiveGasPriceWei) + } + if step.LikelyFeeWei != "75600000000000" { + t.Fatalf("unexpected likely fee: %s", step.LikelyFeeWei) + } + if step.WorstCaseFeeWei != "100800000000000" { + t.Fatalf("unexpected worst-case fee: %s", step.WorstCaseFeeWei) + } + if len(estimate.TotalsByChain) != 1 { + t.Fatalf("expected one chain total, got %d", len(estimate.TotalsByChain)) + } + total := estimate.TotalsByChain[0] + if total.ChainID != "eip155:1" { + t.Fatalf("unexpected chain total id: %s", total.ChainID) + } + if total.LikelyFeeWei != step.LikelyFeeWei { + t.Fatalf("expected likely fee total %s, got %s", step.LikelyFeeWei, total.LikelyFeeWei) + } + if total.WorstCaseFeeWei != step.WorstCaseFeeWei { + t.Fatalf("expected worst-case fee total %s, got %s", step.WorstCaseFeeWei, total.WorstCaseFeeWei) + } +} + +func TestEstimateActionGasCanonicalizesStepChainID(t *testing.T) { + rpc := newEstimateRPCServer(t) + defer rpc.Close() + + action := Action{ + ActionID: "act_chain", + FromAddress: "0x00000000000000000000000000000000000000aa", + Steps: []ActionStep{{ + StepID: "swap-step", + Type: StepTypeSwap, + Status: StepStatusPending, + ChainID: "", + RPCURL: rpc.URL, + Target: "0x00000000000000000000000000000000000000bb", + Data: "0x", + Value: "0", + }}, + } + + estimate, err := EstimateActionGas(context.Background(), action, DefaultEstimateOptions()) + if err != nil { + t.Fatalf("EstimateActionGas failed: %v", err) + } + if got := estimate.Steps[0].ChainID; got != "eip155:1" { + t.Fatalf("expected canonical step chain id eip155:1, got %s", got) + } + if got := estimate.TotalsByChain[0].ChainID; got != "eip155:1" { + t.Fatalf("expected canonical totals chain id eip155:1, got %s", got) + } +} + +func TestEstimateActionGasFiltersSteps(t *testing.T) { + rpc := newEstimateRPCServer(t) + defer rpc.Close() + + action := Action{ + ActionID: "act_filter", + FromAddress: "0x00000000000000000000000000000000000000aa", + Steps: []ActionStep{ + { + StepID: "first-step", + Type: StepTypeApproval, + Status: StepStatusPending, + ChainID: "eip155:1", + RPCURL: rpc.URL, + Target: "0x00000000000000000000000000000000000000bb", + Data: "0x", + Value: "0", + }, + { + StepID: "second-step", + Type: StepTypeSwap, + Status: StepStatusPending, + ChainID: "eip155:1", + RPCURL: rpc.URL, + Target: "0x00000000000000000000000000000000000000cc", + Data: "0x", + Value: "0", + }, + }, + } + + opts := DefaultEstimateOptions() + opts.StepIDs = []string{"second-step"} + + estimate, err := EstimateActionGas(context.Background(), action, opts) + if err != nil { + t.Fatalf("EstimateActionGas failed: %v", err) + } + if len(estimate.Steps) != 1 { + t.Fatalf("expected one estimated step, got %d", len(estimate.Steps)) + } + if estimate.Steps[0].StepID != "second-step" { + t.Fatalf("expected second-step, got %s", estimate.Steps[0].StepID) + } +} + +func TestEstimateActionGasFilterNoMatches(t *testing.T) { + rpc := newEstimateRPCServer(t) + defer rpc.Close() + + action := Action{ + ActionID: "act_filter_none", + FromAddress: "0x00000000000000000000000000000000000000aa", + Steps: []ActionStep{{ + StepID: "only-step", + Type: StepTypeSwap, + Status: StepStatusPending, + ChainID: "eip155:1", + RPCURL: rpc.URL, + Target: "0x00000000000000000000000000000000000000bb", + Data: "0x", + Value: "0", + }}, + } + + opts := DefaultEstimateOptions() + opts.StepIDs = []string{"missing-step"} + if _, err := EstimateActionGas(context.Background(), action, opts); err == nil { + t.Fatal("expected no-match filter error") + } +} + +func newEstimateRPCServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var req estimateRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch req.Method { + case "eth_chainId": + writeEstimateRPCResult(t, w, req.ID, "0x1") + case "eth_estimateGas": + if len(req.Params) < 2 { + writeEstimateRPCError(w, req.ID, -32602, "missing block tag") + return + } + var tag string + if err := json.Unmarshal(req.Params[1], &tag); err != nil { + writeEstimateRPCError(w, req.ID, -32602, "invalid block tag") + return + } + if tag != "pending" && tag != "latest" { + writeEstimateRPCError(w, req.ID, -32602, "unsupported block tag") + return + } + writeEstimateRPCResult(t, w, req.ID, "0x5208") + case "eth_maxPriorityFeePerGas": + writeEstimateRPCResult(t, w, req.ID, "0x77359400") + case "eth_getBlockByNumber": + writeEstimateRPCResult(t, w, req.ID, map[string]any{ + "baseFeePerGas": "0x3b9aca00", + }) + default: + writeEstimateRPCError(w, req.ID, -32601, fmt.Sprintf("method not supported in test: %s", req.Method)) + } + })) +} + +func writeEstimateRPCResult(t *testing.T, w http.ResponseWriter, id json.RawMessage, result any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "jsonrpc": "2.0", + "id": decodeEstimateRPCID(id), + "result": result, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("encode rpc result: %v", err) + } +} + +func writeEstimateRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "jsonrpc": "2.0", + "id": decodeEstimateRPCID(id), + "error": map[string]any{ + "code": code, + "message": message, + }, + } + _ = json.NewEncoder(w).Encode(resp) +} + +func decodeEstimateRPCID(raw json.RawMessage) any { + if len(raw) == 0 { + return 1 + } + var out any + if err := json.Unmarshal(raw, &out); err != nil { + return 1 + } + return out +}