diff --git a/AGENTS.md b/AGENTS.md index 281de54..d42ce43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ cmd/ internal/ app/runner.go # command wiring, provider routing, cache flow providers/ # external adapters - aave/ morpho/ # lending + yield (read + execution) + aave/ morpho/ moonwell/ # lending + yield (read + execution) defillama/ # market/yield normalization + fallback + bridge analytics across/ lifi/ # bridge quotes + lifi execution planning oneinch/ uniswap/ taikoswap/ tempo/ # swap quotes + execution planning providers @@ -67,10 +67,10 @@ README.md # user-facing usage + caveats - Error output always returns a full envelope, even with `--results-only` or `--select`. - Config precedence is `flags > env > config file > defaults`. -- `yield --providers` expects provider names (`aave,morpho,kamino`), 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. -- `yield positions` currently supports `aave|morpho`; `kamino` does not expose positions yet. +- `yield --providers` expects provider names (`aave,morpho,kamino,moonwell`), not protocol categories. +- Lending routes by `--provider` use direct protocol adapters (`aave`, `morpho`, `kamino`, `moonwell`). +- `lend positions` currently supports `--provider aave|morpho|moonwell`; `kamino` does not expose positions yet. +- `yield positions` currently supports `aave|morpho|moonwell`; `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`). @@ -91,8 +91,8 @@ README.md # user-facing usage + caveats - `bridge plan|submit|status` (Across, LiFi) - `approvals plan|submit|status` - `transfer plan|submit|status` - - `lend supply|withdraw|borrow|repay plan|submit|status` (Aave, Morpho) - - `yield deposit|withdraw plan|submit|status` (Aave, Morpho) + - `lend supply|withdraw|borrow|repay plan|submit|status` (Aave, Morpho, Moonwell) + - `yield deposit|withdraw plan|submit|status` (Aave, Morpho, Moonwell) - `rewards claim|compound plan|submit|status` (Aave) - `actions list|show|estimate` - Execution builder architecture is intentionally split: @@ -107,6 +107,9 @@ README.md # user-facing usage + caveats - 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). - Morpho yield execution requires `--vault-address` (Morpho vault contract address). +- Moonwell lending/yield uses on-chain RPC reads (no API key required); supported on Base and Optimism. +- Moonwell execution targets mToken contracts (Compound v2 style); use `--pool-address` to specify the mToken directly or let auto-resolution match by underlying asset via `Comptroller.getAllMarkets()`. +- Moonwell's WETH mToken (mWETH) auto-unwraps to native ETH on borrow/withdraw and expects native ETH (not WETH) for supply/repay on some chains. Callers (UIs, automation) must wrap ETH → WETH before calling `repayBorrow` or handle the native ETH received from `borrow`/`redeemUnderlying`. The CLI planner currently uses the standard ERC-20 path (approve + call with value=0), so WETH wrapping is the caller's responsibility. - 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 `tempo`/`tempo mainnet`/`presto`, `tempo testnet`/`moderato`, `tempo devnet`, `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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a8abe9..685cb5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format: ## [Unreleased] ### Added +- Moonwell lending provider (Base, Optimism) — markets, rates, positions, yield opportunities/positions, and execution (supply, withdraw, borrow, repay). - Added `--chain` filter to `protocols fees` and `protocols revenue` to filter by chain presence (e.g. `protocols fees --chain Ethereum`). Supports combined `--category` and `--chain` filtering, consistent with `dexes volume --chain` behavior. - Added `--chain` filter to `protocols top` to rank protocols by chain-specific TVL (e.g. `protocols top --chain Ethereum`). When specified, TVL reflects the protocol's value locked on that chain rather than total TVL. Supports combined `--category` and `--chain` filtering. Output now includes `chains` count. - Added `protocols revenue` command to rank protocols by 24h revenue (protocol-retained fees) with 7d/30d totals and 1d/7d/1m percentage changes (no API key required, uses DefiLlama revenue API). Supports `--category` filter and `--limit`. @@ -38,7 +39,7 @@ Format: - Tempo swap planning/quotes now validate TIP-20 currency metadata up front and return `unsupported` for non-USD assets or DEX reverts such as missing pairs, instead of reporting them as transient provider outages. ### Fixed -- None yet. +- Optimism USDC bootstrap address now points to native USDC (`0x0b2c...ff85`) instead of bridged USDC.e; added separate `USDC.e` entry for the bridged variant. ### Docs - Documented Tempo chain aliases, provider support, native DEX caveats, and execution examples across README, AGENTS, and Mintlify docs. diff --git a/README.md b/README.md index 545d328..049119f 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ Built for AI agents and scripts. Stable JSON output, canonical identifiers (CAIP ## Features -- **Lending** — query markets/rates from Aave/Morpho/Kamino, account positions from Aave/Morpho, and execute loan actions (`lend supply|withdraw|borrow|repay`). -- **Yield** — compare opportunities, query positions, fetch historical series, and execute deposit/withdraw flows (Aave, Morpho). +- **Lending** — query markets/rates from Aave/Morpho/Kamino/Moonwell, account positions from Aave/Morpho/Moonwell, and execute loan actions (`lend supply|withdraw|borrow|repay`). +- **Yield** — compare opportunities, query positions, fetch historical series, and execute deposit/withdraw flows (Aave, Morpho, Moonwell). - **Bridging** — get cross-chain quotes (Across, LiFi, Bungee), bridge analytics, and execute bridge plans (Across, LiFi). - **Swapping** — get swap quotes (1inch, Uniswap, Jupiter, Tempo, TaikoSwap, Fibrous, Bungee) and execute swap plans (Tempo with native type 0x76 transactions and batched calls, TaikoSwap). - **Approvals, transfers & rewards** — ERC-20 approvals/transfers and Aave rewards claim/compound flows. @@ -133,8 +133,8 @@ defi actions estimate --action-id --results-only - `swap plan|submit|status` (Tempo, TaikoSwap) - `bridge plan|submit|status` (Across, LiFi) -- `lend supply|withdraw|borrow|repay plan|submit|status` (Aave, Morpho) -- `yield deposit|withdraw plan|submit|status` (Aave, Morpho) +- `lend supply|withdraw|borrow|repay plan|submit|status` (Aave, Morpho, Moonwell) +- `yield deposit|withdraw plan|submit|status` (Aave, Morpho, Moonwell) - `rewards claim|compound plan|submit|status` (Aave) - `approvals plan|submit|status` - `transfer plan|submit|status` @@ -142,7 +142,7 @@ defi actions estimate --action-id --results-only All `plan` commands support `--rpc-url` to override chain default RPCs. `plan` and `submit` accept `--input-json` / `--input-file` for structured input; explicit flags override JSON values. -`--providers` flags accept provider names from `defi providers list` (e.g. `aave,morpho,kamino`). +`--providers` flags accept provider names from `defi providers list` (e.g. `aave,morpho,kamino,moonwell`). ### More quote examples @@ -301,6 +301,7 @@ providers: - `yield` and `lend` are split by intent: `yield` for passive deposits/withdrawals, `lend` for loan lifecycle. - Morpho: `yield deposit|withdraw` targets vaults (`--vault-address`), `lend` targets markets (`--market-id`). - Aave execution auto-resolves pool addresses on Ethereum, Optimism, Polygon, Base, Arbitrum, and Avalanche; use `--pool-address` on other chains. +- Moonwell execution targets mToken contracts (Compound v2 style) on Base and Optimism; use `--pool-address` to specify the mToken directly or let auto-resolution match by underlying asset. - Bridge execution waits for destination settlement; adjust `--step-timeout` for slower routes. - Pre-sign checks enforce bounded ERC-20 approvals by default; use `--allow-max-approval` to opt in to larger approvals. - Bridge pre-sign checks validate settlement endpoints; use `--unsafe-provider-tx` to bypass. @@ -337,7 +338,7 @@ cmd/ internal/ app/runner.go # command wiring, routing, cache flow providers/ # external adapters - aave/ morpho/ # lending + yield (read + execution) + aave/ morpho/ moonwell/ # lending + yield (read + execution) defillama/ # normalization + fallback + bridge analytics across/ lifi/ # bridge quotes + lifi execution planning oneinch/ uniswap/ taikoswap/ # swap (quote + taikoswap execution planning) diff --git a/docs/act-execution-design.md b/docs/act-execution-design.md index 093f7a2..1875f1e 100644 --- a/docs/act-execution-design.md +++ b/docs/act-execution-design.md @@ -21,8 +21,8 @@ Execution is integrated inside existing domain commands (for example `swap`, `br | Swap | `swap plan|submit|status` | `--provider` required | `taikoswap` execution today | | Bridge | `bridge plan|submit|status` | `--provider` required | `across`, `lifi` execution | | Transfer | `transfer plan|submit|status` | no provider selector | native ERC-20 wallet transfer execution | -| Lend | `lend (supply|withdraw|borrow|repay) plan|submit|status` | `--provider` required | `aave`, `morpho` execution (`morpho` requires `--market-id`) | -| Yield | `yield (deposit|withdraw) plan|submit|status` | `--provider` required | `aave`, `morpho` execution (`morpho` requires `--vault-address`) | +| Lend | `lend (supply|withdraw|borrow|repay) plan|submit|status` | `--provider` required | `aave`, `morpho`, `moonwell` execution (`morpho` requires `--market-id`) | +| Yield | `yield (deposit|withdraw) plan|submit|status` | `--provider` required | `aave`, `morpho`, `moonwell` execution (`morpho` requires `--vault-address`) | | Rewards | `rewards (claim|compound) plan|submit|status` | `--provider` required | `aave` execution | | Approvals | `approvals plan|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 | diff --git a/docs/concepts/providers-and-auth.mdx b/docs/concepts/providers-and-auth.mdx index c776a98..839aaa3 100644 --- a/docs/concepts/providers-and-auth.mdx +++ b/docs/concepts/providers-and-auth.mdx @@ -11,6 +11,7 @@ description: Provider coverage, key requirements, and routing behavior. | `aave` | lend (read + execution), yield (read + execution), rewards (execution) | No | | `morpho` | lend (read + execution), yield (read + execution) | No | | `kamino` | lend, yield (Solana mainnet, read only) | No | +| `moonwell` | lend (read + execution), yield (read + execution) | No | | `across` | bridge quote + execution | No | | `lifi` | bridge quote + execution | No | | `bungee` | bridge quote, swap quote | No (default mode) | @@ -39,10 +40,10 @@ If either is missing, bungee quotes use public backend. ## Routing and fallback -- Lending routes by `--provider` (`aave`, `morpho`, `kamino`) using direct adapters only. -- `lend positions` currently supports `--provider aave|morpho`. -- `yield positions` currently supports `--providers aave,morpho`. -- `yield opportunities` aggregates direct providers and accepts `--providers aave,morpho,kamino`. +- Lending routes by `--provider` (`aave`, `morpho`, `kamino`, `moonwell`) using direct adapters only. +- `lend positions` currently supports `--provider aave|morpho|moonwell`. +- `yield positions` currently supports `--providers aave,morpho,moonwell`. +- `yield opportunities` aggregates direct providers and accepts `--providers aave,morpho,kamino,moonwell`. - `yield history` uses the same direct providers and accepts `--providers aave,morpho,kamino`. - `bridge quote` and `swap quote` require explicit `--provider`; there are no implicit provider defaults. - Execution commands (`plan`, `run`, `submit`, `status`) require `--provider` for multi-provider surfaces. @@ -62,3 +63,4 @@ If either is missing, bungee quotes use public backend. - Tempo DEX swap execution settles to the caller; `--recipient` must match `--from-address` (or be omitted). - `actions estimate` returns fee-token-denominated estimates for Tempo actions (includes `fee_unit` and `fee_token` fields). - `--signer tempo` enables agent wallet support via the Tempo CLI (`tempo wallet -j whoami`), with delegated access keys, spending limits, and expiry checks. Requires the Tempo CLI installed. +- Moonwell uses on-chain RPC reads (no API key); supported on Base and Optimism. Execution targets mToken contracts (Compound v2 style); use `--pool-address` to specify the mToken directly or let auto-resolution match by underlying asset. Moonwell does not support `--on-behalf-of`. diff --git a/docs/guides/lending.mdx b/docs/guides/lending.mdx index 02954d1..69f76b1 100644 --- a/docs/guides/lending.mdx +++ b/docs/guides/lending.mdx @@ -22,6 +22,7 @@ defi lend rates --provider kamino --chain solana --asset USDC --limit 10 --resul - `--provider aave` -> Aave adapter - `--provider morpho` -> Morpho adapter - `--provider kamino` -> Kamino adapter (Solana mainnet only) +- `--provider moonwell` -> Moonwell adapter (Base, Optimism) ## Positions @@ -31,7 +32,7 @@ defi lend positions --provider morpho --chain 1 --address 0xYourEOA --type borro ``` `--type all` returns disjoint rows: `supply`, `collateral`, and `borrow`. -Supported providers: `aave`, `morpho`. +Supported providers: `aave`, `morpho`, `moonwell`. ## Execution (supply, withdraw, borrow, repay) @@ -48,6 +49,14 @@ defi lend supply plan --provider morpho --chain 1 --asset USDC --market-id 0x... Aave auto-resolves pool addresses on Ethereum, Optimism, Polygon, Base, Arbitrum, and Avalanche. Use `--pool-address` on other chains. +Moonwell execution targets mToken contracts (Compound v2 style) on Base and Optimism: + +```bash +defi lend supply plan --provider moonwell --chain 8453 --asset USDC --amount 1000000 --from-address 0xYourEOA --results-only +``` + +Use `--pool-address` to specify the mToken directly, or omit it to auto-resolve by underlying asset. Moonwell does not support `--on-behalf-of`. + ## Suggested filters and reliability defaults ```bash diff --git a/docs/guides/yield.mdx b/docs/guides/yield.mdx index 4d41d6c..4f93ea5 100644 --- a/docs/guides/yield.mdx +++ b/docs/guides/yield.mdx @@ -68,7 +68,7 @@ defi yield positions --chain 1 --address 0xYourEOA --providers aave,morpho --lim defi yield positions --chain 1 --address 0xYourEOA --providers morpho --asset USDC --results-only ``` -Supported providers: `aave`, `morpho`. +Supported providers: `aave`, `morpho`, `moonwell`. ## Execution (deposit, withdraw) @@ -90,6 +90,7 @@ defi yield deposit plan --provider morpho --chain 1 --asset USDC --vault-address - 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 (read only, no execution). +- Moonwell yield routes are supported on Base and Optimism; Moonwell does not support `--on-behalf-of`. - `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/pr-description-plan-execution.md b/docs/pr-description-plan-execution.md index 77f292c..ed2b5e1 100644 --- a/docs/pr-description-plan-execution.md +++ b/docs/pr-description-plan-execution.md @@ -18,7 +18,7 @@ This lets agents and humans move from quote/data discovery to deterministic plan - Added local signer support for execution (`env`, `file`, `keystore`, plus one-off `--private-key`). - Added execution support for: - bridge: `across`, `lifi` - - lend: `aave`, `morpho` + - lend: `aave`, `morpho`, `moonwell` - rewards: `aave` - swap: `taikoswap` (same interface as Univ3) - approvals: native ERC-20 approvals @@ -29,7 +29,7 @@ This is the initial execution-capable set; more providers will be added under th - `swap plan|submit|status` - `bridge plan|submit|status` (provider: `across|lifi`) - `approvals plan|submit|status` -- `lend supply|withdraw|borrow|repay plan|submit|status` (provider: `aave|morpho`) +- `lend supply|withdraw|borrow|repay plan|submit|status` (provider: `aave|morpho|moonwell`) - `rewards claim|compound plan|submit|status` (provider: `aave`) - `actions list|show` diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 963a12e..7d85a02 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -41,7 +41,7 @@ defi yield opportunities --chain 1 --asset USDC --providers aave,morpho --min-tv 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. +`--providers` expects provider names from `providers list` (`aave,morpho,kamino,moonwell`), not protocol categories. ## 6. Get bridge and swap quotes diff --git a/docs/reference/lending-and-yield-commands.mdx b/docs/reference/lending-and-yield-commands.mdx index 31a2289..7ce7197 100644 --- a/docs/reference/lending-and-yield-commands.mdx +++ b/docs/reference/lending-and-yield-commands.mdx @@ -12,7 +12,7 @@ defi lend markets --provider kamino --chain solana --asset USDC --limit 20 --res Flags: -- `--provider string` (`aave`, `morpho`, `kamino`) required +- `--provider string` (`aave`, `morpho`, `kamino`, `moonwell`) required - `--chain string` required - `--asset string` required - `--limit int` (default `20`) @@ -35,7 +35,7 @@ defi lend positions --provider morpho --chain 1 --address 0xYourEOA --type borro Flags: -- `--provider string` (`aave`, `morpho`) required +- `--provider string` (`aave`, `morpho`, `moonwell`) required - `--chain string` required - `--address string` required - `--asset string` optional filter (`symbol`/address/CAIP-19) @@ -62,7 +62,7 @@ Flags: - `--limit int` (default `20`) - `--min-tvl-usd float` (default `0`) - `--min-apy float` (default `0`) -- `--providers string` (`aave,morpho,kamino`) +- `--providers string` (`aave,morpho,kamino,moonwell`) - `--sort string` (`apy_total|tvl_usd|liquidity_usd`, default `apy_total`) - `--include-incomplete` bool (default `false`) @@ -83,7 +83,7 @@ Flags: - `--chain string` required - `--address string` required - `--asset string` optional filter (`symbol`/address/CAIP-19) -- `--providers string` (`aave,morpho,kamino`) +- `--providers string` (`aave,morpho,kamino,moonwell`) - `--limit int` (default `20`) - `--rpc-url string` optional provider RPC override (only used by providers that need on-chain valuation) @@ -105,7 +105,7 @@ Flags: - `--chain string` required - `--asset string` required -- `--providers string` (`aave,morpho,kamino`) +- `--providers string` (`aave,morpho,kamino,moonwell`) - `--metrics string` (`apy_total,tvl_usd`, default `apy_total`) - `--interval string` (`hour|day`, default `day`) - `--window string` lookback duration (for example `24h`, `7d`, `30d`) @@ -124,7 +124,7 @@ defi yield deposit submit --action-id --results-only Flags (`plan`): -- `--provider string` (`aave|morpho`) required +- `--provider string` (`aave|morpho|moonwell`) required - `--chain string` required - `--asset string` required - `--amount string` or `--amount-decimal string` required @@ -159,7 +159,7 @@ defi lend supply plan --provider morpho --chain 1 --asset USDC --market-id 0x... defi lend supply submit --action-id --results-only ``` -Execution providers: `aave|morpho`. Morpho requires `--market-id`. `plan` and `submit` accept `--input-json` / `--input-file`. +Execution providers: `aave|morpho|moonwell`. Morpho requires `--market-id`. Moonwell uses `--pool-address` for explicit mToken or auto-resolves by underlying asset. `plan` and `submit` accept `--input-json` / `--input-file`. ## `rewards claim|compound plan|submit|status` @@ -176,7 +176,7 @@ Execution provider: `aave`. `--assets` expects comma-separated on-chain addresse ## Routing note -`lend` and `yield` routes are direct-provider only (`aave`, `morpho`, `kamino`). +`lend` and `yield` routes are direct-provider only (`aave`, `morpho`, `kamino`, `moonwell`). `yield` and `lend` intentionally represent different user intents: - `yield`: passive deposit/withdraw flows (Morpho vaults, Aave reserve-yield alias) diff --git a/internal/app/lend_execution_commands.go b/internal/app/lend_execution_commands.go index 916c6a6..7f11a72 100644 --- a/internal/app/lend_execution_commands.go +++ b/internal/app/lend_execution_commands.go @@ -30,7 +30,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh expectedIntent := "lend_" + string(verb) type lendArgs struct { - Provider string `json:"provider" flag:"provider" required:"true" enum:"aave,morpho"` + Provider string `json:"provider" flag:"provider" required:"true" enum:"aave,morpho,moonwell"` ChainArg string `json:"chain" flag:"chain" required:"true" format:"chain"` AssetArg string `json:"asset" flag:"asset" required:"true" format:"asset"` MarketID string `json:"market_id" flag:"market-id" format:"bytes32"` @@ -120,7 +120,7 @@ func (s *runtimeState) newLendVerbExecutionCommand(verb planner.AaveLendVerb, sh 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.Provider, "provider", "", "Lending provider (aave|morpho|moonwell)") 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)") diff --git a/internal/app/runner.go b/internal/app/runner.go index 38f0fd1..61abb31 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -38,6 +38,7 @@ import ( "github.com/ggonzalez94/defi-cli/internal/providers/jupiter" "github.com/ggonzalez94/defi-cli/internal/providers/kamino" "github.com/ggonzalez94/defi-cli/internal/providers/lifi" + "github.com/ggonzalez94/defi-cli/internal/providers/moonwell" "github.com/ggonzalez94/defi-cli/internal/providers/morpho" "github.com/ggonzalez94/defi-cli/internal/providers/oneinch" "github.com/ggonzalez94/defi-cli/internal/providers/taikoswap" @@ -154,6 +155,7 @@ func (s *runtimeState) newRootCommand() *cobra.Command { aaveProvider := aave.New(httpClient) morphoProvider := morpho.New(httpClient) kaminoProvider := kamino.New(httpClient) + moonwellProvider := moonwell.New() jupiterProvider := jupiter.New(httpClient, settings.JupiterAPIKey) tempoProvider := tempo.New() taikoSwapProvider := taikoswap.New() @@ -161,12 +163,14 @@ func (s *runtimeState) newRootCommand() *cobra.Command { s.lendingProviders = map[string]providers.LendingProvider{ "aave": aaveProvider, "morpho": morphoProvider, - "kamino": kaminoProvider, + "kamino": kaminoProvider, + "moonwell": moonwellProvider, } s.yieldProviders = map[string]providers.YieldProvider{ "aave": aaveProvider, "morpho": morphoProvider, - "kamino": kaminoProvider, + "kamino": kaminoProvider, + "moonwell": moonwellProvider, } s.bridgeProviders = map[string]providers.BridgeProvider{ @@ -191,6 +195,7 @@ func (s *runtimeState) newRootCommand() *cobra.Command { aaveProvider.Info(), morphoProvider.Info(), kaminoProvider.Info(), + moonwellProvider.Info(), s.bridgeProviders["across"].Info(), s.bridgeProviders["lifi"].Info(), s.bridgeProviders["bungee"].Info(), @@ -754,7 +759,7 @@ func (s *runtimeState) newLendCommand() *cobra.Command { }) }, } - marketsCmd.Flags().StringVar(&providerArg, "provider", "", "Lending provider (aave, morpho, kamino)") + marketsCmd.Flags().StringVar(&providerArg, "provider", "", "Lending provider (aave, morpho, kamino, moonwell)") 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") @@ -795,7 +800,7 @@ func (s *runtimeState) newLendCommand() *cobra.Command { }) }, } - ratesCmd.Flags().StringVar(&ratesProvider, "provider", "", "Lending provider (aave, morpho, kamino)") + ratesCmd.Flags().StringVar(&ratesProvider, "provider", "", "Lending provider (aave, morpho, kamino, moonwell)") 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") @@ -803,7 +808,7 @@ func (s *runtimeState) newLendCommand() *cobra.Command { _ = ratesCmd.MarkFlagRequired("chain") _ = ratesCmd.MarkFlagRequired("asset") - var positionsProvider, positionsChain, positionsAddress, positionsAsset, positionsType string + var positionsProvider, positionsChain, positionsAddress, positionsAsset, positionsType, positionsRPCURL string var positionsLimit int positionsCmd := &cobra.Command{ Use: "positions", @@ -845,6 +850,7 @@ func (s *runtimeState) newLendCommand() *cobra.Command { "asset": chainAssetFilterCacheValue(asset, positionsAsset), "type": string(positionType), "limit": positionsLimit, + "rpc_url": strings.TrimSpace(positionsRPCURL), } 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) { @@ -864,18 +870,20 @@ func (s *runtimeState) newLendCommand() *cobra.Command { Asset: asset, PositionType: positionType, Limit: positionsLimit, + RPCURL: strings.TrimSpace(positionsRPCURL), }) 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(&positionsProvider, "provider", "", "Lending provider (aave, morpho, moonwell)") 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") + positionsCmd.Flags().StringVar(&positionsRPCURL, "rpc-url", "", "Optional RPC URL override used by providers that need on-chain reads") _ = positionsCmd.MarkFlagRequired("provider") _ = positionsCmd.MarkFlagRequired("chain") _ = positionsCmd.MarkFlagRequired("address") @@ -1704,7 +1712,7 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { 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(&opportunitiesProvidersArg, "providers", "", "Filter by provider names (aave,morpho,kamino,moonwell)") 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") @@ -1813,7 +1821,7 @@ func (s *runtimeState) newYieldCommand() *cobra.Command { positionsCmd.Flags().StringVar(&positionsChainArg, "chain", "", "Chain identifier") positionsCmd.Flags().StringVar(&positionsAddressArg, "address", "", "Position owner address") positionsCmd.Flags().StringVar(&positionsAssetArg, "asset", "", "Optional asset filter (symbol/address/CAIP-19)") - positionsCmd.Flags().StringVar(&positionsProvidersArg, "providers", "", "Filter by provider names (aave,morpho,kamino)") + positionsCmd.Flags().StringVar(&positionsProvidersArg, "providers", "", "Filter by provider names (aave,morpho,kamino,moonwell)") positionsCmd.Flags().IntVar(&positionsLimit, "limit", 20, "Maximum positions to return") positionsCmd.Flags().StringVar(&positionsRPCURL, "rpc-url", "", "Optional RPC URL override used by providers that need on-chain valuation") _ = positionsCmd.MarkFlagRequired("chain") @@ -2223,6 +2231,8 @@ func yieldProviderSupportsChain(name string, chain id.Chain) bool { return chain.IsSolana() case "aave", "morpho": return chain.IsEVM() + case "moonwell": + return chain.IsEVM() && (chain.EVMChainID == 8453 || chain.EVMChainID == 10) default: return true } diff --git a/internal/app/yield_execution_commands.go b/internal/app/yield_execution_commands.go index 79afca2..9cfde8a 100644 --- a/internal/app/yield_execution_commands.go +++ b/internal/app/yield_execution_commands.go @@ -27,7 +27,7 @@ func (s *runtimeState) newYieldVerbExecutionCommand(verb actionbuilder.YieldVerb expectedIntent := "yield_" + string(verb) type yieldArgs struct { - Provider string `json:"provider" flag:"provider" required:"true" enum:"aave,morpho"` + Provider string `json:"provider" flag:"provider" required:"true" enum:"aave,morpho,moonwell"` ChainArg string `json:"chain" flag:"chain" required:"true" format:"chain"` AssetArg string `json:"asset" flag:"asset" required:"true" format:"asset"` VaultAddress string `json:"vault_address" flag:"vault-address" format:"evm-address"` @@ -115,7 +115,7 @@ func (s *runtimeState) newYieldVerbExecutionCommand(verb actionbuilder.YieldVerb return s.emitSuccess(trimRootPath(cmd.CommandPath()), action, nil, cacheMetaBypass(), statuses, false) }, } - planCmd.Flags().StringVar(&plan.Provider, "provider", "", "Yield provider (aave|morpho)") + planCmd.Flags().StringVar(&plan.Provider, "provider", "", "Yield provider (aave|morpho|moonwell)") planCmd.Flags().StringVar(&plan.ChainArg, "chain", "", "Chain identifier") planCmd.Flags().StringVar(&plan.AssetArg, "asset", "", "Asset symbol/address/CAIP-19") planCmd.Flags().StringVar(&plan.VaultAddress, "vault-address", "", "Morpho vault address (required for --provider morpho)") diff --git a/internal/execution/actionbuilder/registry.go b/internal/execution/actionbuilder/registry.go index 89d2775..d4d3e5b 100644 --- a/internal/execution/actionbuilder/registry.go +++ b/internal/execution/actionbuilder/registry.go @@ -157,8 +157,23 @@ func (r *Registry) BuildLendAction(ctx context.Context, req LendRequest) (execut Simulate: req.Simulate, RPCURL: req.RPCURL, }) + case "moonwell": + if strings.TrimSpace(req.OnBehalfOf) != "" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "moonwell does not support --on-behalf-of; Compound v2 calls operate on msg.sender only") + } + return planner.BuildMoonwellLendAction(ctx, planner.MoonwellLendRequest{ + Verb: req.Verb, + Chain: req.Chain, + Asset: req.Asset, + AmountBaseUnits: req.AmountBaseUnits, + Sender: req.Sender, + Recipient: req.Recipient, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + MTokenAddress: req.PoolAddress, + }) default: - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports provider=aave|morpho") + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "lend execution currently supports provider=aave|morpho|moonwell") } } @@ -220,8 +235,42 @@ func (r *Registry) BuildYieldAction(ctx context.Context, req YieldRequest) (exec Simulate: req.Simulate, RPCURL: req.RPCURL, }) + case "moonwell": + if strings.TrimSpace(req.OnBehalfOf) != "" { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "moonwell does not support --on-behalf-of; Compound v2 calls operate on msg.sender only") + } + var lendVerb planner.AaveLendVerb + switch yieldVerb { + case string(YieldVerbDeposit): + lendVerb = planner.AaveVerbSupply + case string(YieldVerbWithdraw): + lendVerb = planner.AaveVerbWithdraw + default: + return execution.Action{}, clierr.New(clierr.CodeUsage, "yield action must be deposit or withdraw") + } + action, err := planner.BuildMoonwellLendAction(ctx, planner.MoonwellLendRequest{ + Verb: lendVerb, + Chain: req.Chain, + Asset: req.Asset, + AmountBaseUnits: req.AmountBaseUnits, + Sender: req.Sender, + Recipient: req.Recipient, + Simulate: req.Simulate, + RPCURL: req.RPCURL, + MTokenAddress: req.PoolAddress, + }) + if err != nil { + return execution.Action{}, err + } + action.IntentType = "yield_" + yieldVerb + if action.Metadata == nil { + action.Metadata = map[string]any{} + } + action.Metadata["yield_action"] = yieldVerb + action.Metadata["yield_product"] = "moonwell_market" + return action, nil default: - return execution.Action{}, clierr.New(clierr.CodeUnsupported, "yield execution currently supports provider=aave|morpho") + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "yield execution currently supports provider=aave|morpho|moonwell") } } diff --git a/internal/execution/actionbuilder/registry_test.go b/internal/execution/actionbuilder/registry_test.go index 12a9659..8aca299 100644 --- a/internal/execution/actionbuilder/registry_test.go +++ b/internal/execution/actionbuilder/registry_test.go @@ -79,6 +79,43 @@ func TestBuildRewardsClaimActionRejectsUnsupportedProvider(t *testing.T) { } } +func TestBuildLendActionMoonwellRejectsOnBehalfOf(t *testing.T) { + reg := New(nil, nil) + _, err := reg.BuildLendAction(context.Background(), LendRequest{ + Provider: "moonwell", + OnBehalfOf: "0x00000000000000000000000000000000000000aa", + }) + if err == nil { + t.Fatal("expected on-behalf-of rejection for moonwell lend") + } + cErr, ok := clierr.As(err) + if !ok || cErr.Code != clierr.CodeUnsupported { + t.Fatalf("expected unsupported cli error, got %v", err) + } + if !strings.Contains(err.Error(), "--on-behalf-of") { + t.Fatalf("error should mention --on-behalf-of, got: %v", err) + } +} + +func TestBuildYieldActionMoonwellRejectsOnBehalfOf(t *testing.T) { + reg := New(nil, nil) + _, err := reg.BuildYieldAction(context.Background(), YieldRequest{ + Provider: "moonwell", + Verb: YieldVerbDeposit, + OnBehalfOf: "0x00000000000000000000000000000000000000aa", + }) + if err == nil { + t.Fatal("expected on-behalf-of rejection for moonwell yield") + } + cErr, ok := clierr.As(err) + if !ok || cErr.Code != clierr.CodeUnsupported { + t.Fatalf("expected unsupported cli error, got %v", err) + } + if !strings.Contains(err.Error(), "--on-behalf-of") { + t.Fatalf("error should mention --on-behalf-of, got: %v", err) + } +} + func TestNormalizeLendingProviderAliases(t *testing.T) { if got := providers.NormalizeLendingProvider("AAVE-V3"); got != "aave" { t.Fatalf("expected aave, got %s", got) diff --git a/internal/execution/planner/moonwell.go b/internal/execution/planner/moonwell.go new file mode 100644 index 0000000..905dc13 --- /dev/null +++ b/internal/execution/planner/moonwell.go @@ -0,0 +1,332 @@ +package planner + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum" + "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/registry" +) + +var plannerMC3Addr = common.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11") + +type plannerMC3Call struct { + Target common.Address + AllowFailure bool + CallData []byte +} + +type plannerMC3Result struct { + Success bool + ReturnData []byte +} + +type MoonwellLendRequest struct { + Verb AaveLendVerb // reuse same verb type: supply/withdraw/borrow/repay + Chain id.Chain + Asset id.Asset + AmountBaseUnits string + Sender string + Recipient string + Simulate bool + RPCURL string + MTokenAddress string // optional explicit mToken; auto-resolved if empty +} + +func BuildMoonwellLendAction(ctx context.Context, req MoonwellLendRequest) (execution.Action, error) { + verb := strings.ToLower(strings.TrimSpace(string(req.Verb))) + sender := strings.TrimSpace(req.Sender) + if !common.IsHexAddress(sender) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "lend action requires sender address") + } + recipient := strings.TrimSpace(req.Recipient) + if recipient == "" { + recipient = sender + } + if !common.IsHexAddress(recipient) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "invalid recipient address") + } + if !strings.EqualFold(recipient, sender) { + return execution.Action{}, clierr.New(clierr.CodeUnsupported, "moonwell does not support alternate recipients; Compound v2 calls operate on msg.sender only") + } + if !common.IsHexAddress(req.Asset.Address) { + return execution.Action{}, clierr.New(clierr.CodeUsage, "moonwell 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 execution.Action{}, 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 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() + + senderAddr := common.HexToAddress(sender) + recipientAddr := common.HexToAddress(recipient) + tokenAddr := common.HexToAddress(req.Asset.Address) + + // Resolve mToken address. + mTokenAddr, err := resolveMoonwellMToken(ctx, client, req.Chain, req.MTokenAddress, tokenAddr) + if err != nil { + return execution.Action{}, err + } + + action := execution.NewAction(execution.NewActionID(), "lend_"+verb, req.Chain.CAIP2, execution.Constraints{Simulate: req.Simulate}) + action.Provider = "moonwell" + action.FromAddress = senderAddr.Hex() + action.ToAddress = recipientAddr.Hex() + action.InputAmount = amount.String() + action.Metadata = map[string]any{ + "protocol": "moonwell", + "asset_id": req.Asset.AssetID, + "mtoken": mTokenAddr.Hex(), + "lending_action": verb, + } + + switch verb { + case string(AaveVerbSupply): + // Supply: approve underlying → enterMarkets (if needed) → mToken.mint(amount) + if err := appendApprovalIfNeeded(ctx, client, &action, req.Chain.CAIP2, rpcURL, tokenAddr, senderAddr, mTokenAddr, amount, "Approve token for Moonwell supply"); err != nil { + return execution.Action{}, err + } + // Enable mToken as collateral if not already entered. + if err := appendEnterMarketsIfNeeded(ctx, client, &action, req.Chain, rpcURL, senderAddr, mTokenAddr); err != nil { + return execution.Action{}, err + } + data, err := moonwellMTokenABI.Pack("mint", amount) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack moonwell mint calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "moonwell-supply", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Supply asset to Moonwell", + Target: mTokenAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + + case string(AaveVerbWithdraw): + // Withdraw: mToken.redeemUnderlying(amount) + data, err := moonwellMTokenABI.Pack("redeemUnderlying", amount) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack moonwell redeemUnderlying calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "moonwell-withdraw", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Withdraw asset from Moonwell", + Target: mTokenAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + + case string(AaveVerbBorrow): + // Borrow: mToken.borrow(amount) — requires collateral + data, err := moonwellMTokenABI.Pack("borrow", amount) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack moonwell borrow calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "moonwell-borrow", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Borrow asset from Moonwell", + Target: mTokenAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + + case string(AaveVerbRepay): + // Repay: approve underlying → mToken, then mToken.repayBorrow(amount) + if err := appendApprovalIfNeeded(ctx, client, &action, req.Chain.CAIP2, rpcURL, tokenAddr, senderAddr, mTokenAddr, amount, "Approve token for Moonwell repay"); err != nil { + return execution.Action{}, err + } + data, err := moonwellMTokenABI.Pack("repayBorrow", amount) + if err != nil { + return execution.Action{}, clierr.Wrap(clierr.CodeInternal, "pack moonwell repayBorrow calldata", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "moonwell-repay", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: req.Chain.CAIP2, + RPCURL: rpcURL, + Description: "Repay borrowed asset on Moonwell", + Target: mTokenAddr.Hex(), + Data: "0x" + common.Bytes2Hex(data), + Value: "0", + }) + + default: + return execution.Action{}, clierr.New(clierr.CodeUsage, "unsupported moonwell lend action verb") + } + + return action, nil +} + +// appendEnterMarketsIfNeeded checks if the sender has already entered the mToken market +// as collateral. If not, appends a Comptroller.enterMarkets([mToken]) step. +func appendEnterMarketsIfNeeded(ctx context.Context, client *ethclient.Client, action *execution.Action, chain id.Chain, rpcURL string, sender, mToken common.Address) error { + comptrollerAddr, ok := registry.MoonwellComptroller(chain.EVMChainID) + if !ok { + // No comptroller — skip check; enterMarkets not possible. + return nil + } + comptroller := common.HexToAddress(comptrollerAddr) + + // Check if already a member. + checkData, err := moonwellComptrollerABI.Pack("checkMembership", sender, mToken) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "pack checkMembership", err) + } + checkOut, err := client.CallContract(ctx, ethereum.CallMsg{To: &comptroller, Data: checkData}, nil) + if err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "call checkMembership", err) + } + decoded, err := moonwellComptrollerABI.Unpack("checkMembership", checkOut) + if err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "decode checkMembership", err) + } + isMember, ok := decoded[0].(bool) + if ok && isMember { + return nil // already entered + } + + // Build enterMarkets calldata. + enterData, err := moonwellComptrollerABI.Pack("enterMarkets", []common.Address{mToken}) + if err != nil { + return clierr.Wrap(clierr.CodeInternal, "pack enterMarkets", err) + } + action.Steps = append(action.Steps, execution.ActionStep{ + StepID: "moonwell-enter-market", + Type: execution.StepTypeLend, + Status: execution.StepStatusPending, + ChainID: chain.CAIP2, + RPCURL: rpcURL, + Description: "Enable asset as collateral on Moonwell", + Target: comptroller.Hex(), + Data: "0x" + common.Bytes2Hex(enterData), + Value: "0", + }) + return nil +} + +// resolveMoonwellMToken resolves the mToken contract for a given underlying asset. +// If mTokenAddress is provided explicitly (via --pool-address), use it directly. +// Otherwise, call Comptroller.getAllMarkets() and batch-resolve underlying() via Multicall3. +func resolveMoonwellMToken(ctx context.Context, client *ethclient.Client, chain id.Chain, mTokenAddress string, underlying common.Address) (common.Address, error) { + if strings.TrimSpace(mTokenAddress) != "" { + if !common.IsHexAddress(mTokenAddress) { + return common.Address{}, clierr.New(clierr.CodeUsage, "invalid --pool-address (mToken address)") + } + return common.HexToAddress(mTokenAddress), nil + } + + comptrollerAddr, ok := registry.MoonwellComptroller(chain.EVMChainID) + if !ok { + return common.Address{}, clierr.New(clierr.CodeUnsupported, "moonwell is not supported on this chain; pass --pool-address with the mToken address") + } + comptroller := common.HexToAddress(comptrollerAddr) + + // RPC call 1: getAllMarkets(). + data, err := moonwellComptrollerABI.Pack("getAllMarkets") + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeInternal, "pack getAllMarkets", err) + } + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &comptroller, Data: data}, nil) + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "call getAllMarkets", err) + } + decoded, err := moonwellComptrollerABI.Unpack("getAllMarkets", out) + if err != nil || len(decoded) == 0 { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "decode getAllMarkets", err) + } + markets, ok := decoded[0].([]common.Address) + if !ok { + return common.Address{}, clierr.New(clierr.CodeUnavailable, "invalid getAllMarkets response") + } + + // RPC call 2: batch underlying() for all markets via Multicall3. + underlyingCD, err := moonwellMTokenABI.Pack("underlying") + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeInternal, "pack underlying calldata", err) + } + + calls := make([]plannerMC3Call, len(markets)) + for i, mt := range markets { + calls[i] = plannerMC3Call{Target: mt, AllowFailure: true, CallData: underlyingCD} + } + + results, err := plannerExecMulticall3(ctx, client, calls) + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "multicall3 underlying resolution", err) + } + + for i, r := range results { + if !r.Success || len(r.ReturnData) < 32 { + continue + } + addr := common.BytesToAddress(r.ReturnData[12:32]) + if strings.EqualFold(addr.Hex(), underlying.Hex()) { + return markets[i], nil + } + } + + return common.Address{}, clierr.New(clierr.CodeUnsupported, fmt.Sprintf("no moonwell mToken found for underlying %s on chain %d; pass --pool-address with the mToken address", underlying.Hex(), chain.EVMChainID)) +} + +// plannerExecMulticall3 batches calls via Multicall3.aggregate3 in a single RPC round-trip. +func plannerExecMulticall3(ctx context.Context, client *ethclient.Client, calls []plannerMC3Call) ([]plannerMC3Result, error) { + packed, err := plannerMC3ABI.Pack("aggregate3", calls) + if err != nil { + return nil, fmt.Errorf("pack aggregate3: %w", err) + } + mc3 := plannerMC3Addr + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &mc3, Data: packed}, nil) + if err != nil { + return nil, fmt.Errorf("call multicall3: %w", err) + } + dec, err := plannerMC3ABI.Unpack("aggregate3", out) + if err != nil { + return nil, fmt.Errorf("unpack aggregate3: %w", err) + } + if len(dec) == 0 { + return nil, fmt.Errorf("empty aggregate3 response") + } + rawResults := dec[0].([]struct { + Success bool `json:"success"` + ReturnData []byte `json:"returnData"` + }) + results := make([]plannerMC3Result, len(rawResults)) + for i, r := range rawResults { + results[i].Success = r.Success + results[i].ReturnData = r.ReturnData + } + return results, nil +} + +var moonwellMTokenABI = mustPlannerABI(registry.MoonwellMTokenABI) +var moonwellComptrollerABI = mustPlannerABI(registry.MoonwellComptrollerABI) +var plannerMC3ABI = mustPlannerABI(registry.Multicall3ABI) diff --git a/internal/execution/planner/moonwell_test.go b/internal/execution/planner/moonwell_test.go new file mode 100644 index 0000000..a59c8ee --- /dev/null +++ b/internal/execution/planner/moonwell_test.go @@ -0,0 +1,482 @@ +package planner + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ggonzalez94/defi-cli/internal/id" +) + +const ( + testMToken = "0x0000000000000000000000000000000000000011" + testUnderlying = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" // USDC on Ethereum (used as Base USDC for test purposes) + testSender = "0x00000000000000000000000000000000000000AA" + testRecipient = "0x00000000000000000000000000000000000000BB" +) + +// newMoonwellPlannerRPCServer creates a mock that dispatches by selector: +// - allowance → returns the given allowance +// - checkMembership → returns the given isMember bool +func newMoonwellPlannerRPCServer(t *testing.T, allowance *big.Int, isMember bool) *httptest.Server { + t.Helper() + allowanceSel := hex.EncodeToString(plannerERC20ABI.Methods["allowance"].ID) + checkMembershipSel := hex.EncodeToString(moonwellComptrollerABI.Methods["checkMembership"].ID) + + 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 + } + if req.Method != "eth_call" { + writePlannerRPCError(w, req.ID, -32601, fmt.Sprintf("method not supported: %s", req.Method)) + return + } + var callObj struct { + Data string `json:"data"` + Input string `json:"input"` + } + if err := json.Unmarshal(req.Params[0], &callObj); err != nil { + writePlannerRPCError(w, req.ID, -32602, "bad params") + return + } + rawData := callObj.Data + if rawData == "" { + rawData = callObj.Input + } + data, _ := hex.DecodeString(strings.TrimPrefix(rawData, "0x")) + if len(data) < 4 { + writePlannerRPCError(w, req.ID, -32602, "data too short") + return + } + selector := hex.EncodeToString(data[:4]) + + switch selector { + case allowanceSel: + encoded, _ := plannerERC20ABI.Methods["allowance"].Outputs.Pack(allowance) + writePlannerRPCResult(w, req.ID, "0x"+hex.EncodeToString(encoded)) + case checkMembershipSel: + encoded, _ := moonwellComptrollerABI.Methods["checkMembership"].Outputs.Pack(isMember) + writePlannerRPCResult(w, req.ID, "0x"+hex.EncodeToString(encoded)) + default: + // Fallback: return allowance (backward compat for non-Moonwell tests). + encoded, _ := plannerERC20ABI.Methods["allowance"].Outputs.Pack(allowance) + writePlannerRPCResult(w, req.ID, "0x"+hex.EncodeToString(encoded)) + } + })) +} + +func TestBuildMoonwellSupplyWithExplicitMToken(t *testing.T) { + rpc := newMoonwellPlannerRPCServer(t, big.NewInt(0), false) + defer rpc.Close() + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + action, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: testSender, + Recipient: testSender, + Simulate: true, + RPCURL: rpc.URL, + MTokenAddress: testMToken, + }) + if err != nil { + t.Fatalf("BuildMoonwellLendAction supply failed: %v", err) + } + if action.IntentType != "lend_supply" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + if action.Provider != "moonwell" { + t.Fatalf("unexpected provider: %s", action.Provider) + } + // Should have approval + enterMarkets + supply steps. + if len(action.Steps) != 3 { + t.Fatalf("expected 3 steps (approval + enterMarkets + supply), 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].StepID != "moonwell-enter-market" { + t.Fatalf("expected second step moonwell-enter-market, got %s", action.Steps[1].StepID) + } + if action.Steps[2].Type != "lend_call" { + t.Fatalf("expected third step lend_call, got %s", action.Steps[2].Type) + } + if !strings.EqualFold(action.Steps[2].Target, testMToken) { + t.Fatalf("unexpected lend target: %s", action.Steps[2].Target) + } + if action.Steps[2].StepID != "moonwell-supply" { + t.Fatalf("unexpected step ID: %s", action.Steps[2].StepID) + } +} + +func TestBuildMoonwellLendRejectsAlternateRecipient(t *testing.T) { + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + _, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: testSender, + Recipient: testRecipient, + Simulate: true, + MTokenAddress: testMToken, + }) + if err == nil { + t.Fatal("expected error when recipient differs from sender") + } + if !strings.Contains(err.Error(), "alternate recipients") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildMoonwellSupplySkipsApprovalWhenSufficient(t *testing.T) { + // Allowance already large enough + already entered — should skip both. + rpc := newMoonwellPlannerRPCServer(t, new(big.Int).SetUint64(10_000_000), true) + defer rpc.Close() + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + action, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: testSender, + Simulate: true, + RPCURL: rpc.URL, + MTokenAddress: testMToken, + }) + if err != nil { + t.Fatalf("BuildMoonwellLendAction failed: %v", err) + } + if len(action.Steps) != 1 { + t.Fatalf("expected 1 step (supply only, no approval or enterMarkets), got %d", len(action.Steps)) + } + if action.Steps[0].StepID != "moonwell-supply" { + t.Fatalf("unexpected step ID: %s", action.Steps[0].StepID) + } +} + +func TestBuildMoonwellWithdraw(t *testing.T) { + rpc := newPlannerRPCServer(t, big.NewInt(0)) + defer rpc.Close() + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + action, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbWithdraw, + Chain: chain, + Asset: asset, + AmountBaseUnits: "500000", + Sender: testSender, + Simulate: true, + RPCURL: rpc.URL, + MTokenAddress: testMToken, + }) + if err != nil { + t.Fatalf("BuildMoonwellLendAction withdraw failed: %v", err) + } + if action.IntentType != "lend_withdraw" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + // Withdraw has no approval step. + if len(action.Steps) != 1 { + t.Fatalf("expected 1 step (withdraw only), got %d", len(action.Steps)) + } + if action.Steps[0].StepID != "moonwell-withdraw" { + t.Fatalf("unexpected step ID: %s", action.Steps[0].StepID) + } + if !strings.EqualFold(action.Steps[0].Target, testMToken) { + t.Fatalf("unexpected target: %s", action.Steps[0].Target) + } +} + +func TestBuildMoonwellBorrow(t *testing.T) { + rpc := newPlannerRPCServer(t, big.NewInt(0)) + defer rpc.Close() + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + action, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbBorrow, + Chain: chain, + Asset: asset, + AmountBaseUnits: "250000", + Sender: testSender, + Simulate: true, + RPCURL: rpc.URL, + MTokenAddress: testMToken, + }) + if err != nil { + t.Fatalf("BuildMoonwellLendAction borrow failed: %v", err) + } + if action.IntentType != "lend_borrow" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + // Borrow has no approval step. + if len(action.Steps) != 1 { + t.Fatalf("expected 1 step (borrow only), got %d", len(action.Steps)) + } + if action.Steps[0].StepID != "moonwell-borrow" { + t.Fatalf("unexpected step ID: %s", action.Steps[0].StepID) + } +} + +func TestBuildMoonwellRepay(t *testing.T) { + rpc := newPlannerRPCServer(t, big.NewInt(0)) + defer rpc.Close() + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + action, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbRepay, + Chain: chain, + Asset: asset, + AmountBaseUnits: "750000", + Sender: testSender, + Recipient: testSender, + Simulate: true, + RPCURL: rpc.URL, + MTokenAddress: testMToken, + }) + if err != nil { + t.Fatalf("BuildMoonwellLendAction repay failed: %v", err) + } + if action.IntentType != "lend_repay" { + t.Fatalf("unexpected intent type: %s", action.IntentType) + } + // Repay has approval + repay steps. + if len(action.Steps) != 2 { + t.Fatalf("expected 2 steps (approval + repay), 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].StepID != "moonwell-repay" { + t.Fatalf("unexpected step ID: %s", action.Steps[1].StepID) + } +} + +func TestBuildMoonwellRequiresSender(t *testing.T) { + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + _, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + MTokenAddress: testMToken, + }) + if err == nil { + t.Fatal("expected missing sender error") + } +} + +func TestBuildMoonwellRejectsInvalidAmount(t *testing.T) { + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + _, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbSupply, + Chain: chain, + Asset: asset, + AmountBaseUnits: "0", + Sender: testSender, + MTokenAddress: testMToken, + }) + if err == nil { + t.Fatal("expected invalid amount error") + } +} + +func TestBuildMoonwellRejectsUnsupportedVerb(t *testing.T) { + rpc := newPlannerRPCServer(t, big.NewInt(0)) + defer rpc.Close() + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + _, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveLendVerb("invalid"), + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: testSender, + RPCURL: rpc.URL, + MTokenAddress: testMToken, + }) + if err == nil { + t.Fatal("expected unsupported verb error") + } +} + +func TestResolveMoonwellMTokenExplicit(t *testing.T) { + addr, err := resolveMoonwellMToken(context.Background(), nil, id.Chain{EVMChainID: 8453}, testMToken, common.Address{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.EqualFold(addr.Hex(), testMToken) { + t.Fatalf("unexpected address: %s", addr.Hex()) + } +} + +func TestResolveMoonwellMTokenInvalidExplicit(t *testing.T) { + _, err := resolveMoonwellMToken(context.Background(), nil, id.Chain{EVMChainID: 8453}, "not-hex", common.Address{}) + if err == nil { + t.Fatal("expected invalid address error") + } +} + +func TestResolveMoonwellMTokenAutoResolve(t *testing.T) { + mTokenAddr := common.HexToAddress(testMToken) + underlyingAddr := common.HexToAddress(testUnderlying) + + getAllMarketsSel := hex.EncodeToString(moonwellComptrollerABI.Methods["getAllMarkets"].ID) + underlyingSel := hex.EncodeToString(moonwellMTokenABI.Methods["underlying"].ID) + mc3Sel := hex.EncodeToString(plannerMC3ABI.Methods["aggregate3"].ID) + + // dispatchSingle handles an individual contract call and returns the hex-encoded result. + dispatchSingle := func(selector string) string { + switch selector { + case getAllMarketsSel: + encoded, _ := moonwellComptrollerABI.Methods["getAllMarkets"].Outputs.Pack([]common.Address{mTokenAddr}) + return hex.EncodeToString(encoded) + case underlyingSel: + encoded, _ := moonwellMTokenABI.Methods["underlying"].Outputs.Pack(underlyingAddr) + return hex.EncodeToString(encoded) + default: + return "" + } + } + + // Build a mock RPC that handles getAllMarkets + aggregate3 (batched underlying). + srv := 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 + } + if req.Method != "eth_call" { + writePlannerRPCError(w, req.ID, -32601, "unsupported") + return + } + var callObj struct { + To string `json:"to"` + Data string `json:"data"` + Input string `json:"input"` + } + if err := json.Unmarshal(req.Params[0], &callObj); err != nil { + writePlannerRPCError(w, req.ID, -32602, "bad params") + return + } + rawData := callObj.Data + if rawData == "" { + rawData = callObj.Input + } + data, _ := hex.DecodeString(strings.TrimPrefix(rawData, "0x")) + if len(data) < 4 { + writePlannerRPCError(w, req.ID, -32602, "data too short") + return + } + selector := hex.EncodeToString(data[:4]) + + // Handle Multicall3 aggregate3. + if strings.EqualFold(callObj.To, plannerMC3Addr.Hex()) && selector == mc3Sel { + decoded, err := plannerMC3ABI.Methods["aggregate3"].Inputs.Unpack(data[4:]) + if err != nil { + writePlannerRPCError(w, req.ID, -32602, "unpack aggregate3") + return + } + subcalls := decoded[0].([]struct { + Target common.Address `json:"target"` + AllowFailure bool `json:"allowFailure"` + CallData []byte `json:"callData"` + }) + type mc3Res struct { + Success bool + ReturnData []byte + } + results := make([]mc3Res, len(subcalls)) + for i, sc := range subcalls { + if len(sc.CallData) < 4 { + results[i] = mc3Res{Success: false} + continue + } + subSel := hex.EncodeToString(sc.CallData[:4]) + resHex := dispatchSingle(subSel) + if resHex == "" { + results[i] = mc3Res{Success: false} + } else { + resBytes, _ := hex.DecodeString(resHex) + results[i] = mc3Res{Success: true, ReturnData: resBytes} + } + } + encoded, _ := plannerMC3ABI.Methods["aggregate3"].Outputs.Pack(results) + writePlannerRPCResult(w, req.ID, "0x"+hex.EncodeToString(encoded)) + return + } + + // Handle direct calls (getAllMarkets). + resHex := dispatchSingle(selector) + if resHex != "" { + writePlannerRPCResult(w, req.ID, "0x"+resHex) + } else { + writePlannerRPCError(w, req.ID, -32601, fmt.Sprintf("unknown selector: %s", selector)) + } + })) + defer srv.Close() + + // Use a chain with known comptroller (Base = 8453). + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Address: testUnderlying, AssetID: "eip155:8453/erc20:" + testUnderlying} + + action, err := BuildMoonwellLendAction(context.Background(), MoonwellLendRequest{ + Verb: AaveVerbWithdraw, + Chain: chain, + Asset: asset, + AmountBaseUnits: "1000000", + Sender: testSender, + Simulate: true, + RPCURL: srv.URL, + // MTokenAddress intentionally empty — triggers auto-resolution. + }) + if err != nil { + t.Fatalf("auto-resolve failed: %v", err) + } + if !strings.EqualFold(action.Steps[0].Target, testMToken) { + t.Fatalf("unexpected target after auto-resolve: %s", action.Steps[0].Target) + } +} + +func TestResolveMoonwellMTokenUnsupportedChain(t *testing.T) { + // Chain 999 has no comptroller entry. + _, err := resolveMoonwellMToken(context.Background(), nil, id.Chain{EVMChainID: 999}, "", common.Address{}) + if err == nil { + t.Fatal("expected unsupported chain error") + } + if !strings.Contains(err.Error(), "not supported") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/id/amount.go b/internal/id/amount.go index 707de45..3823755 100644 --- a/internal/id/amount.go +++ b/internal/id/amount.go @@ -11,6 +11,9 @@ import ( var decimalPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) +// MaxUint256 is the decimal string representation of 2^256 - 1. +const MaxUint256 = "115792089237316195423570985008687907853269984665640564039457584007913129639935" + func NormalizeAmount(baseUnits, decimal string, decimals int) (string, string, error) { if baseUnits != "" && decimal != "" { return "", "", clierr.New(clierr.CodeUsage, "use either --amount or --amount-decimal, not both") @@ -22,6 +25,11 @@ func NormalizeAmount(baseUnits, decimal string, decimals int) (string, string, e return "", "", clierr.New(clierr.CodeUsage, "decimals must be >= 0") } + // "max" resolves to uint256.max — used by repay to close full borrow balance. + if strings.EqualFold(strings.TrimSpace(baseUnits), "max") { + return MaxUint256, "max", nil + } + if baseUnits != "" { if _, ok := new(big.Int).SetString(baseUnits, 10); !ok { return "", "", clierr.New(clierr.CodeUsage, "--amount must be a positive integer string") diff --git a/internal/id/amount_test.go b/internal/id/amount_test.go index 7c6ac0d..2d0c584 100644 --- a/internal/id/amount_test.go +++ b/internal/id/amount_test.go @@ -22,6 +22,28 @@ func TestNormalizeAmountDecimal(t *testing.T) { } } +func TestNormalizeAmountMax(t *testing.T) { + base, dec, err := NormalizeAmount("max", "", 18) + if err != nil { + t.Fatalf("NormalizeAmount(max) failed: %v", err) + } + if base != MaxUint256 { + t.Fatalf("expected MaxUint256, got %s", base) + } + if dec != "max" { + t.Fatalf("expected decimal 'max', got %s", dec) + } + + // Case-insensitive. + base2, _, err := NormalizeAmount("MAX", "", 6) + if err != nil { + t.Fatalf("NormalizeAmount(MAX) failed: %v", err) + } + if base2 != MaxUint256 { + t.Fatalf("expected MaxUint256 for MAX, got %s", base2) + } +} + func TestNormalizeAmountValidation(t *testing.T) { if _, _, err := NormalizeAmount("10", "1", 6); err == nil { t.Fatal("expected mutual exclusivity error") diff --git a/internal/id/id.go b/internal/id/id.go index 102b8e8..759d771 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -197,7 +197,8 @@ var tokenRegistry = map[string][]Token{ {Symbol: "PENDLE", Address: "0xbc7b1ff1c6989f006a1185318ed4e7b5796e66e1", Decimals: 18}, {Symbol: "TUSD", Address: "0xcb59a0a753fdb7491d5f3d794316f1ade197b21e", Decimals: 18}, {Symbol: "UNI", Address: "0x6fd9d7ad17242c41f7131d257212c54a0e816691", Decimals: 18}, - {Symbol: "USDC", Address: "0x7f5c764cbc14f9669b88837ca1490cca17c31607", Decimals: 6}, + {Symbol: "USDC", Address: "0x0b2c639c533813f4aa9d7837caf62653d097ff85", Decimals: 6}, + {Symbol: "USDC.e", Address: "0x7f5c764cbc14f9669b88837ca1490cca17c31607", Decimals: 6}, {Symbol: "USDE", Address: "0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34", Decimals: 18}, {Symbol: "USDT", Address: "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", Decimals: 6}, {Symbol: "USDT0", Address: "0x01bff41798a0bcf287b996046ca68b395dbc1071", Decimals: 6}, diff --git a/internal/providers/moonwell/client.go b/internal/providers/moonwell/client.go new file mode 100644 index 0000000..d13883e --- /dev/null +++ b/internal/providers/moonwell/client.go @@ -0,0 +1,1041 @@ +package moonwell + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "math" + "math/big" + "sort" + "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/id" + "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 secondsPerYear = 365.25 * 24 * 3600 + +// Multicall3 is deployed at a standard address on all major EVM chains. +var multicall3Addr = common.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11") + +// multicall3Call matches Multicall3.Call3 struct. +type multicall3Call struct { + Target common.Address + AllowFailure bool + CallData []byte +} + +// multicall3Result matches Multicall3.Result struct. +type multicall3Result struct { + Success bool + ReturnData []byte +} + +type Client struct { + now func() time.Time + rpcOverride string // used in tests to point at a mock RPC server +} + +func New() *Client { + return &Client{now: time.Now} +} + +func (c *Client) Info() model.ProviderInfo { + return model.ProviderInfo{ + Name: "moonwell", + Type: "lending+yield", + RequiresKey: false, + Capabilities: []string{ + "lend.markets", + "lend.rates", + "lend.positions", + "yield.opportunities", + "yield.positions", + "lend.plan", + "lend.execute", + "yield.plan", + "yield.execute", + }, + } +} + +// ── internal market struct ────────────────────────────────────────────── + +type moonwellMarket struct { + MTokenAddress string + UnderlyingAddress string + UnderlyingSymbol string + UnderlyingDecimals int + SupplyAPY float64 // percentage points + BorrowAPY float64 + TVLUSD float64 + TotalBorrowsUSD float64 + LiquidityUSD float64 + Utilization float64 +} + +// ── LendingProvider ───────────────────────────────────────────────────── + +func (c *Client) LendMarkets(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendMarket, error) { + if !chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "moonwell supports only EVM chains") + } + markets, comptroller, err := c.fetchMarkets(ctx, chain, c.rpcOverride) + if err != nil { + return nil, err + } + _ = comptroller + + out := make([]model.LendMarket, 0, len(markets)) + for _, m := range markets { + if !matchesAsset(m.UnderlyingAddress, m.UnderlyingSymbol, asset) { + continue + } + assetID := canonicalAssetIDForChain(chain.CAIP2, m.UnderlyingAddress) + if assetID == "" { + continue + } + nativeID := providerNativeID("moonwell", chain.CAIP2, comptroller, m.UnderlyingAddress) + out = append(out, model.LendMarket{ + Protocol: "moonwell", + Provider: "moonwell", + ChainID: chain.CAIP2, + AssetID: assetID, + ProviderNativeID: nativeID, + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + SupplyAPY: m.SupplyAPY, + BorrowAPY: m.BorrowAPY, + TVLUSD: m.TVLUSD, + LiquidityUSD: m.LiquidityUSD, + SourceURL: "https://moonwell.fi", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].TVLUSD != out[j].TVLUSD { + return out[i].TVLUSD > out[j].TVLUSD + } + return out[i].AssetID < out[j].AssetID + }) + return out, nil +} + +func (c *Client) LendRates(ctx context.Context, provider string, chain id.Chain, asset id.Asset) ([]model.LendRate, error) { + if !chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "moonwell supports only EVM chains") + } + markets, comptroller, err := c.fetchMarkets(ctx, chain, c.rpcOverride) + if err != nil { + return nil, err + } + + out := make([]model.LendRate, 0, len(markets)) + for _, m := range markets { + if !matchesAsset(m.UnderlyingAddress, m.UnderlyingSymbol, asset) { + continue + } + assetID := canonicalAssetIDForChain(chain.CAIP2, m.UnderlyingAddress) + if assetID == "" { + continue + } + nativeID := providerNativeID("moonwell", chain.CAIP2, comptroller, m.UnderlyingAddress) + out = append(out, model.LendRate{ + Protocol: "moonwell", + Provider: "moonwell", + ChainID: chain.CAIP2, + AssetID: assetID, + ProviderNativeID: nativeID, + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + SupplyAPY: m.SupplyAPY, + BorrowAPY: m.BorrowAPY, + Utilization: m.Utilization, + SourceURL: "https://moonwell.fi", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].SupplyAPY != out[j].SupplyAPY { + return out[i].SupplyAPY > out[j].SupplyAPY + } + return out[i].AssetID < out[j].AssetID + }) + return out, nil +} + +// ── LendingPositionsProvider ──────────────────────────────────────────── + +func (c *Client) LendPositions(ctx context.Context, req providers.LendPositionsRequest) ([]model.LendPosition, error) { + if !req.Chain.IsEVM() { + return nil, clierr.New(clierr.CodeUnsupported, "moonwell supports only EVM chains") + } + account := normalizeEVMAddress(req.Account) + if account == "" { + return nil, clierr.New(clierr.CodeUsage, "lend positions requires a valid EVM address") + } + + rpcOverride := c.rpcOverride + if req.RPCURL != "" { + rpcOverride = req.RPCURL + } + rpcURL, err := registry.ResolveRPCURL(rpcOverride, req.Chain.EVMChainID) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUnsupported, "resolve rpc url", err) + } + comptrollerAddr, ok := registry.MoonwellComptroller(req.Chain.EVMChainID) + if !ok { + return nil, clierr.New(clierr.CodeUnsupported, "moonwell is not supported on this chain") + } + + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + defer client.Close() + + comptroller := common.HexToAddress(comptrollerAddr) + accountAddr := common.HexToAddress(account) + + // Get all markets + collateral set + oracle (3 sequential RPC calls). + allMarkets, err := callGetAllMarkets(ctx, client, comptroller) + if err != nil { + return nil, err + } + collateralSet, err := callGetAssetsIn(ctx, client, comptroller, accountAddr) + if err != nil { + return nil, err + } + oracleAddr, err := callOracle(ctx, client, comptroller) + if err != nil { + return nil, err + } + + // Batch all per-market calls via multicall: + // Per mToken: getAccountSnapshot, underlying, supplyRate, borrowRate, getUnderlyingPrice + // Per underlying: symbol, decimals (phase 2 after we know underlying addresses) + const posCallsPerMarket = 5 // snapshot, underlying, supplyRate, borrowRate, price + snapshotCalls := make([]multicall3Call, 0, len(allMarkets)*posCallsPerMarket) + underlyingCD, _ := mTokenABI.Pack("underlying") + supplyRateCD, _ := mTokenABI.Pack("supplyRatePerTimestamp") + borrowRateCD, _ := mTokenABI.Pack("borrowRatePerTimestamp") + + for _, mt := range allMarkets { + snapshotCD, _ := mTokenABI.Pack("getAccountSnapshot", accountAddr) + priceCD, _ := oracleABI.Pack("getUnderlyingPrice", mt) + snapshotCalls = append(snapshotCalls, + multicall3Call{Target: mt, AllowFailure: true, CallData: snapshotCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: underlyingCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: supplyRateCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: borrowRateCD}, + multicall3Call{Target: oracleAddr, AllowFailure: true, CallData: priceCD}, + ) + } + + phase1Results, err := execMulticall3(ctx, client, snapshotCalls) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUnavailable, "multicall positions", err) + } + + // Parse phase 1, collect underlying addresses for phase 2 metadata. + type posMarket struct { + mToken common.Address + underlying common.Address + errCode *big.Int + mTokenBal *big.Int + borrowBal *big.Int + exchangeRate *big.Int + supplyRate *big.Int + borrowRate *big.Int + priceMantissa *big.Int + } + posMarkets := make([]posMarket, 0) + + for i, mt := range allMarkets { + base := i * posCallsPerMarket + r := phase1Results[base : base+posCallsPerMarket] + + // getAccountSnapshot + if !r[0].Success || len(r[0].ReturnData) < 128 { + continue + } + snapDec, err := mTokenABI.Unpack("getAccountSnapshot", r[0].ReturnData) + if err != nil || len(snapDec) < 4 { + continue + } + errCode := asBigInt(snapDec[0]) + mTokenBal := asBigInt(snapDec[1]) + borrowBal := asBigInt(snapDec[2]) + exchangeRate := asBigInt(snapDec[3]) + + if errCode.Sign() != 0 || (mTokenBal.Sign() == 0 && borrowBal.Sign() == 0) { + continue + } + + // underlying + if !r[1].Success || len(r[1].ReturnData) < 32 { + continue + } + ulDec, err := mTokenABI.Unpack("underlying", r[1].ReturnData) + if err != nil || len(ulDec) == 0 { + continue + } + underlying, ok := ulDec[0].(common.Address) + if !ok { + continue + } + + posMarkets = append(posMarkets, posMarket{ + mToken: mt, + underlying: underlying, + errCode: errCode, + mTokenBal: mTokenBal, + borrowBal: borrowBal, + exchangeRate: exchangeRate, + supplyRate: decodeUint256Result(r[2], mTokenABI, "supplyRatePerTimestamp"), + borrowRate: decodeUint256Result(r[3], mTokenABI, "borrowRatePerTimestamp"), + priceMantissa: decodeUint256Result(r[4], oracleABI, "getUnderlyingPrice"), + }) + } + + if len(posMarkets) == 0 { + return []model.LendPosition{}, nil + } + + // Phase 2: get symbol + decimals for each underlying. + symbolCD, _ := erc20ABI.Pack("symbol") + decimalsCD, _ := erc20ABI.Pack("decimals") + phase2Calls := make([]multicall3Call, 0, len(posMarkets)*2) + for _, pm := range posMarkets { + phase2Calls = append(phase2Calls, + multicall3Call{Target: pm.underlying, AllowFailure: true, CallData: symbolCD}, + multicall3Call{Target: pm.underlying, AllowFailure: true, CallData: decimalsCD}, + ) + } + + phase2Results, err := execMulticall3(ctx, client, phase2Calls) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUnavailable, "multicall position metadata", err) + } + + filterType := providers.LendPositionType(strings.ToLower(strings.TrimSpace(string(req.PositionType)))) + out := make([]model.LendPosition, 0) + + for i, pm := range posMarkets { + base := i * 2 + var symbol string + if phase2Results[base].Success && len(phase2Results[base].ReturnData) >= 32 { + dec, err := erc20ABI.Unpack("symbol", phase2Results[base].ReturnData) + if err == nil && len(dec) > 0 { + symbol, _ = dec[0].(string) + } + } + var decimals int + if phase2Results[base+1].Success && len(phase2Results[base+1].ReturnData) >= 32 { + dec, err := erc20ABI.Unpack("decimals", phase2Results[base+1].ReturnData) + if err == nil && len(dec) > 0 { + d, _ := dec[0].(uint8) + decimals = int(d) + } + } + if symbol == "" || decimals == 0 { + continue + } + + ulAddr := strings.ToLower(pm.underlying.Hex()) + if !matchesAsset(ulAddr, symbol, req.Asset) { + continue + } + assetID := canonicalAssetIDForChain(req.Chain.CAIP2, ulAddr) + if assetID == "" { + continue + } + nativeID := providerNativeID("moonwell", req.Chain.CAIP2, comptrollerAddr, ulAddr) + priceUSD := mantissaToUSD(pm.priceMantissa, decimals) + + // Supply position. + if pm.mTokenBal.Sign() > 0 { + underlyingBal := new(big.Int).Mul(pm.mTokenBal, pm.exchangeRate) + underlyingBal.Div(underlyingBal, big.NewInt(1e18)) + + posType := providers.LendPositionTypeSupply + if collateralSet[pm.mToken] { + posType = providers.LendPositionTypeCollateral + } + if matchesPositionType(filterType, posType) { + amountUSD := bigIntToFloat(underlyingBal, decimals) * priceUSD + out = append(out, model.LendPosition{ + Protocol: "moonwell", + Provider: "moonwell", + ChainID: req.Chain.CAIP2, + AccountAddress: account, + PositionType: string(posType), + AssetID: assetID, + ProviderNativeID: nativeID, + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + Amount: amountInfoFromBigInt(underlyingBal, decimals), + AmountUSD: amountUSD, + APY: rateToAPY(pm.supplyRate), + SourceURL: "https://moonwell.fi", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + + // Borrow position. + if pm.borrowBal.Sign() > 0 && matchesPositionType(filterType, providers.LendPositionTypeBorrow) { + amountUSD := bigIntToFloat(pm.borrowBal, decimals) * priceUSD + out = append(out, model.LendPosition{ + Protocol: "moonwell", + Provider: "moonwell", + ChainID: req.Chain.CAIP2, + AccountAddress: account, + PositionType: string(providers.LendPositionTypeBorrow), + AssetID: assetID, + ProviderNativeID: nativeID, + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + Amount: amountInfoFromBigInt(pm.borrowBal, decimals), + AmountUSD: amountUSD, + APY: rateToAPY(pm.borrowRate), + SourceURL: "https://moonwell.fi", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + } + + sortLendPositions(out) + if req.Limit > 0 && len(out) > req.Limit { + out = out[:req.Limit] + } + return out, nil +} + +// ── YieldProvider ─────────────────────────────────────────────────────── + +func (c *Client) YieldOpportunities(ctx context.Context, req providers.YieldRequest) ([]model.YieldOpportunity, error) { + markets, comptroller, err := c.fetchMarkets(ctx, req.Chain, c.rpcOverride) + if err != nil { + return nil, err + } + + out := make([]model.YieldOpportunity, 0, len(markets)) + for _, m := range markets { + if !matchesAsset(m.UnderlyingAddress, m.UnderlyingSymbol, req.Asset) { + continue + } + if (m.SupplyAPY == 0 || m.TVLUSD == 0) && !req.IncludeIncomplete { + continue + } + if m.SupplyAPY < req.MinAPY { + continue + } + if m.TVLUSD < req.MinTVLUSD { + continue + } + + assetID := canonicalAssetIDForChain(req.Chain.CAIP2, m.UnderlyingAddress) + if assetID == "" { + continue + } + nativeID := providerNativeID("moonwell", req.Chain.CAIP2, comptroller, m.UnderlyingAddress) + opportunityID := hashOpportunity("moonwell", req.Chain.CAIP2, nativeID, assetID) + + out = append(out, model.YieldOpportunity{ + OpportunityID: opportunityID, + Provider: "moonwell", + Protocol: "moonwell", + ChainID: req.Chain.CAIP2, + AssetID: assetID, + ProviderNativeID: nativeID, + ProviderNativeIDKind: model.NativeIDKindCompositeMarketAsset, + Type: "lend", + APYBase: m.SupplyAPY, + APYReward: 0, + APYTotal: m.SupplyAPY, + TVLUSD: m.TVLUSD, + LiquidityUSD: m.LiquidityUSD, + LockupDays: 0, + WithdrawalTerms: "variable", + BackingAssets: []model.YieldBackingAsset{{ + AssetID: assetID, + Symbol: m.UnderlyingSymbol, + SharePct: 100, + }}, + SourceURL: "https://moonwell.fi", + FetchedAt: c.now().UTC().Format(time.RFC3339), + }) + } + + if len(out) == 0 { + return nil, clierr.New(clierr.CodeUnavailable, "no moonwell yield opportunities for requested chain/asset") + } + yieldutil.Sort(out, req.SortBy) + if req.Limit <= 0 || req.Limit > len(out) { + req.Limit = len(out) + } + return out[:req.Limit], nil +} + +// ── YieldPositionsProvider ────────────────────────────────────────────── + +func (c *Client) YieldPositions(ctx context.Context, req providers.YieldPositionsRequest) ([]model.YieldPosition, error) { + lendRows, err := c.LendPositions(ctx, providers.LendPositionsRequest{ + Chain: req.Chain, + Account: req.Account, + Asset: req.Asset, + PositionType: providers.LendPositionTypeAll, + Limit: req.Limit, + RPCURL: req.RPCURL, + }) + if err != nil { + return nil, err + } + + out := make([]model.YieldPosition, 0, len(lendRows)) + for _, row := range lendRows { + switch row.PositionType { + case string(providers.LendPositionTypeSupply), string(providers.LendPositionTypeCollateral): + default: + continue + } + opportunityID := "" + if strings.TrimSpace(row.ProviderNativeID) != "" { + opportunityID = hashOpportunity("moonwell", row.ChainID, row.ProviderNativeID, row.AssetID) + } + out = append(out, model.YieldPosition{ + Protocol: "moonwell", + Provider: "moonwell", + ChainID: row.ChainID, + AccountAddress: row.AccountAddress, + PositionType: "deposit", + OpportunityID: opportunityID, + AssetID: row.AssetID, + ProviderNativeID: row.ProviderNativeID, + ProviderNativeIDKind: row.ProviderNativeIDKind, + Amount: row.Amount, + AmountUSD: row.AmountUSD, + APYTotal: row.APY, + SourceURL: row.SourceURL, + FetchedAt: row.FetchedAt, + }) + } + + sortYieldPositions(out) + if req.Limit > 0 && len(out) > req.Limit { + out = out[:req.Limit] + } + return out, nil +} + +// ── RPC data fetching ─────────────────────────────────────────────────── + +// callsPerMarketPhase1 is the number of multicall sub-calls per mToken in phase 1. +// Order: underlying, supplyRate, borrowRate, totalSupply, exchangeRate, totalBorrows, getCash, price. +const callsPerMarketPhase1 = 8 + +// callsPerMarketPhase2 is the number of multicall sub-calls per underlying in phase 2. +// Order: symbol, decimals. +const callsPerMarketPhase2 = 2 + +func (c *Client) fetchMarkets(ctx context.Context, chain id.Chain, rpcOverride string) ([]moonwellMarket, string, error) { + if !chain.IsEVM() { + return nil, "", clierr.New(clierr.CodeUnsupported, "moonwell supports only EVM chains") + } + rpcURL, err := registry.ResolveRPCURL(rpcOverride, chain.EVMChainID) + if err != nil { + return nil, "", clierr.Wrap(clierr.CodeUnsupported, "resolve rpc url", err) + } + comptrollerAddr, ok := registry.MoonwellComptroller(chain.EVMChainID) + if !ok { + return nil, "", clierr.New(clierr.CodeUnsupported, "moonwell is not supported on this chain") + } + + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return nil, "", clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + defer client.Close() + + comptroller := common.HexToAddress(comptrollerAddr) + + // 1. Get all mToken addresses + oracle (2 RPC calls). + mTokens, err := callGetAllMarkets(ctx, client, comptroller) + if err != nil { + return nil, "", err + } + if len(mTokens) == 0 { + return nil, comptrollerAddr, nil + } + oracleAddr, err := callOracle(ctx, client, comptroller) + if err != nil { + return nil, "", err + } + + // 2. Phase 1 multicall: per-mToken data (underlying, rates, supply, exchange, borrows, cash, price). + phase1Calls := make([]multicall3Call, 0, len(mTokens)*callsPerMarketPhase1) + underlyingCD, _ := mTokenABI.Pack("underlying") + supplyRateCD, _ := mTokenABI.Pack("supplyRatePerTimestamp") + borrowRateCD, _ := mTokenABI.Pack("borrowRatePerTimestamp") + totalSupplyCD, _ := mTokenABI.Pack("totalSupply") + exchangeRateCD, _ := mTokenABI.Pack("exchangeRateCurrent") + totalBorrowsCD, _ := mTokenABI.Pack("totalBorrowsCurrent") + getCashCD, _ := mTokenABI.Pack("getCash") + + for _, mt := range mTokens { + priceCD, _ := oracleABI.Pack("getUnderlyingPrice", mt) + phase1Calls = append(phase1Calls, + multicall3Call{Target: mt, AllowFailure: true, CallData: underlyingCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: supplyRateCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: borrowRateCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: totalSupplyCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: exchangeRateCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: totalBorrowsCD}, + multicall3Call{Target: mt, AllowFailure: true, CallData: getCashCD}, + multicall3Call{Target: oracleAddr, AllowFailure: true, CallData: priceCD}, + ) + } + + phase1Results, err := execMulticall3(ctx, client, phase1Calls) + if err != nil { + return nil, "", clierr.Wrap(clierr.CodeUnavailable, "multicall market data", err) + } + + // Parse phase 1 results and collect underlying addresses for phase 2. + type phase1Data struct { + mToken common.Address + underlying common.Address + supplyRate *big.Int + borrowRate *big.Int + totalSupply *big.Int + exchangeRate *big.Int + totalBorrows *big.Int + cash *big.Int + priceMantissa *big.Int + } + p1Parsed := make([]phase1Data, 0, len(mTokens)) + + for i, mt := range mTokens { + base := i * callsPerMarketPhase1 + r := phase1Results[base : base+callsPerMarketPhase1] + + // underlying (required) + if !r[0].Success || len(r[0].ReturnData) < 32 { + continue + } + decoded, err := mTokenABI.Unpack("underlying", r[0].ReturnData) + if err != nil || len(decoded) == 0 { + continue + } + underlying, ok := decoded[0].(common.Address) + if !ok { + continue + } + + supplyRate := decodeUint256Result(r[1], mTokenABI, "supplyRatePerTimestamp") + borrowRate := decodeUint256Result(r[2], mTokenABI, "borrowRatePerTimestamp") + totalSupply := decodeUint256Result(r[3], mTokenABI, "totalSupply") + exchangeRate := decodeUint256Result(r[4], mTokenABI, "exchangeRateCurrent") + totalBorrows := decodeUint256Result(r[5], mTokenABI, "totalBorrowsCurrent") + cash := decodeUint256Result(r[6], mTokenABI, "getCash") + priceMantissa := decodeUint256Result(r[7], oracleABI, "getUnderlyingPrice") + + p1Parsed = append(p1Parsed, phase1Data{ + mToken: mt, + underlying: underlying, + supplyRate: supplyRate, + borrowRate: borrowRate, + totalSupply: totalSupply, + exchangeRate: exchangeRate, + totalBorrows: totalBorrows, + cash: cash, + priceMantissa: priceMantissa, + }) + } + + if len(p1Parsed) == 0 { + return nil, comptrollerAddr, nil + } + + // 3. Phase 2 multicall: symbol() + decimals() on each underlying. + symbolCD, _ := erc20ABI.Pack("symbol") + decimalsCD, _ := erc20ABI.Pack("decimals") + + phase2Calls := make([]multicall3Call, 0, len(p1Parsed)*callsPerMarketPhase2) + for _, p := range p1Parsed { + phase2Calls = append(phase2Calls, + multicall3Call{Target: p.underlying, AllowFailure: true, CallData: symbolCD}, + multicall3Call{Target: p.underlying, AllowFailure: true, CallData: decimalsCD}, + ) + } + + phase2Results, err := execMulticall3(ctx, client, phase2Calls) + if err != nil { + return nil, "", clierr.Wrap(clierr.CodeUnavailable, "multicall token metadata", err) + } + + // 4. Assemble markets. + markets := make([]moonwellMarket, 0, len(p1Parsed)) + for i, p := range p1Parsed { + base := i * callsPerMarketPhase2 + symbolRes := phase2Results[base] + decimalsRes := phase2Results[base+1] + + var symbol string + if symbolRes.Success && len(symbolRes.ReturnData) >= 32 { + dec, err := erc20ABI.Unpack("symbol", symbolRes.ReturnData) + if err == nil && len(dec) > 0 { + symbol, _ = dec[0].(string) + } + } + var decimals int + if decimalsRes.Success && len(decimalsRes.ReturnData) >= 32 { + dec, err := erc20ABI.Unpack("decimals", decimalsRes.ReturnData) + if err == nil && len(dec) > 0 { + d, _ := dec[0].(uint8) + decimals = int(d) + } + } + if symbol == "" || decimals == 0 { + continue // can't use markets without metadata + } + + // Convert price mantissa to USD using decimals. + priceUSD := mantissaToUSD(p.priceMantissa, decimals) + + // TVL = totalSupply(mTokens) * exchangeRate / 1e18 → underlying units, then * priceUSD + underlyingTotal := new(big.Int).Mul(p.totalSupply, p.exchangeRate) + underlyingTotal.Div(underlyingTotal, big.NewInt(1e18)) + tvlUSD := bigIntToFloat(underlyingTotal, decimals) * priceUSD + totalBorrowsUSD := bigIntToFloat(p.totalBorrows, decimals) * priceUSD + liquidityUSD := bigIntToFloat(p.cash, decimals) * priceUSD + + var utilization float64 + if tvlUSD > 0 { + utilization = totalBorrowsUSD / tvlUSD + } + + markets = append(markets, moonwellMarket{ + MTokenAddress: strings.ToLower(p.mToken.Hex()), + UnderlyingAddress: strings.ToLower(p.underlying.Hex()), + UnderlyingSymbol: symbol, + UnderlyingDecimals: decimals, + SupplyAPY: rateToAPY(p.supplyRate), + BorrowAPY: rateToAPY(p.borrowRate), + TVLUSD: tvlUSD, + TotalBorrowsUSD: totalBorrowsUSD, + LiquidityUSD: liquidityUSD, + Utilization: utilization, + }) + } + + return markets, comptrollerAddr, nil +} + +// decodeUint256Result decodes a single uint256 from a multicall result. +func decodeUint256Result(r multicall3Result, a abi.ABI, method string) *big.Int { + if !r.Success || len(r.ReturnData) < 32 { + return new(big.Int) + } + dec, err := a.Unpack(method, r.ReturnData) + if err != nil || len(dec) == 0 { + return new(big.Int) + } + return asBigInt(dec[0]) +} + +// mantissaToUSD converts an oracle price mantissa to a USD float. +// Moonwell oracle returns price scaled by 10^(36 - underlyingDecimals). +func mantissaToUSD(priceMantissa *big.Int, underlyingDecimals int) float64 { + if priceMantissa == nil || priceMantissa.Sign() == 0 { + return 0 + } + scalePow := 36 - underlyingDecimals + if scalePow < 0 { + scalePow = 0 + } + scale := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scalePow)), nil)) + priceFloat := new(big.Float).SetInt(priceMantissa) + priceFloat.Quo(priceFloat, scale) + result, _ := priceFloat.Float64() + return result +} + +// execMulticall3 batches multiple contract calls into a single Multicall3.aggregate3 RPC call. +func execMulticall3(ctx context.Context, client *ethclient.Client, calls []multicall3Call) ([]multicall3Result, error) { + if len(calls) == 0 { + return nil, nil + } + + // Build the aggregate3 input as a tuple array. + type call3Tuple struct { + Target common.Address `abi:"target"` + AllowFailure bool `abi:"allowFailure"` + CallData []byte `abi:"callData"` + } + tuples := make([]call3Tuple, len(calls)) + for i, c := range calls { + tuples[i] = call3Tuple{Target: c.Target, AllowFailure: c.AllowFailure, CallData: c.CallData} + } + + data, err := mc3ABI.Pack("aggregate3", tuples) + if err != nil { + return nil, fmt.Errorf("pack aggregate3: %w", err) + } + + mc3 := multicall3Addr + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &mc3, Data: data}, nil) + if err != nil { + return nil, fmt.Errorf("call aggregate3: %w", err) + } + + decoded, err := mc3ABI.Unpack("aggregate3", out) + if err != nil { + return nil, fmt.Errorf("decode aggregate3: %w", err) + } + if len(decoded) == 0 { + return nil, fmt.Errorf("empty aggregate3 response") + } + + // decoded[0] is []struct{Success bool; ReturnData []byte} + type resultTuple struct { + Success bool `abi:"success"` + ReturnData []byte `abi:"returnData"` + } + + // The ABI decoder returns a slice of anonymous structs. + rawResults, ok := decoded[0].([]struct { + Success bool `json:"success"` + ReturnData []byte `json:"returnData"` + }) + if !ok { + return nil, fmt.Errorf("unexpected aggregate3 result type: %T", decoded[0]) + } + + results := make([]multicall3Result, len(rawResults)) + for i, r := range rawResults { + results[i] = multicall3Result{Success: r.Success, ReturnData: r.ReturnData} + } + return results, nil +} + +// ── RPC call helpers ──────────────────────────────────────────────────── + +func callGetAllMarkets(ctx context.Context, client *ethclient.Client, comptroller common.Address) ([]common.Address, error) { + data, err := comptrollerABI.Pack("getAllMarkets") + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "pack getAllMarkets", err) + } + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &comptroller, Data: data}, nil) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUnavailable, "call getAllMarkets", err) + } + decoded, err := comptrollerABI.Unpack("getAllMarkets", out) + if err != nil || len(decoded) == 0 { + return nil, clierr.Wrap(clierr.CodeUnavailable, "decode getAllMarkets", err) + } + addrs, ok := decoded[0].([]common.Address) + if !ok { + return nil, clierr.New(clierr.CodeUnavailable, "invalid getAllMarkets response") + } + return addrs, nil +} + +func callGetAssetsIn(ctx context.Context, client *ethclient.Client, comptroller, account common.Address) (map[common.Address]bool, error) { + data, err := comptrollerABI.Pack("getAssetsIn", account) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "pack getAssetsIn", err) + } + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &comptroller, Data: data}, nil) + if err != nil { + return nil, clierr.Wrap(clierr.CodeUnavailable, "call getAssetsIn", err) + } + decoded, err := comptrollerABI.Unpack("getAssetsIn", out) + if err != nil || len(decoded) == 0 { + return nil, clierr.Wrap(clierr.CodeUnavailable, "decode getAssetsIn", err) + } + addrs, ok := decoded[0].([]common.Address) + if !ok { + return nil, clierr.New(clierr.CodeUnavailable, "invalid getAssetsIn response") + } + set := make(map[common.Address]bool, len(addrs)) + for _, addr := range addrs { + set[addr] = true + } + return set, nil +} + +func callOracle(ctx context.Context, client *ethclient.Client, comptroller common.Address) (common.Address, error) { + data, err := comptrollerABI.Pack("oracle") + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeInternal, "pack oracle", err) + } + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &comptroller, Data: data}, nil) + if err != nil { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "call oracle", err) + } + decoded, err := comptrollerABI.Unpack("oracle", out) + if err != nil || len(decoded) == 0 { + return common.Address{}, clierr.Wrap(clierr.CodeUnavailable, "decode oracle", err) + } + addr, ok := decoded[0].(common.Address) + if !ok { + return common.Address{}, clierr.New(clierr.CodeUnavailable, "invalid oracle response") + } + return addr, nil +} + +// ── Utility helpers ───────────────────────────────────────────────────── + +func rateToAPY(ratePerTimestamp *big.Int) float64 { + if ratePerTimestamp == nil || ratePerTimestamp.Sign() == 0 { + return 0 + } + // APY ≈ ratePerSecond * secondsPerYear / 1e18 * 100 (linear approximation) + rateFloat := new(big.Float).SetInt(ratePerTimestamp) + rateFloat.Mul(rateFloat, big.NewFloat(secondsPerYear)) + rateFloat.Quo(rateFloat, big.NewFloat(1e18)) + rateFloat.Mul(rateFloat, big.NewFloat(100)) + result, _ := rateFloat.Float64() + if math.IsNaN(result) || math.IsInf(result, 0) { + return 0 + } + return result +} + +func bigIntToFloat(v *big.Int, decimals int) float64 { + if v == nil || v.Sign() == 0 { + return 0 + } + f := new(big.Float).SetInt(v) + divisor := new(big.Float).SetFloat64(math.Pow(10, float64(decimals))) + f.Quo(f, divisor) + result, _ := f.Float64() + return result +} + +func asBigInt(v interface{}) *big.Int { + switch val := v.(type) { + case *big.Int: + if val == nil { + return new(big.Int) + } + return val + case big.Int: + return &val + default: + return new(big.Int) + } +} + +func amountInfoFromBigInt(v *big.Int, decimals int) model.AmountInfo { + if v == nil { + v = new(big.Int) + } + base := v.String() + return model.AmountInfo{ + AmountBaseUnits: base, + AmountDecimal: id.FormatDecimalCompat(base, decimals), + Decimals: decimals, + } +} + +func normalizeEVMAddress(address string) string { + addr := strings.ToLower(strings.TrimSpace(address)) + if len(addr) != 42 || !strings.HasPrefix(addr, "0x") { + return "" + } + return addr +} + +func canonicalAssetIDForChain(chainID, address string) string { + addr := normalizeEVMAddress(address) + if chainID == "" || addr == "" { + return "" + } + return fmt.Sprintf("%s/erc20:%s", chainID, addr) +} + +func providerNativeID(provider, chainID, comptrollerAddress, underlyingAddress string) string { + return fmt.Sprintf("%s:%s:%s:%s", provider, chainID, normalizeEVMAddress(comptrollerAddress), normalizeEVMAddress(underlyingAddress)) +} + +func hashOpportunity(provider, chainID, marketID, assetID string) string { + seed := strings.Join([]string{provider, chainID, marketID, assetID}, "|") + h := sha1.Sum([]byte(seed)) + return hex.EncodeToString(h[:]) +} + +func matchesAsset(address, symbol string, asset id.Asset) bool { + assetAddress := strings.TrimSpace(asset.Address) + if assetAddress != "" { + return strings.EqualFold(strings.TrimSpace(address), assetAddress) + } + assetSymbol := strings.TrimSpace(asset.Symbol) + if assetSymbol != "" { + return strings.EqualFold(strings.TrimSpace(symbol), assetSymbol) + } + return true +} + +func matchesPositionType(filter, position providers.LendPositionType) bool { + if filter == "" || filter == providers.LendPositionTypeAll { + return true + } + return filter == position +} + +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 sortYieldPositions(items []model.YieldPosition) { + 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].APYTotal != items[j].APYTotal { + return items[i].APYTotal > items[j].APYTotal + } + if items[i].AssetID != items[j].AssetID { + return items[i].AssetID < items[j].AssetID + } + return items[i].ProviderNativeID < items[j].ProviderNativeID + }) +} + +// ── ABI singletons ────────────────────────────────────────────────────── + +var comptrollerABI = mustABI(registry.MoonwellComptrollerABI) +var mTokenABI = mustABI(registry.MoonwellMTokenABI) +var oracleABI = mustABI(registry.MoonwellOracleABI) +var erc20ABI = mustABI(registry.MoonwellERC20MinimalABI) +var mc3ABI = mustABI(registry.Multicall3ABI) + +func mustABI(raw string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(raw)) + if err != nil { + panic(fmt.Sprintf("invalid ABI: %v", err)) + } + return parsed +} diff --git a/internal/providers/moonwell/client_test.go b/internal/providers/moonwell/client_test.go new file mode 100644 index 0000000..1cf224c --- /dev/null +++ b/internal/providers/moonwell/client_test.go @@ -0,0 +1,443 @@ +package moonwell + +import ( + "context" + "encoding/hex" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "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" +) + +// ── Test RPC helpers ──────────────────────────────────────────────────── + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []interface{} `json:"params"` + ID interface{} `json:"id"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result string `json:"result,omitempty"` + Error interface{} `json:"error,omitempty"` +} + +func selectorHex(a abi.ABI, method string) string { + m, ok := a.Methods[method] + if !ok { + return "" + } + return hex.EncodeToString(m.ID) +} + +func packOutput(sig string, vals ...interface{}) string { + a, _ := abi.JSON(strings.NewReader(sig)) + out, _ := a.Methods["f"].Outputs.Pack(vals...) + return "0x" + hex.EncodeToString(out) +} + +func encodeAddresses(addrs []common.Address) string { + return packOutput(`[{"name":"f","type":"function","outputs":[{"type":"address[]"}]}]`, addrs) +} + +func encodeAddress(addr common.Address) string { + return packOutput(`[{"name":"f","type":"function","outputs":[{"type":"address"}]}]`, addr) +} + +func encodeString(s string) string { + return packOutput(`[{"name":"f","type":"function","outputs":[{"type":"string"}]}]`, s) +} + +func encodeUint8(v uint8) string { + return packOutput(`[{"name":"f","type":"function","outputs":[{"type":"uint8"}]}]`, v) +} + +func encodeUint256(v *big.Int) string { + return packOutput(`[{"name":"f","type":"function","outputs":[{"type":"uint256"}]}]`, v) +} + +func encodeSnapshot(errCode, mTokenBal, borrowBal, exchangeRate *big.Int) string { + return packOutput( + `[{"name":"f","type":"function","outputs":[{"type":"uint256"},{"type":"uint256"},{"type":"uint256"},{"type":"uint256"}]}]`, + errCode, mTokenBal, borrowBal, exchangeRate, + ) +} + +// Test addresses. +var ( + testComptroller = common.HexToAddress("0xfBb21d0380beE3312B33c4353c8936a0F13EF26C") + testOracle = common.HexToAddress("0xEC942bE8A8114bFD0396A5052c36027f2cA6a9d0") + testMTokenUSDC = common.HexToAddress("0xEdc817A28E8B93B03976FBd4a3dDBc9f7D176c22") + testUSDC = common.HexToAddress("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") + testAccount = common.HexToAddress("0x000000000000000000000000000000000000dEaD") +) + +// dispatchSingleCall resolves a single eth_call given the target and calldata. +// Returns the hex-encoded result or "0x" on unknown. +func dispatchSingleCall(to string, dataHex string, cSel, mSel, eSel map[string]string, oSel string, + supplyRate, borrowRate, totalSupply, exchangeRate, totalBorrows, cash, price, mTokenBal, borrowBal *big.Int) string { + + selector := "" + if len(dataHex) >= 8 { + selector = dataHex[:8] + } + to = strings.ToLower(to) + + switch { + case to == strings.ToLower(testComptroller.Hex()): + switch selector { + case cSel["getAllMarkets"]: + return encodeAddresses([]common.Address{testMTokenUSDC}) + case cSel["oracle"]: + return encodeAddress(testOracle) + case cSel["getAssetsIn"]: + return encodeAddresses([]common.Address{testMTokenUSDC}) + } + case to == strings.ToLower(testOracle.Hex()): + if selector == oSel { + return encodeUint256(price) + } + case to == strings.ToLower(testMTokenUSDC.Hex()): + switch selector { + case mSel["underlying"]: + return encodeAddress(testUSDC) + case mSel["supplyRatePerTimestamp"]: + return encodeUint256(supplyRate) + case mSel["borrowRatePerTimestamp"]: + return encodeUint256(borrowRate) + case mSel["totalSupply"]: + return encodeUint256(totalSupply) + case mSel["exchangeRateCurrent"]: + return encodeUint256(exchangeRate) + case mSel["totalBorrowsCurrent"]: + return encodeUint256(totalBorrows) + case mSel["getCash"]: + return encodeUint256(cash) + case mSel["getAccountSnapshot"]: + return encodeSnapshot(big.NewInt(0), mTokenBal, borrowBal, exchangeRate) + } + case to == strings.ToLower(testUSDC.Hex()): + switch selector { + case eSel["symbol"]: + return encodeString("USDC") + case eSel["decimals"]: + return encodeUint8(6) + } + } + return "0x" +} + +func newTestRPCServer(t *testing.T) *httptest.Server { + t.Helper() + + cSel := map[string]string{ + "getAllMarkets": selectorHex(comptrollerABI, "getAllMarkets"), + "oracle": selectorHex(comptrollerABI, "oracle"), + "getAssetsIn": selectorHex(comptrollerABI, "getAssetsIn"), + } + mSel := map[string]string{ + "underlying": selectorHex(mTokenABI, "underlying"), + "supplyRatePerTimestamp": selectorHex(mTokenABI, "supplyRatePerTimestamp"), + "borrowRatePerTimestamp": selectorHex(mTokenABI, "borrowRatePerTimestamp"), + "totalSupply": selectorHex(mTokenABI, "totalSupply"), + "exchangeRateCurrent": selectorHex(mTokenABI, "exchangeRateCurrent"), + "totalBorrowsCurrent": selectorHex(mTokenABI, "totalBorrowsCurrent"), + "getCash": selectorHex(mTokenABI, "getCash"), + "getAccountSnapshot": selectorHex(mTokenABI, "getAccountSnapshot"), + } + eSel := map[string]string{ + "symbol": selectorHex(erc20ABI, "symbol"), + "decimals": selectorHex(erc20ABI, "decimals"), + } + oABI, _ := abi.JSON(strings.NewReader(registry.MoonwellOracleABI)) + oSel := selectorHex(oABI, "getUnderlyingPrice") + mc3Sel := selectorHex(mc3ABI, "aggregate3") + + supplyRate := big.NewInt(951293759) + borrowRate := big.NewInt(1585489599) + totalSupply := new(big.Int).Mul(big.NewInt(100_000_000), big.NewInt(1e8)) + exchangeRate := big.NewInt(2e14) + totalBorrows := new(big.Int).Mul(big.NewInt(500_000), big.NewInt(1e6)) + cash := new(big.Int).Mul(big.NewInt(500_000), big.NewInt(1e6)) + price := new(big.Int).Exp(big.NewInt(10), big.NewInt(30), nil) + mTokenBal := new(big.Int).Mul(big.NewInt(10_000), big.NewInt(1e8)) + borrowBal := new(big.Int).Mul(big.NewInt(1_000), big.NewInt(1e6)) + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", 400) + return + } + + if req.Method != "eth_call" { + json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: "0x"}) + return + } + + params, ok := req.Params[0].(map[string]interface{}) + if !ok { + json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: "0x"}) + return + } + dataHex, _ := params["data"].(string) + if dataHex == "" { + dataHex, _ = params["input"].(string) + } + toHex, _ := params["to"].(string) + dataHex = strings.TrimPrefix(dataHex, "0x") + selector := "" + if len(dataHex) >= 8 { + selector = dataHex[:8] + } + to := strings.ToLower(toHex) + + // Handle Multicall3.aggregate3 — decode Call3[], dispatch each, re-encode Result[]. + if to == strings.ToLower(multicall3Addr.Hex()) && selector == mc3Sel { + rawData, _ := hex.DecodeString(dataHex) + decoded, err := mc3ABI.Methods["aggregate3"].Inputs.Unpack(rawData[4:]) + if err != nil { + t.Logf("aggregate3 unpack error: %v", err) + json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: "0x"}) + return + } + calls := decoded[0].([]struct { + Target common.Address `json:"target"` + AllowFailure bool `json:"allowFailure"` + CallData []byte `json:"callData"` + }) + + type mc3Result struct { + Success bool + ReturnData []byte + } + results := make([]mc3Result, len(calls)) + for i, call := range calls { + subData := hex.EncodeToString(call.CallData) + subResult := dispatchSingleCall(call.Target.Hex(), subData, cSel, mSel, eSel, oSel, + supplyRate, borrowRate, totalSupply, exchangeRate, totalBorrows, cash, price, mTokenBal, borrowBal) + subBytes, _ := hex.DecodeString(strings.TrimPrefix(subResult, "0x")) + results[i] = mc3Result{Success: subResult != "0x", ReturnData: subBytes} + } + + // Encode as aggregate3 output: tuple[](bool success, bytes returnData) + encoded, err := mc3ABI.Methods["aggregate3"].Outputs.Pack(results) + if err != nil { + t.Logf("aggregate3 pack error: %v", err) + json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: "0x"}) + return + } + json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: "0x" + hex.EncodeToString(encoded)}) + return + } + + // Handle direct (non-multicall) calls. + result := dispatchSingleCall(to, dataHex, cSel, mSel, eSel, oSel, + supplyRate, borrowRate, totalSupply, exchangeRate, totalBorrows, cash, price, mTokenBal, borrowBal) + json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result}) + })) +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +func TestLendMarketsAndYield(t *testing.T) { + srv := newTestRPCServer(t) + defer srv.Close() + + client := New() + client.rpcOverride = srv.URL + client.now = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + asset := id.Asset{Symbol: "USDC", ChainID: "eip155:8453"} + + markets, err := client.LendMarkets(context.Background(), "moonwell", chain, asset) + if err != nil { + t.Fatalf("LendMarkets failed: %v", err) + } + if len(markets) != 1 { + t.Fatalf("expected 1 market, got %d", len(markets)) + } + if markets[0].Provider != "moonwell" || markets[0].Protocol != "moonwell" { + t.Fatalf("expected moonwell provider, got %+v", markets[0]) + } + if markets[0].ProviderNativeID == "" || markets[0].ProviderNativeIDKind != model.NativeIDKindCompositeMarketAsset { + t.Fatalf("expected provider native id metadata, got %+v", markets[0]) + } + if markets[0].SupplyAPY <= 0 { + t.Fatalf("expected positive supply APY, got %f", markets[0].SupplyAPY) + } + if markets[0].BorrowAPY <= 0 { + t.Fatalf("expected positive borrow APY, got %f", markets[0].BorrowAPY) + } + if markets[0].TVLUSD <= 0 { + t.Fatalf("expected positive TVL, got %f", markets[0].TVLUSD) + } + + // Rates + rates, err := client.LendRates(context.Background(), "moonwell", chain, asset) + if err != nil { + t.Fatalf("LendRates failed: %v", err) + } + if len(rates) != 1 || rates[0].Utilization <= 0 { + t.Fatalf("expected 1 rate with positive utilization, got %+v", rates) + } + + // Yield opportunities + 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 != "moonwell" { + t.Fatalf("unexpected yield response: %+v", opps) + } + if opps[0].Type != "lend" || opps[0].WithdrawalTerms != "variable" { + t.Fatalf("unexpected yield type/terms: %+v", opps[0]) + } + if len(opps[0].BackingAssets) != 1 || opps[0].BackingAssets[0].SharePct != 100 || opps[0].BackingAssets[0].Symbol != "USDC" { + t.Fatalf("unexpected backing assets: %+v", opps[0].BackingAssets) + } +} + +func TestLendPositions(t *testing.T) { + srv := newTestRPCServer(t) + defer srv.Close() + + client := New() + client.rpcOverride = srv.URL + client.now = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + + positions, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, + Account: testAccount.Hex(), + PositionType: providers.LendPositionTypeAll, + }) + if err != nil { + t.Fatalf("LendPositions failed: %v", err) + } + if len(positions) != 2 { + t.Fatalf("expected 2 positions (collateral + borrow), got %d: %+v", len(positions), positions) + } + + var hasCollateral, hasBorrow bool + for _, p := range positions { + if p.PositionType == string(providers.LendPositionTypeCollateral) { + hasCollateral = true + if p.Provider != "moonwell" { + t.Fatalf("expected moonwell provider, got %+v", p) + } + if p.AmountUSD <= 0 { + t.Fatalf("expected positive supply USD, got %f", p.AmountUSD) + } + } + if p.PositionType == string(providers.LendPositionTypeBorrow) { + hasBorrow = true + if p.AmountUSD <= 0 { + t.Fatalf("expected positive borrow USD, got %f", p.AmountUSD) + } + } + } + if !hasCollateral || !hasBorrow { + t.Fatalf("expected both collateral and borrow, got %+v", positions) + } +} + +func TestLendPositionsFiltering(t *testing.T) { + srv := newTestRPCServer(t) + defer srv.Close() + + client := New() + client.rpcOverride = srv.URL + client.now = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + + collateral, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, Account: testAccount.Hex(), PositionType: providers.LendPositionTypeCollateral, + }) + if err != nil { + t.Fatalf("failed: %v", err) + } + if len(collateral) != 1 || collateral[0].PositionType != string(providers.LendPositionTypeCollateral) { + t.Fatalf("expected 1 collateral, got %+v", collateral) + } + + borrows, err := client.LendPositions(context.Background(), providers.LendPositionsRequest{ + Chain: chain, Account: testAccount.Hex(), PositionType: providers.LendPositionTypeBorrow, + }) + if err != nil { + t.Fatalf("failed: %v", err) + } + if len(borrows) != 1 || borrows[0].PositionType != string(providers.LendPositionTypeBorrow) { + t.Fatalf("expected 1 borrow, got %+v", borrows) + } +} + +func TestYieldPositions(t *testing.T) { + srv := newTestRPCServer(t) + defer srv.Close() + + client := New() + client.rpcOverride = srv.URL + client.now = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } + + chain := id.Chain{CAIP2: "eip155:8453", EVMChainID: 8453} + + positions, err := client.YieldPositions(context.Background(), providers.YieldPositionsRequest{ + Chain: chain, Account: testAccount.Hex(), + }) + if err != nil { + t.Fatalf("YieldPositions failed: %v", err) + } + if len(positions) != 1 { + t.Fatalf("expected 1 yield position, got %d", len(positions)) + } + if positions[0].PositionType != "deposit" || positions[0].Provider != "moonwell" { + t.Fatalf("unexpected: %+v", positions[0]) + } +} + +func TestUnsupportedChain(t *testing.T) { + client := New() + chain := id.Chain{CAIP2: "eip155:999", EVMChainID: 999} + asset := id.Asset{Symbol: "USDC", ChainID: "eip155:999"} + + _, err := client.LendMarkets(context.Background(), "moonwell", chain, asset) + if err == nil { + t.Fatalf("expected error for unsupported chain") + } +} + +func TestRateToAPY(t *testing.T) { + rate := big.NewInt(951293759) // ~3% APY (rate per second scaled by 1e18) + apy := rateToAPY(rate) + if apy < 2.9 || apy > 3.1 { + t.Fatalf("expected ~3%% APY, got %f", apy) + } + if rateToAPY(big.NewInt(0)) != 0 { + t.Fatalf("expected 0 APY for zero rate") + } +} + +func TestBigIntToFloat(t *testing.T) { + v := big.NewInt(1_000_000) + f := bigIntToFloat(v, 6) + if f != 1.0 { + t.Fatalf("expected 1.0, got %f", f) + } +} diff --git a/internal/providers/normalize.go b/internal/providers/normalize.go index 50cb73f..e831c1c 100644 --- a/internal/providers/normalize.go +++ b/internal/providers/normalize.go @@ -11,6 +11,8 @@ func NormalizeLendingProvider(input string) string { return "morpho" case "kamino", "kamino-lend", "kamino-finance": return "kamino" + case "moonwell", "moonwell-v2": + return "moonwell" default: return strings.ToLower(strings.TrimSpace(input)) } diff --git a/internal/providers/types.go b/internal/providers/types.go index 5c501a1..51d2c15 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -47,6 +47,7 @@ type LendPositionsRequest struct { Asset id.Asset PositionType LendPositionType Limit int + RPCURL string // optional RPC URL override (used by on-chain providers like Moonwell) } type LendingPositionsProvider interface { diff --git a/internal/registry/abis.go b/internal/registry/abis.go index 224f372..cd118f3 100644 --- a/internal/registry/abis.go +++ b/internal/registry/abis.go @@ -50,6 +50,45 @@ const ( {"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"}]} ]` + MoonwellComptrollerABI = `[ + {"name":"getAllMarkets","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address[]"}]}, + {"name":"getAssetsIn","type":"function","stateMutability":"view","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"address[]"}]}, + {"name":"checkMembership","type":"function","stateMutability":"view","inputs":[{"name":"account","type":"address"},{"name":"mToken","type":"address"}],"outputs":[{"name":"","type":"bool"}]}, + {"name":"enterMarkets","type":"function","stateMutability":"nonpayable","inputs":[{"name":"mTokens","type":"address[]"}],"outputs":[{"name":"","type":"uint256[]"}]}, + {"name":"markets","type":"function","stateMutability":"view","inputs":[{"name":"","type":"address"}],"outputs":[{"name":"isListed","type":"bool"},{"name":"collateralFactorMantissa","type":"uint256"}]}, + {"name":"oracle","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]} + ]` + + MoonwellMTokenABI = `[ + {"name":"underlying","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]}, + {"name":"symbol","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"string"}]}, + {"name":"decimals","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint8"}]}, + {"name":"supplyRatePerTimestamp","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"borrowRatePerTimestamp","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"totalSupply","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"totalBorrowsCurrent","type":"function","stateMutability":"nonpayable","inputs":[],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"exchangeRateCurrent","type":"function","stateMutability":"nonpayable","inputs":[],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"getCash","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"getAccountSnapshot","type":"function","stateMutability":"view","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"uint256"},{"name":"","type":"uint256"},{"name":"","type":"uint256"},{"name":"","type":"uint256"}]}, + {"name":"mint","type":"function","stateMutability":"nonpayable","inputs":[{"name":"mintAmount","type":"uint256"}],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"redeemUnderlying","type":"function","stateMutability":"nonpayable","inputs":[{"name":"redeemAmount","type":"uint256"}],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"borrow","type":"function","stateMutability":"nonpayable","inputs":[{"name":"borrowAmount","type":"uint256"}],"outputs":[{"name":"","type":"uint256"}]}, + {"name":"repayBorrow","type":"function","stateMutability":"nonpayable","inputs":[{"name":"repayAmount","type":"uint256"}],"outputs":[{"name":"","type":"uint256"}]} + ]` + + MoonwellOracleABI = `[ + {"name":"getUnderlyingPrice","type":"function","stateMutability":"view","inputs":[{"name":"mToken","type":"address"}],"outputs":[{"name":"","type":"uint256"}]} + ]` + + MoonwellERC20MinimalABI = `[ + {"name":"symbol","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"string"}]}, + {"name":"decimals","type":"function","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint8"}]} + ]` + + Multicall3ABI = `[ + {"name":"aggregate3","type":"function","stateMutability":"payable","inputs":[{"name":"calls","type":"tuple[]","components":[{"name":"target","type":"address"},{"name":"allowFailure","type":"bool"},{"name":"callData","type":"bytes"}]}],"outputs":[{"name":"returnData","type":"tuple[]","components":[{"name":"success","type":"bool"},{"name":"returnData","type":"bytes"}]}]} + ]` + 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"}]}, diff --git a/internal/registry/contracts.go b/internal/registry/contracts.go index 0df8a0b..7c5ad28 100644 --- a/internal/registry/contracts.go +++ b/internal/registry/contracts.go @@ -39,6 +39,28 @@ func AavePoolAddressProvider(chainID int64) (string, bool) { return value, ok } +// Canonical Moonwell Comptroller (Unitroller) contracts per chain. +var moonwellComptrollerByChainID = map[int64]string{ + 8453: "0xfBb21d0380beE3312B33c4353c8936a0F13EF26C", // Base + 10: "0xCa889f40aae37FFf165BccF69aeF1E82b5C511B9", // Optimism +} + +func MoonwellComptroller(chainID int64) (string, bool) { + value, ok := moonwellComptrollerByChainID[chainID] + return value, ok +} + +// Canonical Moonwell MultiRewardDistributor contracts per chain. +var moonwellRewardDistributorByChainID = map[int64]string{ + 8453: "0xe9005b078701e2A0948D2EaC43010D35870Ad9d2", // Base + 10: "0xF9524bfa18C19C3E605FbfE8DFd05C6e967574Aa", // Optimism +} + +func MoonwellRewardDistributor(chainID int64) (string, bool) { + value, ok := moonwellRewardDistributorByChainID[chainID] + return value, ok +} + const tempoStablecoinDEXAddress = "0xdec0000000000000000000000000000000000000" var tempoChainIDs = map[int64]struct{}{