From b5a2c0a057cfea195aa53fc3becdb5af07d10a0e Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sat, 21 Mar 2026 18:43:41 +0800 Subject: [PATCH 01/11] feat: add `cg contract` command for token price lookup by contract address Add a new command that looks up token price by contract address using two data sources: CoinGecko's aggregated price (default) and GeckoTerminal's DEX price (--onchain). Both modes produce identical output columns (address, price, market cap, 24h volume, 24h change). - Aggregated mode: queries /simple/token_price/{platform} with --platform - Onchain mode: queries /onchain/simple/networks/{network}/token_price/{addresses} with --network and --onchain flags (paid plans only) - Non-USD currency support: aggregated uses API natively, onchain converts via /exchange_rates endpoint - Supports --dry-run, -o json, --vs, --export CSV - Includes validation with discovery doc hints in error messages - 16 command tests + 6 API-layer tests covering both modes --- CLAUDE.md | 4 + cmd/commands.go | 12 ++ cmd/contract.go | 239 +++++++++++++++++++++++++++ cmd/contract_test.go | 328 +++++++++++++++++++++++++++++++++++++ internal/api/coins.go | 46 +++++- internal/api/coins_test.go | 122 ++++++++++++++ internal/api/types.go | 51 +++++- 7 files changed, 791 insertions(+), 11 deletions(-) create mode 100644 cmd/contract.go create mode 100644 cmd/contract_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 5e4968a..edf0bcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,8 @@ coingecko-cli/ | `cg history --from/--to --interval hourly` | `/coins/{id}/market_chart/range` (batched) | `coins-id-market-chart-range` | | `cg history --from/--to --ohlc` | `/coins/{id}/ohlc/range` (batched for large ranges) | `coins-id-ohlc-range` | | `cg top-gainers-losers` | `/coins/top_gainers_losers` | `coins-top-gainers-losers` | +| `cg contract` | `/simple/token_price/{platform}` | `simple-token-price` | +| `cg contract --onchain` | `/onchain/simple/networks/{network}/token_price/{addresses}` | `onchain-simple-price` | | `cg watch` | `wss://stream.coingecko.com/v1` (WebSocket) | — | ## Distribution @@ -134,3 +136,5 @@ coingecko-cli/ - **Command test seams**: `cmd/client_factory.go` exposes injectable `newAPIClient`, `loadConfig`, and `newStreamer` vars so command integration tests can swap in httptest servers and test configs without touching real API or config files - **Pagination helper**: `FetchAllMarkets` in `internal/api/coins.go` handles multi-page fetching (250/page) with trim-to-total, used by both `cg markets` and `cg tui markets` - **TUI trending tier awareness**: demo gets 15 coins, paid gets 30 via `show_max=coins` API param +- **Onchain currency conversion**: `--onchain` returns USD only. For `--vs` non-USD, the CLI fetches `/exchange_rates` and multiplies price/mcap/volume by `targetRate/usdRate`. 24h change % is unchanged. No caching — one extra API call per non-USD onchain request +- **Platform/network discoverability**: `--platform` IDs come from `/asset_platforms` ([docs](https://docs.coingecko.com/reference/asset-platforms-list)), `--network` IDs from `/onchain/networks` ([docs](https://docs.coingecko.com/reference/networks-list)). These are different namespaces — no auto-translation between them diff --git a/cmd/commands.go b/cmd/commands.go index d2b11d2..cbc76e5 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -77,6 +77,18 @@ var commandMeta = map[string]commandAnnotation{ PaidOnly: true, RequiresAuth: true, }, + "contract": { + APIEndpoints: map[string]string{ + "default": "/simple/token_price/{platform}", + "--onchain": "/onchain/simple/networks/{network}/token_price/{addresses}", + }, + OASOperationIDs: map[string]string{ + "default": "simple-token-price", + "--onchain": "onchain-simple-price", + }, + OASSpec: "coingecko-demo.json", + RequiresAuth: true, + }, } type flagInfo struct { diff --git a/cmd/contract.go b/cmd/contract.go new file mode 100644 index 0000000..9d2f6bf --- /dev/null +++ b/cmd/contract.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/coingecko/coingecko-cli/internal/display" + + "github.com/spf13/cobra" +) + +var contractCmd = &cobra.Command{ + Use: "contract", + Short: "Get token price by contract address", + Long: `Fetch token price by contract address. Uses CoinGecko's aggregated price by +default, or DEX price from GeckoTerminal with --onchain. + +Find valid --platform IDs: https://docs.coingecko.com/reference/asset-platforms-list +Find valid --network IDs: https://docs.coingecko.com/reference/networks-list + +Note: --platform (e.g. "ethereum") and --network (e.g. "eth") are different +identifiers from different API specs — they are not interchangeable.`, + Example: ` cg contract --address 0x1f98... --platform ethereum + cg contract --address 0x1f98... --platform ethereum --vs eur + cg contract --address 0x1f98... --network eth --onchain + cg contract --address 0x1f98... --network eth --onchain --vs eur`, + RunE: runContract, +} + +func init() { + contractCmd.Flags().String("address", "", "Contract address (required)") + contractCmd.Flags().String("platform", "", "Platform ID for aggregated mode (e.g. ethereum). See https://docs.coingecko.com/reference/asset-platforms-list") + contractCmd.Flags().String("network", "", "Network ID for onchain mode (e.g. eth). See https://docs.coingecko.com/reference/networks-list") + contractCmd.Flags().Bool("onchain", false, "Use DEX price from GeckoTerminal") + contractCmd.Flags().String("vs", "usd", "Target currency") + contractCmd.Flags().String("export", "", "Export to CSV file path") + rootCmd.AddCommand(contractCmd) +} + +func runContract(cmd *cobra.Command, args []string) error { + address, _ := cmd.Flags().GetString("address") + platform, _ := cmd.Flags().GetString("platform") + network, _ := cmd.Flags().GetString("network") + onchain, _ := cmd.Flags().GetBool("onchain") + vs, _ := cmd.Flags().GetString("vs") + exportPath, _ := cmd.Flags().GetString("export") + jsonOut := outputJSON(cmd) + + if !jsonOut { + display.PrintBanner() + } + + // Validation — fail fast before config load. + if address == "" { + return fmt.Errorf("--address is required") + } + if onchain && platform != "" { + return fmt.Errorf("--platform and --onchain are mutually exclusive") + } + if network != "" && !onchain { + return fmt.Errorf("--network requires --onchain flag; did you mean --platform?") + } + if onchain && network == "" { + return fmt.Errorf("--network is required with --onchain; find valid network IDs at https://docs.coingecko.com/reference/networks-list") + } + if !onchain && platform == "" { + return fmt.Errorf("--platform is required (or use --onchain with --network); find valid platform IDs at https://docs.coingecko.com/reference/asset-platforms-list") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + // Dry-run path. + if isDryRun(cmd) { + if onchain { + params := map[string]string{ + "include_market_cap": "true", + "include_24hr_vol": "true", + "include_24hr_price_change": "true", + } + note := "" + if strings.ToLower(vs) != "usd" { + note = fmt.Sprintf("Additional request: GET /exchange_rates (currency conversion from USD to %s)", vs) + } + return printDryRunFull(cfg, "contract", "--onchain", + "/onchain/simple/networks/"+network+"/token_price/"+address, + params, nil, note) + } + params := map[string]string{ + "contract_addresses": address, + "vs_currencies": vs, + "include_market_cap": "true", + "include_24hr_vol": "true", + "include_24hr_change": "true", + } + return printDryRunWithOp(cfg, "contract", "default", + "/simple/token_price/"+platform, params, nil) + } + + client := newAPIClient(cfg) + ctx := cmd.Context() + + // Shared output variables. + var price, marketCap, volume, change float64 + var displayAddr string + + if onchain { + // Onchain path. + resp, err := client.OnchainSimpleTokenPrice(ctx, network, []string{address}) + if err != nil { + return err + } + + priceStr, ok := resp.Data.Attributes.TokenPrices[address] + if !ok { + // Try case-insensitive lookup. + lowerAddr := strings.ToLower(address) + for k, v := range resp.Data.Attributes.TokenPrices { + if strings.ToLower(k) == lowerAddr { + priceStr = v + address = k // use the key as returned by API + ok = true + break + } + } + if !ok { + return fmt.Errorf("no data returned for address %s", address) + } + } + displayAddr = address + + price, err = strconv.ParseFloat(priceStr, 64) + if err != nil { + return fmt.Errorf("parsing price: %w", err) + } + + if mcStr, ok := resp.Data.Attributes.MarketCapUSD[address]; ok { + marketCap, _ = strconv.ParseFloat(mcStr, 64) + } + if volStr, ok := resp.Data.Attributes.H24VolumeUSD[address]; ok { + volume, _ = strconv.ParseFloat(volStr, 64) + } + if chgStr, ok := resp.Data.Attributes.H24PriceChangePct[address]; ok { + change, _ = strconv.ParseFloat(chgStr, 64) + } + + // Currency conversion for non-USD. + if strings.ToLower(vs) != "usd" { + rates, err := client.ExchangeRates(ctx) + if err != nil { + return fmt.Errorf("fetching exchange rates: %w", err) + } + usdRate, usdOK := rates.Rates["usd"] + targetRate, targetOK := rates.Rates[strings.ToLower(vs)] + if !usdOK || !targetOK { + return fmt.Errorf("unsupported currency %q", vs) + } + factor := targetRate.Value / usdRate.Value + price *= factor + marketCap *= factor + volume *= factor + // 24h change % stays the same + } + } else { + // Aggregated path. + resp, err := client.SimpleTokenPrice(ctx, platform, []string{address}, vs) + if err != nil { + return err + } + + data, ok := resp[address] + if !ok { + // Try case-insensitive lookup. + lowerAddr := strings.ToLower(address) + for k, v := range resp { + if strings.ToLower(k) == lowerAddr { + data = v + address = k + ok = true + break + } + } + if !ok { + return fmt.Errorf("no data returned for address %s", address) + } + } + displayAddr = address + + price = data[vs] + marketCap = data[vs+"_market_cap"] + volume = data[vs+"_24h_vol"] + change = data[vs+"_24h_change"] + } + + if jsonOut { + normalized := map[string]interface{}{ + displayAddr: map[string]interface{}{ + "price": price, + "market_cap": marketCap, + "volume_24h": volume, + "change_24h": change, + }, + } + return printJSONRaw(normalized) + } + + // Table output. + headers := []string{"Address", "Price", "Market Cap", "24h Volume", "24h Change"} + rows := [][]string{ + { + display.SanitizeCell(displayAddr), + display.FormatPrice(price, vs), + display.FormatLargeNumber(marketCap, vs), + display.FormatLargeNumber(volume, vs), + display.ColorPercent(change), + }, + } + display.PrintTable(headers, rows) + + if exportPath != "" { + csvRows := [][]string{ + { + display.SanitizeCell(displayAddr), + fmt.Sprintf("%.8f", price), + fmt.Sprintf("%.2f", marketCap), + fmt.Sprintf("%.2f", volume), + fmt.Sprintf("%.2f", change), + }, + } + if err := exportCSV(exportPath, headers, csvRows); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/contract_test.go b/cmd/contract_test.go new file mode 100644 index 0000000..4b77306 --- /dev/null +++ b/cmd/contract_test.go @@ -0,0 +1,328 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContract_MissingAddress(t *testing.T) { + _, _, err := executeCommand(t, "contract", "--platform", "ethereum", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--address is required") +} + +func TestContract_MissingPlatform(t *testing.T) { + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--platform is required") + assert.Contains(t, err.Error(), "asset-platforms-list") +} + +func TestContract_MutualExclusion(t *testing.T) { + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "--platform", "ethereum", "--onchain", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--platform and --onchain are mutually exclusive") +} + +func TestContract_OnchainMissingNetwork(t *testing.T) { + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "--onchain", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--network is required with --onchain") + assert.Contains(t, err.Error(), "networks-list") +} + +func TestContract_NetworkWithoutOnchain(t *testing.T) { + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--network requires --onchain flag") +} + +func TestContract_DryRun_Aggregated(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make HTTP call in dry-run mode") + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", "0xabc", "--platform", "ethereum", "--dry-run", "-o", "json") + require.NoError(t, err) + + var out dryRunOutput + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + assert.Equal(t, "GET", out.Method) + assert.Contains(t, out.URL, "/simple/token_price/ethereum") + assert.Equal(t, "0xabc", out.Params["contract_addresses"]) + assert.Equal(t, "usd", out.Params["vs_currencies"]) + assert.Equal(t, "true", out.Params["include_market_cap"]) + assert.Equal(t, "true", out.Params["include_24hr_vol"]) + assert.Equal(t, "true", out.Params["include_24hr_change"]) + assert.Equal(t, "simple-token-price", out.OASOperationID) +} + +func TestContract_DryRun_Onchain(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make HTTP call in dry-run mode") + }) + defer srv.Close() + withTestClientPaid(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "--onchain", "--dry-run", "-o", "json") + require.NoError(t, err) + + var out dryRunOutput + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + assert.Equal(t, "GET", out.Method) + assert.Contains(t, out.URL, "/onchain/simple/networks/eth/token_price/0xabc") + assert.Equal(t, "true", out.Params["include_market_cap"]) + assert.Equal(t, "true", out.Params["include_24hr_vol"]) + assert.Equal(t, "true", out.Params["include_24hr_price_change"]) + assert.Equal(t, "onchain-simple-price", out.OASOperationID) + assert.Empty(t, out.Note) +} + +func TestContract_DryRun_Onchain_NonUSD(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make HTTP call in dry-run mode") + }) + defer srv.Close() + withTestClientPaid(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "--onchain", "--vs", "eur", "--dry-run", "-o", "json") + require.NoError(t, err) + + var out dryRunOutput + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + assert.Contains(t, out.Note, "exchange_rates") + assert.Contains(t, out.Note, "eur") +} + +func TestContract_Aggregated_JSONOutput(t *testing.T) { + addr := "0xdac17f958d2ee523a2206206994597c13d831ec7" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/simple/token_price/ethereum", r.URL.Path) + assert.Equal(t, addr, r.URL.Query().Get("contract_addresses")) + assert.Equal(t, "usd", r.URL.Query().Get("vs_currencies")) + + resp := map[string]map[string]float64{ + addr: { + "usd": 1.001, + "usd_market_cap": 50000000000, + "usd_24h_vol": 30000000000, + "usd_24h_change": 0.05, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--platform", "ethereum", "-o", "json") + require.NoError(t, err) + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, 1.001, result[addr]["price"]) + assert.Equal(t, float64(50000000000), result[addr]["market_cap"]) + assert.Equal(t, float64(30000000000), result[addr]["volume_24h"]) + assert.Equal(t, 0.05, result[addr]["change_24h"]) +} + +func TestContract_Aggregated_TableOutput(t *testing.T) { + addr := "0xdac17f958d2ee523a2206206994597c13d831ec7" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]map[string]float64{ + addr: { + "usd": 1.001, + "usd_market_cap": 50000000000, + "usd_24h_vol": 30000000000, + "usd_24h_change": 2.5, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--platform", "ethereum") + require.NoError(t, err) + assert.Contains(t, stdout, addr) + assert.Contains(t, stdout, "Price") + assert.Contains(t, stdout, "Market Cap") + assert.Contains(t, stdout, "24h Volume") + assert.Contains(t, stdout, "24h Change") +} + +func TestContract_Aggregated_NoData(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", "0xnonexistent", "--platform", "ethereum", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "no data returned for address") +} + +func TestContract_Onchain_JSONOutput(t *testing.T) { + addr := "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.True(t, strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/eth/token_price/")) + + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "id": "123", + "type": "simple_token_price", + "attributes": map[string]interface{}{ + "token_prices": map[string]string{addr: "2289.33"}, + "market_cap_usd": map[string]string{addr: "6692452895.78"}, + "h24_volume_usd": map[string]string{addr: "965988358.73"}, + "h24_price_change_percentage": map[string]string{addr: "3.387"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientPaid(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "-o", "json") + require.NoError(t, err) + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.InDelta(t, 2289.33, result[addr]["price"], 0.01) + assert.InDelta(t, 6692452895.78, result[addr]["market_cap"], 0.01) + assert.InDelta(t, 965988358.73, result[addr]["volume_24h"], 0.01) + assert.InDelta(t, 3.387, result[addr]["change_24h"], 0.001) +} + +func TestContract_Onchain_NonUSD_Conversion(t *testing.T) { + addr := "0xaddr" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "id": "123", + "type": "simple_token_price", + "attributes": map[string]interface{}{ + "token_prices": map[string]string{addr: "2000"}, + "market_cap_usd": map[string]string{addr: "1000000"}, + "h24_volume_usd": map[string]string{addr: "500000"}, + "h24_price_change_percentage": map[string]string{addr: "5.0"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + case r.URL.Path == "/exchange_rates": + resp := map[string]interface{}{ + "rates": map[string]interface{}{ + "usd": map[string]interface{}{"name": "US Dollar", "unit": "$", "value": 67187.0, "type": "fiat"}, + "eur": map[string]interface{}{"name": "Euro", "unit": "\u20ac", "value": 62345.0, "type": "fiat"}, + }, + } + _ = json.NewEncoder(w).Encode(resp) + } + }) + defer srv.Close() + withTestClientPaid(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "--vs", "eur", "-o", "json") + require.NoError(t, err) + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + + // factor = 62345 / 67187 ≈ 0.9280 + factor := 62345.0 / 67187.0 + assert.InDelta(t, 2000*factor, result[addr]["price"], 0.01) + assert.InDelta(t, 1000000*factor, result[addr]["market_cap"], 0.01) + assert.InDelta(t, 500000*factor, result[addr]["volume_24h"], 0.01) + // 24h change should be unchanged + assert.InDelta(t, 5.0, result[addr]["change_24h"], 0.001) +} + +func TestContract_Onchain_UnsupportedCurrency(t *testing.T) { + addr := "0xaddr" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "id": "123", + "type": "simple_token_price", + "attributes": map[string]interface{}{ + "token_prices": map[string]string{addr: "2000"}, + "market_cap_usd": map[string]string{addr: "1000000"}, + "h24_volume_usd": map[string]string{addr: "500000"}, + "h24_price_change_percentage": map[string]string{addr: "5.0"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + case r.URL.Path == "/exchange_rates": + resp := map[string]interface{}{ + "rates": map[string]interface{}{ + "usd": map[string]interface{}{"name": "US Dollar", "unit": "$", "value": 67187.0, "type": "fiat"}, + }, + } + _ = json.NewEncoder(w).Encode(resp) + } + }) + defer srv.Close() + withTestClientPaid(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "--vs", "xyz", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported currency") +} + +func TestContract_Onchain_RequiresPaid(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not reach the server with a demo client") + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "--onchain", "-o", "json") + require.Error(t, err) +} + +func TestContract_Export_CSV(t *testing.T) { + addr := "0xdac17f958d2ee523a2206206994597c13d831ec7" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]map[string]float64{ + addr: { + "usd": 1.001, + "usd_market_cap": 50000000000, + "usd_24h_vol": 30000000000, + "usd_24h_change": 0.05, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + tmpDir := t.TempDir() + csvPath := filepath.Join(tmpDir, "contract.csv") + + _, _, err := executeCommand(t, "contract", "--address", addr, "--platform", "ethereum", "--export", csvPath) + require.NoError(t, err) + + data, err := os.ReadFile(csvPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "Address") + assert.Contains(t, content, "Price") + assert.Contains(t, content, "Market Cap") + assert.Contains(t, content, addr) +} diff --git a/internal/api/coins.go b/internal/api/coins.go index 445d31a..5edde24 100644 --- a/internal/api/coins.go +++ b/internal/api/coins.go @@ -24,9 +24,9 @@ func (c *Client) SimplePrice(ctx context.Context, ids []string, vsCurrency strin // https://docs.coingecko.com/v3.0.1/reference/simple-price func (c *Client) SimplePriceBySymbols(ctx context.Context, symbols []string, vsCurrency string) (PriceResponse, error) { params := url.Values{ - "symbols": {strings.Join(symbols, ",")}, - "vs_currencies": {vsCurrency}, - "include_24hr_change": {"true"}, + "symbols": {strings.Join(symbols, ",")}, + "vs_currencies": {vsCurrency}, + "include_24hr_change": {"true"}, } var result PriceResponse err := c.get(ctx, "/simple/price?"+params.Encode(), &result) @@ -193,6 +193,46 @@ func (c *Client) TopGainersLosers(ctx context.Context, vsCurrency, duration, top return &result, err } +// SimpleTokenPrice fetches current prices for tokens by contract address on a given platform. +// https://docs.coingecko.com/reference/simple-token-price +func (c *Client) SimpleTokenPrice(ctx context.Context, platform string, addresses []string, vsCurrency string) (TokenPriceResponse, error) { + params := url.Values{ + "contract_addresses": {strings.Join(addresses, ",")}, + "vs_currencies": {vsCurrency}, + "include_market_cap": {"true"}, + "include_24hr_vol": {"true"}, + "include_24hr_change": {"true"}, + } + var result TokenPriceResponse + err := c.get(ctx, fmt.Sprintf("/simple/token_price/%s?%s", url.PathEscape(platform), params.Encode()), &result) + return result, err +} + +// OnchainSimpleTokenPrice fetches DEX prices for tokens by contract address on a given network (paid plans only). +// https://docs.coingecko.com/reference/onchain-simple-price +func (c *Client) OnchainSimpleTokenPrice(ctx context.Context, network string, addresses []string) (*OnchainTokenPriceResponse, error) { + if err := c.requirePaid(); err != nil { + return nil, err + } + params := url.Values{ + "include_market_cap": {"true"}, + "include_24hr_vol": {"true"}, + "include_24hr_price_change": {"true"}, + } + var result OnchainTokenPriceResponse + err := c.get(ctx, fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s?%s", + url.PathEscape(network), strings.Join(addresses, ","), params.Encode()), &result) + return &result, err +} + +// ExchangeRates fetches BTC-based exchange rates for all supported currencies. +// https://docs.coingecko.com/reference/exchange-rates +func (c *Client) ExchangeRates(ctx context.Context) (*ExchangeRatesResponse, error) { + var result ExchangeRatesResponse + err := c.get(ctx, "/exchange_rates", &result) + return &result, err +} + // CoinDetail fetches detailed coin data (used in TUI detail view). // https://docs.coingecko.com/v3.0.1/reference/coins-id func (c *Client) CoinDetail(ctx context.Context, id string) (*CoinDetail, error) { diff --git a/internal/api/coins_test.go b/internal/api/coins_test.go index 39fc3fb..544f498 100644 --- a/internal/api/coins_test.go +++ b/internal/api/coins_test.go @@ -514,6 +514,128 @@ func TestCoinDetail_PathEscaping(t *testing.T) { assert.Equal(t, "coin/with-slash", result.ID) } +// --------------------------------------------------------------------------- +// SimpleTokenPrice +// --------------------------------------------------------------------------- + +func TestSimpleTokenPrice_Success(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/simple/token_price/ethereum", r.URL.Path) + q := r.URL.Query() + assert.Equal(t, "0xdac17f958d2ee523a2206206994597c13d831ec7", q.Get("contract_addresses")) + assert.Equal(t, "usd", q.Get("vs_currencies")) + assert.Equal(t, "true", q.Get("include_market_cap")) + assert.Equal(t, "true", q.Get("include_24hr_vol")) + assert.Equal(t, "true", q.Get("include_24hr_change")) + + w.WriteHeader(200) + _, _ = w.Write([]byte(`{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": { + "usd": 1.001, + "usd_market_cap": 50000000000, + "usd_24h_vol": 30000000000, + "usd_24h_change": 0.05 + } + }`)) + }) + defer srv.Close() + + result, err := c.SimpleTokenPrice(context.Background(), "ethereum", []string{"0xdac17f958d2ee523a2206206994597c13d831ec7"}, "usd") + require.NoError(t, err) + addr := "0xdac17f958d2ee523a2206206994597c13d831ec7" + assert.Equal(t, 1.001, result[addr]["usd"]) + assert.Equal(t, float64(50000000000), result[addr]["usd_market_cap"]) + assert.Equal(t, float64(30000000000), result[addr]["usd_24h_vol"]) + assert.Equal(t, 0.05, result[addr]["usd_24h_change"]) +} + +func TestSimpleTokenPrice_PathEncoding(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/simple/token_price/binance-smart-chain", r.URL.Path) + _, _ = w.Write([]byte(`{}`)) + }) + defer srv.Close() + + _, err := c.SimpleTokenPrice(context.Background(), "binance-smart-chain", []string{"0xaddr"}, "usd") + require.NoError(t, err) +} + +// --------------------------------------------------------------------------- +// OnchainSimpleTokenPrice +// --------------------------------------------------------------------------- + +func TestOnchainSimpleTokenPrice_Success(t *testing.T) { + c, srv := testPaidClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/onchain/simple/networks/eth/token_price/0xaddr", r.URL.Path) + q := r.URL.Query() + assert.Equal(t, "true", q.Get("include_market_cap")) + assert.Equal(t, "true", q.Get("include_24hr_vol")) + assert.Equal(t, "true", q.Get("include_24hr_price_change")) + + w.WriteHeader(200) + _, _ = w.Write([]byte(`{ + "data": { + "id": "123", + "type": "simple_token_price", + "attributes": { + "token_prices": {"0xaddr": "2289.33"}, + "market_cap_usd": {"0xaddr": "6692452895.779648"}, + "h24_volume_usd": {"0xaddr": "965988358.733808"}, + "h24_price_change_percentage": {"0xaddr": "3.387"} + } + } + }`)) + }) + defer srv.Close() + + result, err := c.OnchainSimpleTokenPrice(context.Background(), "eth", []string{"0xaddr"}) + require.NoError(t, err) + assert.Equal(t, "2289.33", result.Data.Attributes.TokenPrices["0xaddr"]) + assert.Equal(t, "6692452895.779648", result.Data.Attributes.MarketCapUSD["0xaddr"]) + assert.Equal(t, "965988358.733808", result.Data.Attributes.H24VolumeUSD["0xaddr"]) + assert.Equal(t, "3.387", result.Data.Attributes.H24PriceChangePct["0xaddr"]) +} + +func TestOnchainSimpleTokenPrice_RequiresPaid(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not reach the server with a demo client") + }) + defer srv.Close() + + _, err := c.OnchainSimpleTokenPrice(context.Background(), "eth", []string{"0xaddr"}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrPlanRestricted) +} + +// --------------------------------------------------------------------------- +// ExchangeRates +// --------------------------------------------------------------------------- + +func TestExchangeRates_Success(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/exchange_rates", r.URL.Path) + + w.WriteHeader(200) + _, _ = w.Write([]byte(`{ + "rates": { + "btc": {"name": "Bitcoin", "unit": "BTC", "value": 1.0, "type": "crypto"}, + "usd": {"name": "US Dollar", "unit": "$", "value": 67187.0, "type": "fiat"}, + "eur": {"name": "Euro", "unit": "\u20ac", "value": 62345.0, "type": "fiat"} + } + }`)) + }) + defer srv.Close() + + result, err := c.ExchangeRates(context.Background()) + require.NoError(t, err) + assert.Equal(t, float64(1), result.Rates["btc"].Value) + assert.Equal(t, float64(67187), result.Rates["usd"].Value) + assert.Equal(t, "US Dollar", result.Rates["usd"].Name) + assert.Equal(t, "$", result.Rates["usd"].Unit) + assert.Equal(t, "fiat", result.Rates["usd"].Type) + assert.Equal(t, float64(62345), result.Rates["eur"].Value) +} + // --------------------------------------------------------------------------- // FetchAllMarkets (existing tests kept below) // --------------------------------------------------------------------------- diff --git a/internal/api/types.go b/internal/api/types.go index ce9d509..05b8bc4 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -47,17 +47,17 @@ type TrendingCoinWrapper struct { } type TrendingCoin struct { - ID string `json:"id"` - Name string `json:"name"` - Symbol string `json:"symbol"` - MarketCapRank int `json:"market_cap_rank"` - Score int `json:"score"` - Data *TrendingCoinData `json:"data"` + ID string `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + MarketCapRank int `json:"market_cap_rank"` + Score int `json:"score"` + Data *TrendingCoinData `json:"data"` } type TrendingCoinData struct { - Price float64 `json:"price"` - PriceChangePercentage24h map[string]float64 `json:"price_change_percentage_24h"` + Price float64 `json:"price"` + PriceChangePercentage24h map[string]float64 `json:"price_change_percentage_24h"` } type TrendingNFT struct { @@ -158,6 +158,41 @@ func (g GainerCoin) MarshalJSON() ([]byte, error) { return json.Marshal(g.Extra) } +// TokenPriceResponse: map[contractAddress]map[field]value +// Fields: {vs} (price), {vs}_market_cap, {vs}_24h_vol, {vs}_24h_change, last_updated_at +// https://docs.coingecko.com/reference/simple-token-price +type TokenPriceResponse map[string]map[string]float64 + +// OnchainTokenPriceResponse is the GeckoTerminal simple token price response. +// All numeric values are strings; callers must use strconv.ParseFloat. +// https://docs.coingecko.com/reference/onchain-simple-price +type OnchainTokenPriceResponse struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + TokenPrices map[string]string `json:"token_prices"` + MarketCapUSD map[string]string `json:"market_cap_usd"` + H24VolumeUSD map[string]string `json:"h24_volume_usd"` + H24PriceChangePct map[string]string `json:"h24_price_change_percentage"` + } `json:"attributes"` + } `json:"data"` +} + +// ExchangeRatesResponse contains BTC-based exchange rates for all supported currencies. +// https://docs.coingecko.com/reference/exchange-rates +type ExchangeRatesResponse struct { + Rates map[string]ExchangeRate `json:"rates"` +} + +// ExchangeRate represents a single currency rate relative to BTC. +type ExchangeRate struct { + Name string `json:"name"` + Unit string `json:"unit"` + Value float64 `json:"value"` + Type string `json:"type"` +} + type CoinDetail struct { ID string `json:"id"` Symbol string `json:"symbol"` From bd5ad2360f30953753d1099da739aebef5653735 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sat, 21 Mar 2026 20:03:36 +0800 Subject: [PATCH 02/11] fix: encode per-mode paid/spec metadata for contract --onchain Address three review issues with the --onchain variant: 1. Catalog metadata: add per-mode `paid_modes` and `oas_specs` maps to the command annotation and catalog output so agents/LLMs know that --onchain requires a paid plan and uses coingecko-pro.json spec. 2. Dry-run output: --onchain --dry-run now always emits a pro-tier request (pro base URL, pro auth header, coingecko-pro.json spec) regardless of the user's actual tier. Demo users get a note: "Paid plan required for --onchain". 3. Flag help: --onchain description now says "(paid plan required)" so the restriction is visible in --help and the flag catalog. --- cmd/commands.go | 18 +++++++++++-- cmd/contract.go | 58 ++++++++++++++++-------------------------- cmd/contract_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++ cmd/dryrun.go | 7 +++++- 4 files changed, 104 insertions(+), 39 deletions(-) diff --git a/cmd/commands.go b/cmd/commands.go index cbc76e5..059cda2 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -16,8 +16,10 @@ type commandAnnotation struct { OASOperationID string OASOperationIDs map[string]string OASSpec string - Transport string // "rest" (default) or "websocket" + OASSpecs map[string]string // Per-mode OAS spec overrides (e.g. "--onchain" → "coingecko-pro.json") + Transport string // "rest" (default) or "websocket" PaidOnly bool + PaidModes map[string]bool // Per-mode paid-only flags (e.g. "--onchain" → true) RequiresAuth bool } @@ -86,7 +88,13 @@ var commandMeta = map[string]commandAnnotation{ "default": "simple-token-price", "--onchain": "onchain-simple-price", }, - OASSpec: "coingecko-demo.json", + OASSpecs: map[string]string{ + "default": "coingecko-demo.json", + "--onchain": "coingecko-pro.json", + }, + PaidModes: map[string]bool{ + "--onchain": true, + }, RequiresAuth: true, }, } @@ -146,11 +154,14 @@ type commandInfo struct { OutputFormats []string `json:"output_formats"` RequiresAuth bool `json:"requires_auth"` PaidOnly bool `json:"paid_only"` + PaidModes map[string]bool `json:"paid_modes,omitempty"` Transport string `json:"transport,omitempty"` APIEndpoint string `json:"api_endpoint,omitempty"` APIEndpoints map[string]string `json:"api_endpoints,omitempty"` OASOperationID string `json:"oas_operation_id,omitempty"` OASOperationIDs map[string]string `json:"oas_operation_ids,omitempty"` + OASSpec string `json:"oas_spec,omitempty"` + OASSpecs map[string]string `json:"oas_specs,omitempty"` } type commandCatalog struct { @@ -226,12 +237,15 @@ func runCommands(cmd *cobra.Command, args []string) error { if meta, ok := commandMeta[c.Name()]; ok { info.PaidOnly = meta.PaidOnly + info.PaidModes = meta.PaidModes info.RequiresAuth = meta.RequiresAuth info.Transport = meta.Transport info.APIEndpoint = meta.APIEndpoint info.APIEndpoints = meta.APIEndpoints info.OASOperationID = meta.OASOperationID info.OASOperationIDs = meta.OASOperationIDs + info.OASSpec = meta.OASSpec + info.OASSpecs = meta.OASSpecs } catalog.Commands = append(catalog.Commands, info) diff --git a/cmd/contract.go b/cmd/contract.go index 9d2f6bf..7089da7 100644 --- a/cmd/contract.go +++ b/cmd/contract.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net/url" "strconv" "strings" @@ -32,7 +33,7 @@ func init() { contractCmd.Flags().String("address", "", "Contract address (required)") contractCmd.Flags().String("platform", "", "Platform ID for aggregated mode (e.g. ethereum). See https://docs.coingecko.com/reference/asset-platforms-list") contractCmd.Flags().String("network", "", "Network ID for onchain mode (e.g. eth). See https://docs.coingecko.com/reference/networks-list") - contractCmd.Flags().Bool("onchain", false, "Use DEX price from GeckoTerminal") + contractCmd.Flags().Bool("onchain", false, "Use DEX price from GeckoTerminal (paid plan required)") contractCmd.Flags().String("vs", "usd", "Target currency") contractCmd.Flags().String("export", "", "Export to CSV file path") rootCmd.AddCommand(contractCmd) @@ -44,6 +45,7 @@ func runContract(cmd *cobra.Command, args []string) error { network, _ := cmd.Flags().GetString("network") onchain, _ := cmd.Flags().GetBool("onchain") vs, _ := cmd.Flags().GetString("vs") + vs = strings.ToLower(vs) exportPath, _ := cmd.Flags().GetString("export") jsonOut := outputJSON(cmd) @@ -81,13 +83,21 @@ func runContract(cmd *cobra.Command, args []string) error { "include_24hr_vol": "true", "include_24hr_price_change": "true", } - note := "" - if strings.ToLower(vs) != "usd" { - note = fmt.Sprintf("Additional request: GET /exchange_rates (currency conversion from USD to %s)", vs) + var notes []string + // Onchain endpoint is paid-only; always show pro-tier request in dry-run. + dryCfg := cfg + if !cfg.IsPaid() { + paidCfg := *cfg + paidCfg.Tier = "paid" + dryCfg = &paidCfg + notes = append(notes, "Paid plan required for --onchain") } - return printDryRunFull(cfg, "contract", "--onchain", - "/onchain/simple/networks/"+network+"/token_price/"+address, - params, nil, note) + if vs != "usd" { + notes = append(notes, fmt.Sprintf("Additional request: GET /exchange_rates (currency conversion from USD to %s)", vs)) + } + return printDryRunFull(dryCfg, "contract", "--onchain", + fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s", url.PathEscape(network), address), + params, nil, strings.Join(notes, "; ")) } params := map[string]string{ "contract_addresses": address, @@ -97,7 +107,7 @@ func runContract(cmd *cobra.Command, args []string) error { "include_24hr_change": "true", } return printDryRunWithOp(cfg, "contract", "default", - "/simple/token_price/"+platform, params, nil) + "/simple/token_price/"+url.PathEscape(platform), params, nil) } client := newAPIClient(cfg) @@ -116,19 +126,7 @@ func runContract(cmd *cobra.Command, args []string) error { priceStr, ok := resp.Data.Attributes.TokenPrices[address] if !ok { - // Try case-insensitive lookup. - lowerAddr := strings.ToLower(address) - for k, v := range resp.Data.Attributes.TokenPrices { - if strings.ToLower(k) == lowerAddr { - priceStr = v - address = k // use the key as returned by API - ok = true - break - } - } - if !ok { - return fmt.Errorf("no data returned for address %s", address) - } + return fmt.Errorf("no data returned for address %s", address) } displayAddr = address @@ -148,13 +146,13 @@ func runContract(cmd *cobra.Command, args []string) error { } // Currency conversion for non-USD. - if strings.ToLower(vs) != "usd" { + if vs != "usd" { rates, err := client.ExchangeRates(ctx) if err != nil { return fmt.Errorf("fetching exchange rates: %w", err) } usdRate, usdOK := rates.Rates["usd"] - targetRate, targetOK := rates.Rates[strings.ToLower(vs)] + targetRate, targetOK := rates.Rates[vs] if !usdOK || !targetOK { return fmt.Errorf("unsupported currency %q", vs) } @@ -173,19 +171,7 @@ func runContract(cmd *cobra.Command, args []string) error { data, ok := resp[address] if !ok { - // Try case-insensitive lookup. - lowerAddr := strings.ToLower(address) - for k, v := range resp { - if strings.ToLower(k) == lowerAddr { - data = v - address = k - ok = true - break - } - } - if !ok { - return fmt.Errorf("no data returned for address %s", address) - } + return fmt.Errorf("no data returned for address %s", address) } displayAddr = address diff --git a/cmd/contract_test.go b/cmd/contract_test.go index 4b77306..155b979 100644 --- a/cmd/contract_test.go +++ b/cmd/contract_test.go @@ -64,6 +64,7 @@ func TestContract_DryRun_Aggregated(t *testing.T) { assert.Equal(t, "true", out.Params["include_24hr_vol"]) assert.Equal(t, "true", out.Params["include_24hr_change"]) assert.Equal(t, "simple-token-price", out.OASOperationID) + assert.Equal(t, "coingecko-demo.json", out.OASSpec) } func TestContract_DryRun_Onchain(t *testing.T) { @@ -80,13 +81,37 @@ func TestContract_DryRun_Onchain(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(stdout), &out)) assert.Equal(t, "GET", out.Method) assert.Contains(t, out.URL, "/onchain/simple/networks/eth/token_price/0xabc") + assert.Contains(t, out.URL, "pro-api.coingecko.com") assert.Equal(t, "true", out.Params["include_market_cap"]) assert.Equal(t, "true", out.Params["include_24hr_vol"]) assert.Equal(t, "true", out.Params["include_24hr_price_change"]) assert.Equal(t, "onchain-simple-price", out.OASOperationID) + assert.Equal(t, "coingecko-pro.json", out.OASSpec) assert.Empty(t, out.Note) } +func TestContract_DryRun_Onchain_DemoTier(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make HTTP call in dry-run mode") + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "--onchain", "--dry-run", "-o", "json") + require.NoError(t, err) + + var out dryRunOutput + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + // Even with demo config, onchain dry-run must show pro-tier URL and spec. + assert.Contains(t, out.URL, "pro-api.coingecko.com") + assert.Contains(t, out.URL, "/onchain/simple/networks/eth/token_price/0xabc") + assert.Equal(t, "coingecko-pro.json", out.OASSpec) + assert.Equal(t, "onchain-simple-price", out.OASOperationID) + assert.Contains(t, out.Note, "Paid plan required") + // Auth header should reflect pro-tier. + assert.NotEmpty(t, out.Headers["x-cg-pro-api-key"]) +} + func TestContract_DryRun_Onchain_NonUSD(t *testing.T) { srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not make HTTP call in dry-run mode") @@ -296,6 +321,41 @@ func TestContract_Onchain_RequiresPaid(t *testing.T) { require.Error(t, err) } +func TestContract_CatalogMetadata(t *testing.T) { + stdout, _, err := executeCommand(t, "commands") + require.NoError(t, err) + + var catalog commandCatalog + require.NoError(t, json.Unmarshal([]byte(stdout), &catalog)) + + var contractInfo *commandInfo + for i := range catalog.Commands { + if catalog.Commands[i].Name == "contract" { + contractInfo = &catalog.Commands[i] + break + } + } + require.NotNil(t, contractInfo, "contract command not in catalog") + + // Per-mode OAS specs should be present. + assert.Equal(t, "coingecko-demo.json", contractInfo.OASSpecs["default"]) + assert.Equal(t, "coingecko-pro.json", contractInfo.OASSpecs["--onchain"]) + + // Per-mode paid flags: --onchain is paid-only. + assert.True(t, contractInfo.PaidModes["--onchain"]) + + // Command-level paid_only is false (default mode is free). + assert.False(t, contractInfo.PaidOnly) + + // Flag description for --onchain includes paid-only note. + for _, f := range contractInfo.Flags { + if f.Name == "onchain" { + assert.Contains(t, f.Description, "paid plan required") + break + } + } +} + func TestContract_Export_CSV(t *testing.T) { addr := "0xdac17f958d2ee523a2206206994597c13d831ec7" srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/dryrun.go b/cmd/dryrun.go index 236760b..71b46d9 100644 --- a/cmd/dryrun.go +++ b/cmd/dryrun.go @@ -93,7 +93,12 @@ func printDryRunFull(cfg *config.Config, cmdName, opKey, endpoint string, params } if meta, ok := commandMeta[cmdName]; ok { - out.OASSpec = meta.OASSpec + // Look up per-mode spec first, fall back to command-level. + if opKey != "" && meta.OASSpecs != nil { + out.OASSpec = meta.OASSpecs[opKey] + } else { + out.OASSpec = meta.OASSpec + } if opKey != "" && meta.OASOperationIDs != nil { out.OASOperationID = meta.OASOperationIDs[opKey] } else { From 7244b517fc0320a4413fc8eb5525758785d0c822 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sat, 21 Mar 2026 21:02:21 +0800 Subject: [PATCH 03/11] fix: remove stale paid-only restriction from onchain endpoint The onchain-simple-price endpoint is available on both demo and paid tiers per CoinGecko docs (demo: api.coingecko.com, pro: pro-api). The previous implementation incorrectly treated it as paid-only. Changes: - Remove requirePaid() guard from OnchainSimpleTokenPrice - Remove forced pro-tier config override in onchain dry-run - Remove "(paid plan required)" from --onchain flag description - Remove PaidModes/OASSpecs per-mode metadata from contract entry (both modes work on demo tier, single OASSpec suffices) - Replace TestOnchainSimpleTokenPrice_RequiresPaid with _DemoTier test that verifies demo clients can call the endpoint - Replace TestContract_Onchain_RequiresPaid with _DemoTier test - Update dry-run and catalog tests to reflect demo-compatible behavior --- cmd/commands.go | 8 +--- cmd/contract.go | 18 +++------ cmd/contract_test.go | 76 ++++++++++++++++---------------------- internal/api/coins.go | 9 ++--- internal/api/coins_test.go | 12 +++--- 5 files changed, 49 insertions(+), 74 deletions(-) diff --git a/cmd/commands.go b/cmd/commands.go index 059cda2..a67f709 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -88,13 +88,7 @@ var commandMeta = map[string]commandAnnotation{ "default": "simple-token-price", "--onchain": "onchain-simple-price", }, - OASSpecs: map[string]string{ - "default": "coingecko-demo.json", - "--onchain": "coingecko-pro.json", - }, - PaidModes: map[string]bool{ - "--onchain": true, - }, + OASSpec: "coingecko-demo.json", RequiresAuth: true, }, } diff --git a/cmd/contract.go b/cmd/contract.go index 7089da7..d302b46 100644 --- a/cmd/contract.go +++ b/cmd/contract.go @@ -33,7 +33,7 @@ func init() { contractCmd.Flags().String("address", "", "Contract address (required)") contractCmd.Flags().String("platform", "", "Platform ID for aggregated mode (e.g. ethereum). See https://docs.coingecko.com/reference/asset-platforms-list") contractCmd.Flags().String("network", "", "Network ID for onchain mode (e.g. eth). See https://docs.coingecko.com/reference/networks-list") - contractCmd.Flags().Bool("onchain", false, "Use DEX price from GeckoTerminal (paid plan required)") + contractCmd.Flags().Bool("onchain", false, "Use DEX price from GeckoTerminal") contractCmd.Flags().String("vs", "usd", "Target currency") contractCmd.Flags().String("export", "", "Export to CSV file path") rootCmd.AddCommand(contractCmd) @@ -83,21 +83,13 @@ func runContract(cmd *cobra.Command, args []string) error { "include_24hr_vol": "true", "include_24hr_price_change": "true", } - var notes []string - // Onchain endpoint is paid-only; always show pro-tier request in dry-run. - dryCfg := cfg - if !cfg.IsPaid() { - paidCfg := *cfg - paidCfg.Tier = "paid" - dryCfg = &paidCfg - notes = append(notes, "Paid plan required for --onchain") - } + note := "" if vs != "usd" { - notes = append(notes, fmt.Sprintf("Additional request: GET /exchange_rates (currency conversion from USD to %s)", vs)) + note = fmt.Sprintf("Additional request: GET /exchange_rates (currency conversion from USD to %s)", vs) } - return printDryRunFull(dryCfg, "contract", "--onchain", + return printDryRunFull(cfg, "contract", "--onchain", fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s", url.PathEscape(network), address), - params, nil, strings.Join(notes, "; ")) + params, nil, note) } params := map[string]string{ "contract_addresses": address, diff --git a/cmd/contract_test.go b/cmd/contract_test.go index 155b979..35e9a0d 100644 --- a/cmd/contract_test.go +++ b/cmd/contract_test.go @@ -72,7 +72,7 @@ func TestContract_DryRun_Onchain(t *testing.T) { t.Fatal("should not make HTTP call in dry-run mode") }) defer srv.Close() - withTestClientPaid(t, srv) + withTestClientDemo(t, srv) stdout, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "--onchain", "--dry-run", "-o", "json") require.NoError(t, err) @@ -81,37 +81,14 @@ func TestContract_DryRun_Onchain(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(stdout), &out)) assert.Equal(t, "GET", out.Method) assert.Contains(t, out.URL, "/onchain/simple/networks/eth/token_price/0xabc") - assert.Contains(t, out.URL, "pro-api.coingecko.com") assert.Equal(t, "true", out.Params["include_market_cap"]) assert.Equal(t, "true", out.Params["include_24hr_vol"]) assert.Equal(t, "true", out.Params["include_24hr_price_change"]) assert.Equal(t, "onchain-simple-price", out.OASOperationID) - assert.Equal(t, "coingecko-pro.json", out.OASSpec) + assert.Equal(t, "coingecko-demo.json", out.OASSpec) assert.Empty(t, out.Note) } -func TestContract_DryRun_Onchain_DemoTier(t *testing.T) { - srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("should not make HTTP call in dry-run mode") - }) - defer srv.Close() - withTestClientDemo(t, srv) - - stdout, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "--onchain", "--dry-run", "-o", "json") - require.NoError(t, err) - - var out dryRunOutput - require.NoError(t, json.Unmarshal([]byte(stdout), &out)) - // Even with demo config, onchain dry-run must show pro-tier URL and spec. - assert.Contains(t, out.URL, "pro-api.coingecko.com") - assert.Contains(t, out.URL, "/onchain/simple/networks/eth/token_price/0xabc") - assert.Equal(t, "coingecko-pro.json", out.OASSpec) - assert.Equal(t, "onchain-simple-price", out.OASOperationID) - assert.Contains(t, out.Note, "Paid plan required") - // Auth header should reflect pro-tier. - assert.NotEmpty(t, out.Headers["x-cg-pro-api-key"]) -} - func TestContract_DryRun_Onchain_NonUSD(t *testing.T) { srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not make HTTP call in dry-run mode") @@ -216,7 +193,7 @@ func TestContract_Onchain_JSONOutput(t *testing.T) { _ = json.NewEncoder(w).Encode(resp) }) defer srv.Close() - withTestClientPaid(t, srv) + withTestClientDemo(t, srv) stdout, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "-o", "json") require.NoError(t, err) @@ -310,15 +287,33 @@ func TestContract_Onchain_UnsupportedCurrency(t *testing.T) { assert.Contains(t, err.Error(), "unsupported currency") } -func TestContract_Onchain_RequiresPaid(t *testing.T) { +func TestContract_Onchain_DemoTier(t *testing.T) { + addr := "0xabc" srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("should not reach the server with a demo client") + assert.Contains(t, r.URL.Path, "/onchain/simple/networks/eth/token_price/") + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "id": "1", + "type": "simple_token_price", + "attributes": map[string]interface{}{ + "token_prices": map[string]string{addr: "100.5"}, + "market_cap_usd": map[string]string{addr: "500000"}, + "h24_volume_usd": map[string]string{addr: "250000"}, + "h24_price_change_percentage": map[string]string{addr: "2.5"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) }) defer srv.Close() withTestClientDemo(t, srv) - _, _, err := executeCommand(t, "contract", "--address", "0xabc", "--network", "eth", "--onchain", "-o", "json") - require.Error(t, err) + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "-o", "json") + require.NoError(t, err) + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.InDelta(t, 100.5, result[addr]["price"], 0.01) } func TestContract_CatalogMetadata(t *testing.T) { @@ -337,23 +332,16 @@ func TestContract_CatalogMetadata(t *testing.T) { } require.NotNil(t, contractInfo, "contract command not in catalog") - // Per-mode OAS specs should be present. - assert.Equal(t, "coingecko-demo.json", contractInfo.OASSpecs["default"]) - assert.Equal(t, "coingecko-pro.json", contractInfo.OASSpecs["--onchain"]) + // OAS spec should be present (both modes use demo-compatible endpoints). + assert.Equal(t, "coingecko-demo.json", contractInfo.OASSpec) - // Per-mode paid flags: --onchain is paid-only. - assert.True(t, contractInfo.PaidModes["--onchain"]) - - // Command-level paid_only is false (default mode is free). + // Not paid-only — onchain endpoint is available on both demo and paid tiers. assert.False(t, contractInfo.PaidOnly) + assert.Empty(t, contractInfo.PaidModes) - // Flag description for --onchain includes paid-only note. - for _, f := range contractInfo.Flags { - if f.Name == "onchain" { - assert.Contains(t, f.Description, "paid plan required") - break - } - } + // Both operation IDs should be present. + assert.Equal(t, "simple-token-price", contractInfo.OASOperationIDs["default"]) + assert.Equal(t, "onchain-simple-price", contractInfo.OASOperationIDs["--onchain"]) } func TestContract_Export_CSV(t *testing.T) { diff --git a/internal/api/coins.go b/internal/api/coins.go index 5edde24..fc3be85 100644 --- a/internal/api/coins.go +++ b/internal/api/coins.go @@ -208,12 +208,11 @@ func (c *Client) SimpleTokenPrice(ctx context.Context, platform string, addresse return result, err } -// OnchainSimpleTokenPrice fetches DEX prices for tokens by contract address on a given network (paid plans only). -// https://docs.coingecko.com/reference/onchain-simple-price +// OnchainSimpleTokenPrice fetches DEX prices for tokens by contract address on a given network. +// Available on both demo and paid plans (demo: api.coingecko.com, paid: pro-api.coingecko.com). +// https://docs.coingecko.com/v3.0.1/reference/onchain-simple-price (demo) +// https://docs.coingecko.com/reference/onchain-simple-price (pro) func (c *Client) OnchainSimpleTokenPrice(ctx context.Context, network string, addresses []string) (*OnchainTokenPriceResponse, error) { - if err := c.requirePaid(); err != nil { - return nil, err - } params := url.Values{ "include_market_cap": {"true"}, "include_24hr_vol": {"true"}, diff --git a/internal/api/coins_test.go b/internal/api/coins_test.go index 544f498..037303e 100644 --- a/internal/api/coins_test.go +++ b/internal/api/coins_test.go @@ -596,15 +596,17 @@ func TestOnchainSimpleTokenPrice_Success(t *testing.T) { assert.Equal(t, "3.387", result.Data.Attributes.H24PriceChangePct["0xaddr"]) } -func TestOnchainSimpleTokenPrice_RequiresPaid(t *testing.T) { +func TestOnchainSimpleTokenPrice_DemoTier(t *testing.T) { c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("should not reach the server with a demo client") + assert.Contains(t, r.URL.Path, "/onchain/simple/networks/eth/token_price/0xaddr") + resp := `{"data":{"id":"1","type":"simple_token_price","attributes":{"token_prices":{"0xaddr":"100.5"},"market_cap_usd":{},"h24_volume_usd":{},"h24_price_change_percentage":{}}}}` + _, _ = w.Write([]byte(resp)) }) defer srv.Close() - _, err := c.OnchainSimpleTokenPrice(context.Background(), "eth", []string{"0xaddr"}) - require.Error(t, err) - assert.ErrorIs(t, err, ErrPlanRestricted) + result, err := c.OnchainSimpleTokenPrice(context.Background(), "eth", []string{"0xaddr"}) + require.NoError(t, err) + assert.Equal(t, "100.5", result.Data.Attributes.TokenPrices["0xaddr"]) } // --------------------------------------------------------------------------- From f923ff4a83e9f8ddb76a3ff18fb8a56bfb982a2f Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Tue, 24 Mar 2026 12:08:32 +0800 Subject: [PATCH 04/11] refactor: clean up contract command and harden dry-run fallback logic - Remove redundant displayAddr variable and inline comments in contract.go - Improve printDryRunFull per-mode override logic: set command-level defaults first, then override only when the opKey exists in the per-mode maps (proper fallback for missing keys) - Add captureStdout test helper for capturing stdout in unit tests - Add TestDryRun_PartialOverrideFallback to verify fallback behavior when a command defines per-mode maps but the requested opKey is absent --- cmd/contract.go | 14 ++----- cmd/dryrun.go | 24 ++++++----- cmd/dryrun_test.go | 86 ++++++++++++++++++++++++++++++++++++++++ cmd/test_helpers_test.go | 16 ++++++++ 4 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 cmd/dryrun_test.go diff --git a/cmd/contract.go b/cmd/contract.go index d302b46..aef352e 100644 --- a/cmd/contract.go +++ b/cmd/contract.go @@ -75,7 +75,6 @@ func runContract(cmd *cobra.Command, args []string) error { return err } - // Dry-run path. if isDryRun(cmd) { if onchain { params := map[string]string{ @@ -105,12 +104,9 @@ func runContract(cmd *cobra.Command, args []string) error { client := newAPIClient(cfg) ctx := cmd.Context() - // Shared output variables. var price, marketCap, volume, change float64 - var displayAddr string if onchain { - // Onchain path. resp, err := client.OnchainSimpleTokenPrice(ctx, network, []string{address}) if err != nil { return err @@ -120,7 +116,6 @@ func runContract(cmd *cobra.Command, args []string) error { if !ok { return fmt.Errorf("no data returned for address %s", address) } - displayAddr = address price, err = strconv.ParseFloat(priceStr, 64) if err != nil { @@ -155,7 +150,6 @@ func runContract(cmd *cobra.Command, args []string) error { // 24h change % stays the same } } else { - // Aggregated path. resp, err := client.SimpleTokenPrice(ctx, platform, []string{address}, vs) if err != nil { return err @@ -165,7 +159,6 @@ func runContract(cmd *cobra.Command, args []string) error { if !ok { return fmt.Errorf("no data returned for address %s", address) } - displayAddr = address price = data[vs] marketCap = data[vs+"_market_cap"] @@ -175,7 +168,7 @@ func runContract(cmd *cobra.Command, args []string) error { if jsonOut { normalized := map[string]interface{}{ - displayAddr: map[string]interface{}{ + address: map[string]interface{}{ "price": price, "market_cap": marketCap, "volume_24h": volume, @@ -185,11 +178,10 @@ func runContract(cmd *cobra.Command, args []string) error { return printJSONRaw(normalized) } - // Table output. headers := []string{"Address", "Price", "Market Cap", "24h Volume", "24h Change"} rows := [][]string{ { - display.SanitizeCell(displayAddr), + display.SanitizeCell(address), display.FormatPrice(price, vs), display.FormatLargeNumber(marketCap, vs), display.FormatLargeNumber(volume, vs), @@ -201,7 +193,7 @@ func runContract(cmd *cobra.Command, args []string) error { if exportPath != "" { csvRows := [][]string{ { - display.SanitizeCell(displayAddr), + display.SanitizeCell(address), fmt.Sprintf("%.8f", price), fmt.Sprintf("%.2f", marketCap), fmt.Sprintf("%.2f", volume), diff --git a/cmd/dryrun.go b/cmd/dryrun.go index 71b46d9..c9fd606 100644 --- a/cmd/dryrun.go +++ b/cmd/dryrun.go @@ -93,16 +93,20 @@ func printDryRunFull(cfg *config.Config, cmdName, opKey, endpoint string, params } if meta, ok := commandMeta[cmdName]; ok { - // Look up per-mode spec first, fall back to command-level. - if opKey != "" && meta.OASSpecs != nil { - out.OASSpec = meta.OASSpecs[opKey] - } else { - out.OASSpec = meta.OASSpec - } - if opKey != "" && meta.OASOperationIDs != nil { - out.OASOperationID = meta.OASOperationIDs[opKey] - } else { - out.OASOperationID = meta.OASOperationID + // Set command-level defaults, then override with per-mode values if provided. + out.OASSpec = meta.OASSpec + out.OASOperationID = meta.OASOperationID + if opKey != "" { + if meta.OASSpecs != nil { + if v, ok := meta.OASSpecs[opKey]; ok { + out.OASSpec = v + } + } + if meta.OASOperationIDs != nil { + if v, ok := meta.OASOperationIDs[opKey]; ok { + out.OASOperationID = v + } + } } } diff --git a/cmd/dryrun_test.go b/cmd/dryrun_test.go new file mode 100644 index 0000000..1022bd1 --- /dev/null +++ b/cmd/dryrun_test.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDryRun_PartialOverrideFallback verifies that when a command defines +// per-mode OASSpecs or OASOperationIDs maps but the requested opKey is NOT +// in those maps, the output falls back to the command-level defaults instead +// of emitting empty strings. +func TestDryRun_PartialOverrideFallback(t *testing.T) { + // Inject a synthetic command with partial override maps: + // OASSpecs has "--modeA" but NOT "--modeB". + // OASOperationIDs has "--modeA" but NOT "--modeB". + const cmdName = "_test_partial_override" + commandMeta[cmdName] = commandAnnotation{ + OASSpec: "default-spec.json", + OASOperationID: "default-op", + OASSpecs: map[string]string{ + "--modeA": "modeA-spec.json", + // "--modeB" deliberately absent + }, + OASOperationIDs: map[string]string{ + "--modeA": "modeA-op", + // "--modeB" deliberately absent + }, + RequiresAuth: true, + } + t.Cleanup(func() { delete(commandMeta, cmdName) }) + + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("dry-run should not make HTTP calls") + }) + defer srv.Close() + withTestClientDemo(t, srv) + + tests := []struct { + name string + opKey string + wantSpec string + wantOperationID string + }{ + { + name: "opKey present in override maps", + opKey: "--modeA", + wantSpec: "modeA-spec.json", + wantOperationID: "modeA-op", + }, + { + name: "opKey missing from override maps falls back to defaults", + opKey: "--modeB", + wantSpec: "default-spec.json", + wantOperationID: "default-op", + }, + { + name: "empty opKey uses defaults", + opKey: "", + wantSpec: "default-spec.json", + wantOperationID: "default-op", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Call printDryRunFull directly by capturing its JSON output via stdout. + // We use executeCommand with a helper command that calls printDryRunFull. + // Instead, since printDryRunFull writes to stdout, capture it directly. + stdout := captureStdout(t, func() { + cfg, err := loadConfig() + require.NoError(t, err) + err = printDryRunFull(cfg, cmdName, tt.opKey, "/test/endpoint", nil, nil, "") + require.NoError(t, err) + }) + + var out dryRunOutput + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + assert.Equal(t, tt.wantSpec, out.OASSpec, "OASSpec") + assert.Equal(t, tt.wantOperationID, out.OASOperationID, "OASOperationID") + }) + } +} diff --git a/cmd/test_helpers_test.go b/cmd/test_helpers_test.go index 2f023d0..8d450ad 100644 --- a/cmd/test_helpers_test.go +++ b/cmd/test_helpers_test.go @@ -101,6 +101,22 @@ func newTestServer(handler http.HandlerFunc) *httptest.Server { return httptest.NewServer(handler) } +// captureStdout runs fn and returns everything it wrote to os.Stdout. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + _ = w.Close() + os.Stdout = oldStdout + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + // resetAllFlags resets all flags on a command and its children to their default values. func resetAllFlags(cmd *cobra.Command) { cmd.Flags().VisitAll(func(f *pflag.Flag) { From d769899a1e73278c453f3f8bd66614fdd0831d04 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sun, 5 Apr 2026 10:01:36 +0800 Subject: [PATCH 05/11] feat: add smart routing, FDV fallback, and reserve to contract command Add address-only smart routing that auto-resolves network/platform via parallel pool search + networks list, tries CG aggregated price first, and falls back to onchain DEX price if unavailable. - Smart routing: /onchain/search/pools + /onchain/networks (parallel) extracts network from token relationship ID prefix ({network}_{addr}), maps to CG platform via coingecko_asset_platform_id - CG-first with onchain fallback when CG returns no data - --onchain without --network also triggers smart routing - FDV fallback: mcap_fdv_fallback=true always sent to onchain endpoint - Reserve/liquidity: include_total_reserve_in_usd=true, shown in onchain output only (table Reserve column, JSON total_reserve, CSV) - Address case normalization for API response key matching - Dry-run shows resolution endpoint info for smart routing mode - 12 new command tests + 3 new API tests covering all routing paths --- CLAUDE.md | 10 +- cmd/commands.go | 2 + cmd/contract.go | 237 +++++++++++---- cmd/contract_test.go | 581 +++++++++++++++++++++++++++++++++---- internal/api/coins.go | 26 +- internal/api/coins_test.go | 89 +++++- internal/api/types.go | 49 +++- 7 files changed, 862 insertions(+), 132 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index edf0bcd..78857f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ go test -race ./... ``` coingecko-cli/ ├── main.go # Entry point -├── cmd/ # Cobra commands (auth, status, price, markets, search, trending, history, top_gainers_losers, watch, tui, version) +├── cmd/ # Cobra commands (auth, status, price, markets, search, trending, history, top_gainers_losers, contract, watch, tui, version) ├── internal/ │ ├── api/ │ │ ├── client.go # HTTP client, auth, error handling @@ -103,7 +103,8 @@ coingecko-cli/ | `cg history --from/--to --interval hourly` | `/coins/{id}/market_chart/range` (batched) | `coins-id-market-chart-range` | | `cg history --from/--to --ohlc` | `/coins/{id}/ohlc/range` (batched for large ranges) | `coins-id-ohlc-range` | | `cg top-gainers-losers` | `/coins/top_gainers_losers` | `coins-top-gainers-losers` | -| `cg contract` | `/simple/token_price/{platform}` | `simple-token-price` | +| `cg contract` (smart routing) | `/onchain/search/pools` + `/onchain/networks` → `/simple/token_price/{platform}` (CG first, onchain fallback) | `search-pools`, `simple-token-price`, `onchain-simple-price` | +| `cg contract --platform` | `/simple/token_price/{platform}` | `simple-token-price` | | `cg contract --onchain` | `/onchain/simple/networks/{network}/token_price/{addresses}` | `onchain-simple-price` | | `cg watch` | `wss://stream.coingecko.com/v1` (WebSocket) | — | @@ -137,4 +138,7 @@ coingecko-cli/ - **Pagination helper**: `FetchAllMarkets` in `internal/api/coins.go` handles multi-page fetching (250/page) with trim-to-total, used by both `cg markets` and `cg tui markets` - **TUI trending tier awareness**: demo gets 15 coins, paid gets 30 via `show_max=coins` API param - **Onchain currency conversion**: `--onchain` returns USD only. For `--vs` non-USD, the CLI fetches `/exchange_rates` and multiplies price/mcap/volume by `targetRate/usdRate`. 24h change % is unchanged. No caching — one extra API call per non-USD onchain request -- **Platform/network discoverability**: `--platform` IDs come from `/asset_platforms` ([docs](https://docs.coingecko.com/reference/asset-platforms-list)), `--network` IDs from `/onchain/networks` ([docs](https://docs.coingecko.com/reference/networks-list)). These are different namespaces — no auto-translation between them +- **Platform/network discoverability**: `--platform` IDs come from `/asset_platforms` ([docs](https://docs.coingecko.com/reference/asset-platforms-list)), `--network` IDs from `/onchain/networks` ([docs](https://docs.coingecko.com/reference/networks-list)). These are different namespaces but auto-translated via the `coingecko_asset_platform_id` field in `/onchain/networks` +- **Contract smart routing**: when `--platform`/`--network` are omitted, `resolveAddress()` fires `/onchain/search/pools` and `/onchain/networks` in parallel, extracts the network from the token relationship ID prefix (`{network}_{address}`), maps to CG platform via `coingecko_asset_platform_id`, then tries CG aggregated price first with onchain fallback. Address lookups are case-insensitive (normalized to lowercase) +- **Onchain FDV fallback**: `OnchainSimpleTokenPrice` always sends `mcap_fdv_fallback=true` — the API returns FDV in `market_cap_usd` when market cap is unavailable +- **Onchain reserve/liquidity**: `OnchainSimpleTokenPrice` always sends `include_total_reserve_in_usd=true` — the Reserve column appears only in onchain mode output (table/JSON/CSV) diff --git a/cmd/commands.go b/cmd/commands.go index a67f709..8acfa0e 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -83,10 +83,12 @@ var commandMeta = map[string]commandAnnotation{ APIEndpoints: map[string]string{ "default": "/simple/token_price/{platform}", "--onchain": "/onchain/simple/networks/{network}/token_price/{addresses}", + "resolve": "/onchain/search/pools + /onchain/networks", }, OASOperationIDs: map[string]string{ "default": "simple-token-price", "--onchain": "onchain-simple-price", + "resolve": "search-pools", }, OASSpec: "coingecko-demo.json", RequiresAuth: true, diff --git a/cmd/contract.go b/cmd/contract.go index aef352e..cc63fe3 100644 --- a/cmd/contract.go +++ b/cmd/contract.go @@ -1,11 +1,14 @@ package cmd import ( + "context" "fmt" "net/url" "strconv" "strings" + "sync" + "github.com/coingecko/coingecko-cli/internal/api" "github.com/coingecko/coingecko-cli/internal/display" "github.com/spf13/cobra" @@ -17,13 +20,19 @@ var contractCmd = &cobra.Command{ Long: `Fetch token price by contract address. Uses CoinGecko's aggregated price by default, or DEX price from GeckoTerminal with --onchain. +When --platform and --network are omitted, smart routing automatically detects +the token's network via pool search, then tries the aggregated CoinGecko price +first and falls back to onchain DEX price if unavailable. + Find valid --platform IDs: https://docs.coingecko.com/reference/asset-platforms-list Find valid --network IDs: https://docs.coingecko.com/reference/networks-list Note: --platform (e.g. "ethereum") and --network (e.g. "eth") are different identifiers from different API specs — they are not interchangeable.`, - Example: ` cg contract --address 0x1f98... --platform ethereum + Example: ` cg contract --address 0x1f98... # smart routing + cg contract --address 0x1f98... --platform ethereum # explicit CG aggregated cg contract --address 0x1f98... --platform ethereum --vs eur + cg contract --address 0x1f98... --onchain # smart routing, onchain only cg contract --address 0x1f98... --network eth --onchain cg contract --address 0x1f98... --network eth --onchain --vs eur`, RunE: runContract, @@ -39,6 +48,74 @@ func init() { rootCmd.AddCommand(contractCmd) } +type resolvedAddress struct { + network string // onchain network ID (e.g. "eth") + platform string // CG asset platform ID (e.g. "ethereum"), may be empty +} + +// resolveAddress searches onchain pools to find which network a contract address +// lives on, then maps the network to its CoinGecko asset platform ID. +// The two API calls run in parallel since they are independent. +func resolveAddress(ctx context.Context, client *api.Client, address string) (*resolvedAddress, error) { + var ( + pools *api.OnchainSearchPoolsResponse + networks *api.OnchainNetworksResponse + poolsErr, networksErr error + ) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + pools, poolsErr = client.OnchainSearchPools(ctx, address) + }() + go func() { + defer wg.Done() + networks, networksErr = client.OnchainNetworks(ctx) + }() + wg.Wait() + + if poolsErr != nil { + return nil, fmt.Errorf("searching for address: %w", poolsErr) + } + if len(pools.Data) == 0 { + return nil, fmt.Errorf("no pools found for address %s — specify --platform or --network manually", address) + } + + // Token relationship IDs follow the format "{network}_{token_address}". + // Find the token matching the queried address and extract the network prefix. + addrLower := strings.ToLower(address) + pool := pools.Data[0] + var networkID string + for _, tokenID := range []string{ + pool.Relationships.BaseToken.Data.ID, + pool.Relationships.QuoteToken.Data.ID, + } { + lower := strings.ToLower(tokenID) + if strings.HasSuffix(lower, "_"+addrLower) { + networkID = tokenID[:len(tokenID)-len(address)-1] + break + } + } + if networkID == "" { + return nil, fmt.Errorf("could not determine network for address %s", address) + } + + if networksErr != nil { + return nil, fmt.Errorf("fetching network mappings: %w", networksErr) + } + + var platformID string + for _, n := range networks.Data { + if n.ID == networkID { + platformID = n.Attributes.CoingeckoAssetPlatformID + break + } + } + + return &resolvedAddress{network: networkID, platform: platformID}, nil +} + func runContract(cmd *cobra.Command, args []string) error { address, _ := cmd.Flags().GetString("address") platform, _ := cmd.Flags().GetString("platform") @@ -53,7 +130,6 @@ func runContract(cmd *cobra.Command, args []string) error { display.PrintBanner() } - // Validation — fail fast before config load. if address == "" { return fmt.Errorf("--address is required") } @@ -63,12 +139,8 @@ func runContract(cmd *cobra.Command, args []string) error { if network != "" && !onchain { return fmt.Errorf("--network requires --onchain flag; did you mean --platform?") } - if onchain && network == "" { - return fmt.Errorf("--network is required with --onchain; find valid network IDs at https://docs.coingecko.com/reference/networks-list") - } - if !onchain && platform == "" { - return fmt.Errorf("--platform is required (or use --onchain with --network); find valid platform IDs at https://docs.coingecko.com/reference/asset-platforms-list") - } + + needsResolve := (!onchain && platform == "") || (onchain && network == "") cfg, err := loadConfig() if err != nil { @@ -76,11 +148,20 @@ func runContract(cmd *cobra.Command, args []string) error { } if isDryRun(cmd) { + if needsResolve { + note := "Smart routing: searches pools to determine network, then fetches /onchain/networks for platform mapping. " + + "After resolution, the aggregated CG endpoint is tried first; if no data, falls back to onchain." + return printDryRunFull(cfg, "contract", "resolve", + "/onchain/search/pools", + map[string]string{"query": address}, nil, note) + } if onchain { params := map[string]string{ - "include_market_cap": "true", - "include_24hr_vol": "true", - "include_24hr_price_change": "true", + "include_market_cap": "true", + "include_24hr_vol": "true", + "include_24hr_price_change": "true", + "mcap_fdv_fallback": "true", + "include_total_reserve_in_usd": "true", } note := "" if vs != "usd" { @@ -104,7 +185,53 @@ func runContract(cmd *cobra.Command, args []string) error { client := newAPIClient(cfg) ctx := cmd.Context() - var price, marketCap, volume, change float64 + if needsResolve { + resolved, err := resolveAddress(ctx, client, address) + if err != nil { + return err + } + + if resolved.platform != "" { + warnf("Resolved address to network=%s, platform=%s\n", resolved.network, resolved.platform) + } else { + warnf("Resolved address to network=%s (no CG platform mapping)\n", resolved.network) + } + + if onchain { + network = resolved.network + } else { + platform = resolved.platform + network = resolved.network + } + } + + // Normalize for case-insensitive API response key matching. + addressLower := strings.ToLower(address) + + var price, marketCap, volume, change, reserve float64 + + if !onchain && platform != "" { + resp, err := client.SimpleTokenPrice(ctx, platform, []string{address}, vs) + if err != nil { + return err + } + if data, ok := resp[addressLower]; ok { + price = data[vs] + marketCap = data[vs+"_market_cap"] + volume = data[vs+"_24h_vol"] + change = data[vs+"_24h_change"] + } else if network != "" { + warnf("No aggregated price found; falling back to onchain (network=%s)\n", network) + onchain = true + } else { + return fmt.Errorf("no data returned for address %s", address) + } + } + + if !onchain && platform == "" && network != "" { + warnf("No CG platform mapping; using onchain (network=%s)\n", network) + onchain = true + } if onchain { resp, err := client.OnchainSimpleTokenPrice(ctx, network, []string{address}) @@ -112,7 +239,7 @@ func runContract(cmd *cobra.Command, args []string) error { return err } - priceStr, ok := resp.Data.Attributes.TokenPrices[address] + priceStr, ok := resp.Data.Attributes.TokenPrices[addressLower] if !ok { return fmt.Errorf("no data returned for address %s", address) } @@ -122,17 +249,19 @@ func runContract(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing price: %w", err) } - if mcStr, ok := resp.Data.Attributes.MarketCapUSD[address]; ok { + if mcStr, ok := resp.Data.Attributes.MarketCapUSD[addressLower]; ok { marketCap, _ = strconv.ParseFloat(mcStr, 64) } - if volStr, ok := resp.Data.Attributes.H24VolumeUSD[address]; ok { + if volStr, ok := resp.Data.Attributes.H24VolumeUSD[addressLower]; ok { volume, _ = strconv.ParseFloat(volStr, 64) } - if chgStr, ok := resp.Data.Attributes.H24PriceChangePct[address]; ok { + if chgStr, ok := resp.Data.Attributes.H24PriceChangePct[addressLower]; ok { change, _ = strconv.ParseFloat(chgStr, 64) } + if resStr, ok := resp.Data.Attributes.TotalReserveInUSD[addressLower]; ok { + reserve, _ = strconv.ParseFloat(resStr, 64) + } - // Currency conversion for non-USD. if vs != "usd" { rates, err := client.ExchangeRates(ctx) if err != nil { @@ -147,60 +276,50 @@ func runContract(cmd *cobra.Command, args []string) error { price *= factor marketCap *= factor volume *= factor + reserve *= factor // 24h change % stays the same } - } else { - resp, err := client.SimpleTokenPrice(ctx, platform, []string{address}, vs) - if err != nil { - return err - } - - data, ok := resp[address] - if !ok { - return fmt.Errorf("no data returned for address %s", address) - } - - price = data[vs] - marketCap = data[vs+"_market_cap"] - volume = data[vs+"_24h_vol"] - change = data[vs+"_24h_change"] } if jsonOut { - normalized := map[string]interface{}{ - address: map[string]interface{}{ - "price": price, - "market_cap": marketCap, - "volume_24h": volume, - "change_24h": change, - }, + data := map[string]interface{}{ + "price": price, + "market_cap": marketCap, + "volume_24h": volume, + "change_24h": change, + } + if onchain { + data["total_reserve"] = reserve } - return printJSONRaw(normalized) + return printJSONRaw(map[string]interface{}{address: data}) } headers := []string{"Address", "Price", "Market Cap", "24h Volume", "24h Change"} - rows := [][]string{ - { - display.SanitizeCell(address), - display.FormatPrice(price, vs), - display.FormatLargeNumber(marketCap, vs), - display.FormatLargeNumber(volume, vs), - display.ColorPercent(change), - }, + row := []string{ + display.SanitizeCell(address), + display.FormatPrice(price, vs), + display.FormatLargeNumber(marketCap, vs), + display.FormatLargeNumber(volume, vs), + display.ColorPercent(change), + } + if onchain { + headers = append(headers, "Reserve") + row = append(row, display.FormatLargeNumber(reserve, vs)) } - display.PrintTable(headers, rows) + display.PrintTable(headers, [][]string{row}) if exportPath != "" { - csvRows := [][]string{ - { - display.SanitizeCell(address), - fmt.Sprintf("%.8f", price), - fmt.Sprintf("%.2f", marketCap), - fmt.Sprintf("%.2f", volume), - fmt.Sprintf("%.2f", change), - }, - } - if err := exportCSV(exportPath, headers, csvRows); err != nil { + csvRow := []string{ + display.SanitizeCell(address), + fmt.Sprintf("%.8f", price), + fmt.Sprintf("%.2f", marketCap), + fmt.Sprintf("%.2f", volume), + fmt.Sprintf("%.2f", change), + } + if onchain { + csvRow = append(csvRow, fmt.Sprintf("%.2f", reserve)) + } + if err := exportCSV(exportPath, headers, [][]string{csvRow}); err != nil { return err } } diff --git a/cmd/contract_test.go b/cmd/contract_test.go index 35e9a0d..506c630 100644 --- a/cmd/contract_test.go +++ b/cmd/contract_test.go @@ -12,17 +12,86 @@ import ( "github.com/stretchr/testify/require" ) +// poolSearchJSON builds a pool search response with the given network and address. +// Token relationship IDs follow the "{network}_{address}" convention. +func poolSearchJSON(networkID, addr string) map[string]interface{} { + return map[string]interface{}{ + "data": []map[string]interface{}{ + { + "id": networkID + "_0xpool", + "type": "pool", + "relationships": map[string]interface{}{ + "base_token": map[string]interface{}{ + "data": map[string]string{"id": networkID + "_" + addr, "type": "token"}, + }, + "quote_token": map[string]interface{}{ + "data": map[string]string{"id": networkID + "_0xquote", "type": "token"}, + }, + }, + }, + }, + } +} + +// networksJSON builds an onchain networks response mapping networkID to platformID. +func networksJSON(networkID, platformID string) map[string]interface{} { + return map[string]interface{}{ + "data": []map[string]interface{}{ + { + "id": networkID, + "type": "network", + "attributes": map[string]interface{}{ + "name": networkID, + "coingecko_asset_platform_id": platformID, + }, + }, + }, + } +} + +// onchainPriceJSON builds a standard onchain simple token price API response. +func onchainPriceJSON(addr, price, mcap, vol, change, reserve string) map[string]interface{} { + return map[string]interface{}{ + "data": map[string]interface{}{ + "id": "1", + "type": "simple_token_price", + "attributes": map[string]interface{}{ + "token_prices": map[string]string{addr: price}, + "market_cap_usd": map[string]string{addr: mcap}, + "h24_volume_usd": map[string]string{addr: vol}, + "h24_price_change_percentage": map[string]string{addr: change}, + "total_reserve_in_usd": map[string]string{addr: reserve}, + }, + }, + } +} + func TestContract_MissingAddress(t *testing.T) { _, _, err := executeCommand(t, "contract", "--platform", "ethereum", "-o", "json") require.Error(t, err) assert.Contains(t, err.Error(), "--address is required") } -func TestContract_MissingPlatform(t *testing.T) { - _, _, err := executeCommand(t, "contract", "--address", "0xabc", "-o", "json") +func TestContract_MissingPlatform_TriggersSmartRoute(t *testing.T) { + // With smart routing, omitting --platform no longer errors immediately; + // it triggers pool search. This test verifies it reaches the search endpoint. + addr := "0xabc" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/onchain/search/pools": + _, _ = w.Write([]byte(`{"data": []}`)) + case "/onchain/networks": + _, _ = w.Write([]byte(`{"data": []}`)) + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", addr, "-o", "json") require.Error(t, err) - assert.Contains(t, err.Error(), "--platform is required") - assert.Contains(t, err.Error(), "asset-platforms-list") + assert.Contains(t, err.Error(), "no pools found") } func TestContract_MutualExclusion(t *testing.T) { @@ -31,11 +100,26 @@ func TestContract_MutualExclusion(t *testing.T) { assert.Contains(t, err.Error(), "--platform and --onchain are mutually exclusive") } -func TestContract_OnchainMissingNetwork(t *testing.T) { - _, _, err := executeCommand(t, "contract", "--address", "0xabc", "--onchain", "-o", "json") +func TestContract_OnchainMissingNetwork_TriggersSmartRoute(t *testing.T) { + // With smart routing, --onchain without --network triggers pool search + // to auto-detect the network. + addr := "0xabc" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/onchain/search/pools": + _, _ = w.Write([]byte(`{"data": []}`)) + case "/onchain/networks": + _, _ = w.Write([]byte(`{"data": []}`)) + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", addr, "--onchain", "-o", "json") require.Error(t, err) - assert.Contains(t, err.Error(), "--network is required with --onchain") - assert.Contains(t, err.Error(), "networks-list") + assert.Contains(t, err.Error(), "no pools found") } func TestContract_NetworkWithoutOnchain(t *testing.T) { @@ -84,6 +168,8 @@ func TestContract_DryRun_Onchain(t *testing.T) { assert.Equal(t, "true", out.Params["include_market_cap"]) assert.Equal(t, "true", out.Params["include_24hr_vol"]) assert.Equal(t, "true", out.Params["include_24hr_price_change"]) + assert.Equal(t, "true", out.Params["mcap_fdv_fallback"]) + assert.Equal(t, "true", out.Params["include_total_reserve_in_usd"]) assert.Equal(t, "onchain-simple-price", out.OASOperationID) assert.Equal(t, "coingecko-demo.json", out.OASSpec) assert.Empty(t, out.Note) @@ -177,20 +263,7 @@ func TestContract_Onchain_JSONOutput(t *testing.T) { addr := "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { assert.True(t, strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/eth/token_price/")) - - resp := map[string]interface{}{ - "data": map[string]interface{}{ - "id": "123", - "type": "simple_token_price", - "attributes": map[string]interface{}{ - "token_prices": map[string]string{addr: "2289.33"}, - "market_cap_usd": map[string]string{addr: "6692452895.78"}, - "h24_volume_usd": map[string]string{addr: "965988358.73"}, - "h24_price_change_percentage": map[string]string{addr: "3.387"}, - }, - }, - } - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "2289.33", "6692452895.78", "965988358.73", "3.387", "0")) }) defer srv.Close() withTestClientDemo(t, srv) @@ -211,19 +284,7 @@ func TestContract_Onchain_NonUSD_Conversion(t *testing.T) { srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): - resp := map[string]interface{}{ - "data": map[string]interface{}{ - "id": "123", - "type": "simple_token_price", - "attributes": map[string]interface{}{ - "token_prices": map[string]string{addr: "2000"}, - "market_cap_usd": map[string]string{addr: "1000000"}, - "h24_volume_usd": map[string]string{addr: "500000"}, - "h24_price_change_percentage": map[string]string{addr: "5.0"}, - }, - }, - } - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "2000", "1000000", "500000", "5.0", "0")) case r.URL.Path == "/exchange_rates": resp := map[string]interface{}{ "rates": map[string]interface{}{ @@ -257,19 +318,7 @@ func TestContract_Onchain_UnsupportedCurrency(t *testing.T) { srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): - resp := map[string]interface{}{ - "data": map[string]interface{}{ - "id": "123", - "type": "simple_token_price", - "attributes": map[string]interface{}{ - "token_prices": map[string]string{addr: "2000"}, - "market_cap_usd": map[string]string{addr: "1000000"}, - "h24_volume_usd": map[string]string{addr: "500000"}, - "h24_price_change_percentage": map[string]string{addr: "5.0"}, - }, - }, - } - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "2000", "1000000", "500000", "5.0", "0")) case r.URL.Path == "/exchange_rates": resp := map[string]interface{}{ "rates": map[string]interface{}{ @@ -291,19 +340,7 @@ func TestContract_Onchain_DemoTier(t *testing.T) { addr := "0xabc" srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/onchain/simple/networks/eth/token_price/") - resp := map[string]interface{}{ - "data": map[string]interface{}{ - "id": "1", - "type": "simple_token_price", - "attributes": map[string]interface{}{ - "token_prices": map[string]string{addr: "100.5"}, - "market_cap_usd": map[string]string{addr: "500000"}, - "h24_volume_usd": map[string]string{addr: "250000"}, - "h24_price_change_percentage": map[string]string{addr: "2.5"}, - }, - }, - } - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "100.5", "500000", "250000", "2.5", "0")) }) defer srv.Close() withTestClientDemo(t, srv) @@ -339,9 +376,10 @@ func TestContract_CatalogMetadata(t *testing.T) { assert.False(t, contractInfo.PaidOnly) assert.Empty(t, contractInfo.PaidModes) - // Both operation IDs should be present. + // All operation IDs should be present. assert.Equal(t, "simple-token-price", contractInfo.OASOperationIDs["default"]) assert.Equal(t, "onchain-simple-price", contractInfo.OASOperationIDs["--onchain"]) + assert.Equal(t, "search-pools", contractInfo.OASOperationIDs["resolve"]) } func TestContract_Export_CSV(t *testing.T) { @@ -374,3 +412,422 @@ func TestContract_Export_CSV(t *testing.T) { assert.Contains(t, content, "Market Cap") assert.Contains(t, content, addr) } + +// --------------------------------------------------------------------------- +// Smart routing tests +// --------------------------------------------------------------------------- + +// poolSearchHandler returns a handler that serves pool search, network list, +// and optionally CG/onchain price endpoints for smart routing tests. +func smartRouteHandler(t *testing.T, addr, networkID, platformID string, cgPrice map[string]float64, onchainAttrs map[string]map[string]string) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/onchain/search/pools": + assert.Equal(t, addr, r.URL.Query().Get("query")) + _ = json.NewEncoder(w).Encode(poolSearchJSON(networkID, addr)) + + case r.URL.Path == "/onchain/networks": + _ = json.NewEncoder(w).Encode(networksJSON(networkID, platformID)) + + case strings.HasPrefix(r.URL.Path, "/simple/token_price/"): + if cgPrice != nil { + resp := map[string]map[string]float64{addr: cgPrice} + _ = json.NewEncoder(w).Encode(resp) + } else { + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + } + + case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): + if onchainAttrs == nil { + onchainAttrs = map[string]map[string]string{ + "token_prices": {addr: "100.5"}, + "market_cap_usd": {addr: "500000"}, + "h24_volume_usd": {addr: "250000"}, + "h24_price_change_percentage": {addr: "2.5"}, + "total_reserve_in_usd": {addr: "1000000"}, + } + } + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "id": "1", + "type": "simple_token_price", + "attributes": onchainAttrs, + }, + } + _ = json.NewEncoder(w).Encode(resp) + + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + } +} + +func TestContract_SmartRoute_AddressOnly(t *testing.T) { + addr := "0xdac17f958d2ee523a2206206994597c13d831ec7" + cgPrice := map[string]float64{ + "usd": 1.001, + "usd_market_cap": 50000000000, + "usd_24h_vol": 30000000000, + "usd_24h_change": 0.05, + } + + srv := newTestServer(smartRouteHandler(t, addr, "eth", "ethereum", cgPrice, nil)) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, stderr, err := executeCommand(t, "contract", "--address", addr, "-o", "json") + require.NoError(t, err) + + // Should have resolved via smart routing. + assert.Contains(t, stderr, "Resolved address to network=eth, platform=ethereum") + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, 1.001, result[addr]["price"]) + assert.Equal(t, float64(50000000000), result[addr]["market_cap"]) +} + +func TestContract_SmartRoute_FallbackToOnchain(t *testing.T) { + addr := "0xabc" + + // CG returns empty (no data), so it should fall back to onchain. + srv := newTestServer(smartRouteHandler(t, addr, "eth", "ethereum", nil, nil)) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, stderr, err := executeCommand(t, "contract", "--address", addr, "-o", "json") + require.NoError(t, err) + + assert.Contains(t, stderr, "falling back to onchain") + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.InDelta(t, 100.5, result[addr]["price"], 0.01) +} + +func TestContract_SmartRoute_NoPools(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/onchain/search/pools": + _, _ = w.Write([]byte(`{"data": []}`)) + case "/onchain/networks": + _, _ = w.Write([]byte(`{"data": []}`)) + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", "0xnonexistent", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "no pools found") + assert.Contains(t, err.Error(), "--platform") +} + +func TestContract_SmartRoute_OnchainOnly(t *testing.T) { + addr := "0xabc" + + // --onchain without --network: should resolve network, then go straight to onchain. + var cgCalled bool + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/onchain/search/pools": + _ = json.NewEncoder(w).Encode(poolSearchJSON("eth", addr)) + + case r.URL.Path == "/onchain/networks": + _ = json.NewEncoder(w).Encode(networksJSON("eth", "ethereum")) + + case strings.HasPrefix(r.URL.Path, "/simple/token_price/"): + cgCalled = true + t.Fatal("should not call CG endpoint when --onchain is specified") + + case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "100.5", "500000", "250000", "2.5", "1000000")) + + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--onchain", "-o", "json") + require.NoError(t, err) + assert.False(t, cgCalled) + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.InDelta(t, 100.5, result[addr]["price"], 0.01) +} + +func TestContract_SmartRoute_NoPlatformMapping(t *testing.T) { + addr := "0xabc" + + // Network found but no CG platform mapping — should auto-use onchain. + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/onchain/search/pools": + _ = json.NewEncoder(w).Encode(poolSearchJSON("obscure-net", addr)) + + case r.URL.Path == "/onchain/networks": + _ = json.NewEncoder(w).Encode(networksJSON("obscure-net", "")) + + case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "0.05", "100000", "50000", "1.0", "200000")) + + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, stderr, err := executeCommand(t, "contract", "--address", addr, "-o", "json") + require.NoError(t, err) + + assert.Contains(t, stderr, "no CG platform mapping") + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.InDelta(t, 0.05, result[addr]["price"], 0.001) +} + +func TestContract_SmartRoute_DryRun(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make HTTP call in dry-run mode") + }) + defer srv.Close() + withTestClientDemo(t, srv) + + // Address-only dry-run should show the resolution endpoint. + stdout, _, err := executeCommand(t, "contract", "--address", "0xabc", "--dry-run", "-o", "json") + require.NoError(t, err) + + var out dryRunOutput + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + assert.Contains(t, out.URL, "/onchain/search/pools") + assert.Equal(t, "0xabc", out.Params["query"]) + assert.Contains(t, out.Note, "Smart routing") + assert.Equal(t, "search-pools", out.OASOperationID) +} + +// --------------------------------------------------------------------------- +// Reserve/liquidity tests +// --------------------------------------------------------------------------- + +func TestContract_Onchain_Reserve(t *testing.T) { + addr := "0xabc" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "100.5", "500000", "250000", "2.5", "1234567.89")) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + // JSON output should include total_reserve. + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "-o", "json") + require.NoError(t, err) + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.InDelta(t, 1234567.89, result[addr]["total_reserve"], 0.01) + + // Table output should include Reserve header. + tableOut, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain") + require.NoError(t, err) + assert.Contains(t, tableOut, "Reserve") +} + +func TestContract_Onchain_Reserve_CurrencyConversion(t *testing.T) { + addr := "0xabc" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/onchain/simple/networks/"): + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "2000", "1000000", "500000", "5.0", "800000")) + case r.URL.Path == "/exchange_rates": + resp := map[string]interface{}{ + "rates": map[string]interface{}{ + "usd": map[string]interface{}{"name": "US Dollar", "unit": "$", "value": 67187.0, "type": "fiat"}, + "eur": map[string]interface{}{"name": "Euro", "unit": "€", "value": 62345.0, "type": "fiat"}, + }, + } + _ = json.NewEncoder(w).Encode(resp) + } + }) + defer srv.Close() + withTestClientPaid(t, srv) + + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "--vs", "eur", "-o", "json") + require.NoError(t, err) + + var result map[string]map[string]float64 + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + + factor := 62345.0 / 67187.0 + assert.InDelta(t, 800000*factor, result[addr]["total_reserve"], 0.01) +} + +func TestContract_Aggregated_NoReserveColumn(t *testing.T) { + addr := "0xdac17f958d2ee523a2206206994597c13d831ec7" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]map[string]float64{ + addr: { + "usd": 1.001, + "usd_market_cap": 50000000000, + "usd_24h_vol": 30000000000, + "usd_24h_change": 0.05, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + // Table output should NOT contain Reserve header in aggregated mode. + stdout, _, err := executeCommand(t, "contract", "--address", addr, "--platform", "ethereum") + require.NoError(t, err) + assert.NotContains(t, stdout, "Reserve") + + // JSON output should NOT contain total_reserve in aggregated mode. + jsonOut, _, err := executeCommand(t, "contract", "--address", addr, "--platform", "ethereum", "-o", "json") + require.NoError(t, err) + assert.NotContains(t, jsonOut, "total_reserve") +} + +// --------------------------------------------------------------------------- +// resolveAddress error paths +// --------------------------------------------------------------------------- + +func TestContract_SmartRoute_PoolSearchError(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/onchain/search/pools": + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"status":{"error_code":500,"error_message":"Internal error"}}`)) + case "/onchain/networks": + _, _ = w.Write([]byte(`{"data": []}`)) + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "searching for address") +} + +func TestContract_SmartRoute_EmptyNetworkID(t *testing.T) { + // Pool found but network relationship has empty ID. + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/onchain/search/pools": + // Pool found but neither token matches the queried address. + resp := map[string]interface{}{ + "data": []map[string]interface{}{ + { + "id": "unknown_0xpool", + "type": "pool", + "relationships": map[string]interface{}{ + "base_token": map[string]interface{}{ + "data": map[string]string{"id": "unknown_0xother1", "type": "token"}, + }, + "quote_token": map[string]interface{}{ + "data": map[string]string{"id": "unknown_0xother2", "type": "token"}, + }, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + case "/onchain/networks": + _, _ = w.Write([]byte(`{"data": []}`)) + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "could not determine network") +} + +func TestContract_SmartRoute_NetworksAPIError(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/onchain/search/pools": + _ = json.NewEncoder(w).Encode(poolSearchJSON("eth", "0xabc")) + case r.URL.Path == "/onchain/networks": + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"status":{"error_code":500,"error_message":"Internal error"}}`)) + default: + t.Fatalf("unexpected request: %s", r.URL.Path) + } + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", "0xabc", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "fetching network mappings") +} + +// --------------------------------------------------------------------------- +// Onchain no-data path +// --------------------------------------------------------------------------- + +func TestContract_Onchain_NoData(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + // Return response with empty token_prices map. + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "id": "1", + "type": "simple_token_price", + "attributes": map[string]interface{}{ + "token_prices": map[string]string{}, + "market_cap_usd": map[string]string{}, + "h24_volume_usd": map[string]string{}, + "h24_price_change_percentage": map[string]string{}, + "total_reserve_in_usd": map[string]string{}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "contract", "--address", "0xnonexistent", "--network", "eth", "--onchain", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "no data returned for address") +} + +// --------------------------------------------------------------------------- +// CSV export with onchain reserve column +// --------------------------------------------------------------------------- + +func TestContract_Export_CSV_Onchain_WithReserve(t *testing.T) { + addr := "0xabc" + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "100.5", "500000", "250000", "2.5", "1234567.89")) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + tmpDir := t.TempDir() + csvPath := filepath.Join(tmpDir, "onchain.csv") + + _, _, err := executeCommand(t, "contract", "--address", addr, "--network", "eth", "--onchain", "--export", csvPath) + require.NoError(t, err) + + data, err := os.ReadFile(csvPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "Reserve") + assert.Contains(t, content, addr) + assert.Contains(t, content, "1234567.89") +} diff --git a/internal/api/coins.go b/internal/api/coins.go index fc3be85..d841545 100644 --- a/internal/api/coins.go +++ b/internal/api/coins.go @@ -214,9 +214,11 @@ func (c *Client) SimpleTokenPrice(ctx context.Context, platform string, addresse // https://docs.coingecko.com/reference/onchain-simple-price (pro) func (c *Client) OnchainSimpleTokenPrice(ctx context.Context, network string, addresses []string) (*OnchainTokenPriceResponse, error) { params := url.Values{ - "include_market_cap": {"true"}, - "include_24hr_vol": {"true"}, - "include_24hr_price_change": {"true"}, + "include_market_cap": {"true"}, + "include_24hr_vol": {"true"}, + "include_24hr_price_change": {"true"}, + "mcap_fdv_fallback": {"true"}, + "include_total_reserve_in_usd": {"true"}, } var result OnchainTokenPriceResponse err := c.get(ctx, fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s?%s", @@ -224,6 +226,24 @@ func (c *Client) OnchainSimpleTokenPrice(ctx context.Context, network string, ad return &result, err } +// OnchainSearchPools searches for pools by token address across all networks. +// Used for smart routing: resolving a contract address to its network. +// https://docs.coingecko.com/reference/search-pools +func (c *Client) OnchainSearchPools(ctx context.Context, query string) (*OnchainSearchPoolsResponse, error) { + params := url.Values{"query": {query}} + var result OnchainSearchPoolsResponse + err := c.get(ctx, "/onchain/search/pools?"+params.Encode(), &result) + return &result, err +} + +// OnchainNetworks fetches all onchain networks with their CoinGecko asset platform mappings. +// https://docs.coingecko.com/reference/networks-list +func (c *Client) OnchainNetworks(ctx context.Context) (*OnchainNetworksResponse, error) { + var result OnchainNetworksResponse + err := c.get(ctx, "/onchain/networks", &result) + return &result, err +} + // ExchangeRates fetches BTC-based exchange rates for all supported currencies. // https://docs.coingecko.com/reference/exchange-rates func (c *Client) ExchangeRates(ctx context.Context) (*ExchangeRatesResponse, error) { diff --git a/internal/api/coins_test.go b/internal/api/coins_test.go index 037303e..cf20120 100644 --- a/internal/api/coins_test.go +++ b/internal/api/coins_test.go @@ -571,6 +571,8 @@ func TestOnchainSimpleTokenPrice_Success(t *testing.T) { assert.Equal(t, "true", q.Get("include_market_cap")) assert.Equal(t, "true", q.Get("include_24hr_vol")) assert.Equal(t, "true", q.Get("include_24hr_price_change")) + assert.Equal(t, "true", q.Get("mcap_fdv_fallback")) + assert.Equal(t, "true", q.Get("include_total_reserve_in_usd")) w.WriteHeader(200) _, _ = w.Write([]byte(`{ @@ -581,7 +583,8 @@ func TestOnchainSimpleTokenPrice_Success(t *testing.T) { "token_prices": {"0xaddr": "2289.33"}, "market_cap_usd": {"0xaddr": "6692452895.779648"}, "h24_volume_usd": {"0xaddr": "965988358.733808"}, - "h24_price_change_percentage": {"0xaddr": "3.387"} + "h24_price_change_percentage": {"0xaddr": "3.387"}, + "total_reserve_in_usd": {"0xaddr": "12345678.90"} } } }`)) @@ -594,6 +597,7 @@ func TestOnchainSimpleTokenPrice_Success(t *testing.T) { assert.Equal(t, "6692452895.779648", result.Data.Attributes.MarketCapUSD["0xaddr"]) assert.Equal(t, "965988358.733808", result.Data.Attributes.H24VolumeUSD["0xaddr"]) assert.Equal(t, "3.387", result.Data.Attributes.H24PriceChangePct["0xaddr"]) + assert.Equal(t, "12345678.90", result.Data.Attributes.TotalReserveInUSD["0xaddr"]) } func TestOnchainSimpleTokenPrice_DemoTier(t *testing.T) { @@ -638,6 +642,89 @@ func TestExchangeRates_Success(t *testing.T) { assert.Equal(t, float64(62345), result.Rates["eur"].Value) } +// --------------------------------------------------------------------------- +// OnchainSearchPools +// --------------------------------------------------------------------------- + +func TestOnchainSearchPools_Success(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/onchain/search/pools", r.URL.Path) + assert.Equal(t, "0xaddr", r.URL.Query().Get("query")) + + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "eth_0xpool1", + "type": "pool", + "relationships": { + "base_token": { + "data": {"id": "eth_0xaddr", "type": "token"} + }, + "quote_token": { + "data": {"id": "eth_0xquote", "type": "token"} + } + } + }] + }`)) + }) + defer srv.Close() + + result, err := c.OnchainSearchPools(context.Background(), "0xaddr") + require.NoError(t, err) + require.Len(t, result.Data, 1) + assert.Equal(t, "eth_0xaddr", result.Data[0].Relationships.BaseToken.Data.ID) +} + +func TestOnchainSearchPools_NoResults(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data": []}`)) + }) + defer srv.Close() + + result, err := c.OnchainSearchPools(context.Background(), "0xnonexistent") + require.NoError(t, err) + assert.Empty(t, result.Data) +} + +// --------------------------------------------------------------------------- +// OnchainNetworks +// --------------------------------------------------------------------------- + +func TestOnchainNetworks_Success(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/onchain/networks", r.URL.Path) + + _, _ = w.Write([]byte(`{ + "data": [ + { + "id": "eth", + "type": "network", + "attributes": { + "name": "Ethereum", + "coingecko_asset_platform_id": "ethereum" + } + }, + { + "id": "bsc", + "type": "network", + "attributes": { + "name": "BNB Chain", + "coingecko_asset_platform_id": "binance-smart-chain" + } + } + ] + }`)) + }) + defer srv.Close() + + result, err := c.OnchainNetworks(context.Background()) + require.NoError(t, err) + require.Len(t, result.Data, 2) + assert.Equal(t, "eth", result.Data[0].ID) + assert.Equal(t, "ethereum", result.Data[0].Attributes.CoingeckoAssetPlatformID) + assert.Equal(t, "bsc", result.Data[1].ID) + assert.Equal(t, "binance-smart-chain", result.Data[1].Attributes.CoingeckoAssetPlatformID) +} + // --------------------------------------------------------------------------- // FetchAllMarkets (existing tests kept below) // --------------------------------------------------------------------------- diff --git a/internal/api/types.go b/internal/api/types.go index 05b8bc4..06eef0d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -171,10 +171,11 @@ type OnchainTokenPriceResponse struct { ID string `json:"id"` Type string `json:"type"` Attributes struct { - TokenPrices map[string]string `json:"token_prices"` - MarketCapUSD map[string]string `json:"market_cap_usd"` - H24VolumeUSD map[string]string `json:"h24_volume_usd"` - H24PriceChangePct map[string]string `json:"h24_price_change_percentage"` + TokenPrices map[string]string `json:"token_prices"` + MarketCapUSD map[string]string `json:"market_cap_usd"` + H24VolumeUSD map[string]string `json:"h24_volume_usd"` + H24PriceChangePct map[string]string `json:"h24_price_change_percentage"` + TotalReserveInUSD map[string]string `json:"total_reserve_in_usd"` } `json:"attributes"` } `json:"data"` } @@ -193,6 +194,46 @@ type ExchangeRate struct { Type string `json:"type"` } +// OnchainSearchPoolsResponse is the response from /onchain/search/pools. +// Used for smart routing: resolving a contract address to its network. +// https://docs.coingecko.com/reference/search-pools +type OnchainSearchPoolsResponse struct { + Data []OnchainPool `json:"data"` +} + +type OnchainPool struct { + ID string `json:"id"` + Type string `json:"type"` + Relationships OnchainPoolRelations `json:"relationships"` +} + +type OnchainPoolRelations struct { + BaseToken OnchainRelRef `json:"base_token"` + QuoteToken OnchainRelRef `json:"quote_token"` +} + +type OnchainRelRef struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"data"` +} + +// OnchainNetworksResponse is the response from /onchain/networks. +// https://docs.coingecko.com/reference/networks-list +type OnchainNetworksResponse struct { + Data []OnchainNetwork `json:"data"` +} + +type OnchainNetwork struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + Name string `json:"name"` + CoingeckoAssetPlatformID string `json:"coingecko_asset_platform_id"` + } `json:"attributes"` +} + type CoinDetail struct { ID string `json:"id"` Symbol string `json:"symbol"` From bd3a7a947cd834efb77ab546e77dd6bef2851d92 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sun, 5 Apr 2026 10:05:18 +0800 Subject: [PATCH 06/11] fix: use tagged switch to satisfy QF1002 lint rule --- cmd/contract_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/contract_test.go b/cmd/contract_test.go index 506c630..881bbdb 100644 --- a/cmd/contract_test.go +++ b/cmd/contract_test.go @@ -758,10 +758,10 @@ func TestContract_SmartRoute_EmptyNetworkID(t *testing.T) { func TestContract_SmartRoute_NetworksAPIError(t *testing.T) { srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/onchain/search/pools": + switch r.URL.Path { + case "/onchain/search/pools": _ = json.NewEncoder(w).Encode(poolSearchJSON("eth", "0xabc")) - case r.URL.Path == "/onchain/networks": + case "/onchain/networks": w.WriteHeader(500) _, _ = w.Write([]byte(`{"status":{"error_code":500,"error_message":"Internal error"}}`)) default: From c996bf2ce3e2850e2d716988d37e2690cca9ecab Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sun, 5 Apr 2026 10:09:23 +0800 Subject: [PATCH 07/11] chore: remove unused PaidModes field from command metadata PaidModes was added when --onchain was thought to be paid-only but the restriction was removed in e8ff189. The field was never populated for any command. Remove it from commandAnnotation, commandInfo, and the catalog builder. --- cmd/commands.go | 9 +++------ cmd/contract_test.go | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/cmd/commands.go b/cmd/commands.go index 8acfa0e..e4b8f19 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -18,9 +18,8 @@ type commandAnnotation struct { OASSpec string OASSpecs map[string]string // Per-mode OAS spec overrides (e.g. "--onchain" → "coingecko-pro.json") Transport string // "rest" (default) or "websocket" - PaidOnly bool - PaidModes map[string]bool // Per-mode paid-only flags (e.g. "--onchain" → true) - RequiresAuth bool + PaidOnly bool + RequiresAuth bool } var commandMeta = map[string]commandAnnotation{ @@ -149,8 +148,7 @@ type commandInfo struct { Examples []string `json:"examples,omitempty"` OutputFormats []string `json:"output_formats"` RequiresAuth bool `json:"requires_auth"` - PaidOnly bool `json:"paid_only"` - PaidModes map[string]bool `json:"paid_modes,omitempty"` + PaidOnly bool `json:"paid_only"` Transport string `json:"transport,omitempty"` APIEndpoint string `json:"api_endpoint,omitempty"` APIEndpoints map[string]string `json:"api_endpoints,omitempty"` @@ -233,7 +231,6 @@ func runCommands(cmd *cobra.Command, args []string) error { if meta, ok := commandMeta[c.Name()]; ok { info.PaidOnly = meta.PaidOnly - info.PaidModes = meta.PaidModes info.RequiresAuth = meta.RequiresAuth info.Transport = meta.Transport info.APIEndpoint = meta.APIEndpoint diff --git a/cmd/contract_test.go b/cmd/contract_test.go index 881bbdb..33bf0f2 100644 --- a/cmd/contract_test.go +++ b/cmd/contract_test.go @@ -374,7 +374,6 @@ func TestContract_CatalogMetadata(t *testing.T) { // Not paid-only — onchain endpoint is available on both demo and paid tiers. assert.False(t, contractInfo.PaidOnly) - assert.Empty(t, contractInfo.PaidModes) // All operation IDs should be present. assert.Equal(t, "simple-token-price", contractInfo.OASOperationIDs["default"]) From cf2c238d55ec9449835c1db09983345ce2e5e66e Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sun, 5 Apr 2026 10:11:56 +0800 Subject: [PATCH 08/11] chore: remove unused OASSpecs per-mode override Both contract modes use the same OAS spec (coingecko-demo.json), so the per-mode OASSpecs map was never populated. Remove the field from structs, the override logic from dryrun.go, and simplify the fallback test to only cover OASOperationIDs (which is actually used). --- cmd/commands.go | 7 ++----- cmd/dryrun.go | 14 +++----------- cmd/dryrun_test.go | 40 ++++++++++++---------------------------- 3 files changed, 17 insertions(+), 44 deletions(-) diff --git a/cmd/commands.go b/cmd/commands.go index e4b8f19..3587b3d 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -16,8 +16,7 @@ type commandAnnotation struct { OASOperationID string OASOperationIDs map[string]string OASSpec string - OASSpecs map[string]string // Per-mode OAS spec overrides (e.g. "--onchain" → "coingecko-pro.json") - Transport string // "rest" (default) or "websocket" + Transport string // "rest" (default) or "websocket" PaidOnly bool RequiresAuth bool } @@ -154,8 +153,7 @@ type commandInfo struct { APIEndpoints map[string]string `json:"api_endpoints,omitempty"` OASOperationID string `json:"oas_operation_id,omitempty"` OASOperationIDs map[string]string `json:"oas_operation_ids,omitempty"` - OASSpec string `json:"oas_spec,omitempty"` - OASSpecs map[string]string `json:"oas_specs,omitempty"` + OASSpec string `json:"oas_spec,omitempty"` } type commandCatalog struct { @@ -238,7 +236,6 @@ func runCommands(cmd *cobra.Command, args []string) error { info.OASOperationID = meta.OASOperationID info.OASOperationIDs = meta.OASOperationIDs info.OASSpec = meta.OASSpec - info.OASSpecs = meta.OASSpecs } catalog.Commands = append(catalog.Commands, info) diff --git a/cmd/dryrun.go b/cmd/dryrun.go index c9fd606..9bb28fd 100644 --- a/cmd/dryrun.go +++ b/cmd/dryrun.go @@ -93,19 +93,11 @@ func printDryRunFull(cfg *config.Config, cmdName, opKey, endpoint string, params } if meta, ok := commandMeta[cmdName]; ok { - // Set command-level defaults, then override with per-mode values if provided. out.OASSpec = meta.OASSpec out.OASOperationID = meta.OASOperationID - if opKey != "" { - if meta.OASSpecs != nil { - if v, ok := meta.OASSpecs[opKey]; ok { - out.OASSpec = v - } - } - if meta.OASOperationIDs != nil { - if v, ok := meta.OASOperationIDs[opKey]; ok { - out.OASOperationID = v - } + if opKey != "" && meta.OASOperationIDs != nil { + if v, ok := meta.OASOperationIDs[opKey]; ok { + out.OASOperationID = v } } } diff --git a/cmd/dryrun_test.go b/cmd/dryrun_test.go index 1022bd1..604deac 100644 --- a/cmd/dryrun_test.go +++ b/cmd/dryrun_test.go @@ -9,25 +9,16 @@ import ( "github.com/stretchr/testify/require" ) -// TestDryRun_PartialOverrideFallback verifies that when a command defines -// per-mode OASSpecs or OASOperationIDs maps but the requested opKey is NOT -// in those maps, the output falls back to the command-level defaults instead -// of emitting empty strings. -func TestDryRun_PartialOverrideFallback(t *testing.T) { - // Inject a synthetic command with partial override maps: - // OASSpecs has "--modeA" but NOT "--modeB". - // OASOperationIDs has "--modeA" but NOT "--modeB". - const cmdName = "_test_partial_override" +// TestDryRun_OperationIDFallback verifies that when a command defines +// per-mode OASOperationIDs but the requested opKey is NOT in the map, +// the output falls back to the command-level default OASOperationID. +func TestDryRun_OperationIDFallback(t *testing.T) { + const cmdName = "_test_opid_fallback" commandMeta[cmdName] = commandAnnotation{ OASSpec: "default-spec.json", OASOperationID: "default-op", - OASSpecs: map[string]string{ - "--modeA": "modeA-spec.json", - // "--modeB" deliberately absent - }, OASOperationIDs: map[string]string{ "--modeA": "modeA-op", - // "--modeB" deliberately absent }, RequiresAuth: true, } @@ -40,36 +31,29 @@ func TestDryRun_PartialOverrideFallback(t *testing.T) { withTestClientDemo(t, srv) tests := []struct { - name string - opKey string - wantSpec string + name string + opKey string wantOperationID string }{ { - name: "opKey present in override maps", + name: "opKey present in map", opKey: "--modeA", - wantSpec: "modeA-spec.json", wantOperationID: "modeA-op", }, { - name: "opKey missing from override maps falls back to defaults", + name: "opKey missing falls back to default", opKey: "--modeB", - wantSpec: "default-spec.json", wantOperationID: "default-op", }, { - name: "empty opKey uses defaults", + name: "empty opKey uses default", opKey: "", - wantSpec: "default-spec.json", wantOperationID: "default-op", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Call printDryRunFull directly by capturing its JSON output via stdout. - // We use executeCommand with a helper command that calls printDryRunFull. - // Instead, since printDryRunFull writes to stdout, capture it directly. stdout := captureStdout(t, func() { cfg, err := loadConfig() require.NoError(t, err) @@ -79,8 +63,8 @@ func TestDryRun_PartialOverrideFallback(t *testing.T) { var out dryRunOutput require.NoError(t, json.Unmarshal([]byte(stdout), &out)) - assert.Equal(t, tt.wantSpec, out.OASSpec, "OASSpec") - assert.Equal(t, tt.wantOperationID, out.OASOperationID, "OASOperationID") + assert.Equal(t, "default-spec.json", out.OASSpec) + assert.Equal(t, tt.wantOperationID, out.OASOperationID) }) } } From 3a5ecbf6623e684154e2bf7d48d2cacb0d2e89c7 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sun, 5 Apr 2026 10:27:28 +0800 Subject: [PATCH 09/11] fix: preserve address case for non-EVM chains (Solana, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lowercase normalization broke Solana token lookups — base58 addresses are case-sensitive, so lowercasing the key produced no match against the API response. Fix: try original case first, fall back to lowercase. This handles both Solana (case-sensitive base58) and Ethereum (API may return lowercase hex). --- cmd/contract.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/cmd/contract.go b/cmd/contract.go index cc63fe3..732123f 100644 --- a/cmd/contract.go +++ b/cmd/contract.go @@ -205,9 +205,6 @@ func runContract(cmd *cobra.Command, args []string) error { } } - // Normalize for case-insensitive API response key matching. - addressLower := strings.ToLower(address) - var price, marketCap, volume, change, reserve float64 if !onchain && platform != "" { @@ -215,7 +212,12 @@ func runContract(cmd *cobra.Command, args []string) error { if err != nil { return err } - if data, ok := resp[addressLower]; ok { + // Try original case first (Solana is case-sensitive), then lowercase (Ethereum). + data, ok := resp[address] + if !ok { + data, ok = resp[strings.ToLower(address)] + } + if ok { price = data[vs] marketCap = data[vs+"_market_cap"] volume = data[vs+"_24h_vol"] @@ -239,7 +241,14 @@ func runContract(cmd *cobra.Command, args []string) error { return err } - priceStr, ok := resp.Data.Attributes.TokenPrices[addressLower] + // Try original case first (Solana is case-sensitive), then lowercase (Ethereum). + attrs := resp.Data.Attributes + addrKey := address + if _, ok := attrs.TokenPrices[addrKey]; !ok { + addrKey = strings.ToLower(address) + } + + priceStr, ok := attrs.TokenPrices[addrKey] if !ok { return fmt.Errorf("no data returned for address %s", address) } @@ -249,16 +258,16 @@ func runContract(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing price: %w", err) } - if mcStr, ok := resp.Data.Attributes.MarketCapUSD[addressLower]; ok { + if mcStr, ok := attrs.MarketCapUSD[addrKey]; ok { marketCap, _ = strconv.ParseFloat(mcStr, 64) } - if volStr, ok := resp.Data.Attributes.H24VolumeUSD[addressLower]; ok { + if volStr, ok := attrs.H24VolumeUSD[addrKey]; ok { volume, _ = strconv.ParseFloat(volStr, 64) } - if chgStr, ok := resp.Data.Attributes.H24PriceChangePct[addressLower]; ok { + if chgStr, ok := attrs.H24PriceChangePct[addrKey]; ok { change, _ = strconv.ParseFloat(chgStr, 64) } - if resStr, ok := resp.Data.Attributes.TotalReserveInUSD[addressLower]; ok { + if resStr, ok := attrs.TotalReserveInUSD[addrKey]; ok { reserve, _ = strconv.ParseFloat(resStr, 64) } From 777de1b0af6ec40b437ae738f8d80e528a7922e2 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sun, 5 Apr 2026 10:29:29 +0800 Subject: [PATCH 10/11] refactor: remove unnecessary lowercase fallback for address lookup The API echoes back the same case we send, so original case always matches. The lowercase fallback was defending against a scenario that doesn't happen and broke Solana addresses unnecessarily. --- cmd/contract.go | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/cmd/contract.go b/cmd/contract.go index 732123f..7b2ebd2 100644 --- a/cmd/contract.go +++ b/cmd/contract.go @@ -212,12 +212,7 @@ func runContract(cmd *cobra.Command, args []string) error { if err != nil { return err } - // Try original case first (Solana is case-sensitive), then lowercase (Ethereum). - data, ok := resp[address] - if !ok { - data, ok = resp[strings.ToLower(address)] - } - if ok { + if data, ok := resp[address]; ok { price = data[vs] marketCap = data[vs+"_market_cap"] volume = data[vs+"_24h_vol"] @@ -241,14 +236,8 @@ func runContract(cmd *cobra.Command, args []string) error { return err } - // Try original case first (Solana is case-sensitive), then lowercase (Ethereum). attrs := resp.Data.Attributes - addrKey := address - if _, ok := attrs.TokenPrices[addrKey]; !ok { - addrKey = strings.ToLower(address) - } - - priceStr, ok := attrs.TokenPrices[addrKey] + priceStr, ok := attrs.TokenPrices[address] if !ok { return fmt.Errorf("no data returned for address %s", address) } @@ -258,16 +247,16 @@ func runContract(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing price: %w", err) } - if mcStr, ok := attrs.MarketCapUSD[addrKey]; ok { + if mcStr, ok := attrs.MarketCapUSD[address]; ok { marketCap, _ = strconv.ParseFloat(mcStr, 64) } - if volStr, ok := attrs.H24VolumeUSD[addrKey]; ok { + if volStr, ok := attrs.H24VolumeUSD[address]; ok { volume, _ = strconv.ParseFloat(volStr, 64) } - if chgStr, ok := attrs.H24PriceChangePct[addrKey]; ok { + if chgStr, ok := attrs.H24PriceChangePct[address]; ok { change, _ = strconv.ParseFloat(chgStr, 64) } - if resStr, ok := attrs.TotalReserveInUSD[addrKey]; ok { + if resStr, ok := attrs.TotalReserveInUSD[address]; ok { reserve, _ = strconv.ParseFloat(resStr, 64) } From eaee17f13314135f230918065d70140e098c8ff8 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Sun, 5 Apr 2026 10:39:32 +0800 Subject: [PATCH 11/11] feat: support comma-separated --vs currencies in contract command CG aggregated path sends multiple currencies natively to the API in a single call (vs_currencies=usd,eur,sgd). Onchain path fetches USD from the API and converts to each currency via a single /exchange_rates call. Single currency output is backward compatible (flat JSON, no Currency column). Multiple currencies nest JSON by currency and add a Currency column to the table. --- cmd/contract.go | 207 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 151 insertions(+), 56 deletions(-) diff --git a/cmd/contract.go b/cmd/contract.go index 7b2ebd2..57b2071 100644 --- a/cmd/contract.go +++ b/cmd/contract.go @@ -32,6 +32,7 @@ identifiers from different API specs — they are not interchangeable.`, Example: ` cg contract --address 0x1f98... # smart routing cg contract --address 0x1f98... --platform ethereum # explicit CG aggregated cg contract --address 0x1f98... --platform ethereum --vs eur + cg contract --address 0x1f98... --platform ethereum --vs usd,eur,sgd cg contract --address 0x1f98... --onchain # smart routing, onchain only cg contract --address 0x1f98... --network eth --onchain cg contract --address 0x1f98... --network eth --onchain --vs eur`, @@ -43,7 +44,7 @@ func init() { contractCmd.Flags().String("platform", "", "Platform ID for aggregated mode (e.g. ethereum). See https://docs.coingecko.com/reference/asset-platforms-list") contractCmd.Flags().String("network", "", "Network ID for onchain mode (e.g. eth). See https://docs.coingecko.com/reference/networks-list") contractCmd.Flags().Bool("onchain", false, "Use DEX price from GeckoTerminal") - contractCmd.Flags().String("vs", "usd", "Target currency") + contractCmd.Flags().String("vs", "usd", "Target currency (comma-separated for multiple, e.g. usd,eur,sgd)") contractCmd.Flags().String("export", "", "Export to CSV file path") rootCmd.AddCommand(contractCmd) } @@ -53,6 +54,16 @@ type resolvedAddress struct { platform string // CG asset platform ID (e.g. "ethereum"), may be empty } +// contractRow holds price data for one currency. +type contractRow struct { + currency string + price float64 + marketCap float64 + volume float64 + change float64 + reserve float64 +} + // resolveAddress searches onchain pools to find which network a contract address // lives on, then maps the network to its CoinGecko asset platform ID. // The two API calls run in parallel since they are independent. @@ -121,8 +132,11 @@ func runContract(cmd *cobra.Command, args []string) error { platform, _ := cmd.Flags().GetString("platform") network, _ := cmd.Flags().GetString("network") onchain, _ := cmd.Flags().GetBool("onchain") - vs, _ := cmd.Flags().GetString("vs") - vs = strings.ToLower(vs) + vsRaw, _ := cmd.Flags().GetString("vs") + currencies := splitTrim(strings.ToLower(vsRaw)) + if len(currencies) == 0 { + currencies = []string{"usd"} + } exportPath, _ := cmd.Flags().GetString("export") jsonOut := outputJSON(cmd) @@ -147,6 +161,9 @@ func runContract(cmd *cobra.Command, args []string) error { return err } + // vs as comma-separated string for API params. + vs := strings.Join(currencies, ",") + if isDryRun(cmd) { if needsResolve { note := "Smart routing: searches pools to determine network, then fetches /onchain/networks for platform mapping. " + @@ -205,18 +222,24 @@ func runContract(cmd *cobra.Command, args []string) error { } } - var price, marketCap, volume, change, reserve float64 + var rows []contractRow + // CG aggregated: API natively supports multiple vs_currencies. if !onchain && platform != "" { resp, err := client.SimpleTokenPrice(ctx, platform, []string{address}, vs) if err != nil { return err } if data, ok := resp[address]; ok { - price = data[vs] - marketCap = data[vs+"_market_cap"] - volume = data[vs+"_24h_vol"] - change = data[vs+"_24h_change"] + for _, cur := range currencies { + rows = append(rows, contractRow{ + currency: cur, + price: data[cur], + marketCap: data[cur+"_market_cap"], + volume: data[cur+"_24h_vol"], + change: data[cur+"_24h_change"], + }) + } } else if network != "" { warnf("No aggregated price found; falling back to onchain (network=%s)\n", network) onchain = true @@ -230,6 +253,7 @@ func runContract(cmd *cobra.Command, args []string) error { onchain = true } + // Onchain: USD only, convert to each requested currency via /exchange_rates. if onchain { resp, err := client.OnchainSimpleTokenPrice(ctx, network, []string{address}) if err != nil { @@ -242,82 +266,153 @@ func runContract(cmd *cobra.Command, args []string) error { return fmt.Errorf("no data returned for address %s", address) } - price, err = strconv.ParseFloat(priceStr, 64) + priceUSD, err := strconv.ParseFloat(priceStr, 64) if err != nil { return fmt.Errorf("parsing price: %w", err) } - if mcStr, ok := attrs.MarketCapUSD[address]; ok { - marketCap, _ = strconv.ParseFloat(mcStr, 64) + var mcapUSD, volUSD, changeUSD, reserveUSD float64 + if v, ok := attrs.MarketCapUSD[address]; ok { + mcapUSD, _ = strconv.ParseFloat(v, 64) } - if volStr, ok := attrs.H24VolumeUSD[address]; ok { - volume, _ = strconv.ParseFloat(volStr, 64) + if v, ok := attrs.H24VolumeUSD[address]; ok { + volUSD, _ = strconv.ParseFloat(v, 64) } - if chgStr, ok := attrs.H24PriceChangePct[address]; ok { - change, _ = strconv.ParseFloat(chgStr, 64) + if v, ok := attrs.H24PriceChangePct[address]; ok { + changeUSD, _ = strconv.ParseFloat(v, 64) } - if resStr, ok := attrs.TotalReserveInUSD[address]; ok { - reserve, _ = strconv.ParseFloat(resStr, 64) + if v, ok := attrs.TotalReserveInUSD[address]; ok { + reserveUSD, _ = strconv.ParseFloat(v, 64) } - if vs != "usd" { - rates, err := client.ExchangeRates(ctx) + // Single /exchange_rates call covers all currencies. + needsConversion := len(currencies) > 1 || currencies[0] != "usd" + var rates *api.ExchangeRatesResponse + if needsConversion { + rates, err = client.ExchangeRates(ctx) if err != nil { return fmt.Errorf("fetching exchange rates: %w", err) } - usdRate, usdOK := rates.Rates["usd"] - targetRate, targetOK := rates.Rates[vs] - if !usdOK || !targetOK { - return fmt.Errorf("unsupported currency %q", vs) + } + + for _, cur := range currencies { + row := contractRow{ + currency: cur, + price: priceUSD, + marketCap: mcapUSD, + volume: volUSD, + change: changeUSD, + reserve: reserveUSD, } - factor := targetRate.Value / usdRate.Value - price *= factor - marketCap *= factor - volume *= factor - reserve *= factor - // 24h change % stays the same + if cur != "usd" { + usdRate, usdOK := rates.Rates["usd"] + targetRate, targetOK := rates.Rates[cur] + if !usdOK || !targetOK { + return fmt.Errorf("unsupported currency %q", cur) + } + factor := targetRate.Value / usdRate.Value + row.price *= factor + row.marketCap *= factor + row.volume *= factor + row.reserve *= factor + // 24h change % stays the same + } + rows = append(rows, row) } } if jsonOut { - data := map[string]interface{}{ - "price": price, - "market_cap": marketCap, - "volume_24h": volume, - "change_24h": change, + if len(currencies) == 1 { + // Single currency: flat output (backward compatible). + r := rows[0] + data := map[string]interface{}{ + "price": r.price, + "market_cap": r.marketCap, + "volume_24h": r.volume, + "change_24h": r.change, + } + if onchain { + data["total_reserve"] = r.reserve + } + return printJSONRaw(map[string]interface{}{address: data}) } - if onchain { - data["total_reserve"] = reserve + // Multiple currencies: nested by currency. + currencyData := make(map[string]interface{}, len(rows)) + for _, r := range rows { + data := map[string]interface{}{ + "price": r.price, + "market_cap": r.marketCap, + "volume_24h": r.volume, + "change_24h": r.change, + } + if onchain { + data["total_reserve"] = r.reserve + } + currencyData[r.currency] = data } - return printJSONRaw(map[string]interface{}{address: data}) + return printJSONRaw(map[string]interface{}{address: currencyData}) } - headers := []string{"Address", "Price", "Market Cap", "24h Volume", "24h Change"} - row := []string{ - display.SanitizeCell(address), - display.FormatPrice(price, vs), - display.FormatLargeNumber(marketCap, vs), - display.FormatLargeNumber(volume, vs), - display.ColorPercent(change), - } + headers := []string{"Address", "Currency", "Price", "Market Cap", "24h Volume", "24h Change"} if onchain { headers = append(headers, "Reserve") - row = append(row, display.FormatLargeNumber(reserve, vs)) } - display.PrintTable(headers, [][]string{row}) + // Single currency: omit Currency column for cleaner output. + if len(currencies) == 1 { + headers = []string{"Address", "Price", "Market Cap", "24h Volume", "24h Change"} + if onchain { + headers = append(headers, "Reserve") + } + } - if exportPath != "" { - csvRow := []string{ - display.SanitizeCell(address), - fmt.Sprintf("%.8f", price), - fmt.Sprintf("%.2f", marketCap), - fmt.Sprintf("%.2f", volume), - fmt.Sprintf("%.2f", change), + var tableRows [][]string + var csvRows [][]string + for _, r := range rows { + var tableRow, csvRow []string + if len(currencies) == 1 { + tableRow = []string{ + display.SanitizeCell(address), + display.FormatPrice(r.price, r.currency), + display.FormatLargeNumber(r.marketCap, r.currency), + display.FormatLargeNumber(r.volume, r.currency), + display.ColorPercent(r.change), + } + csvRow = []string{ + display.SanitizeCell(address), + fmt.Sprintf("%.8f", r.price), + fmt.Sprintf("%.2f", r.marketCap), + fmt.Sprintf("%.2f", r.volume), + fmt.Sprintf("%.2f", r.change), + } + } else { + tableRow = []string{ + display.SanitizeCell(address), + strings.ToUpper(r.currency), + display.FormatPrice(r.price, r.currency), + display.FormatLargeNumber(r.marketCap, r.currency), + display.FormatLargeNumber(r.volume, r.currency), + display.ColorPercent(r.change), + } + csvRow = []string{ + display.SanitizeCell(address), + strings.ToUpper(r.currency), + fmt.Sprintf("%.8f", r.price), + fmt.Sprintf("%.2f", r.marketCap), + fmt.Sprintf("%.2f", r.volume), + fmt.Sprintf("%.2f", r.change), + } } if onchain { - csvRow = append(csvRow, fmt.Sprintf("%.2f", reserve)) + tableRow = append(tableRow, display.FormatLargeNumber(r.reserve, r.currency)) + csvRow = append(csvRow, fmt.Sprintf("%.2f", r.reserve)) } - if err := exportCSV(exportPath, headers, [][]string{csvRow}); err != nil { + tableRows = append(tableRows, tableRow) + csvRows = append(csvRows, csvRow) + } + display.PrintTable(headers, tableRows) + + if exportPath != "" { + if err := exportCSV(exportPath, headers, csvRows); err != nil { return err } }