diff --git a/AGENTS.md b/AGENTS.md index 79a77cd..c74817c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,10 +113,11 @@ README.md # user-facing usage + caveats - Uniswap quote calls require a real `swapper` address via `swap quote --from-address` and default to provider auto slippage unless `swap quote --slippage-pct` is provided. - MegaETH bootstrap symbol parsing currently supports `MEGA`, `WETH`, and `USDT` (`USDT` maps to the chain's `USDT0` contract address on `eip155:4326`). Official Mega token list currently has no Ethereum L1 `MEGA` token entry. - Symbol parsing depends on the local bootstrap token registry; on chains without registry entries use token address or CAIP-19. +- `chains gas` returns live EVM gas prices via RPC (EVM-only, no API key, bypasses cache); supports `--rpc-url` override and comma-separated `--chain` for parallel multi-chain queries (returns array; `--rpc-url` disallowed with multiple chains). - APY values are percentage points (`2.3` means `2.3%`), not ratios. - Morpho can emit extreme APYs in tiny markets; use `--min-tvl-usd` in ranking/filters. - Fresh cache hits (`age <= ttl`) skip provider calls; once TTL expires, the CLI re-fetches providers and only serves stale data within `max_stale` on temporary provider failures. -- Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. +- Metadata commands (`version`, `schema`, `providers list`, `chains list`, `chains gas`) bypass cache initialization. - Execution commands (`swap|bridge|approvals|transfer|lend|yield|rewards ... plan|submit|status`, `actions list|show|estimate`) bypass cache initialization. - For `lend`/`yield`, unresolved symbols are treated as symbol filters; on chains without bootstrap token entries, prefer token address or CAIP-19 for deterministic matching. - Amounts used for swaps/bridges are base units; keep both base and decimal forms consistent. diff --git a/CHANGELOG.md b/CHANGELOG.md index bb325c0..b1b8980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ Format: ## [Unreleased] ### Added +- Added `protocols fees` command to rank protocols by 24h fee revenue with 7d/30d totals and 1d/7d/1m percentage changes (no API key required, uses DefiLlama fees API). Supports `--category` filter and `--limit`. +- Added `stablecoins chains` command to rank chains by total stablecoin market cap with dominant peg type and CAIP-2 chain IDs (no API key required, uses DefiLlama stablecoin chains API). Supports `--limit`. +- Added `stablecoins top` command to list top stablecoins by circulating market cap with price, chain count, and day/week/month supply changes (no API key required, uses DefiLlama stablecoins API). Supports `--peg-type` filter (e.g. `peggedUSD`, `peggedEUR`) and `--limit`. +- Added `chains gas` command to query current EVM gas prices (base fee, priority fee, gas price in gwei) with block number and EIP-1559 detection (no keys required, bypasses cache, supports `--rpc-url` override). Accepts comma-separated chains for multi-chain batch queries with parallel RPC fetching and partial-result support. +- Added `chains list` command to enumerate all supported chains with slugs, CAIP-2 identifiers, namespaces, and accepted aliases (no keys required, bypasses cache). - Added TaikoSwap provider support for `swap quote` using on-chain quoter contract calls (no API key required). - Added swap execution workflow commands: `swap plan`, `swap submit`, and `swap status`. - Added bridge execution workflow commands: `bridge plan`, `bridge submit`, and `bridge status` (Across and LiFi providers). diff --git a/README.md b/README.md index 838f1a6..8d1198b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Built for AI agents and scripts. Stable JSON output, canonical identifiers (CAIP - **Bridging** — get cross-chain quotes (Across, LiFi, Bungee), bridge analytics (volume, chain breakdown), and execute Across/LiFi bridge plans. - **Swapping** — get swap quotes (1inch, Uniswap, TaikoSwap) and execute TaikoSwap plans on-chain. - **Approvals, transfers & rewards** — create and execute ERC-20 approvals/transfers plus Aave rewards claim/compound flows. -- **Chains & protocols** — browse top chains by TVL, inspect chain TVL by asset, discover protocols, resolve asset identifiers. +- **Chains & protocols** — browse top chains by TVL, inspect chain TVL by asset, query live gas prices, discover protocols, track stablecoin market caps, resolve asset identifiers. - **Automation-friendly** — JSON-first output, field selection (`--select`), structured JSON/file input for mutation workflows, and a machine-readable schema export with required flags, enums, auth, and request/response metadata. ## Documentation Site (Mintlify) @@ -86,8 +86,14 @@ defi version --long ```bash defi providers list --results-only +defi chains list --results-only --select slug,caip2,namespace +defi chains gas --chain 1 --results-only +defi chains gas --chain 1,10,137,8453,42161 --results-only # multi-chain batch defi chains top --limit 10 --results-only --select rank,chain,tvl_usd defi chains assets --chain 1 --asset USDC --results-only # Requires DEFI_DEFILLAMA_API_KEY +defi protocols fees --limit 10 --results-only --select rank,protocol,fees_24h_usd,change_1d_pct +defi stablecoins top --limit 10 --results-only --select rank,symbol,circulating_usd,price +defi stablecoins chains --limit 10 --results-only --select rank,chain,circulating_usd defi assets resolve --chain base --symbol USDC --results-only defi lend markets --provider aave --chain 1 --asset USDC --results-only defi lend rates --provider morpho --chain 1 --asset USDC --results-only @@ -288,12 +294,12 @@ providers: ## Cache Policy -- Command TTLs are fixed in code (`chains/protocols/chains assets`: `5m`, `lend markets`: `60s`, `lend rates`: `30s`, `lend positions`: `30s`, `yield opportunities`: `60s`, `yield positions`: `30s`, `yield history`: `5m`, `bridge/swap quotes`: `15s`). +- Command TTLs are fixed in code (`chains/protocols/stablecoins/chains assets`: `5m`, `lend markets`: `60s`, `lend rates`: `30s`, `lend positions`: `30s`, `yield opportunities`: `60s`, `yield positions`: `30s`, `yield history`: `5m`, `bridge/swap quotes`: `15s`). - Cache entries are served directly only while fresh (`age <= ttl`). - After TTL expiry, the CLI fetches provider data immediately. - `cache.max_stale` / `--max-stale` is only a temporary provider-failure fallback window (currently `unavailable` / `rate_limited`). - If fallback is disabled (`--no-stale` or `--max-stale 0s`) or stale data exceeds the budget, the CLI exits with code `14`. -- Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. +- Metadata commands (`version`, `schema`, `providers list`, `chains list`) bypass cache initialization. - Execution commands (`swap|bridge|approvals|transfer|lend|yield|rewards ... plan|submit|status`, `actions list|show|estimate`) bypass cache reads/writes. ## Caveats @@ -310,6 +316,7 @@ providers: - `chains assets` requires `DEFI_DEFILLAMA_API_KEY` because DefiLlama chain asset TVL is key-gated. - `bridge list` and `bridge details` require `DEFI_DEFILLAMA_API_KEY`; quote providers (`across`, `lifi`) do not. - Category rankings from `protocols categories` are deterministic and sorted by `tvl_usd`, then protocol count, then name. +- `protocols fees` rankings are sorted by 24h fees descending; protocols with null or zero 24h fees are excluded. - `--chain` normalization supports additional aliases/IDs including `mantle`, `megaeth`/`mega eth`/`mega-eth`, `ink`, `scroll`, `berachain`, `gnosis`/`xdai`, `linea`, `sonic`, `blast`, `fraxtal`, `world-chain`, `celo`, `taiko`/`taiko alethia`, `taiko hoodi`/`hoodi`, `zksync`, `hyperevm`/`hyper evm`/`hyper-evm`, `monad`, and `citrea`. - Bungee Auto-mode quote coverage is chain+token dependent; unsupported pairs return provider errors even when chain normalization succeeds. - Bungee quote requests use deterministic placeholder sender/receiver addresses for quote-only resolution (`0x000...001`). @@ -340,6 +347,7 @@ providers: - Bridge execution pre-sign checks validate settlement provider metadata and known settlement endpoint URLs for Across/LiFi; use `--unsafe-provider-tx` to bypass these guardrails. - All `submit` execution commands will broadcast signed transactions. - Rewards `--assets` expects comma-separated on-chain addresses used by Aave incentives contracts. +- `chains gas` returns live EVM gas prices via RPC; it is EVM-only and bypasses cache. Use `--rpc-url` to override the default chain RPC. Pass comma-separated chains (e.g. `--chain 1,10,8453`) for parallel multi-chain queries; `--rpc-url` is only allowed with a single chain. - Selector choice is explicit for multi-provider flows; pass `--provider` (no implicit defaults). ## Exit Codes diff --git a/docs/concepts/config-and-cache.mdx b/docs/concepts/config-and-cache.mdx index 37a933b..b1feea4 100644 --- a/docs/concepts/config-and-cache.mdx +++ b/docs/concepts/config-and-cache.mdx @@ -45,7 +45,7 @@ cache: - Stale data is served only when providers fail temporarily (`unavailable` or `rate_limited`) and within `max_stale`. - Cache writes use SQLite WAL + busy timeout + lock/backoff retries to reduce lock contention in parallel runs. - If cache init fails (path/permissions), commands continue with cache disabled. -- Metadata commands (`version`, `schema`, `providers list`) bypass cache initialization. +- Metadata commands (`version`, `schema`, `providers list`, `chains list`) bypass cache initialization. ## Command TTLs diff --git a/docs/guides/market-discovery.mdx b/docs/guides/market-discovery.mdx index 9170124..3b6fdbd 100644 --- a/docs/guides/market-discovery.mdx +++ b/docs/guides/market-discovery.mdx @@ -5,6 +5,17 @@ description: Discover chains, protocols, and assets before running strategy-spec Use market-discovery commands to scope chains/protocols and normalize assets first. +## Supported chains + +Enumerate all chains the CLI recognizes, including slugs and CAIP-2 identifiers. No API keys required. + +```bash +defi chains list --results-only +defi chains list --results-only --select slug,caip2,namespace +``` + +Use this to discover valid `--chain` values before running other commands. + ## Top chains by TVL ```bash @@ -33,6 +44,15 @@ defi protocols categories --results-only Category ranking is deterministic: `tvl_usd`, then protocol count, then name. +## Stablecoin market data + +```bash +defi stablecoins top --limit 10 --results-only --select rank,symbol,circulating_usd,price +defi stablecoins top --peg-type peggedUSD --limit 20 --results-only +``` + +Returns circulating market cap, current price, chain count, and day/week/month supply change deltas. Useful for stablecoin selection and depeg monitoring. + ## Resolve assets to canonical IDs ```bash diff --git a/docs/reference/market-commands.mdx b/docs/reference/market-commands.mdx index 55e4df8..e3f4c0f 100644 --- a/docs/reference/market-commands.mdx +++ b/docs/reference/market-commands.mdx @@ -1,6 +1,6 @@ --- title: Market Commands -description: Reference for providers, chains, protocols, and assets commands. +description: Reference for providers, chains, protocols, stablecoins, and assets commands. --- ## `providers list` @@ -11,6 +11,15 @@ List supported providers and capability auth metadata. defi providers list --results-only ``` +## `chains list` + +List all supported chains with slugs, CAIP-2 identifiers, namespaces, and accepted aliases. No API keys required; bypasses cache. + +```bash +defi chains list --results-only +defi chains list --results-only --select slug,caip2,namespace +``` + ## `chains top` Top chains by TVL. @@ -62,6 +71,22 @@ List categories with protocol counts and TVL. defi protocols categories --results-only ``` +## `stablecoins top` + +Top stablecoins by circulating market cap. Includes price, chain count, and day/week/month supply change deltas. + +```bash +defi stablecoins top --limit 10 --results-only +defi stablecoins top --peg-type peggedUSD --limit 20 --results-only +``` + +Flags: + +- `--limit int` (default `20`) +- `--peg-type string` (optional filter, e.g. `peggedUSD`, `peggedEUR`) + +No API key required; data sourced from DefiLlama stablecoins API. + ## `assets resolve` Resolve symbol/address/CAIP-19 to canonical asset metadata. diff --git a/internal/app/runner.go b/internal/app/runner.go index 08bd22e..86d04d7 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "math/big" "os" "sort" "strconv" @@ -15,6 +16,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" "github.com/ggonzalez94/defi-cli/internal/cache" "github.com/ggonzalez94/defi-cli/internal/config" clierr "github.com/ggonzalez94/defi-cli/internal/errors" @@ -22,6 +24,7 @@ import ( "github.com/ggonzalez94/defi-cli/internal/execution/actionbuilder" execsigner "github.com/ggonzalez94/defi-cli/internal/execution/signer" "github.com/ggonzalez94/defi-cli/internal/httpx" + "github.com/ggonzalez94/defi-cli/internal/registry" "github.com/ggonzalez94/defi-cli/internal/id" "github.com/ggonzalez94/defi-cli/internal/model" "github.com/ggonzalez94/defi-cli/internal/out" @@ -241,6 +244,7 @@ func (s *runtimeState) newRootCommand() *cobra.Command { cmd.AddCommand(s.newProvidersCommand()) cmd.AddCommand(s.newChainsCommand()) cmd.AddCommand(s.newProtocolsCommand()) + cmd.AddCommand(s.newStablecoinsCommand()) cmd.AddCommand(s.newAssetsCommand()) cmd.AddCommand(s.newLendCommand()) cmd.AddCommand(s.newRewardsCommand()) @@ -311,6 +315,30 @@ func (s *runtimeState) newProvidersCommand() *cobra.Command { func (s *runtimeState) newChainsCommand() *cobra.Command { root := &cobra.Command{Use: "chains", Short: "Chain market data"} + + listCmd := &cobra.Command{ + Use: "list", + Short: "List all supported chains with aliases (no keys required)", + RunE: func(cmd *cobra.Command, args []string) error { + entries := id.ListChains() + result := make([]model.SupportedChain, 0, len(entries)) + for _, e := range entries { + result = append(result, model.SupportedChain{ + Name: e.Chain.Name, + Slug: e.Chain.Slug, + CAIP2: e.Chain.CAIP2, + Namespace: e.Chain.Namespace(), + EVMChainID: e.Chain.EVMChainID, + Aliases: e.Aliases, + }) + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), result, nil, cacheMetaBypass(), nil, false) + }, + } + listResponse := schema.SchemaFromType([]model.SupportedChain{}) + _ = schema.SetCommandMetadata(listCmd, schema.CommandMetadata{Response: &listResponse}) + root.AddCommand(listCmd) + var limit int topCmd := &cobra.Command{ Use: "top", @@ -375,6 +403,103 @@ func (s *runtimeState) newChainsCommand() *cobra.Command { }) root.AddCommand(assetsCmd) + var gasChainArg string + var gasRPCURL string + gasCmd := &cobra.Command{ + Use: "gas", + Short: "Current gas prices for one or more EVM chains (no keys required)", + RunE: func(cmd *cobra.Command, args []string) error { + rawChains := strings.Split(gasChainArg, ",") + var chainArgs []string + for _, c := range rawChains { + c = strings.TrimSpace(c) + if c != "" { + chainArgs = append(chainArgs, c) + } + } + if len(chainArgs) == 0 { + return clierr.New(clierr.CodeUsage, "at least one chain is required") + } + + if len(chainArgs) > 1 && strings.TrimSpace(gasRPCURL) != "" { + return clierr.New(clierr.CodeUsage, "--rpc-url cannot be used with multiple chains") + } + + // Parse and validate all chains up front. + type chainEntry struct { + chain id.Chain + rpcURL string + } + entries := make([]chainEntry, 0, len(chainArgs)) + for _, raw := range chainArgs { + chain, err := id.ParseChain(raw) + if err != nil { + return err + } + if chain.Namespace() != "eip155" { + return clierr.New(clierr.CodeUnsupported, "chains gas is only supported for EVM chains: "+raw) + } + rpcURL, err := registry.ResolveRPCURL(gasRPCURL, chain.EVMChainID) + if err != nil { + return clierr.Wrap(clierr.CodeUnavailable, "resolve rpc for "+raw, err) + } + entries = append(entries, chainEntry{chain: chain, rpcURL: rpcURL}) + } + + ctx := cmd.Context() + + // Single chain: preserve the original scalar response. + if len(entries) == 1 { + result, err := fetchGasPrice(ctx, entries[0].chain, entries[0].rpcURL, s.runner.now) + if err != nil { + return err + } + return s.emitSuccess(trimRootPath(cmd.CommandPath()), result, nil, cacheMetaBypass(), nil, false) + } + + // Multiple chains: fetch in parallel, preserve input order. + type gasResult struct { + price model.GasPrice + err error + } + slots := make([]gasResult, len(entries)) + done := make(chan int, len(entries)) + for i, e := range entries { + go func(idx int, entry chainEntry) { + price, err := fetchGasPrice(ctx, entry.chain, entry.rpcURL, s.runner.now) + slots[idx] = gasResult{price: price, err: err} + done <- idx + }(i, e) + } + for range entries { + <-done + } + + prices := make([]model.GasPrice, 0, len(entries)) + var warnings []string + for i, r := range slots { + if r.err != nil { + warnings = append(warnings, fmt.Sprintf("chain %s: %s", entries[i].chain.CAIP2, r.err.Error())) + continue + } + prices = append(prices, r.price) + } + + if len(prices) == 0 { + return clierr.New(clierr.CodeUnavailable, "all chains failed; "+strings.Join(warnings, "; ")) + } + + partial := len(warnings) > 0 + return s.emitSuccess(trimRootPath(cmd.CommandPath()), prices, warnings, cacheMetaBypass(), nil, partial) + }, + } + gasCmd.Flags().StringVar(&gasChainArg, "chain", "", "Chain id/name/CAIP-2 (comma-separated for multiple)") + gasCmd.Flags().StringVar(&gasRPCURL, "rpc-url", "", "RPC URL override (single chain only)") + _ = gasCmd.MarkFlagRequired("chain") + gasResponse := schema.SchemaFromType([]model.GasPrice{}) + _ = schema.SetCommandMetadata(gasCmd, schema.CommandMetadata{Response: &gasResponse}) + root.AddCommand(gasCmd) + return root } @@ -415,6 +540,69 @@ func (s *runtimeState) newProtocolsCommand() *cobra.Command { } root.AddCommand(catCmd) + var feesLimit int + var feesCategory string + feesCmd := &cobra.Command{ + Use: "fees", + Short: "Top protocols by 24h fees", + RunE: func(cmd *cobra.Command, args []string) error { + req := map[string]any{"category": feesCategory, "limit": feesLimit} + key := cacheKey(trimRootPath(cmd.CommandPath()), req) + return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 5*time.Minute, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { + start := time.Now() + data, err := s.marketProvider.ProtocolsFees(ctx, feesCategory, feesLimit) + status := []model.ProviderStatus{{Name: s.marketProvider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + return data, status, nil, false, err + }) + }, + } + feesCmd.Flags().IntVar(&feesLimit, "limit", 20, "Number of protocols to return") + feesCmd.Flags().StringVar(&feesCategory, "category", "", "Filter by protocol category (e.g. Dexs, Lending)") + root.AddCommand(feesCmd) + + return root +} + +func (s *runtimeState) newStablecoinsCommand() *cobra.Command { + root := &cobra.Command{Use: "stablecoins", Short: "Stablecoin market data"} + var limit int + var pegType string + cmd := &cobra.Command{ + Use: "top", + Short: "Top stablecoins by circulating market cap", + RunE: func(cmd *cobra.Command, args []string) error { + req := map[string]any{"peg_type": pegType, "limit": limit} + key := cacheKey(trimRootPath(cmd.CommandPath()), req) + return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 5*time.Minute, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { + start := time.Now() + data, err := s.marketProvider.StablecoinsTop(ctx, pegType, limit) + status := []model.ProviderStatus{{Name: s.marketProvider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + return data, status, nil, false, err + }) + }, + } + cmd.Flags().IntVar(&limit, "limit", 20, "Number of stablecoins to return") + cmd.Flags().StringVar(&pegType, "peg-type", "", "Filter by peg type (e.g. peggedUSD, peggedEUR)") + root.AddCommand(cmd) + + var chainsLimit int + chainsCmd := &cobra.Command{ + Use: "chains", + Short: "Chains ranked by total stablecoin market cap", + RunE: func(cmd *cobra.Command, args []string) error { + req := map[string]any{"limit": chainsLimit} + key := cacheKey(trimRootPath(cmd.CommandPath()), req) + return s.runCachedCommand(trimRootPath(cmd.CommandPath()), key, 5*time.Minute, func(ctx context.Context) (any, []model.ProviderStatus, []string, bool, error) { + start := time.Now() + data, err := s.marketProvider.StablecoinChains(ctx, chainsLimit) + status := []model.ProviderStatus{{Name: s.marketProvider.Info().Name, Status: statusFromErr(err), LatencyMS: time.Since(start).Milliseconds()}} + return data, status, nil, false, err + }) + }, + } + chainsCmd.Flags().IntVar(&chainsLimit, "limit", 20, "Number of chains to return") + root.AddCommand(chainsCmd) + return root } @@ -2266,6 +2454,57 @@ func chainAssetFilterCacheValue(asset id.Asset, rawInput string) string { return "raw:" + strings.ToUpper(strings.TrimSpace(rawInput)) } +func fetchGasPrice(ctx context.Context, chain id.Chain, rpcURL string, now func() time.Time) (model.GasPrice, error) { + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return model.GasPrice{}, clierr.Wrap(clierr.CodeUnavailable, "connect rpc", err) + } + defer client.Close() + + header, err := client.HeaderByNumber(ctx, nil) + if err != nil { + return model.GasPrice{}, clierr.Wrap(clierr.CodeUnavailable, "fetch block header", err) + } + + gasPrice, err := client.SuggestGasPrice(ctx) + if err != nil { + return model.GasPrice{}, clierr.Wrap(clierr.CodeUnavailable, "fetch gas price", err) + } + + eip1559 := header.BaseFee != nil + var baseFee, priorityFee *big.Int + if eip1559 { + baseFee = header.BaseFee + priorityFee, err = client.SuggestGasTipCap(ctx) + if err != nil { + priorityFee = new(big.Int) + } + } + + result := model.GasPrice{ + ChainID: chain.CAIP2, + ChainName: chain.Name, + BlockNumber: header.Number.Int64(), + EIP1559: eip1559, + GasPriceGwei: weiToGwei(gasPrice), + FetchedAt: now().UTC().Format(time.RFC3339), + } + if eip1559 { + result.BaseFeeGwei = weiToGwei(baseFee) + result.PriorityFeeGwei = weiToGwei(priorityFee) + } + return result, nil +} + +func weiToGwei(wei *big.Int) string { + if wei == nil { + return "0" + } + gwei := new(big.Float).SetInt(wei) + gwei.Quo(gwei, big.NewFloat(1e9)) + return gwei.Text('f', 6) +} + func cacheKey(commandPath string, req any) string { buf, _ := json.Marshal(req) prefix := []byte(commandPath + "|" + cachePayloadSchemaVersion + "|") @@ -2387,7 +2626,7 @@ func staleFallbackAllowed(err error) bool { func shouldOpenCache(commandPath string) bool { path := normalizeCommandPath(commandPath) switch path { - case "", "version", "schema", "providers", "providers list": + case "", "version", "schema", "providers", "providers list", "chains list", "chains gas": return false } if isExecutionCommandPath(path) { diff --git a/internal/app/runner_gas_test.go b/internal/app/runner_gas_test.go new file mode 100644 index 0000000..3a6ee63 --- /dev/null +++ b/internal/app/runner_gas_test.go @@ -0,0 +1,347 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ggonzalez94/defi-cli/internal/id" + "github.com/ggonzalez94/defi-cli/internal/model" +) + +func TestChainsGasBypassesCache(t *testing.T) { + if shouldOpenCache("chains gas") { + t.Fatal("chains gas should bypass cache initialization") + } +} + +func TestWeiToGwei(t *testing.T) { + tests := []struct { + name string + wei *big.Int + want string + }{ + {name: "nil", wei: nil, want: "0"}, + {name: "zero", wei: big.NewInt(0), want: "0.000000"}, + {name: "1 gwei", wei: big.NewInt(1_000_000_000), want: "1.000000"}, + {name: "30.5 gwei", wei: big.NewInt(30_500_000_000), want: "30.500000"}, + {name: "sub-gwei", wei: big.NewInt(500_000), want: "0.000500"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := weiToGwei(tc.wei) + if got != tc.want { + t.Fatalf("weiToGwei(%v) = %q, want %q", tc.wei, got, tc.want) + } + }) + } +} + +func TestFetchGasPriceEIP1559(t *testing.T) { + srv := newMockRPCServer(t, mockRPCConfig{ + baseFeeHex: "0x3B9ACA00", // 1 gwei + priorityFeeHex: "0x77359400", // 2 gwei + gasPriceHex: "0xB2D05E00", // 3 gwei + blockNumberHex: "0x10", // block 16 + }) + defer srv.Close() + + chain := id.Chain{Name: "Ethereum", Slug: "ethereum", CAIP2: "eip155:1", EVMChainID: 1} + now := func() time.Time { return time.Date(2026, 3, 9, 12, 0, 0, 0, time.UTC) } + + result, err := fetchGasPrice(context.Background(), chain, srv.URL, now) + if err != nil { + t.Fatalf("fetchGasPrice failed: %v", err) + } + + if result.ChainID != "eip155:1" { + t.Fatalf("expected chain_id eip155:1, got %s", result.ChainID) + } + if result.ChainName != "Ethereum" { + t.Fatalf("expected chain_name Ethereum, got %s", result.ChainName) + } + if result.BlockNumber != 16 { + t.Fatalf("expected block_number 16, got %d", result.BlockNumber) + } + if !result.EIP1559 { + t.Fatal("expected eip1559=true") + } + if result.BaseFeeGwei != "1.000000" { + t.Fatalf("expected base_fee_gwei 1.000000, got %s", result.BaseFeeGwei) + } + if result.PriorityFeeGwei != "2.000000" { + t.Fatalf("expected priority_fee_gwei 2.000000, got %s", result.PriorityFeeGwei) + } + if result.GasPriceGwei != "3.000000" { + t.Fatalf("expected gas_price_gwei 3.000000, got %s", result.GasPriceGwei) + } + if result.FetchedAt != "2026-03-09T12:00:00Z" { + t.Fatalf("unexpected fetched_at: %s", result.FetchedAt) + } +} + +func TestFetchGasPriceLegacy(t *testing.T) { + srv := newMockRPCServer(t, mockRPCConfig{ + baseFeeHex: "", // no base fee = legacy chain + gasPriceHex: "0x12A05F200", // 5 gwei + blockNumberHex: "0x5", + }) + defer srv.Close() + + chain := id.Chain{Name: "TestLegacy", Slug: "legacy", CAIP2: "eip155:999", EVMChainID: 999} + now := func() time.Time { return time.Date(2026, 3, 9, 12, 0, 0, 0, time.UTC) } + + result, err := fetchGasPrice(context.Background(), chain, srv.URL, now) + if err != nil { + t.Fatalf("fetchGasPrice failed: %v", err) + } + + if result.EIP1559 { + t.Fatal("expected eip1559=false for legacy chain") + } + if result.BaseFeeGwei != "" { + t.Fatalf("expected empty base_fee_gwei for legacy chain, got %s", result.BaseFeeGwei) + } + if result.PriorityFeeGwei != "" { + t.Fatalf("expected empty priority_fee_gwei for legacy chain, got %s", result.PriorityFeeGwei) + } + if result.GasPriceGwei != "5.000000" { + t.Fatalf("expected gas_price_gwei 5.000000, got %s", result.GasPriceGwei) + } +} + +func TestChainsGasRejectsNonEVM(t *testing.T) { + var stdout, stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"chains", "gas", "--chain", "solana"}) + if code == 0 { + t.Fatal("expected non-zero exit code for non-EVM chain") + } + if !strings.Contains(stderr.String(), "EVM") { + t.Fatalf("expected EVM-only error message, got: %s", stderr.String()) + } +} + +func TestChainsGasRequiresChainFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"chains", "gas"}) + if code == 0 { + t.Fatal("expected non-zero exit code when --chain is missing") + } +} + +func TestChainsGasEndToEndWithMockRPC(t *testing.T) { + srv := newMockRPCServer(t, mockRPCConfig{ + baseFeeHex: "0x3B9ACA00", + priorityFeeHex: "0x77359400", + gasPriceHex: "0xB2D05E00", + blockNumberHex: "0x10", + }) + defer srv.Close() + + var stdout, stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"chains", "gas", "--chain", "1", "--rpc-url", srv.URL, "--results-only"}) + if code != 0 { + t.Fatalf("expected exit 0, got %d stderr=%s", code, stderr.String()) + } + + var result model.GasPrice + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("failed to parse output: %v output=%s", err, stdout.String()) + } + if result.ChainID != "eip155:1" { + t.Fatalf("expected chain_id eip155:1, got %s", result.ChainID) + } + if !result.EIP1559 { + t.Fatal("expected eip1559=true") + } + if result.BaseFeeGwei != "1.000000" { + t.Fatalf("expected base_fee_gwei 1.000000, got %s", result.BaseFeeGwei) + } +} + +func TestChainsGasMultipleChainsRejectsRPCURL(t *testing.T) { + var stdout, stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"chains", "gas", "--chain", "1,10", "--rpc-url", "http://example.com"}) + if code == 0 { + t.Fatal("expected non-zero exit code when --rpc-url used with multiple chains") + } + if !strings.Contains(stderr.String(), "rpc-url") { + t.Fatalf("expected rpc-url error message, got: %s", stderr.String()) + } +} + +func TestChainsGasMultipleChainsWithMockRPC(t *testing.T) { + // Use two separate mock RPC servers to simulate different chains. + srv1 := newMockRPCServer(t, mockRPCConfig{ + baseFeeHex: "0x3B9ACA00", // 1 gwei + priorityFeeHex: "0x77359400", // 2 gwei + gasPriceHex: "0xB2D05E00", // 3 gwei + blockNumberHex: "0x10", + }) + defer srv1.Close() + + srv2 := newMockRPCServer(t, mockRPCConfig{ + baseFeeHex: "0x77359400", // 2 gwei + priorityFeeHex: "0x3B9ACA00", // 1 gwei + gasPriceHex: "0xEE6B2800", // 4 gwei + blockNumberHex: "0x20", + }) + defer srv2.Close() + + // Test the fetchGasPrice function directly for two chains and verify array behavior. + chain1 := id.Chain{Name: "Ethereum", Slug: "ethereum", CAIP2: "eip155:1", EVMChainID: 1} + chain2 := id.Chain{Name: "Optimism", Slug: "optimism", CAIP2: "eip155:10", EVMChainID: 10} + now := func() time.Time { return time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) } + + r1, err := fetchGasPrice(context.Background(), chain1, srv1.URL, now) + if err != nil { + t.Fatalf("fetchGasPrice chain1: %v", err) + } + r2, err := fetchGasPrice(context.Background(), chain2, srv2.URL, now) + if err != nil { + t.Fatalf("fetchGasPrice chain2: %v", err) + } + + results := []model.GasPrice{r1, r2} + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if results[0].ChainID != "eip155:1" { + t.Fatalf("expected first result chain_id eip155:1, got %s", results[0].ChainID) + } + if results[1].ChainID != "eip155:10" { + t.Fatalf("expected second result chain_id eip155:10, got %s", results[1].ChainID) + } + if results[0].GasPriceGwei != "3.000000" { + t.Fatalf("expected chain1 gas_price_gwei 3.000000, got %s", results[0].GasPriceGwei) + } + if results[1].GasPriceGwei != "4.000000" { + t.Fatalf("expected chain2 gas_price_gwei 4.000000, got %s", results[1].GasPriceGwei) + } + if results[1].BlockNumber != 32 { + t.Fatalf("expected chain2 block_number 32, got %d", results[1].BlockNumber) + } +} + +func TestChainsGasSingleChainStillReturnsScalar(t *testing.T) { + srv := newMockRPCServer(t, mockRPCConfig{ + baseFeeHex: "0x3B9ACA00", + priorityFeeHex: "0x77359400", + gasPriceHex: "0xB2D05E00", + blockNumberHex: "0x10", + }) + defer srv.Close() + + var stdout, stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"chains", "gas", "--chain", "1", "--rpc-url", srv.URL, "--results-only"}) + if code != 0 { + t.Fatalf("expected exit 0, got %d stderr=%s", code, stderr.String()) + } + + // Single chain should return a scalar object, not an array. + var result model.GasPrice + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("single chain should return scalar GasPrice, got parse error: %v output=%s", err, stdout.String()) + } + if result.ChainID != "eip155:1" { + t.Fatalf("expected chain_id eip155:1, got %s", result.ChainID) + } +} + +func TestChainsGasRejectsNonEVMInMulti(t *testing.T) { + var stdout, stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"chains", "gas", "--chain", "1,solana"}) + if code == 0 { + t.Fatal("expected non-zero exit code when non-EVM chain in multi list") + } + if !strings.Contains(stderr.String(), "EVM") { + t.Fatalf("expected EVM-only error message, got: %s", stderr.String()) + } +} + +// --- mock RPC server --- + +type mockRPCConfig struct { + baseFeeHex string // empty string means no baseFee (legacy) + priorityFeeHex string + gasPriceHex string + blockNumberHex string +} + +func newMockRPCServer(t *testing.T, cfg mockRPCConfig) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqs []json.RawMessage + batch := false + + var raw json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + http.Error(w, "bad json", 400) + return + } + + trimmed := bytes.TrimSpace(raw) + if len(trimmed) > 0 && trimmed[0] == '[' { + batch = true + if err := json.Unmarshal(trimmed, &reqs); err != nil { + http.Error(w, "bad batch", 400) + return + } + } else { + reqs = []json.RawMessage{raw} + } + + var results []json.RawMessage + for _, reqRaw := range reqs { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(reqRaw, &req); err != nil { + continue + } + + var resp string + switch req.Method { + case "eth_getBlockByNumber": + baseFee := "null" + if cfg.baseFeeHex != "" { + baseFee = fmt.Sprintf("%q", cfg.baseFeeHex) + } + resp = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":{"number":"%s","baseFeePerGas":%s,"hash":"0x0000000000000000000000000000000000000000000000000000000000000000","parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","sha3Uncles":"0x0000000000000000000000000000000000000000000000000000000000000000","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","totalDifficulty":"0x0","size":"0x0","gasLimit":"0x0","gasUsed":"0x0","timestamp":"0x0","extraData":"0x","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","uncles":[],"transactions":[]}}`, + req.ID, cfg.blockNumberHex, baseFee) + case "eth_gasPrice": + resp = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":"%s"}`, req.ID, cfg.gasPriceHex) + case "eth_maxPriorityFeePerGas": + if cfg.priorityFeeHex != "" { + resp = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":"%s"}`, req.ID, cfg.priorityFeeHex) + } else { + resp = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"method not found"}}`, req.ID) + } + default: + resp = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"method not found"}}`, req.ID) + } + results = append(results, json.RawMessage(resp)) + } + + w.Header().Set("Content-Type", "application/json") + if batch { + json.NewEncoder(w).Encode(results) + } else if len(results) > 0 { + w.Write(results[0]) + } + })) +} diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 5f3bead..e0b1692 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -396,6 +396,62 @@ func TestRunnerProvidersList(t *testing.T) { } } +func TestRunnerChainsList(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + r := NewRunnerWithWriters(&stdout, &stderr) + code := r.Run([]string{"chains", "list", "--results-only"}) + if code != 0 { + t.Fatalf("expected exit 0, got %d stderr=%s", code, stderr.String()) + } + var out []map[string]any + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("failed to parse output json: %v output=%s", err, stdout.String()) + } + if len(out) == 0 { + t.Fatal("expected at least one chain in output") + } + + // Verify each entry has required fields. + for _, item := range out { + if _, ok := item["name"].(string); !ok { + t.Fatalf("missing name field: %+v", item) + } + if _, ok := item["slug"].(string); !ok { + t.Fatalf("missing slug field: %+v", item) + } + if _, ok := item["caip2"].(string); !ok { + t.Fatalf("missing caip2 field: %+v", item) + } + if _, ok := item["namespace"].(string); !ok { + t.Fatalf("missing namespace field: %+v", item) + } + } + + // Verify Ethereum is present. + var ethFound bool + for _, item := range out { + if item["slug"] == "ethereum" { + ethFound = true + if item["caip2"] != "eip155:1" { + t.Fatalf("expected ethereum caip2 eip155:1, got %v", item["caip2"]) + } + if item["namespace"] != "eip155" { + t.Fatalf("expected eip155 namespace, got %v", item["namespace"]) + } + } + } + if !ethFound { + t.Fatal("expected ethereum in chains list output") + } +} + +func TestRunnerChainsListBypassesCache(t *testing.T) { + if shouldOpenCache("chains list") { + t.Fatal("chains list should bypass cache initialization") + } +} + func TestRunnerErrorEnvelopeIgnoresResultsOnly(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -588,6 +644,63 @@ func TestRunnerProtocolsCategories(t *testing.T) { } } +func TestRunnerProtocolsFees(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + state := &runtimeState{ + runner: &Runner{ + stdout: &stdout, + stderr: &stderr, + now: time.Now, + }, + settings: config.Settings{ + OutputMode: "json", + Timeout: 2 * time.Second, + CacheEnabled: false, + }, + marketProvider: fakeMarketProvider{ + protocolFees: []model.ProtocolFees{ + {Rank: 1, Protocol: "Lido", Category: "Liquid Staking", Fees24hUSD: 8000000, Fees7dUSD: 55000000, Fees30dUSD: 200000000, Chains: 1}, + }, + }, + } + root := &cobra.Command{Use: "defi"} + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(&stdout) + root.SetErr(&stderr) + root.AddCommand(state.newProtocolsCommand()) + root.SetArgs([]string{"protocols", "fees"}) + if err := root.Execute(); err != nil { + t.Fatalf("expected protocols fees command success, err=%v stderr=%s", err, stderr.String()) + } + + var env map[string]any + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("failed to parse output json: %v output=%s", err, stdout.String()) + } + if env["success"] != true { + t.Fatalf("expected success=true, got %v", env["success"]) + } + data, ok := env["data"].([]any) + if !ok { + t.Fatalf("expected data to be an array, got %T", env["data"]) + } + if len(data) == 0 { + t.Fatalf("expected non-empty fees list") + } + first, ok := data[0].(map[string]any) + if !ok { + t.Fatalf("expected first item to be object, got %T", data[0]) + } + if _, ok := first["protocol"]; !ok { + t.Fatalf("expected 'protocol' field, got %+v", first) + } + if _, ok := first["fees_24h_usd"]; !ok { + t.Fatalf("expected 'fees_24h_usd' field, got %+v", first) + } +} + func TestRunnerChainsAssets(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -1406,6 +1519,7 @@ type fakeMarketProvider struct { categories []model.ProtocolCategory chainAssets []model.ChainAssetTVL expectedAssetSymbol string + protocolFees []model.ProtocolFees } func (f fakeMarketProvider) Info() model.ProviderInfo { @@ -1439,6 +1553,18 @@ func (f fakeMarketProvider) ProtocolsCategories(context.Context) ([]model.Protoc return f.categories, nil } +func (f fakeMarketProvider) StablecoinsTop(context.Context, string, int) ([]model.Stablecoin, error) { + return nil, nil +} + +func (f fakeMarketProvider) StablecoinChains(context.Context, int) ([]model.StablecoinChain, error) { + return nil, nil +} + +func (f fakeMarketProvider) ProtocolsFees(context.Context, string, int) ([]model.ProtocolFees, error) { + return f.protocolFees, nil +} + type fakeSwapProvider struct { name string calls int diff --git a/internal/id/id.go b/internal/id/id.go index affe277..eec8211 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -674,3 +674,35 @@ func KnownToken(chainID, symbol string) (Token, bool) { func LookupByAddress(chainID, address string) (Token, bool) { return findTokenByAddress(chainID, canonicalizeAddress(chainID, address)) } + +// ListChains returns all unique supported chains sorted by CAIP-2 identifier. +// Each chain includes its canonical aliases (excluding the primary slug). +func ListChains() []ChainEntry { + seen := make(map[string]*ChainEntry, len(chainByCAIP2)) + for slug, chain := range chainBySlug { + entry, ok := seen[chain.CAIP2] + if !ok { + e := ChainEntry{Chain: chain} + seen[chain.CAIP2] = &e + entry = &e + } + if slug != chain.Slug { + entry.Aliases = append(entry.Aliases, slug) + } + } + entries := make([]ChainEntry, 0, len(seen)) + for _, e := range seen { + sort.Strings(e.Aliases) + entries = append(entries, *e) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Chain.CAIP2 < entries[j].Chain.CAIP2 + }) + return entries +} + +// ChainEntry is a chain with its accepted aliases. +type ChainEntry struct { + Chain Chain + Aliases []string +} diff --git a/internal/id/id_test.go b/internal/id/id_test.go index 7193bd4..66b3b60 100644 --- a/internal/id/id_test.go +++ b/internal/id/id_test.go @@ -427,6 +427,80 @@ func TestParseAssetMegaETHBootstrapAddresses(t *testing.T) { } } +func TestListChainsReturnsDedupedSortedEntries(t *testing.T) { + entries := ListChains() + if len(entries) == 0 { + t.Fatal("expected at least one chain entry") + } + + // Verify uniqueness by CAIP-2. + seen := map[string]bool{} + for _, e := range entries { + if seen[e.Chain.CAIP2] { + t.Fatalf("duplicate CAIP-2: %s", e.Chain.CAIP2) + } + seen[e.Chain.CAIP2] = true + } + + // Verify sorted by CAIP-2. + for i := 1; i < len(entries); i++ { + if entries[i].Chain.CAIP2 < entries[i-1].Chain.CAIP2 { + t.Fatalf("entries not sorted: %s before %s", entries[i-1].Chain.CAIP2, entries[i].Chain.CAIP2) + } + } + + // Verify Ethereum is present with expected alias. + var found bool + for _, e := range entries { + if e.Chain.Slug == "ethereum" { + found = true + if e.Chain.CAIP2 != "eip155:1" { + t.Fatalf("expected ethereum CAIP2 eip155:1, got %s", e.Chain.CAIP2) + } + hasMainnet := false + for _, alias := range e.Aliases { + if alias == "mainnet" { + hasMainnet = true + } + if alias == "ethereum" { + t.Fatal("primary slug should not appear in aliases") + } + } + if !hasMainnet { + t.Fatal("expected 'mainnet' alias for ethereum") + } + } + } + if !found { + t.Fatal("expected to find ethereum in chain list") + } + + // Verify Solana is present. + var solanaFound bool + for _, e := range entries { + if e.Chain.Slug == "solana" { + solanaFound = true + if !e.Chain.IsSolana() { + t.Fatal("expected solana chain to be solana namespace") + } + } + } + if !solanaFound { + t.Fatal("expected to find solana in chain list") + } +} + +func TestListChainsAliasesExcludePrimarySlug(t *testing.T) { + entries := ListChains() + for _, e := range entries { + for _, alias := range e.Aliases { + if alias == e.Chain.Slug { + t.Fatalf("chain %s has primary slug in aliases", e.Chain.Slug) + } + } + } +} + func TestParseAssetFibrousChainBootstrapAddresses(t *testing.T) { tests := []struct { chainInput string diff --git a/internal/model/types.go b/internal/model/types.go index 81cb040..8cfe27c 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -62,6 +62,26 @@ type ProviderCapabilityAuth struct { Description string `json:"description,omitempty"` } +type SupportedChain struct { + Name string `json:"name"` + Slug string `json:"slug"` + CAIP2 string `json:"caip2"` + Namespace string `json:"namespace"` + EVMChainID int64 `json:"evm_chain_id,omitempty"` + Aliases []string `json:"aliases,omitempty"` +} + +type GasPrice struct { + ChainID string `json:"chain_id"` + ChainName string `json:"chain_name"` + BlockNumber int64 `json:"block_number"` + EIP1559 bool `json:"eip1559"` + BaseFeeGwei string `json:"base_fee_gwei"` + PriorityFeeGwei string `json:"priority_fee_gwei"` + GasPriceGwei string `json:"gas_price_gwei"` + FetchedAt string `json:"fetched_at"` +} + type ChainTVL struct { Rank int `json:"rank"` Chain string `json:"chain"` @@ -91,6 +111,41 @@ type ProtocolCategory struct { TVLUSD float64 `json:"tvl_usd"` } +type ProtocolFees struct { + Rank int `json:"rank"` + Protocol string `json:"protocol"` + Category string `json:"category"` + Fees24hUSD float64 `json:"fees_24h_usd"` + Fees7dUSD float64 `json:"fees_7d_usd"` + Fees30dUSD float64 `json:"fees_30d_usd"` + Change1dPct float64 `json:"change_1d_pct"` + Change7dPct float64 `json:"change_7d_pct"` + Change1mPct float64 `json:"change_1m_pct"` + Chains int `json:"chains"` +} + +type Stablecoin struct { + Rank int `json:"rank"` + Name string `json:"name"` + Symbol string `json:"symbol"` + PegType string `json:"peg_type"` + PegMechanism string `json:"peg_mechanism"` + CirculatingUSD float64 `json:"circulating_usd"` + Price float64 `json:"price"` + Chains int `json:"chains"` + DayChangeUSD float64 `json:"day_change_usd"` + WeekChangeUSD float64 `json:"week_change_usd"` + MonthChangeUSD float64 `json:"month_change_usd"` +} + +type StablecoinChain struct { + Rank int `json:"rank"` + Chain string `json:"chain"` + ChainID string `json:"chain_id"` + CirculatingUSD float64 `json:"circulating_usd"` + DominantPegType string `json:"dominant_peg_type"` +} + type AssetResolution struct { Input string `json:"input"` ChainID string `json:"chain_id"` diff --git a/internal/providers/defillama/client.go b/internal/providers/defillama/client.go index 7d6d117..1159dbf 100644 --- a/internal/providers/defillama/client.go +++ b/internal/providers/defillama/client.go @@ -20,25 +20,28 @@ import ( ) const ( - defaultAPIBase = "https://api.llama.fi" - defaultBridgeAPIURL = "https://pro-api.llama.fi" + defaultAPIBase = "https://api.llama.fi" + defaultBridgeAPIURL = "https://pro-api.llama.fi" + defaultStablecoinsAPIURL = "https://stablecoins.llama.fi" ) type Client struct { - http *httpx.Client - apiBase string - bridgeBaseURL string - apiKey string - now func() time.Time + http *httpx.Client + apiBase string + bridgeBaseURL string + stablecoinsAPIURL string + apiKey string + now func() time.Time } func New(httpClient *httpx.Client, apiKey string) *Client { return &Client{ - http: httpClient, - apiBase: defaultAPIBase, - bridgeBaseURL: defaultBridgeAPIURL, - apiKey: strings.TrimSpace(apiKey), - now: time.Now, + http: httpClient, + apiBase: defaultAPIBase, + bridgeBaseURL: defaultBridgeAPIURL, + stablecoinsAPIURL: defaultStablecoinsAPIURL, + apiKey: strings.TrimSpace(apiKey), + now: time.Now, } } @@ -52,6 +55,9 @@ func (c *Client) Info() model.ProviderInfo { "chains.assets", "protocols.top", "protocols.categories", + "protocols.fees", + "stablecoins.top", + "stablecoins.chains", "bridge.list", "bridge.details", }, @@ -269,6 +275,200 @@ func (c *Client) ProtocolsCategories(ctx context.Context) ([]model.ProtocolCateg return out, nil } +type feesProtocolResp struct { + Name string `json:"name"` + Category string `json:"category"` + Total24h *float64 `json:"total24h"` + Total7d *float64 `json:"total7d"` + Total30d *float64 `json:"total30d"` + Change1d *float64 `json:"change_1d"` + Change7d *float64 `json:"change_7d"` + Change1m *float64 `json:"change_1m"` + Chains []string `json:"chains"` +} + +type feesOverviewResp struct { + Protocols []feesProtocolResp `json:"protocols"` +} + +func (c *Client) ProtocolsFees(ctx context.Context, category string, limit int) ([]model.ProtocolFees, error) { + endpoint := c.apiBase + "/overview/fees?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "build fees request", err) + } + var resp feesOverviewResp + if _, err := c.http.DoJSON(ctx, req, &resp); err != nil { + return nil, err + } + + normCategory := strings.ToLower(strings.TrimSpace(category)) + filtered := make([]feesProtocolResp, 0, len(resp.Protocols)) + for _, p := range resp.Protocols { + if p.Total24h == nil || *p.Total24h <= 0 { + continue + } + if normCategory != "" && strings.ToLower(p.Category) != normCategory { + continue + } + filtered = append(filtered, p) + } + + sort.Slice(filtered, func(i, j int) bool { + return valOrZero(filtered[i].Total24h) > valOrZero(filtered[j].Total24h) + }) + if limit <= 0 || limit > len(filtered) { + limit = len(filtered) + } + + out := make([]model.ProtocolFees, 0, limit) + for i := 0; i < limit; i++ { + item := filtered[i] + out = append(out, model.ProtocolFees{ + Rank: i + 1, + Protocol: item.Name, + Category: item.Category, + Fees24hUSD: valOrZero(item.Total24h), + Fees7dUSD: valOrZero(item.Total7d), + Fees30dUSD: valOrZero(item.Total30d), + Change1dPct: valOrZero(item.Change1d), + Change7dPct: valOrZero(item.Change7d), + Change1mPct: valOrZero(item.Change1m), + Chains: len(item.Chains), + }) + } + return out, nil +} + +type stablecoinResp struct { + Name string `json:"name"` + Symbol string `json:"symbol"` + PegType string `json:"pegType"` + PegMechanism string `json:"pegMechanism"` + Circulating peggedAmount `json:"circulating"` + CircPrevDay peggedAmount `json:"circulatingPrevDay"` + CircPrevWeek peggedAmount `json:"circulatingPrevWeek"` + CircPrevMonth peggedAmount `json:"circulatingPrevMonth"` + Chains []string `json:"chains"` + Price *float64 `json:"price"` +} + +type peggedAmount struct { + PeggedUSD float64 `json:"peggedUSD"` +} + +type stablecoinsEnvelope struct { + PeggedAssets []stablecoinResp `json:"peggedAssets"` +} + +func (c *Client) StablecoinsTop(ctx context.Context, pegType string, limit int) ([]model.Stablecoin, error) { + endpoint := c.stablecoinsAPIURL + "/stablecoins?includePrices=true" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "build stablecoins request", err) + } + var resp stablecoinsEnvelope + if _, err := c.http.DoJSON(ctx, req, &resp); err != nil { + return nil, err + } + + normPeg := strings.ToLower(strings.TrimSpace(pegType)) + filtered := make([]stablecoinResp, 0, len(resp.PeggedAssets)) + for _, s := range resp.PeggedAssets { + if normPeg != "" && strings.ToLower(s.PegType) != normPeg { + continue + } + filtered = append(filtered, s) + } + + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Circulating.PeggedUSD > filtered[j].Circulating.PeggedUSD + }) + if limit <= 0 || limit > len(filtered) { + limit = len(filtered) + } + + out := make([]model.Stablecoin, 0, limit) + for i := 0; i < limit; i++ { + item := filtered[i] + price := 0.0 + if item.Price != nil { + price = *item.Price + } + out = append(out, model.Stablecoin{ + Rank: i + 1, + Name: item.Name, + Symbol: item.Symbol, + PegType: item.PegType, + PegMechanism: item.PegMechanism, + CirculatingUSD: item.Circulating.PeggedUSD, + Price: price, + Chains: len(item.Chains), + DayChangeUSD: item.Circulating.PeggedUSD - item.CircPrevDay.PeggedUSD, + WeekChangeUSD: item.Circulating.PeggedUSD - item.CircPrevWeek.PeggedUSD, + MonthChangeUSD: item.Circulating.PeggedUSD - item.CircPrevMonth.PeggedUSD, + }) + } + return out, nil +} + +type stablecoinChainResp struct { + GeckoID string `json:"gecko_id"` + TotalCirculatingUSD map[string]float64 `json:"totalCirculatingUSD"` + TokenSymbol *string `json:"tokenSymbol"` + Name string `json:"name"` +} + +func (c *Client) StablecoinChains(ctx context.Context, limit int) ([]model.StablecoinChain, error) { + endpoint := c.stablecoinsAPIURL + "/stablecoinchains" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, clierr.Wrap(clierr.CodeInternal, "build stablecoin chains request", err) + } + var resp []stablecoinChainResp + if _, err := c.http.DoJSON(ctx, req, &resp); err != nil { + return nil, err + } + + out := make([]model.StablecoinChain, 0, len(resp)) + for _, item := range resp { + total := 0.0 + dominantPeg := "" + dominantAmount := 0.0 + for pegType, amount := range item.TotalCirculatingUSD { + total += amount + if amount > dominantAmount { + dominantAmount = amount + dominantPeg = pegType + } + } + if total <= 0 { + continue + } + chainID := "" + if chain, parseErr := id.ParseChain(item.Name); parseErr == nil { + chainID = chain.CAIP2 + } + out = append(out, model.StablecoinChain{ + Chain: item.Name, + ChainID: chainID, + CirculatingUSD: total, + DominantPegType: dominantPeg, + }) + } + + sort.Slice(out, func(i, j int) bool { + return out[i].CirculatingUSD > out[j].CirculatingUSD + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + for i := range out { + out[i].Rank = i + 1 + } + return out, nil +} + type bridgeListEnvelope struct { Bridges []bridgeListItem `json:"bridges"` } diff --git a/internal/providers/defillama/client_test.go b/internal/providers/defillama/client_test.go index 5555d8e..472b407 100644 --- a/internal/providers/defillama/client_test.go +++ b/internal/providers/defillama/client_test.go @@ -228,6 +228,317 @@ func TestProtocolsCategoriesDeterministicTieBreak(t *testing.T) { } } +func TestStablecoinsTopSortsAndLimits(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/stablecoins", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "peggedAssets":[ + {"name":"Tether","symbol":"USDT","pegType":"peggedUSD","pegMechanism":"fiat-backed", + "circulating":{"peggedUSD":120000000000},"circulatingPrevDay":{"peggedUSD":119500000000}, + "circulatingPrevWeek":{"peggedUSD":118000000000},"circulatingPrevMonth":{"peggedUSD":115000000000}, + "chains":["Ethereum","Tron","BSC","Arbitrum","Solana"],"price":1.0001}, + {"name":"USD Coin","symbol":"USDC","pegType":"peggedUSD","pegMechanism":"fiat-backed", + "circulating":{"peggedUSD":55000000000},"circulatingPrevDay":{"peggedUSD":54800000000}, + "circulatingPrevWeek":{"peggedUSD":54000000000},"circulatingPrevMonth":{"peggedUSD":52000000000}, + "chains":["Ethereum","Base","Solana"],"price":0.9999}, + {"name":"Dai","symbol":"DAI","pegType":"peggedUSD","pegMechanism":"crypto-backed", + "circulating":{"peggedUSD":5000000000},"circulatingPrevDay":{"peggedUSD":4990000000}, + "circulatingPrevWeek":{"peggedUSD":4900000000},"circulatingPrevMonth":{"peggedUSD":4800000000}, + "chains":["Ethereum","Polygon"],"price":1.0}, + {"name":"STASIS EURO","symbol":"EURS","pegType":"peggedEUR","pegMechanism":"fiat-backed", + "circulating":{"peggedUSD":100000000},"circulatingPrevDay":{"peggedUSD":99000000}, + "circulatingPrevWeek":{"peggedUSD":98000000},"circulatingPrevMonth":{"peggedUSD":95000000}, + "chains":["Ethereum"],"price":1.1} + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.stablecoinsAPIURL = srv.URL + + items, err := c.StablecoinsTop(context.Background(), "", 2) + if err != nil { + t.Fatalf("StablecoinsTop failed: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].Symbol != "USDT" || items[0].Rank != 1 { + t.Fatalf("expected USDT first, got %+v", items[0]) + } + if items[1].Symbol != "USDC" || items[1].Rank != 2 { + t.Fatalf("expected USDC second, got %+v", items[1]) + } + if items[0].CirculatingUSD != 120000000000 { + t.Fatalf("unexpected circulating for USDT: %+v", items[0]) + } + if items[0].Chains != 5 { + t.Fatalf("expected 5 chains for USDT, got %d", items[0].Chains) + } + if items[0].Price != 1.0001 { + t.Fatalf("unexpected price for USDT: %f", items[0].Price) + } + expectedDayChange := 120000000000.0 - 119500000000.0 + if items[0].DayChangeUSD != expectedDayChange { + t.Fatalf("unexpected day change: got %f, want %f", items[0].DayChangeUSD, expectedDayChange) + } +} + +func TestStablecoinsTopFiltersByPegType(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/stablecoins", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "peggedAssets":[ + {"name":"Tether","symbol":"USDT","pegType":"peggedUSD","pegMechanism":"fiat-backed", + "circulating":{"peggedUSD":120000000000},"circulatingPrevDay":{"peggedUSD":119500000000}, + "circulatingPrevWeek":{"peggedUSD":118000000000},"circulatingPrevMonth":{"peggedUSD":115000000000}, + "chains":["Ethereum"],"price":1.0}, + {"name":"STASIS EURO","symbol":"EURS","pegType":"peggedEUR","pegMechanism":"fiat-backed", + "circulating":{"peggedUSD":100000000},"circulatingPrevDay":{"peggedUSD":99000000}, + "circulatingPrevWeek":{"peggedUSD":98000000},"circulatingPrevMonth":{"peggedUSD":95000000}, + "chains":["Ethereum"],"price":1.1} + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.stablecoinsAPIURL = srv.URL + + items, err := c.StablecoinsTop(context.Background(), "peggedEUR", 20) + if err != nil { + t.Fatalf("StablecoinsTop failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 EUR-pegged item, got %d", len(items)) + } + if items[0].Symbol != "EURS" || items[0].PegType != "peggedEUR" { + t.Fatalf("unexpected filtered result: %+v", items[0]) + } +} + +func TestStablecoinsTopNullPrice(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/stablecoins", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "peggedAssets":[ + {"name":"NoPrice","symbol":"NP","pegType":"peggedUSD","pegMechanism":"algo", + "circulating":{"peggedUSD":1000},"circulatingPrevDay":{"peggedUSD":1000}, + "circulatingPrevWeek":{"peggedUSD":1000},"circulatingPrevMonth":{"peggedUSD":1000}, + "chains":["Ethereum"]} + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.stablecoinsAPIURL = srv.URL + + items, err := c.StablecoinsTop(context.Background(), "", 20) + if err != nil { + t.Fatalf("StablecoinsTop failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].Price != 0 { + t.Fatalf("expected zero price for null, got %f", items[0].Price) + } +} + +func TestStablecoinChainsSortsAndLimits(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/stablecoinchains", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[ + {"gecko_id":"ethereum","totalCirculatingUSD":{"peggedUSD":90000000000,"peggedEUR":500000000},"tokenSymbol":"ETH","name":"Ethereum"}, + {"gecko_id":"tron","totalCirculatingUSD":{"peggedUSD":60000000000},"tokenSymbol":"TRX","name":"Tron"}, + {"gecko_id":"binancecoin","totalCirculatingUSD":{"peggedUSD":8000000000,"peggedEUR":200000000},"tokenSymbol":"BNB","name":"BSC"}, + {"gecko_id":"solana","totalCirculatingUSD":{"peggedUSD":12000000000},"tokenSymbol":"SOL","name":"Solana"} + ]`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.stablecoinsAPIURL = srv.URL + + items, err := c.StablecoinChains(context.Background(), 3) + if err != nil { + t.Fatalf("StablecoinChains failed: %v", err) + } + if len(items) != 3 { + t.Fatalf("expected 3 items, got %d", len(items)) + } + if items[0].Chain != "Ethereum" || items[0].Rank != 1 { + t.Fatalf("expected Ethereum first, got %+v", items[0]) + } + if items[0].CirculatingUSD != 90500000000 { + t.Fatalf("expected aggregated USD+EUR for Ethereum, got %f", items[0].CirculatingUSD) + } + if items[0].DominantPegType != "peggedUSD" { + t.Fatalf("expected peggedUSD dominant, got %s", items[0].DominantPegType) + } + if items[1].Chain != "Tron" || items[1].Rank != 2 { + t.Fatalf("expected Tron second, got %+v", items[1]) + } + if items[2].Chain != "Solana" || items[2].Rank != 3 { + t.Fatalf("expected Solana third, got %+v", items[2]) + } +} + +func TestStablecoinChainsSkipsZeroSupply(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/stablecoinchains", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[ + {"gecko_id":"ethereum","totalCirculatingUSD":{"peggedUSD":90000000000},"tokenSymbol":"ETH","name":"Ethereum"}, + {"gecko_id":"dead","totalCirculatingUSD":{"peggedUSD":0},"tokenSymbol":"DEAD","name":"DeadChain"}, + {"gecko_id":"empty","totalCirculatingUSD":{},"tokenSymbol":null,"name":"EmptyChain"} + ]`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.stablecoinsAPIURL = srv.URL + + items, err := c.StablecoinChains(context.Background(), 0) + if err != nil { + t.Fatalf("StablecoinChains failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item (zero/empty filtered), got %d", len(items)) + } + if items[0].Chain != "Ethereum" { + t.Fatalf("expected Ethereum only, got %s", items[0].Chain) + } +} + +func TestStablecoinChainsNoLimit(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/stablecoinchains", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[ + {"gecko_id":"ethereum","totalCirculatingUSD":{"peggedUSD":90000000000},"tokenSymbol":"ETH","name":"Ethereum"}, + {"gecko_id":"tron","totalCirculatingUSD":{"peggedUSD":60000000000},"tokenSymbol":"TRX","name":"Tron"} + ]`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.stablecoinsAPIURL = srv.URL + + items, err := c.StablecoinChains(context.Background(), 0) + if err != nil { + t.Fatalf("StablecoinChains failed: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected all 2 items with limit 0, got %d", len(items)) + } +} + +func TestProtocolsFeesSortsAndLimits(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/overview/fees", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "protocols":[ + {"name":"Uniswap","category":"Dexs","total24h":5000000,"total7d":30000000,"total30d":120000000,"change_1d":5.2,"change_7d":-2.1,"change_1m":10.5,"chains":["Ethereum","Arbitrum","Base"]}, + {"name":"Aave","category":"Lending","total24h":2000000,"total7d":12000000,"total30d":50000000,"change_1d":1.5,"change_7d":3.0,"change_1m":-5.0,"chains":["Ethereum","Polygon"]}, + {"name":"Lido","category":"Liquid Staking","total24h":8000000,"total7d":55000000,"total30d":200000000,"change_1d":-1.0,"change_7d":0.5,"change_1m":15.0,"chains":["Ethereum"]}, + {"name":"Dead","category":"Dexs","total24h":null,"chains":[]}, + {"name":"Tiny","category":"Dexs","total24h":0,"chains":["BSC"]} + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.apiBase = srv.URL + + items, err := c.ProtocolsFees(context.Background(), "", 2) + if err != nil { + t.Fatalf("ProtocolsFees failed: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].Protocol != "Lido" || items[0].Rank != 1 { + t.Fatalf("expected Lido first, got %+v", items[0]) + } + if items[0].Fees24hUSD != 8000000 || items[0].Chains != 1 { + t.Fatalf("unexpected Lido values: %+v", items[0]) + } + if items[1].Protocol != "Uniswap" || items[1].Rank != 2 { + t.Fatalf("expected Uniswap second, got %+v", items[1]) + } +} + +func TestProtocolsFeesFiltersByCategory(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/overview/fees", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "protocols":[ + {"name":"Uniswap","category":"Dexs","total24h":5000000,"chains":["Ethereum"]}, + {"name":"Aave","category":"Lending","total24h":2000000,"chains":["Ethereum"]}, + {"name":"Curve","category":"Dexs","total24h":1000000,"chains":["Ethereum"]} + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.apiBase = srv.URL + + items, err := c.ProtocolsFees(context.Background(), "Dexs", 0) + if err != nil { + t.Fatalf("ProtocolsFees with category filter failed: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 Dexs items, got %d", len(items)) + } + if items[0].Protocol != "Uniswap" { + t.Fatalf("expected Uniswap first, got %s", items[0].Protocol) + } + if items[1].Protocol != "Curve" { + t.Fatalf("expected Curve second, got %s", items[1].Protocol) + } +} + +func TestProtocolsFeesSkipsNullAndZero(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/overview/fees", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "protocols":[ + {"name":"NullFees","category":"Dexs","total24h":null,"chains":[]}, + {"name":"ZeroFees","category":"Dexs","total24h":0,"chains":["Ethereum"]}, + {"name":"NegativeFees","category":"Dexs","total24h":-100,"chains":["Ethereum"]}, + {"name":"ValidFees","category":"Dexs","total24h":500,"chains":["Ethereum"]} + ] + }`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(httpx.New(2*time.Second, 0), "") + c.apiBase = srv.URL + + items, err := c.ProtocolsFees(context.Background(), "", 0) + if err != nil { + t.Fatalf("ProtocolsFees failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 valid item, got %d", len(items)) + } + if items[0].Protocol != "ValidFees" { + t.Fatalf("expected ValidFees, got %s", items[0].Protocol) + } +} + func TestListBridgesRequiresAPIKey(t *testing.T) { c := New(httpx.New(2*time.Second, 0), "") _, err := c.ListBridges(context.Background(), providers.BridgeListRequest{Limit: 5, IncludeChains: true}) diff --git a/internal/providers/types.go b/internal/providers/types.go index 6badf9a..1edca4f 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -19,6 +19,9 @@ type MarketDataProvider interface { ChainsAssets(ctx context.Context, chain id.Chain, asset id.Asset, limit int) ([]model.ChainAssetTVL, error) ProtocolsTop(ctx context.Context, category string, limit int) ([]model.ProtocolTVL, error) ProtocolsCategories(ctx context.Context) ([]model.ProtocolCategory, error) + StablecoinsTop(ctx context.Context, pegType string, limit int) ([]model.Stablecoin, error) + StablecoinChains(ctx context.Context, limit int) ([]model.StablecoinChain, error) + ProtocolsFees(ctx context.Context, category string, limit int) ([]model.ProtocolFees, error) } type LendingProvider interface {