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 ff14c93..c4530d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,10 +19,9 @@ 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 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 -./defi lend markets --protocol kamino --chain solana --asset USDC --results-only -./defi swap quote --chain solana --from-asset USDC --to-asset SOL --amount 1000000 --results-only ``` ## Folder structure @@ -34,11 +33,13 @@ cmd/ internal/ app/runner.go # command wiring, provider routing, cache flow providers/ # external adapters - aave/ morpho/ kamino/ # direct GraphQL/REST lending + yield - defillama/ # chain/protocol market data + bridge analytics - across/ lifi/ bungee/ # bridge quotes - oneinch/ uniswap/ jupiter/ fibrous/ bungee/ # swap quotes + 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 + 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 + default chain RPC map config/ # defaults + file/env/flags precedence cache/ # sqlite cache + file lock id/ # CAIP parsing + amount normalization @@ -50,6 +51,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 @@ -64,29 +66,52 @@ 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 (`aave,morpho,kamino`), not protocol categories. -- Lending routes by `--protocol` to direct adapters only (`aave`, `morpho`, `kamino`). +- `yield --providers` expects provider names (`defillama,aave,morpho`), not protocol categories. +- 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`). `swap quote --provider jupiter` supports `DEFI_JUPITER_API_KEY` optionally (higher limits). `swap quote --provider fibrous` is keyless. Bungee Auto-mode quotes (`bridge quote --provider bungee`, `swap quote --provider bungee`) are keyless by default; optional dedicated-backend mode requires both `DEFI_BUNGEE_API_KEY` and `DEFI_BUNGEE_AFFILIATE`. +- 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. +- 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) + - `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|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. +- 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. +- 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`, `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 use a deterministic placeholder `swapper` for quote-only mode (`0x000...001`) and default to provider auto slippage unless `swap quote --slippage-pct` is provided. +- 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. - MegaETH bootstrap symbol parsing currently supports `MEGA`, `WETH`, and `USDT` (`USDT` maps to the chain's `USDT0` contract address on `eip155:4326`). Official Mega token list currently has no Ethereum L1 `MEGA` token entry. - 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. -- `lend`/`yield` rows expose retrieval-first ID metadata: `provider`, `provider_native_id`, and `provider_native_id_kind`; IDs are provider-scoped and not guaranteed to be on-chain addresses. -- Bridge quotes now include `fee_breakdown` with provider-reported components (`lp_fee`, `relayer_fee`, `gas_fee`) and amount-delta consistency checks. -- Kamino direct routes currently support Solana mainnet only. -- Solana devnet/testnet aliases and custom Solana CAIP-2 references are intentionally unsupported; use Solana mainnet only. - 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. -- Cache locking uses sqlite WAL + busy timeout + lock/backoff retries to reduce `database is locked` contention under parallel runs. -- Cache initialization is best-effort; if cache path init fails (permissions/path issues), commands continue with cache disabled. -- Across may omit native USD fee fields for some routes; when missing and the input asset is a known stable token, `estimated_fee_usd` falls back to a token-denominated approximation while exact token-unit fees remain in `fee_breakdown`. - Metadata commands (`version`, `schema`, `providers list`) 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`. - Mintlify production docs should use the `docs-live` branch; the release workflow force-syncs `docs-live` to each `v*` tag. @@ -126,10 +151,9 @@ README.md # user-facing usage + caveats - Keep `CHANGELOG.md` in a simple release-notes format with `## [Unreleased]` at the top. - Add user-facing changes under `Unreleased` using sections in this order: `Added`, `Changed`, `Fixed`, `Docs`, `Security`. - Keep entries concise and action-oriented (what changed for users, not internal refactors unless user impact exists). -- Record only the net user-facing outcome in `Unreleased`; omit intermediate implementation steps and fixes for regressions that never shipped in a release. -- Do not add changelog entries for README-only or `AGENTS.md`-only edits. - 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 4300c93..14a6192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,72 @@ 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` (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 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 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. +- 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`. +- Added `actions estimate` to compute per-step gas projections for persisted actions using `eth_estimateGas` and EIP-1559 fee cap/tip resolution. ### Changed -- None yet. +- 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`). +- `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. +- 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` 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. +- 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. +- 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`. +- 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. +- 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 -- None yet. +- Improved bridge execution error messaging to clearly distinguish quote-only providers from execution-capable providers. ### 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. +- 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 4237e0b..21f42b0 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,11 @@ Built for AI agents and scripts. Stable JSON output, canonical identifiers (CAIP ## Features -- **Lending** — query markets and rates from Aave, Morpho, and Kamino (Solana). -- **Yield** — compare opportunities across protocols and chains, filter by TVL and APY. -- **Bridging** — get cross-chain quotes (Across, LiFi, Bungee Auto) and bridge analytics (volume, chain breakdown). -- **Swapping** — get on-chain swap quotes (1inch, Uniswap, Jupiter for Solana, Fibrous, Bungee Auto). -- **Execution IDs** — lend/yield rows include provider-scoped `provider_native_id` plus `provider_native_id_kind` and `provider` for safer cross-provider parsing. -- **Fee transparency** — bridge quotes include `fee_breakdown` (`lp_fee`, `relayer_fee`, `gas_fee`, `total_fee_usd`) plus consistency checks against amount deltas. +- **Lending** — query markets/rates from Aave/Morpho/Kamino and account positions from Aave/Morpho, plus execute Aave/Morpho lend actions. +- **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. - **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. @@ -89,29 +88,38 @@ defi version --long 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 --asset USDC --results-only -defi assets resolve --chain solana --asset USDC --results-only -defi lend markets --protocol aave --chain 1 --asset USDC --results-only -defi lend markets --protocol kamino --chain solana --asset USDC --results-only -defi lend rates --protocol morpho --chain 1 --asset USDC --results-only +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 yield opportunities --chain solana --asset USDC --providers kamino --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 --from 1 --to 8453 --asset USDC --amount 1000000 --results-only -defi swap quote --chain solana --from-asset USDC --to-asset USDT --amount 1000000 --results-only -defi bridge quote --provider bungee --from hyperevm --to 8453 --asset USDC --amount 1000000 --results-only +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 --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 --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 +defi actions estimate --action-id --results-only ``` -`yield opportunities --providers` accepts provider names from `defi providers list` (e.g. `aave,morpho,kamino`). +`yield opportunities --providers` and `yield history --providers` accept provider names from `defi providers list` (for example `aave,morpho,kamino`). 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 -defi bridge quote --provider bungee --from 1 --to 8453 --asset USDC --amount 5000000 --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: @@ -120,28 +128,53 @@ Swap quote examples: export DEFI_1INCH_API_KEY=... export DEFI_UNISWAP_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 +defi swap quote --provider uniswap --chain 1 --from-asset USDC --to-asset DAI --amount 1000000 --from-address 0xYourEOA --results-only # Exact-output on Uniswap -defi swap quote --provider uniswap --chain 1 --from-asset USDC --to-asset DAI --type exact-output --amount-out 1000000000000000000 --results-only +defi swap quote --provider uniswap --chain 1 --from-asset USDC --to-asset DAI --type exact-output --amount-out 1000000000000000000 --from-address 0xYourEOA --results-only # Optional manual slippage override for Uniswap (percent) -defi swap quote --provider uniswap --chain 1 --from-asset USDC --to-asset DAI --amount 1000000 --slippage-pct 1.0 --results-only +defi swap quote --provider uniswap --chain 1 --from-asset USDC --to-asset DAI --amount 1000000 --slippage-pct 1.0 --from-address 0xYourEOA --results-only defi swap quote --provider bungee --chain hyperevm --from-asset USDC --to-asset WHYPE --amount 5000000 --results-only ``` -Swap quote example (`fibrous` does not require an API key): +Swap execution flow (local signer): ```bash -defi swap quote --provider fibrous --chain hyperevm --from-asset USDC --to-asset WHYPE --amount 1000000 --results-only - -# On Solana, provider defaults to jupiter. -defi swap quote --chain solana --from-asset USDC --to-asset SOL --amount 1000000 --results-only +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 \ + --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 \ + --results-only ``` -Solana identifiers: +`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. -- Chain: `solana` (mainnet), or CAIP-2 `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` -- Asset (CAIP-19): `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:` -- Non-mainnet Solana references (`solana-devnet`, `solana-testnet`, custom `solana:`) are unsupported. +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` (provider: `aave|morpho`) +- `rewards claim|compound plan|run|submit|status` (provider: `aave`) +- `actions list|show|estimate` ## Command API Key Requirements @@ -151,13 +184,10 @@ When a provider requires authentication, bring your own key: - `defi swap quote --provider 1inch` -> `DEFI_1INCH_API_KEY` - `defi swap quote --provider uniswap` -> `DEFI_UNISWAP_API_KEY` -- `defi swap quote --provider jupiter` -> `DEFI_JUPITER_API_KEY` (optional for higher limits) -- `defi swap quote --provider fibrous` -> no key required - `defi chains assets` -> `DEFI_DEFILLAMA_API_KEY` - `defi bridge list` -> `DEFI_DEFILLAMA_API_KEY` - `defi bridge details` -> `DEFI_DEFILLAMA_API_KEY` - -Bungee quotes (`bridge quote --provider bungee`, `swap quote --provider bungee`) are keyless by default. Optional dedicated-backend mode is enabled only when both `DEFI_BUNGEE_API_KEY` and `DEFI_BUNGEE_AFFILIATE` are set. +- `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`). @@ -165,25 +195,41 @@ Bungee quotes (`bridge quote --provider bungee`, `swap quote --provider bungee`) - `DEFI_1INCH_API_KEY` (required for `swap quote --provider 1inch`) - `DEFI_UNISWAP_API_KEY` (required for `swap quote --provider uniswap`) -- `DEFI_JUPITER_API_KEY` (optional for `swap quote --provider jupiter`) - `DEFI_DEFILLAMA_API_KEY` (required for `chains assets`, `bridge list`, and `bridge details`) -- `DEFI_BUNGEE_API_KEY` + `DEFI_BUNGEE_AFFILIATE` (optional pair for Bungee dedicated backend on quote routes) Configure keys with environment variables (recommended): ```bash export DEFI_1INCH_API_KEY=... export DEFI_UNISWAP_API_KEY=... -export DEFI_JUPITER_API_KEY=... export DEFI_DEFILLAMA_API_KEY=... -export DEFI_BUNGEE_API_KEY=... -export DEFI_BUNGEE_AFFILIATE=... ``` For persistent shell setup, add exports to your shell profile (for example `~/.zshrc`). 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 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) +- 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`. + +`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). @@ -211,49 +257,71 @@ retries: 2 cache: enabled: true max_stale: 5m +execution: + actions_path: ~/.cache/defi/actions.db + actions_lock_path: ~/.cache/defi/actions.lock +providers: + uniswap: + api_key_env: DEFI_UNISWAP_API_KEY ``` -Optional Bungee dedicated-backend config: +`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. -```yaml -providers: - bungee: - api_key_env: DEFI_BUNGEE_API_KEY - affiliate_env: DEFI_BUNGEE_AFFILIATE -``` +## 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`). +- 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 writes use SQLite WAL + busy timeout + lock/retry backoff to reduce lock contention in parallel agent runs. -- If cache initialization fails (path/permission issues), commands continue with cache disabled instead of failing. - `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|estimate`) bypass cache reads/writes. ## Caveats - Morpho can surface extreme APY values on very small markets. Prefer `--min-tvl-usd` when ranking yield. -- Kamino direct adapter currently supports Solana mainnet (`solana`) only. -- Solana devnet/testnet and custom Solana CAIP-2 references are rejected; only Solana mainnet is supported. -- `provider_native_id` is provider-scoped and should be interpreted with `provider_native_id_kind` (`market_id`, `pool_id`, `composite_market_asset`); do not assume it is an on-chain address. +- `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. -- `bridge list` and `bridge details` require `DEFI_DEFILLAMA_API_KEY`; quote providers (`across`, `lifi`, `bungee`) are keyless by default. -- Across can omit native USD fee fields on some routes; in those cases `estimated_fee_usd` falls back to a stable-asset approximation and exact token-denominated fees remain in `fee_breakdown`. +- `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. - Swap quote type defaults to `--type exact-input`; use `--type exact-output` with `--amount-out`/`--amount-out-decimal` when supported by the provider. -- For EVM exact-output requests without `--provider`, the default provider is `uniswap`; Solana exact-output is currently unsupported. +- Exact-output swap quotes currently support `--provider uniswap` only; Solana exact-output is currently unsupported. - Uniswap supports both `exact-input` and `exact-output`; 1inch/Jupiter/Fibrous/Bungee currently support `exact-input` only. -- Uniswap quote requests use a deterministic placeholder `swapper` (`0x000...001`) and default to provider auto slippage; use `--slippage-pct` to set a manual max slippage percent. +- Uniswap quote requests require `--from-address` as the `swapper`; provider auto slippage is used by default, and `--slippage-pct` sets a manual max slippage percent. - MegaETH bootstrap symbol parsing currently supports `MEGA`, `WETH`, and `USDT` (`USDT` maps to the chain's `USDT0` contract address). Official Mega token list currently has no Ethereum L1 `MEGA` token entry. - `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). +- 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). +- 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. +- Selector choice is explicit for multi-provider flows; pass `--provider` (no implicit defaults). ## Exit Codes @@ -267,6 +335,11 @@ providers: - `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 @@ -279,11 +352,13 @@ cmd/ internal/ app/runner.go # command wiring, routing, cache flow providers/ # external adapters - aave/ morpho/ kamino/ # direct lending + yield - defillama/ # chain/protocol market data + bridge analytics - across/ lifi/ bungee/ # bridge quotes - oneinch/ uniswap/ jupiter/ fibrous/ bungee/ # swap + aave/ morpho/ # direct lending + yield + defillama/ # normalization + fallback + bridge analytics + 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 @@ -295,6 +370,7 @@ internal/ httpx/ # shared HTTP client .github/workflows/ci.yml # CI (test/vet/build) +.github/workflows/nightly-execution-smoke.yml # nightly live execution planning smoke docs/ # Mintlify docs site (docs.json + MDX pages) AGENTS.md # contributor guide for agents ``` @@ -302,4 +378,7 @@ AGENTS.md # contributor guide for agents ```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..12868ee --- /dev/null +++ b/docs/act-execution-design.md @@ -0,0 +1,316 @@ +# Execution Component Design (`plan|run|submit|status`) + +Status: Implemented (v1) +Last Updated: 2026-02-24 +Scope: Current implementation in this branch (not a forward-looking proposal) + +## 1. Purpose + +`defi-cli` started as read-only retrieval. The execution component adds safe transaction workflows while preserving the existing CLI contract: + +- Stable envelope output +- Deterministic command semantics +- Clear execution lifecycle and resumability + +Execution is integrated inside existing domain commands (for example `swap`, `bridge`, `lend`) instead of a separate top-level `act` namespace. + +## 2. Current Execution Surface + +| 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 (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|estimate` | optional `--status` / `--action-id` filters | persisted action inspection + gas/fee estimation | + +Notes: + +- Multi-provider commands do not have implicit defaults. Users must pass `--provider`. + +## 3. Architecture Overview + +### 3.1 Command Integration + +Execution wiring lives in `internal/app/runner.go` and domain files: + +- `internal/app/bridge_execution_commands.go` +- `internal/app/lend_execution_commands.go` +- `internal/app/rewards_command.go` +- `internal/app/approvals_command.go` + +Design decision: + +- Keep execution verbs under the same domain as read paths (`swap`, `bridge`, `lend`, etc). + +Tradeoff: + +- Better command discoverability and API consistency, but more command wiring complexity in each domain. + +### 3.2 Unified ActionBuilder Registry + +Command handlers route action construction through a shared registry: + +- `internal/execution/actionbuilder/registry.go` + +Registry responsibility: + +- 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. + +Design decision: + +- Centralize action-construction dispatch while preserving domain-specific provider/planner implementations. + +Tradeoff: + +- Better consistency and less duplicated dispatch logic in command files, at the cost of one additional abstraction layer. + +### 3.3 Capability Interfaces + +Execution providers are opt-in capability interfaces in `internal/providers/types.go`: + +- `SwapExecutionProvider` +- `BridgeExecutionProvider` + +Lend/rewards/approvals currently use internal planners in `internal/execution/planner` instead of provider interfaces. + +Design decision: + +- Capability interfaces avoid forcing all providers to implement execution. + +Tradeoff: + +- Mixed architecture today (provider-based for swap/bridge, planner-based for lend/rewards) increases conceptual surface. + +### 3.4 Action Model + +Canonical action model is in `internal/execution/types.go`: + +- `Action`: intent metadata + ordered steps +- `ActionStep`: executable transaction step +- `Constraints`: execution constraints + +Lifecycle states: + +- Action: `planned -> running -> completed|failed` +- Step: `pending -> simulated -> submitted -> confirmed|failed` + +Step order is the dependency model (no separate DAG). This keeps execution deterministic and straightforward. + +### 3.5 Persistence + +Persistence is in `internal/execution/store.go` (SQLite + file lock): + +- single `actions` table +- full action JSON blob stored in `payload` +- indexed by `status` and `updated_at` + +Design decision: + +- JSON blob persistence with a light relational index. + +Tradeoff: + +- Easy compatibility/migrations and exact replay of serialized actions, but weaker SQL-level querying of step internals. + +## 4. Command Semantics + +### 4.1 `plan` + +- 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. + +### 4.2 `run` + +- Performs plan + execute in one invocation. +- Persists action first, then executes steps. + +### 4.3 `submit` + +- Loads a previously persisted action by `--action-id`. +- Executes remaining steps. + +### 4.4 `status` and `actions` + +- 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 + +Signer abstractions: + +- Interface: `internal/execution/signer/signer.go` +- Local signer implementation: `internal/execution/signer/local.go` +- Command-level signer setup: `newExecutionSigner(...)` in `internal/app/runner.go` + +Supported backend today: + +- `--signer local` only (other backends intentionally not implemented yet) + +Key sources: + +- `--key-source auto|env|file|keystore` +- `--private-key` (run/submit one-off override) +- Environment variables: + - `DEFI_PRIVATE_KEY` + - `DEFI_PRIVATE_KEY_FILE` + - `DEFI_KEYSTORE_PATH` + - `DEFI_KEYSTORE_PASSWORD` + - `DEFI_KEYSTORE_PASSWORD_FILE` + +`auto` precedence in current code: + +1. `--private-key` (when provided) +2. `DEFI_PRIVATE_KEY` +3. `DEFI_PRIVATE_KEY_FILE` +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: + +- 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: + +- Local key signing first, with backend abstraction retained for future expansion. + +Tradeoff: + +- Fast delivery and low integration complexity now, but no hardware wallet, Safe, or remote signer support yet. + +## 6. Endpoint, Contract, and ABI Management + +Canonical execution metadata is split under `internal/registry/`: + +- `endpoints.go`: + - LiFi quote/status endpoints + - Across quote/status endpoints + - Morpho GraphQL endpoint used by execution planners +- `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 +- `abis.go`: + - ERC-20 minimal + - Uniswap V3 quoter/router + - Aave pool/rewards/provider + - Morpho Blue + +Important nuance: + +- Execution-critical endpoints are centralized; quote-only/read-only provider endpoints may still remain adapter-local. + +Design decision: + +- Compile-time Go registry values instead of external YAML/JSON loading. + +Tradeoff: + +- Strong type safety and fewer runtime failure modes, but lower operational flexibility for hotfixing metadata without a release. + +## 7. Execution Engine, Simulation, and Consistency + +Core executor: `internal/execution/executor.go`. + +Per step execution flow: + +1. Validate RPC URL, target, and chain match. +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: + +- 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). + +Context and timeout behavior: + +- Command timeout is propagated to run/submit execution via `executeActionWithTimeout(...)`. +- Per-step timeout and poll interval are configurable (`--step-timeout`, `--poll-interval`). + +Design decision: + +- 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: + +- Bridge settlement success is API-confirmed; no universal destination on-chain balance verification is enforced yet. + +## 8. Dependency Strategy (`cast` / Foundry) + +Decision: + +- Do not require `forge cast` as a runtime dependency. + +Rationale: + +- Runtime binary dependency increases installation complexity. +- Native Go (`go-ethereum`) gives deterministic behavior in CI and releases. + +Tradeoff: + +- Less convenient ad-hoc debugging for some users who prefer Foundry tooling, but cleaner production runtime. + +## 9. Testing and Nightly Drift Checks + +Standard quality gates: + +- `go test ./...` +- `go test -race ./...` +- `go vet ./...` + +Execution-related tests include planner, executor, settlement polling, and command wiring coverage. + +Nightly workflow: + +- 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. + +Design decision: + +- Nightly job validates external dependency drift without requiring broadcast transactions. + +Tradeoff: + +- Detects endpoint/RPC/contract drift early, but does not prove end-to-end transaction broadcasting on every run. + +## 10. Major Decisions and Tradeoffs Summary + +| 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 | + +## 11. Known Gaps and Next Increments + +- 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/docs/concepts/providers-and-auth.mdx b/docs/concepts/providers-and-auth.mdx index 1c0e350..e6ebd7d 100644 --- a/docs/concepts/providers-and-auth.mdx +++ b/docs/concepts/providers-and-auth.mdx @@ -37,8 +37,10 @@ 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`. +- `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/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/guides/yield.mdx b/docs/guides/yield.mdx index 3d56533..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 @@ -41,8 +47,25 @@ 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 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/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 75adf12..03f1698 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 @@ -37,6 +38,7 @@ defi lend markets --protocol kamino --chain solana --asset USDC --limit 5 --resu 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/commands-overview.mdx b/docs/reference/commands-overview.mdx index c9ed29a..c25b73e 100644 --- a/docs/reference/commands-overview.mdx +++ b/docs/reference/commands-overview.mdx @@ -43,7 +43,8 @@ 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 +defi actions estimate --action-id --results-only ``` diff --git a/docs/reference/lending-and-yield-commands.mdx b/docs/reference/lending-and-yield-commands.mdx index b23e82f..08a4000 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 @@ -34,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 ``` @@ -47,11 +62,42 @@ 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 +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/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..a8dfa57 --- /dev/null +++ b/internal/app/approvals_command.go @@ -0,0 +1,256 @@ +package app + +import ( + "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 s.actionBuilderRegistry().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 runSigner, runKeySource, runPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, run.fromAddress) + if err != nil { + return err + } + runArgs := run + runArgs.fromAddress = runSenderAddress + + start := time.Now() + 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) + 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) + } + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) + if err != nil { + return err + } + if err := s.executeActionWithTimeout(&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 (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(&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") + 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") + + var submitActionID string + var submitSimulate bool + var submitSigner, submitKeySource, submitPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(submitActionID) + 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, submitPrivateKey) + 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") + } + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) + if err != nil { + return err + } + if err := s.executeActionWithTimeout(&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().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") + 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{ + Use: "status", + Short: "Get approval action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID) + 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") + + 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..686399c --- /dev/null +++ b/internal/app/bridge_execution_commands.go @@ -0,0 +1,317 @@ +package app + +import ( + "context" + "strings" + "time" + + clierr "github.com/ggonzalez94/defi-cli/internal/errors" + 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, fromAmountForGas 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, + FromAmountForGas: strings.TrimSpace(fromAmountForGas), + }, nil + } + + var planProviderArg, planFromArg, planToArg, planAssetArg, planToAssetArg string + var planAmountBase, planAmountDecimal, planFromAddress, planRecipient, planFromAmountForGas 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") + } + reqStruct, err := buildRequest(planFromArg, planToArg, planAssetArg, planToAssetArg, planAmountBase, planAmountDecimal, planFromAmountForGas) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, providerInfoName, err := s.actionBuilderRegistry().BuildBridgeAction(ctx, providerName, reqStruct, providers.BridgeExecutionOptions{ + Sender: planFromAddress, + Recipient: planRecipient, + SlippageBps: planSlippageBps, + Simulate: planSimulate, + RPCURL: planRPCURL, + FromAmountForGas: planFromAmountForGas, + }) + 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 + } + 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 (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") + 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, runFromAmountForGas string + var runSlippageBps int64 + var runSimulate bool + var runRPCURL string + var runSigner, runKeySource, runPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + providerName := strings.ToLower(strings.TrimSpace(runProviderArg)) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") + } + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, runFromAddress) + if err != nil { + return err + } + reqStruct, err := buildRequest(runFromArg, runToArg, runAssetArg, runToAssetArg, runAmountBase, runAmountDecimal, runFromAmountForGas) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), s.settings.Timeout) + defer cancel() + start := time.Now() + action, providerInfoName, err := s.actionBuilderRegistry().BuildBridgeAction(ctx, providerName, reqStruct, providers.BridgeExecutionOptions{ + Sender: runSenderAddress, + Recipient: runRecipient, + SlippageBps: runSlippageBps, + Simulate: runSimulate, + RPCURL: runRPCURL, + FromAmountForGas: runFromAmountForGas, + }) + 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 + } + 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) + } + execOpts, err := parseExecuteOptions( + runSimulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.executeActionWithTimeout(&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 (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 (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(&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") + 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") + _ = runCmd.MarkFlagRequired("provider") + + var submitActionID string + var submitSimulate bool + var submitSigner, submitKeySource, submitPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(submitActionID) + 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, submitPrivateKey) + 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") + } + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) + if err != nil { + return err + } + if err := s.executeActionWithTimeout(&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().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") + 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{ + Use: "status", + Short: "Get bridge action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID) + 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") + + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) +} 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 new file mode 100644 index 0000000..ac68848 --- /dev/null +++ b/internal/app/lend_execution_commands.go @@ -0,0 +1,310 @@ +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/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" + "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 { + provider string + chainArg string + assetArg string + marketID 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) { + 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 s.actionBuilderRegistry().BuildLendAction(ctx, actionbuilder.LendRequest{ + Provider: args.provider, + 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 + 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) + providerName := normalizeLendingProvider(plan.provider) + 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 + } + 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.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 --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") + planCmd.Flags().StringVar(&plan.recipient, "recipient", "", "Recipient address (defaults to --from-address)") + 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") + 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("provider") + + var run lendArgs + var runSigner, runKeySource, runPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, 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, runArgs) + providerName := normalizeLendingProvider(run.provider) + 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 + } + 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) + } + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.executeActionWithTimeout(&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.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 --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)") + 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)") + 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(&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") + 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("provider") + + var submitActionID string + var submitSimulate bool + var submitSigner, submitKeySource, submitPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(submitActionID) + 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, submitPrivateKey) + 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") + } + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) + if err != nil { + return err + } + if err := s.executeActionWithTimeout(&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().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") + 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{ + Use: "status", + Short: "Get lend action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID) + 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") + + root.AddCommand(planCmd) + root.AddCommand(runCmd) + root.AddCommand(submitCmd) + root.AddCommand(statusCmd) + return root +} diff --git a/internal/app/provider_selection_test.go b/internal/app/provider_selection_test.go index b1ea45b..4ece051 100644 --- a/internal/app/provider_selection_test.go +++ b/internal/app/provider_selection_test.go @@ -7,18 +7,53 @@ 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) } } +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/rewards_command.go b/internal/app/rewards_command.go new file mode 100644 index 0000000..97c8661 --- /dev/null +++ b/internal/app/rewards_command.go @@ -0,0 +1,564 @@ +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/actionbuilder" + 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 { + provider 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) { + 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 s.actionBuilderRegistry().BuildRewardsClaimAction(ctx, actionbuilder.RewardsClaimRequest{ + Provider: args.provider, + 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, + }) + } + + 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.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)") + 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("provider") + + var run claimArgs + var runSigner, runKeySource, runPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, 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, runArgs) + 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) + } + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.executeActionWithTimeout(&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.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)") + 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(&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") + 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") + _ = runCmd.MarkFlagRequired("provider") + + var submitActionID string + var submitSimulate bool + var submitSigner, submitKeySource, submitPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(submitActionID) + 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, submitPrivateKey) + 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") + } + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) + if err != nil { + return err + } + if err := s.executeActionWithTimeout(&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().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") + 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{ + Use: "status", + Short: "Get rewards-claim action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID) + 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") + + 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 { + provider 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) { + 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 s.actionBuilderRegistry().BuildRewardsCompoundAction(ctx, actionbuilder.RewardsCompoundRequest{ + Provider: args.provider, + 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, + }) + } + + 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.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)") + 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("provider") + + var run compoundArgs + var runSigner, runKeySource, runPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, 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, runArgs) + 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) + } + execOpts, err := parseExecuteOptions( + run.simulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + if err := s.executeActionWithTimeout(&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.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)") + 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(&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") + 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") + _ = runCmd.MarkFlagRequired("amount") + _ = runCmd.MarkFlagRequired("provider") + + var submitActionID string + var submitSimulate bool + var submitSigner, submitKeySource, submitPrivateKey, 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", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(submitActionID) + 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, submitPrivateKey) + 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") + } + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) + if err != nil { + return err + } + if err := s.executeActionWithTimeout(&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().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") + 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{ + Use: "status", + Short: "Get rewards-compound action status", + RunE: func(cmd *cobra.Command, _ []string) error { + actionID, err := resolveActionID(statusActionID) + 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") + + 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 4fd0b01..6f014d3 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -10,12 +10,17 @@ import ( "io" "os" "sort" + "strconv" "strings" "time" + "github.com/ethereum/go-ethereum/common" "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" + "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" "github.com/ggonzalez94/defi-cli/internal/model" @@ -32,6 +37,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" @@ -61,6 +67,8 @@ type runtimeState struct { flags config.GlobalFlags settings config.Settings cache *cache.Store + actionStore *execution.Store + actionBuilder *actionbuilder.Registry root *cobra.Command lastCommand string lastWarnings []string @@ -95,6 +103,9 @@ func (r *Runner) Run(args []string) int { if state.cache != nil { _ = state.cache.Close() } + if state.actionStore != nil { + _ = state.actionStore.Close() + } return 0 } @@ -102,6 +113,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) } @@ -132,6 +146,7 @@ func (s *runtimeState) newRootCommand() *cobra.Command { morphoProvider := morpho.New(httpClient) kaminoProvider := kamino.New(httpClient) jupiterProvider := jupiter.New(httpClient, settings.JupiterAPIKey) + taikoSwapProvider := taikoswap.New() s.marketProvider = llama s.lendingProviders = map[string]providers.LendingProvider{ "aave": aaveProvider, @@ -153,11 +168,12 @@ 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), - "jupiter": jupiterProvider, - "bungee": bungee.NewSwap(httpClient, settings.BungeeAPIKey, settings.BungeeAffiliate), - "fibrous": fibrous.New(httpClient), + "1inch": oneinch.New(httpClient, settings.OneInchAPIKey), + "uniswap": uniswap.New(httpClient, settings.UniswapAPIKey), + "taikoswap": taikoSwapProvider, + "jupiter": jupiterProvider, + "bungee": bungee.NewSwap(httpClient, settings.BungeeAPIKey, settings.BungeeAffiliate), + "fibrous": fibrous.New(httpClient), } s.providerInfos = []model.ProviderInfo{ llama.Info(), @@ -169,11 +185,17 @@ func (s *runtimeState) newRootCommand() *cobra.Command { s.bridgeProviders["bungee"].Info(), s.swapProviders["1inch"].Info(), s.swapProviders["uniswap"].Info(), + s.swapProviders["taikoswap"].Info(), s.swapProviders["jupiter"].Info(), s.swapProviders["bungee"].Info(), 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) @@ -184,6 +206,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 }, } @@ -210,8 +239,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()) @@ -417,7 +449,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 @@ -426,24 +458,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 @@ -453,35 +485,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 @@ -491,20 +523,96 @@ 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") + 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 } 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", @@ -512,7 +620,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 { @@ -553,20 +661,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() @@ -576,16 +686,18 @@ func (s *runtimeState) newBridgeCommand() *cobra.Command { }) }, } - quoteCmd.Flags().StringVar("eProviderArg, "provider", "across", "Bridge provider (across|lifi|bungee; no API key required)") + quoteCmd.Flags().StringVar("eProviderArg, "provider", "", "Bridge provider (across|lifi|bungee; 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") 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") + _ = quoteCmd.MarkFlagRequired("provider") var listLimit int var includeChains bool @@ -653,86 +765,112 @@ 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, tradeTypeArg string - var amountBase, amountDecimal string - var amountOutBase, amountOutDecimal string - var slippagePct float64 - cmd := &cobra.Command{ + root := &cobra.Command{Use: "swap", Short: "Swap quote and execution commands"} + + parseSwapRequest := func(chainArg, fromAssetArg, toAssetArg, amountBase, amountDecimal, rpcURL 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, + RPCURL: strings.TrimSpace(rpcURL), + TradeType: providers.SwapTradeTypeExactInput, + }, nil + } + + var quoteProviderArg, quoteChainArg, quoteFromAssetArg, quoteToAssetArg, quoteTradeTypeArg string + var quoteAmountBase, quoteAmountDecimal, quoteAmountOutBase, quoteAmountOutDecimal, quoteRPCURL string + var quoteFromAddress string + var quoteSlippagePct float64 + quoteCmd := &cobra.Command{ Use: "quote", Short: "Get swap quote", RunE: func(cmd *cobra.Command, args []string) error { - chain, err := id.ParseChain(chainArg) - if err != nil { - return err - } - tradeType := providers.SwapTradeType(strings.ToLower(strings.TrimSpace(tradeTypeArg))) - switch tradeType { - case "", providers.SwapTradeTypeExactInput: - tradeType = providers.SwapTradeTypeExactInput - case providers.SwapTradeTypeExactOutput: - default: - return clierr.New(clierr.CodeUsage, "--type must be exact-input or exact-output") - } - - providerName := strings.ToLower(strings.TrimSpace(providerArg)) + providerName := strings.ToLower(strings.TrimSpace(quoteProviderArg)) if providerName == "" { - if tradeType == providers.SwapTradeTypeExactOutput { - if chain.IsSolana() { - return clierr.New(clierr.CodeUnsupported, "exact-output swap quotes are currently supported only on EVM with --provider uniswap") - } - providerName = "uniswap" - } else { - if chain.IsSolana() { - providerName = "jupiter" - } else { - providerName = "1inch" - } - } + return clierr.New(clierr.CodeUsage, "--provider is required (1inch|uniswap|taikoswap|jupiter|fibrous|bungee)") } provider, ok := s.swapProviders[providerName] if !ok { return clierr.New(clierr.CodeUnsupported, "unsupported swap provider") } - fromAsset, err := id.ParseAsset(fromAssetArg, chain) + chain, err := id.ParseChain(quoteChainArg) + if err != nil { + return err + } + fromAsset, err := id.ParseAsset(quoteFromAssetArg, chain) if err != nil { return err } - toAsset, err := id.ParseAsset(toAssetArg, chain) + toAsset, err := id.ParseAsset(quoteToAssetArg, chain) if err != nil { return err } + tradeType := providers.SwapTradeType(strings.ToLower(strings.TrimSpace(quoteTradeTypeArg))) + switch tradeType { + case "", providers.SwapTradeTypeExactInput: + tradeType = providers.SwapTradeTypeExactInput + case providers.SwapTradeTypeExactOutput: + default: + return clierr.New(clierr.CodeUsage, "--type must be exact-input or exact-output") + } + if tradeType == providers.SwapTradeTypeExactOutput && providerName != "uniswap" { + return clierr.New(clierr.CodeUnsupported, "exact-output swap quotes currently support only --provider uniswap") + } + var base, decimal string switch tradeType { case providers.SwapTradeTypeExactInput: - if amountOutBase != "" || amountOutDecimal != "" { + if quoteAmountOutBase != "" || quoteAmountOutDecimal != "" { return clierr.New(clierr.CodeUsage, "--amount-out/--amount-out-decimal are only valid with --type exact-output") } decimals := fromAsset.Decimals if decimals <= 0 { decimals = 18 } - base, decimal, err = id.NormalizeAmount(amountBase, amountDecimal, decimals) + base, decimal, err = id.NormalizeAmount(quoteAmountBase, quoteAmountDecimal, decimals) if err != nil { return err } case providers.SwapTradeTypeExactOutput: - if amountBase != "" || amountDecimal != "" { + if quoteAmountBase != "" || quoteAmountDecimal != "" { return clierr.New(clierr.CodeUsage, "--amount/--amount-decimal are only valid with --type exact-input") } - if amountOutBase == "" && amountOutDecimal == "" { + if quoteAmountOutBase == "" && quoteAmountOutDecimal == "" { return clierr.New(clierr.CodeUsage, "exact-output requires --amount-out or --amount-out-decimal") } decimals := toAsset.Decimals if decimals <= 0 { decimals = 18 } - base, decimal, err = id.NormalizeAmount(amountOutBase, amountOutDecimal, decimals) + base, decimal, err = id.NormalizeAmount(quoteAmountOutBase, quoteAmountOutDecimal, decimals) if err != nil { return err } @@ -744,11 +882,19 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { if providerName != "uniswap" { return clierr.New(clierr.CodeUsage, "--slippage-pct is supported only with --provider uniswap") } - if slippagePct <= 0 || slippagePct > 100 { + if quoteSlippagePct <= 0 || quoteSlippagePct > 100 { return clierr.New(clierr.CodeUsage, "--slippage-pct must be > 0 and <= 100") } slippageMode = "manual" - slippagePtr = &slippagePct + slippagePtr = "eSlippagePct + } + + swapper := strings.TrimSpace(quoteFromAddress) + if swapper != "" && !common.IsHexAddress(swapper) { + return clierr.New(clierr.CodeUsage, "--from-address must be a valid EVM hex address") + } + if providerName == "uniswap" && swapper == "" { + return clierr.New(clierr.CodeUsage, "--from-address is required for --provider uniswap") } reqStruct := providers.SwapQuoteRequest{ @@ -757,18 +903,22 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { ToAsset: toAsset, AmountBaseUnits: base, AmountDecimal: decimal, + RPCURL: strings.TrimSpace(quoteRPCURL), TradeType: tradeType, SlippagePct: slippagePtr, + Swapper: swapper, } key := cacheKey(trimRootPath(cmd.CommandPath()), map[string]any{ "provider": providerName, - "chain": chain.CAIP2, - "from": fromAsset.AssetID, - "to": toAsset.AssetID, - "trade_type": tradeType, - "amount": base, + "chain": reqStruct.Chain.CAIP2, + "from": reqStruct.FromAsset.AssetID, + "to": reqStruct.ToAsset.AssetID, + "trade_type": reqStruct.TradeType, + "amount": reqStruct.AmountBaseUnits, "slippage_mode": slippageMode, - "slippage_pct": slippagePtr, + "slippage_pct": reqStruct.SlippagePct, + "swapper": strings.ToLower(reqStruct.Swapper), + "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() @@ -778,47 +928,416 @@ func (s *runtimeState) newSwapCommand() *cobra.Command { }) }, } - cmd.Flags().StringVar(&providerArg, "provider", "", "Swap provider (defaults: 1inch for EVM exact-input, uniswap for EVM exact-output, jupiter for Solana exact-input; options: 1inch|uniswap|jupiter|fibrous|bungee; fibrous/bungee require no API key)") - 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(&tradeTypeArg, "type", string(providers.SwapTradeTypeExactInput), "Swap type (exact-input|exact-output)") - cmd.Flags().StringVar(&amountBase, "amount", "", "Exact-input amount in base units") - cmd.Flags().StringVar(&amountDecimal, "amount-decimal", "", "Exact-input amount in decimal units") - cmd.Flags().StringVar(&amountOutBase, "amount-out", "", "Exact-output amount in base units") - cmd.Flags().StringVar(&amountOutDecimal, "amount-out-decimal", "", "Exact-output amount in decimal units") - cmd.Flags().Float64Var(&slippagePct, "slippage-pct", 0, "Manual max slippage percent override (Uniswap only; default uses provider auto slippage)") - _ = cmd.MarkFlagRequired("chain") - _ = cmd.MarkFlagRequired("from-asset") - _ = cmd.MarkFlagRequired("to-asset") - root.AddCommand(cmd) + quoteCmd.Flags().StringVar("eProviderArg, "provider", "", "Swap provider (1inch|uniswap|taikoswap|jupiter|fibrous|bungee)") + 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("eTradeTypeArg, "type", string(providers.SwapTradeTypeExactInput), "Swap type (exact-input|exact-output)") + quoteCmd.Flags().StringVar("eAmountBase, "amount", "", "Exact-input amount in base units") + quoteCmd.Flags().StringVar("eAmountDecimal, "amount-decimal", "", "Exact-input amount in decimal units") + quoteCmd.Flags().StringVar("eAmountOutBase, "amount-out", "", "Exact-output amount in base units") + quoteCmd.Flags().StringVar("eAmountOutDecimal, "amount-out-decimal", "", "Exact-output amount in decimal units") + quoteCmd.Flags().Float64Var("eSlippagePct, "slippage-pct", 0, "Manual max slippage percent override (Uniswap only; default uses provider auto slippage)") + quoteCmd.Flags().StringVar("eFromAddress, "from-address", "", "Swapper/sender EOA address (required for --provider uniswap)") + 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") + _ = quoteCmd.MarkFlagRequired("provider") + + var planProviderArg, planChainArg, planFromAssetArg, 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 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") + } + reqStruct, err := parseSwapRequest(planChainArg, planFromAssetArg, planToAssetArg, planAmountBase, planAmountDecimal, "") + 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, "plan", reqStruct, providers.SwapExecutionOptions{ + Sender: planFromAddress, + Recipient: planRecipient, + SlippageBps: planSlippageBps, + Simulate: planSimulate, + RPCURL: planRPCURL, + }) + 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 + } + 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", "", "Swap execution 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.Flags().StringVar(&planRPCURL, "rpc-url", "", "RPC URL override for the selected chain") + _ = 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 bool + var runRPCURL string + var runSigner, runKeySource, runPrivateKey string + 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", + RunE: func(cmd *cobra.Command, args []string) error { + providerName := strings.ToLower(strings.TrimSpace(runProviderArg)) + if providerName == "" { + return clierr.New(clierr.CodeUsage, "--provider is required") + } + reqStruct, err := parseSwapRequest(runChainArg, runFromAssetArg, runToAssetArg, runAmountBase, runAmountDecimal, "") + if err != nil { + return err + } + txSigner, runSenderAddress, err := resolveRunSignerAndFromAddress(runSigner, runKeySource, runPrivateKey, 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: runSenderAddress, + Recipient: runRecipient, + SlippageBps: runSlippageBps, + Simulate: runSimulate, + RPCURL: runRPCURL, + }) + 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 + } + 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) + } + execOpts, err := parseExecuteOptions( + runSimulate, + runPollInterval, + runStepTimeout, + runGasMultiplier, + runMaxFeeGwei, + runMaxPriorityFeeGwei, + runAllowMaxApproval, + runUnsafeProviderTx, + ) + if err != nil { + s.captureCommandDiagnostics(nil, statuses, false) + return err + } + + if err := s.executeActionWithTimeout(&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", "", "Swap execution 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 (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 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") + 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") + _ = runCmd.MarkFlagRequired("provider") + + var submitActionID string + var submitSimulate bool + var submitSigner, submitKeySource, submitPrivateKey, submitFromAddress string + 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", + RunE: func(cmd *cobra.Command, args []string) error { + actionID, err := resolveActionID(submitActionID) + 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, submitPrivateKey) + 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") + } + execOpts, err := parseExecuteOptions( + submitSimulate, + submitPollInterval, + submitStepTimeout, + submitGasMultiplier, + submitMaxFeeGwei, + submitMaxPriorityFeeGwei, + submitAllowMaxApproval, + submitUnsafeProviderTx, + ) + if err != nil { + return err + } + if err := s.executeActionWithTimeout(&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().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") + 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{ + Use: "status", + Short: "Get swap action status", + 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 + } + 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") + + 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", + 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 + 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") + + 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) + }, + } + 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 } 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 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, + Providers: splitCSV(opportunitiesProvidersArg), + SortBy: opportunitiesSortArg, + IncludeIncomplete: opportunitiesIncludeIncomplete, } key := cacheKey(trimRootPath(cmd.CommandPath()), map[string]any{ "chain": req.Chain.CAIP2, @@ -826,7 +1345,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, @@ -860,7 +1378,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") } @@ -876,25 +1394,190 @@ 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().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", "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") + 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, + SortBy: "apy_total", + 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 } @@ -1028,6 +1711,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" } } @@ -1059,7 +1752,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" @@ -1072,10 +1765,25 @@ func normalizeLendingProtocol(input string) string { } } -func (s *runtimeState) selectLendingProvider(protocol string) (providers.LendingProvider, error) { - primary, ok := s.lendingProviders[protocol] +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 { - 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 } @@ -1128,7 +1836,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 } } @@ -1142,38 +1850,194 @@ 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 + } + 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 { + 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.OpportunityID, b.OpportunityID) < 0 + 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 @@ -1206,6 +2070,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 == "" { @@ -1378,18 +2263,182 @@ 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 show", "actions estimate": + 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 (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) + if actionID == "" { + return "", clierr.New(clierr.CodeUsage, "action id is required (--action-id)") + } + return actionID, nil +} + +func newExecutionSigner(signerBackend, keySource, privateKey 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.NewLocalSignerFromInputs(keySource, privateKey) + if err != nil { + return nil, clierr.Wrap(clierr.CodeSigner, "initialize local signer", err) + } + return localSigner, nil +} + +func resolveRunSignerAndFromAddress(signerBackend, keySource, privateKey, fromAddress string) (execsigner.Signer, string, error) { + txSigner, err := newExecutionSigner(signerBackend, keySource, privateKey) + 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, + allowMaxApproval bool, + unsafeProviderTx bool, +) (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 <= 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 +} + +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 new file mode 100644 index 0000000..9fe524c --- /dev/null +++ b/internal/app/runner_actions_test.go @@ -0,0 +1,311 @@ +package app + +import ( + "bytes" + "encoding/json" + "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 { + t.Fatalf("resolveActionID failed: %v", err) + } + if id != "act_123" { + t.Fatalf("unexpected action id: %s", id) + } + + if _, err := resolveActionID(""); err == nil { + t.Fatal("expected error when action id is missing") + } +} + +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 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") + } +} + +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") + } + 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("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") + } + if shouldOpenActionStore("lend markets") { + t.Fatal("did not expect lend markets to require action store") + } +} + +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["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") + } +} + +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("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") + } + 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 TestRunnerMorphoLendPlanRequiresMarketID(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{ + "lend", "supply", "plan", + "--provider", "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) + + 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 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) + + 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()) + } +} + +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/app/runner_test.go b/internal/app/runner_test.go index b549ac7..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 { @@ -474,6 +619,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 @@ -524,7 +865,7 @@ func TestRunnerBridgeDetailsRequiresBridgeFlag(t *testing.T) { } } -func TestSwapDefaultsToJupiterForSolana(t *testing.T) { +func TestSwapQuoteWithJupiterForSolana(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer oneinch := &fakeSwapProvider{name: "1inch"} @@ -553,6 +894,7 @@ func TestSwapDefaultsToJupiterForSolana(t *testing.T) { root.AddCommand(state.newSwapCommand()) root.SetArgs([]string{ "swap", "quote", + "--provider", "jupiter", "--chain", "solana", "--from-asset", "USDC", "--to-asset", "USDT", @@ -569,7 +911,7 @@ func TestSwapDefaultsToJupiterForSolana(t *testing.T) { } } -func TestSwapDefaultsToOneInchForEVM(t *testing.T) { +func TestSwapQuoteWithOneInchForEVM(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer oneinch := &fakeSwapProvider{name: "1inch"} @@ -598,6 +940,7 @@ func TestSwapDefaultsToOneInchForEVM(t *testing.T) { root.AddCommand(state.newSwapCommand()) root.SetArgs([]string{ "swap", "quote", + "--provider", "1inch", "--chain", "base", "--from-asset", "USDC", "--to-asset", "DAI", @@ -646,6 +989,7 @@ func TestSwapSlippageOverridePassedToProvider(t *testing.T) { "--from-asset", "USDC", "--to-asset", "DAI", "--amount", "1000000", + "--from-address", "0x000000000000000000000000000000000000dEaD", "--slippage-pct", "1.25", }) if err := root.Execute(); err != nil { @@ -658,6 +1002,9 @@ func TestSwapSlippageOverridePassedToProvider(t *testing.T) { if *uniswap.lastReq.SlippagePct != 1.25 { t.Fatalf("expected slippage=1.25, got %v", *uniswap.lastReq.SlippagePct) } + if uniswap.lastReq.Swapper != "0x000000000000000000000000000000000000dEaD" { + t.Fatalf("expected swapper to be forwarded, got %s", uniswap.lastReq.Swapper) + } } func TestSwapSlippageOverrideValidation(t *testing.T) { @@ -692,6 +1039,7 @@ func TestSwapSlippageOverrideValidation(t *testing.T) { "--from-asset", "USDC", "--to-asset", "DAI", "--amount", "1000000", + "--from-address", "0x000000000000000000000000000000000000dEaD", "--slippage-pct", "0", }) if err := root.Execute(); err == nil { @@ -735,6 +1083,7 @@ func TestSwapExactOutputPassedToProvider(t *testing.T) { "--to-asset", "DAI", "--type", "exact-output", "--amount-out", "1000000000000000000", + "--from-address", "0x000000000000000000000000000000000000dEaD", }) if err := root.Execute(); err != nil { t.Fatalf("swap command failed: %v stderr=%s", err, stderr.String()) @@ -749,9 +1098,12 @@ func TestSwapExactOutputPassedToProvider(t *testing.T) { if uniswap.lastReq.AmountDecimal != "1" { t.Fatalf("unexpected amount decimal: %s", uniswap.lastReq.AmountDecimal) } + if uniswap.lastReq.Swapper != "0x000000000000000000000000000000000000dEaD" { + t.Fatalf("expected swapper to be forwarded, got %s", uniswap.lastReq.Swapper) + } } -func TestSwapExactOutputDefaultsToUniswapOnEVM(t *testing.T) { +func TestSwapExactOutputRequiresExplicitProvider(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer oneinch := &fakeSwapProvider{name: "1inch"} @@ -786,18 +1138,15 @@ func TestSwapExactOutputDefaultsToUniswapOnEVM(t *testing.T) { "--type", "exact-output", "--amount-out", "1000000000000000000", }) - if err := root.Execute(); err != nil { - t.Fatalf("swap command failed: %v stderr=%s", err, stderr.String()) + if err := root.Execute(); err == nil { + t.Fatalf("expected provider requirement error, stderr=%s", stderr.String()) } if oneinch.calls != 0 { t.Fatalf("expected 1inch not to be called, got %d calls", oneinch.calls) } - if uniswap.calls != 1 { - t.Fatalf("expected uniswap to be called once, got %d calls", uniswap.calls) - } - if uniswap.lastReq.TradeType != providers.SwapTradeTypeExactOutput { - t.Fatalf("expected trade type exact-output, got %s", uniswap.lastReq.TradeType) + if uniswap.calls != 0 { + t.Fatalf("expected uniswap not to be called, got %d calls", uniswap.calls) } } @@ -835,7 +1184,7 @@ func TestSwapExactOutputWithoutProviderRejectedOnSolana(t *testing.T) { "--amount-out", "1000000", }) if err := root.Execute(); err == nil { - t.Fatalf("expected unsupported error, stderr=%s", stderr.String()) + t.Fatalf("expected provider requirement error, stderr=%s", stderr.String()) } if jupiter.calls != 0 { t.Fatalf("expected jupiter not to be called, got %d calls", jupiter.calls) @@ -874,6 +1223,7 @@ func TestSwapExactOutputRequiresOutputAmount(t *testing.T) { "--from-asset", "USDC", "--to-asset", "DAI", "--type", "exact-output", + "--from-address", "0x000000000000000000000000000000000000dEaD", }) if err := root.Execute(); err == nil { t.Fatalf("expected validation error, stderr=%s", stderr.String()) @@ -916,6 +1266,7 @@ func TestSwapTypeValidation(t *testing.T) { "--to-asset", "DAI", "--type", "limit-order", "--amount", "1000000", + "--from-address", "0x000000000000000000000000000000000000dEaD", }) if err := root.Execute(); err == nil { t.Fatalf("expected validation error, stderr=%s", stderr.String()) @@ -1046,6 +1397,116 @@ 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 +} + +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/config/config.go b/internal/config/config.go index 588a644..2751fe5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,8 @@ type Settings struct { CacheEnabled bool CachePath string CacheLockPath string + ActionStorePath string + ActionLockPath string DefiLlamaAPIKey string UniswapAPIKey string OneInchAPIKey string @@ -59,6 +61,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"` @@ -127,14 +133,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 } @@ -212,6 +221,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 } @@ -292,6 +307,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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 81f672c..df7fe84 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -55,6 +55,21 @@ func TestLoadDefiLlamaAPIKeyFromEnv(t *testing.T) { } } +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 TestLoadJupiterAPIKeyFromEnv(t *testing.T) { t.Setenv("DEFI_JUPITER_API_KEY", "jup-key") settings, err := Load(GlobalFlags{}) 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/actionbuilder/registry.go b/internal/execution/actionbuilder/registry.go new file mode 100644 index 0000000..568d21c --- /dev/null +++ b/internal/execution/actionbuilder/registry.go @@ -0,0 +1,233 @@ +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 { + Provider 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) { + providerName := normalizeLendingProvider(req.Provider) + if providerName == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--provider is required") + } + switch providerName { + 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 provider=aave|morpho") + } +} + +type RewardsClaimRequest struct { + Provider 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) { + providerName := normalizeLendingProvider(req.Provider) + if providerName == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--provider is required") + } + 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, + 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 { + Provider 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) { + providerName := normalizeLendingProvider(req.Provider) + if providerName == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "--provider is required") + } + 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, + 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 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 new file mode 100644 index 0000000..c62866b --- /dev/null +++ b/internal/execution/actionbuilder/registry_test.go @@ -0,0 +1,125 @@ +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 TestBuildLendActionRejectsUnsupportedProvider(t *testing.T) { + reg := New(nil, nil) + _, err := reg.BuildLendAction(context.Background(), LendRequest{Provider: "kamino"}) + if err == nil { + t.Fatal("expected unsupported provider error") + } + cErr, ok := clierr.As(err) + if !ok || cErr.Code != clierr.CodeUnsupported { + t.Fatalf("expected unsupported cli error, got %v", err) + } +} + +func TestBuildRewardsClaimActionRejectsUnsupportedProvider(t *testing.T) { + reg := New(nil, nil) + _, err := reg.BuildRewardsClaimAction(context.Background(), RewardsClaimRequest{Provider: "morpho"}) + if err == nil { + t.Fatal("expected unsupported provider error") + } + cErr, ok := clierr.As(err) + if !ok || cErr.Code != clierr.CodeUnsupported { + t.Fatalf("expected unsupported cli error, got %v", err) + } +} + +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") + 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 +} 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 +} diff --git a/internal/execution/executor.go b/internal/execution/executor.go new file mode 100644 index 0000000..38bbcff --- /dev/null +++ b/internal/execution/executor.go @@ -0,0 +1,665 @@ +package execution + +import ( + "context" + "encoding/hex" + "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 { + Simulate bool + PollInterval time.Duration + StepTimeout time.Duration + 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, + 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 { + 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() + persist() + + 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") + persist() + return clierr.New(clierr.CodeUsage, "missing rpc url for action step") + } + if strings.TrimSpace(step.Target) == "" { + markStepFailed(action, step, "missing target") + 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()) + persist() + return clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + + if err := executeStep(ctx, client, txSigner, action, step, opts, persist); err != nil { + client.Close() + if step.Status != StepStatusFailed { + markStepFailed(action, step, err.Error()) + } + persist() + return err + } + client.Close() + persist() + } + action.Status = ActionStatusCompleted + persist() + return nil +} + +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) + } + 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)) + } + } + 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 wrapEVMExecutionError(clierr.CodeActionSim, "simulate step (eth_call)", err) + } + step.Status = StepStatusSimulated + safePersist(persist) + } + + gasLimit, err := client.EstimateGas(ctx, msg) + if err != nil { + 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 { + 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 + } + 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) + } + + 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 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, txHash) + if err == nil && receipt != nil { + if receipt.Status == types.ReceiptStatusSuccessful { + 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 { + 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 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 + } + 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 = registry.LiFiSettlementURL + } + return waitForLiFiSettlement(ctx, step, sourceTxHash, statusEndpoint, opts) + case "across": + statusEndpoint := strings.TrimSpace(step.ExpectedOutputs["settlement_status_endpoint"]) + if statusEndpoint == "" { + statusEndpoint = registry.AcrossSettlementURL + } + 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 = registry.LiFiSettlementURL + } + 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 + } + 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. + 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 = registry.AcrossSettlementURL + } + 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 + } + 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") { + 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) + 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/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/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/aave.go b/internal/execution/planner/aave.go new file mode 100644 index 0000000..5c94423 --- /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 := registry.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 := registry.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 := 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) + } + 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..de1ed26 --- /dev/null +++ b/internal/execution/planner/approvals.go @@ -0,0 +1,89 @@ +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") + } + 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") + } + 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 := registry.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/planner/morpho.go b/internal/execution/planner/morpho.go new file mode 100644 index 0000000..301eb13 --- /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 = registry.MorphoGraphQLEndpoint + +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/execution/policy_basic.go b/internal/execution/policy_basic.go new file mode 100644 index 0000000..62d5adb --- /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) + policyUniswapV3RouterABI = mustPolicyABI(registry.UniswapV3RouterABI) + + policyApproveSelector = policyERC20ABI.Methods["approve"].ID + policyUniswapV3SwapMethod = policyUniswapV3RouterABI.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], policyUniswapV3SwapMethod) { + return clierr.New(clierr.CodeActionPlan, "taikoswap swap step must call exactInputSingle") + } + _, router, ok := registry.UniswapV3Contracts(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..9619a17 --- /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, policyUniswapV3SwapMethod, 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/execution/signer/local.go b/internal/execution/signer/local.go new file mode 100644 index 0000000..8e44b80 --- /dev/null +++ b/internal/execution/signer/local.go @@ -0,0 +1,212 @@ +package signer + +import ( + "crypto/ecdsa" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "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" + + defaultPrivateKeyRelativePath = "defi/key.hex" + defaultPrivateKeyHintPath = "~/.config/defi/key.hex" +) + +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) { + return NewLocalSignerFromInputs(source, "") +} + +func NewLocalSignerFromInputs(source, privateKeyOverride 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)) + if privateKeyFile == "" { + privateKeyFile = discoverDefaultPrivateKeyFile() + } + + 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) + } + if strings.TrimSpace(privateKeyOverride) != "" { + privateKeyHex = strings.TrimSpace(privateKeyOverride) + privateKeyFile = "" + keystorePath = "" + keystorePassword = "" + keystorePasswordFile = "" + } + + 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) != "" { + 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) != "" { + password := cfg.KeystorePassword + if strings.TrimSpace(password) == "" && strings.TrimSpace(cfg.KeystorePasswordFile) != "" { + 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: 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) { + 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 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() + if err != nil || strings.TrimSpace(home) == "" { + return "" + } + base = filepath.Join(home, ".config") + } + path := filepath.Join(base, defaultPrivateKeyRelativePath) + if path == "" { + return "" + } + return path +} diff --git a/internal/execution/signer/local_test.go b/internal/execution/signer/local_test.go new file mode 100644 index 0000000..4e1d5d8 --- /dev/null +++ b/internal/execution/signer/local_test.go @@ -0,0 +1,144 @@ +package signer + +import ( + "math/big" + "os" + "path/filepath" + "strings" + "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 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.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") + } +} + +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 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 } 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 e94f60b..affe277 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -96,6 +96,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}, "solana": {Name: "Solana", Slug: "solana", CAIP2: solanaMainnetCAIP2}, "solana-mainnet": { Name: "Solana", Slug: "solana", CAIP2: solanaMainnetCAIP2, @@ -127,6 +130,7 @@ var chainByID = map[int64]Chain{ 80094: chainBySlug["berachain"], 81457: chainBySlug["blast"], 167000: chainBySlug["taiko"], + 167013: chainBySlug["taiko-hoodi"], 534352: chainBySlug["scroll"], } @@ -402,6 +406,11 @@ var tokenRegistry = map[string][]Token{ {Symbol: "USDT", Address: "0x2def195713cf4a606b49d07e520e22c17899a736", Decimals: 6}, {Symbol: "WETH", Address: "0xa51894664a773981c6c112c43ce576f315d5b1b6", Decimals: 18}, }, + "eip155:167013": { + {Symbol: "USDC", Address: "0x18d5bb147f3d05d5f6c5e60caf1daeedbf5155b6", Decimals: 6}, + {Symbol: "USDT", Address: "0xeb4e8eb83d6ffba2ce0d8f62ace60648d1ece116", Decimals: 6}, + {Symbol: "WETH", Address: "0x3b39685b5495359c892ddd1057b5712f49976835", Decimals: 18}, + }, "eip155:534352": { {Symbol: "CAKE", Address: "0x1b896893dfc86bb67cf57767298b9073d2c1ba2c", Decimals: 18}, {Symbol: "ENA", Address: "0x58538e6a46e07434d7e7375bc268d3cb839c0133", Decimals: 18}, diff --git a/internal/id/id_test.go b/internal/id/id_test.go index 02f4a4f..7193bd4 100644 --- a/internal/id/id_test.go +++ b/internal/id/id_test.go @@ -213,6 +213,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"}, @@ -227,6 +230,7 @@ func TestParseChainExpandedCoverage(t *testing.T) { {input: "4326", chainID: 4326, caip2: "eip155:4326", slug: "megaeth"}, {input: "143", chainID: 143, caip2: "eip155:143", slug: "monad"}, {input: "167000", chainID: 167000, caip2: "eip155:167000", slug: "taiko"}, + {input: "167013", chainID: 167013, caip2: "eip155:167013", slug: "taiko-hoodi"}, } for _, tc := range tests { @@ -266,6 +270,7 @@ func TestParseAssetExpandedChainRegistry(t *testing.T) { {chainInput: "megaeth", symbol: "USDT"}, {chainInput: "celo", symbol: "USDC"}, {chainInput: "taiko", symbol: "USDC"}, + {chainInput: "hoodi", symbol: "USDC"}, {chainInput: "zksync", symbol: "USDC"}, } diff --git a/internal/model/types.go b/internal/model/types.go index 903f1f6..11716ac 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" ) @@ -130,6 +131,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"` @@ -209,19 +226,21 @@ 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"` - FeeBreakdown *BridgeFeeBreakdown `json:"fee_breakdown,omitempty"` - 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"` + FeeBreakdown *BridgeFeeBreakdown `json:"fee_breakdown,omitempty"` + EstimatedTimeS int64 `json:"estimated_time_s"` + Route string `json:"route"` + SourceURL string `json:"source_url,omitempty"` + FetchedAt string `json:"fetched_at"` } type SwapQuote struct { @@ -239,25 +258,51 @@ 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 { + 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 aa149f0..77c90d3 100644 --- a/internal/providers/aave/client.go +++ b/internal/providers/aave/client.go @@ -41,7 +41,13 @@ func (c *Client) Info() model.ProviderInfo { Capabilities: []string{ "lend.markets", "lend.rates", + "lend.positions", "yield.opportunities", + "yield.history", + "lend.plan", + "lend.execute", + "rewards.plan", + "rewards.execute", }, } } @@ -56,11 +62,41 @@ 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 } } } } }` +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 } + } +}` + +const supplyAPYHistoryQuery = `query SupplyAPYHistory($request: SupplyAPYHistoryRequest!) { + supplyAPYHistory(request: $request) { + date + avgRate { value } + } +}` + type marketsResponse struct { Data struct { Markets []aaveMarket `json:"markets"` @@ -70,6 +106,41 @@ 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 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"` @@ -110,12 +181,61 @@ type aaveReserve struct { UtilizationRate struct { Value string `json:"value"` } `json:"utilizationRate"` + AvailableLiquidity struct { + USD string `json:"usd"` + } `json:"availableLiquidity"` } `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") +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") } markets, err := c.fetchMarkets(ctx, chain) if err != nil { @@ -167,9 +287,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 { @@ -217,15 +337,139 @@ func (c *Client) LendRates(ctx context.Context, protocol string, chain id.Chain, return out, nil } -func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequest) ([]model.YieldOpportunity, error) { - markets, err := c.fetchMarkets(ctx, req.Chain) +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, + }) + } - maxRisk := yieldutil.RiskOrder(req.MaxRisk) - if maxRisk == 0 { - maxRisk = yieldutil.RiskOrder("high") + 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 { + return nil, err } out := make([]model.YieldOpportunity, 0) @@ -246,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) @@ -269,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), }) @@ -291,6 +536,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") @@ -320,6 +666,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 != "" { @@ -336,6 +721,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") { @@ -348,6 +741,168 @@ 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 + } + 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 { @@ -359,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 87c17df..21b4264 100644 --- a/internal/providers/aave/client_test.go +++ b/internal/providers/aave/client_test.go @@ -2,8 +2,11 @@ package aave import ( "context" + "fmt" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -24,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() @@ -61,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) } @@ -71,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) { @@ -107,3 +116,193 @@ 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) + } +} + +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/across/client.go b/internal/providers/across/client.go index 799d657..20df7f2 100644 --- a/internal/providers/across/client.go +++ b/internal/providers/across/client.go @@ -3,20 +3,24 @@ 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" "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 @@ -35,6 +39,8 @@ func (c *Client) Info() model.ProviderInfo { RequiresKey: false, Capabilities: []string{ "bridge.quote", + "bridge.plan", + "bridge.execute", }, } } @@ -128,6 +134,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 := registry.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": 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), + }, + }) + return action, nil +} + func checkAmountWithinLimits(amount string, limits map[string]any) bool { min := pickNumberString(limits, "minDeposit", "minLimit") max := pickNumberString(limits, "maxDeposit", "maxLimit") @@ -341,3 +490,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 1dcc1d7..5d98027 100644 --- a/internal/providers/across/client_test.go +++ b/internal/providers/across/client_test.go @@ -168,6 +168,68 @@ func TestQuoteBridgeRejectsNonEVMChains(t *testing.T) { } } +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"]) + } +} + func TestApproximateStableUSDExcludesEURS(t *testing.T) { if isLikelyStableSymbol("EURS") { t.Fatal("EURS should not be treated as USD-pegged") 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 20adf8a..3da9d51 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,9 +74,19 @@ 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") +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") } reserves, err := c.fetchReserves(ctx, chain) if err != nil { @@ -126,9 +138,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 { @@ -181,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 { @@ -205,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{ @@ -240,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, }) @@ -258,6 +259,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 +504,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 == "" { @@ -404,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 097bb8c..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) { @@ -244,3 +249,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/lifi/client.go b/internal/providers/lifi/client.go index e86c234..266c8f3 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,19 @@ func (c *Client) Info() model.ProviderInfo { RequiresKey: false, Capabilities: []string{ "bridge.quote", + "bridge.plan", + "bridge.execute", }, } } type quoteResponse struct { + ID string `json:"id"` 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 { @@ -50,14 +61,44 @@ 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"` + IncludedSteps []quoteStep `json:"includedSteps"` + 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"` +} + +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) { if !req.FromChain.IsEVM() || !req.ToChain.IsEVM() { return model.BridgeQuote{}, clierr.New(clierr.CodeUnsupported, "lifi bridge quotes support only EVM chains") } + + 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)) @@ -66,6 +107,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) @@ -97,6 +141,7 @@ 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) feeBreakdown := &model.BridgeFeeBreakdown{ TotalFeeUSD: feeUSD, } @@ -121,6 +166,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), @@ -134,3 +181,276 @@ 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") + } + 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)) + 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) + if fromAmountForGas != "" { + vals.Set("fromAmountForGas", fromAmountForGas) + } + + 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 := registry.ResolveRPCURL(opts.RPCURL, req.FromChain.EVMChainID) + 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, + 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 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) { + 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) + } + statusEndpoint := registry.LiFiSettlementURL + 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), + "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 +} + +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 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) +} + +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 index 5be9010..9f54ce6 100644 --- a/internal/providers/lifi/client_test.go +++ b/internal/providers/lifi/client_test.go @@ -2,6 +2,12 @@ package lifi import ( "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" "testing" "time" @@ -10,6 +16,45 @@ import ( "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 TestQuoteBridgeRejectsNonEVMChains(t *testing.T) { fromChain, _ := id.ParseChain("solana") toChain, _ := id.ParseChain("base") @@ -29,3 +74,224 @@ func TestQuoteBridgeRejectsNonEVMChains(t *testing.T) { t.Fatal("expected unsupported chain error") } } + +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() + 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) + } + 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) { + 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, `{ + "id": "quote-id:0", + "estimate": { + "toAmount": "950000", + "toAmountMin": "940000", + "approvalAddress": %q, + "feeCosts": [{"amountUSD":"0.40"}], + "gasCosts": [{"amountUSD":"0.60"}], + "executionDuration": 120 + }, + "toolDetails": {"key":"across","name":"across"}, + "tool": "across", + "includedSteps": [], + "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/morpho/client.go b/internal/providers/morpho/client.go index 4dff488..a00e458 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" @@ -17,9 +18,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 @@ -39,7 +41,11 @@ func (c *Client) Info() model.ProviderInfo { Capabilities: []string{ "lend.markets", "lend.rates", + "lend.positions", "yield.opportunities", + "yield.history", + "lend.plan", + "lend.execute", }, } } @@ -57,6 +63,111 @@ 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 + } + } + } +}` + +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{ + loanAsset{ address symbol } + 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{ + loanAsset{ address symbol } + collateralAsset{ address symbol } + } + } + } + } + } + } + } + } +}` + +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 +) + type marketsResponse struct { Data struct { Markets struct { @@ -68,6 +179,74 @@ 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 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 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"` @@ -95,9 +274,121 @@ 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") +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"` +} + +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 { + LoanAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"loanAsset"` + 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 { + LoanAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"loanAsset"` + CollateralAsset *struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + } `json:"collateralAsset"` + } `json:"market"` +} + +type vaultYieldCandidate struct { + Address string + AssetAddress string + AssetSymbol string + NetAPYPercent float64 + TotalAssetsUSD float64 + LiquidityUSD float64 + BackingShares []collateralShare +} + +type collateralShare struct { + Address string + 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") } markets, err := c.fetchMarkets(ctx, chain, asset) if err != nil { @@ -140,9 +431,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 { @@ -178,41 +469,174 @@ func (c *Client) LendRates(ctx context.Context, protocol string, chain id.Chain, 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) +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 } - maxRisk := yieldutil.RiskOrder(req.MaxRisk) - if maxRisk == 0 { - maxRisk = yieldutil.RiskOrder("high") + if len(resp.Errors) > 0 { + return nil, clierr.New(clierr.CodeUnavailable, fmt.Sprintf("morpho graphql error: %s", resp.Errors[0].Message)) } - 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.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) { + vaults, err := c.fetchYieldVaultCandidates(ctx, req.Chain, req.Asset) + if err != nil { + return nil, err + } + + 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 } if apy < req.MinAPY || tvl < req.MinTVLUSD { continue } - - riskLevel, reasons := riskFromCollateral(m.CollateralAsset) - if yieldutil.RiskOrder(riskLevel) > maxRisk { + 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 == "" { continue } - liq := yieldutil.PositiveFirst(m.State.LiquidityAssetsUSD, m.State.TotalLiquidityUSD, tvl) - assetID := canonicalAssetID(req.Asset, m.LoanAsset.Address) 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, @@ -221,10 +645,8 @@ 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), - SourceURL: "https://app.morpho.org", + BackingAssets: backingAssets, + SourceURL: sourceURLForVault(vaultAddress), FetchedAt: c.now().UTC().Format(time.RFC3339), }) } @@ -239,6 +661,170 @@ 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") + } + + 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, + AssetSymbol: assetSymbol, + NetAPYPercent: netAPY, + TotalAssetsUSD: tvl, + LiquidityUSD: liquidity, + BackingShares: collateralSharesFromAllocation(0, allocationFromVault(vault), assetAddress, assetSymbol), + }) + } + 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, + AssetSymbol: assetSymbol, + NetAPYPercent: vault.NetAPY * 100, + TotalAssetsUSD: vault.TotalAssets, + LiquidityUSD: vault.LiquidityUSD, + BackingShares: collateralSharesFromVaultV2(vault, assetAddress, assetSymbol), + }) + } + 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") @@ -276,6 +862,375 @@ 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 (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) + } + 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, fallbackAddress, fallbackSymbol string) []collateralShare { + if vault.LiquidityData == nil { + if usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD); usd > 0 { + 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{{ + Address: address, + Symbol: symbol, + USD: usd, + }} + case "MetaMorphoLiquidityData": + if vault.LiquidityData.MetaMorpho != nil && vault.LiquidityData.MetaMorpho.State != nil { + shares := collateralSharesFromAllocation(vault.TotalAssets, vault.LiquidityData.MetaMorpho.State.Allocation, fallbackAddress, fallbackSymbol) + if len(shares) > 0 { + return shares + } + } + } + + if usd := yieldutil.PositiveFirst(vault.TotalAssets, vault.LiquidityUSD); usd > 0 { + return []collateralShare{{ + Address: fallbackAddress, + Symbol: fallbackSymbol, + USD: usd, + }} + } + return nil +} + +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 { + 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 + } + 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{Address: address, Symbol: symbol, USD: usd}) + } + return shares +} + +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 + } + assetID := canonicalAssetIDForChain(chainID, share.Address) + symbol := strings.TrimSpace(share.Symbol) + if assetID == "" { + assetID = canonicalAssetIDForChain(chainID, fallbackAddress) + } + if assetID == "" { + assetID = strings.TrimSpace(fallbackAssetID) + } + if assetID == "" { + continue + } + 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 { + 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 == "" { @@ -284,20 +1239,12 @@ func canonicalAssetID(asset id.Asset, address string) string { return fmt.Sprintf("%s/erc20:%s", asset.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 canonicalAssetIDForChain(chainID, address string) string { + addr := normalizeEVMAddress(address) + if chainID == "" || addr == "" { + return "" } + return fmt.Sprintf("%s/erc20:%s", chainID, addr) } func hashOpportunity(provider, chainID, marketID, assetID string) string { @@ -305,3 +1252,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..e29462d 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" @@ -15,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", @@ -29,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": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "collateralAsset": {"address": "0x4200000000000000000000000000000000000006", "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": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "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() @@ -57,17 +134,385 @@ 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) } - 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.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"] + 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.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 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") + 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": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "collateralAsset": {"address": "0x4200000000000000000000000000000000000006", "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": {"loanAsset": {"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC"}, "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: 1, + SortBy: "tvl_usd", + }) + if err != nil { + t.Fatalf("YieldOpportunities failed: %v", err) + } + if len(opps) != 1 { + t.Fatalf("expected one opportunity after limit, got %+v", opps) + } + if opps[0].ProviderNativeID != "0x2222222222222222222222222222222222222222" { + t.Fatalf("expected highest-tvl vault first, 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) + } +} + +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 opps[0].ProviderNativeIDKind != model.NativeIDKindMarketID { - t.Fatalf("expected market_id kind on yield opportunity, got %+v", opps[0]) + 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/taikoswap/client.go b/internal/providers/taikoswap/client.go new file mode 100644 index 0000000..e3dad17 --- /dev/null +++ b/internal/providers/taikoswap/client.go @@ -0,0 +1,296 @@ +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/id" + "github.com/ggonzalez94/defi-cli/internal/model" + "github.com/ggonzalez94/defi-cli/internal/providers" + "github.com/ggonzalez94/defi-cli/internal/registry" +) + +var ( + feeTiers = []uint32{100, 500, 3000, 10000} + + quoterABI = mustABI(registry.UniswapV3QuoterV2ABI) + erc20ABI = mustABI(registry.ERC20MinimalABI) + routerABI = mustABI(registry.UniswapV3RouterABI) +) + +type Client struct { + now func() time.Time +} + +func New() *Client { + return &Client{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, req.RPCURL) + 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) { + sender := strings.TrimSpace(opts.Sender) + if sender == "" { + return execution.Action{}, clierr.New(clierr.CodeUsage, "swap execution requires sender address") + } + 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 + } + 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 = 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(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, 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") + } + 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) { + 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..8809972 --- /dev/null +++ b/internal/providers/taikoswap/client_test.go @@ -0,0 +1,229 @@ +package taikoswap + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "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() + 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", RPCURL: server.URL, + }) + 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() + 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, + RPCURL: server.URL, + }) + 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() + 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 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() + + 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 450c6d4..63d0370 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -2,7 +2,9 @@ package providers import ( "context" + "time" + "github.com/ggonzalez94/defi-cli/internal/execution" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" ) @@ -21,8 +23,30 @@ 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 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 { @@ -30,13 +54,39 @@ 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 Limit int MinTVLUSD float64 MinAPY float64 - MaxRisk string Providers []string SortBy string IncludeIncomplete bool @@ -47,6 +97,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) @@ -54,12 +109,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 { @@ -72,11 +128,25 @@ type BridgeDetailsRequest struct { IncludeChainBreakdown bool } +type BridgeExecutionOptions struct { + Sender string + Recipient string + SlippageBps int64 + Simulate bool + RPCURL string + FromAmountForGas 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 SwapTradeType string const ( @@ -90,6 +160,16 @@ type SwapQuoteRequest struct { ToAsset id.Asset AmountBaseUnits string AmountDecimal string + RPCURL string TradeType SwapTradeType SlippagePct *float64 + Swapper string +} + +type SwapExecutionOptions struct { + Sender string + Recipient string + SlippageBps int64 + Simulate bool + RPCURL string } diff --git a/internal/providers/uniswap/client.go b/internal/providers/uniswap/client.go index 4620a4d..565ffef 100644 --- a/internal/providers/uniswap/client.go +++ b/internal/providers/uniswap/client.go @@ -17,9 +17,6 @@ import ( const defaultBase = "https://trade-api.gateway.uniswap.org" -// quoteOnlySwapper is a deterministic placeholder for quote retrieval flows. -const quoteOnlySwapper = "0x0000000000000000000000000000000000000001" - type Client struct { http *httpx.Client baseURL string @@ -81,6 +78,10 @@ func (c *Client) QuoteSwap(ctx context.Context, req providers.SwapQuoteRequest) default: return model.SwapQuote{}, clierr.New(clierr.CodeUnsupported, "uniswap swap type must be exact-input or exact-output") } + swapper := strings.TrimSpace(req.Swapper) + if swapper == "" { + return model.SwapQuote{}, clierr.New(clierr.CodeUsage, "uniswap swap quotes require a swapper address") + } payload := map[string]any{ "tokenInChainId": req.Chain.EVMChainID, @@ -89,7 +90,7 @@ func (c *Client) QuoteSwap(ctx context.Context, req providers.SwapQuoteRequest) "tokenOut": req.ToAsset.Address, "amount": req.AmountBaseUnits, "type": uniswapTradeType(tradeType), - "swapper": quoteOnlySwapper, + "swapper": swapper, } if req.SlippagePct != nil { payload["slippageTolerance"] = *req.SlippagePct diff --git a/internal/providers/uniswap/client_test.go b/internal/providers/uniswap/client_test.go index 226648f..1653d31 100644 --- a/internal/providers/uniswap/client_test.go +++ b/internal/providers/uniswap/client_test.go @@ -14,6 +14,8 @@ import ( "github.com/ggonzalez94/defi-cli/internal/providers" ) +const testSwapper = "0x000000000000000000000000000000000000dEaD" + func TestQuoteSwapIncludesRequiredSwapper(t *testing.T) { chain, _ := id.ParseChain("ethereum") assetIn, _ := id.ParseAsset("USDC", chain) @@ -66,6 +68,7 @@ func TestQuoteSwapIncludesRequiredSwapper(t *testing.T) { ToAsset: assetOut, AmountBaseUnits: "1000000", AmountDecimal: "1", + Swapper: testSwapper, }) if err != nil { t.Fatalf("QuoteSwap failed: %v", err) @@ -83,8 +86,8 @@ func TestQuoteSwapIncludesRequiredSwapper(t *testing.T) { if got.Type != "EXACT_INPUT" { t.Fatalf("unexpected swap type in payload: %s", got.Type) } - if got.Swapper != quoteOnlySwapper { - t.Fatalf("expected swapper=%s, got %s", quoteOnlySwapper, got.Swapper) + if got.Swapper != testSwapper { + t.Fatalf("expected swapper=%s, got %s", testSwapper, got.Swapper) } if got.AutoSlippage != "DEFAULT" { t.Fatalf("expected autoSlippage=DEFAULT, got %s", got.AutoSlippage) @@ -143,6 +146,7 @@ func TestQuoteSwapUsesManualSlippageOverride(t *testing.T) { AmountBaseUnits: "1000000", AmountDecimal: "1", SlippagePct: &slippage, + Swapper: testSwapper, }) if err != nil { t.Fatalf("QuoteSwap failed: %v", err) @@ -203,6 +207,7 @@ func TestQuoteSwapSupportsExactOutput(t *testing.T) { AmountBaseUnits: "1000000000000000000", AmountDecimal: "1", TradeType: providers.SwapTradeTypeExactOutput, + Swapper: testSwapper, }) if err != nil { t.Fatalf("QuoteSwap failed: %v", err) @@ -261,6 +266,7 @@ func TestQuoteSwapExactOutputFallsBackInputDecimalsWhenMissing(t *testing.T) { AmountBaseUnits: "1000000000000000000", AmountDecimal: "1", TradeType: providers.SwapTradeTypeExactOutput, + Swapper: testSwapper, }) if err != nil { t.Fatalf("QuoteSwap failed: %v", err) @@ -287,6 +293,23 @@ func TestQuoteSwapRequiresAPIKey(t *testing.T) { } } +func TestQuoteSwapRequiresSwapper(t *testing.T) { + chain, _ := id.ParseChain("ethereum") + assetIn, _ := id.ParseAsset("USDC", chain) + assetOut, _ := id.ParseAsset("DAI", chain) + c := New(httpx.New(1*time.Second, 0), "test-key") + _, err := c.QuoteSwap(context.Background(), providers.SwapQuoteRequest{ + Chain: chain, + FromAsset: assetIn, + ToAsset: assetOut, + AmountBaseUnits: "1000000", + AmountDecimal: "1", + }) + if err == nil { + t.Fatal("expected missing swapper error") + } +} + func TestQuoteSwapRejectsNonEVMChain(t *testing.T) { chain, _ := id.ParseChain("solana") assetIn, _ := id.ParseAsset("USDC", chain) 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) } } diff --git a/internal/registry/abis.go b/internal/registry/abis.go new file mode 100644 index 0000000..3007192 --- /dev/null +++ b/internal/registry/abis.go @@ -0,0 +1,40 @@ +package registry + +// 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"}]} + ]` + + 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"}]} + ]` + + 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"}]} + ]` + + 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"}]} + ]` + + 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/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/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..a232878 --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,127 @@ +package registry + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +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 uniswap-v3 contract values: quoter=%q router=%q", quoter, router) + } + + if _, _, ok := UniswapV3Contracts(1); ok { + t.Fatal("did not expect uniswap-v3 contracts for unsupported chain") + } +} + +func TestAavePoolAddressProvider(t *testing.T) { + 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(167000); ok { + t.Fatal("did not expect aave pool address provider for unsupported chain") + } +} + +func TestExecutionABIConstantsParse(t *testing.T) { + abis := []string{ + ERC20MinimalABI, + UniswapV3QuoterV2ABI, + UniswapV3RouterABI, + AavePoolAddressProviderABI, + AavePoolABI, + AaveRewardsABI, + MorphoBlueABI, + } + for _, raw := range abis { + if _, err := abi.JSON(strings.NewReader(raw)); err != nil { + t.Fatalf("failed to parse abi json: %v", err) + } + } +} + +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 { + 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") + } +} diff --git a/internal/registry/rpc.go b/internal/registry/rpc.go new file mode 100644 index 0000000..bda1ffb --- /dev/null +++ b/internal/registry/rpc.go @@ -0,0 +1,47 @@ +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", + 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", + 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) { + 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 value, ok := DefaultRPCURL(chainID); ok { + return value, nil + } + return "", fmt.Errorf("no default rpc configured for chain id %d; provide --rpc-url", chainID) +} diff --git a/scripts/nightly_execution_smoke.sh b/scripts/nightly_execution_smoke.sh new file mode 100644 index 0000000..696d7d4 --- /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 \ + --provider aave \ + --chain 1 \ + --asset USDC \ + --amount 1000000 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --results-only >/dev/null + +./defi rewards claim plan \ + --provider aave \ + --chain 1 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --assets 0x00000000000000000000000000000000000000d1 \ + --reward-token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + --results-only >/dev/null + +./defi rewards compound plan \ + --provider aave \ + --chain 1 \ + --from-address 0x00000000000000000000000000000000000000aa \ + --assets 0x00000000000000000000000000000000000000d1 \ + --reward-token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + --amount 1000 \ + --results-only >/dev/null + +rm -f ./defi