diff --git a/CLAUDE.md b/CLAUDE.md index 5e4968a..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,6 +103,9 @@ 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` (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) | — | ## Distribution @@ -134,3 +137,8 @@ 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 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 d2b11d2..3587b3d 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -16,9 +16,9 @@ type commandAnnotation struct { OASOperationID string OASOperationIDs map[string]string OASSpec string - Transport string // "rest" (default) or "websocket" - PaidOnly bool - RequiresAuth bool + Transport string // "rest" (default) or "websocket" + PaidOnly bool + RequiresAuth bool } var commandMeta = map[string]commandAnnotation{ @@ -77,6 +77,20 @@ 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}", + "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, + }, } type flagInfo struct { @@ -133,12 +147,13 @@ type commandInfo struct { Examples []string `json:"examples,omitempty"` OutputFormats []string `json:"output_formats"` RequiresAuth bool `json:"requires_auth"` - PaidOnly bool `json:"paid_only"` + PaidOnly bool `json:"paid_only"` 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"` } type commandCatalog struct { @@ -220,6 +235,7 @@ func runCommands(cmd *cobra.Command, args []string) error { info.APIEndpoints = meta.APIEndpoints info.OASOperationID = meta.OASOperationID info.OASOperationIDs = meta.OASOperationIDs + info.OASSpec = meta.OASSpec } catalog.Commands = append(catalog.Commands, info) diff --git a/cmd/contract.go b/cmd/contract.go new file mode 100644 index 0000000..57b2071 --- /dev/null +++ b/cmd/contract.go @@ -0,0 +1,421 @@ +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" +) + +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. + +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... # 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`, + 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 (comma-separated for multiple, e.g. usd,eur,sgd)") + contractCmd.Flags().String("export", "", "Export to CSV file path") + 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 +} + +// 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. +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") + network, _ := cmd.Flags().GetString("network") + onchain, _ := cmd.Flags().GetBool("onchain") + 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) + + if !jsonOut { + display.PrintBanner() + } + + 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?") + } + + needsResolve := (!onchain && platform == "") || (onchain && network == "") + + cfg, err := loadConfig() + if err != nil { + 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. " + + "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", + "mcap_fdv_fallback": "true", + "include_total_reserve_in_usd": "true", + } + note := "" + if vs != "usd" { + note = fmt.Sprintf("Additional request: GET /exchange_rates (currency conversion from USD to %s)", vs) + } + return printDryRunFull(cfg, "contract", "--onchain", + fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s", url.PathEscape(network), 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/"+url.PathEscape(platform), params, nil) + } + + client := newAPIClient(cfg) + ctx := cmd.Context() + + 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 + } + } + + 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 { + 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 + } 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 + } + + // Onchain: USD only, convert to each requested currency via /exchange_rates. + if onchain { + resp, err := client.OnchainSimpleTokenPrice(ctx, network, []string{address}) + if err != nil { + return err + } + + attrs := resp.Data.Attributes + priceStr, ok := attrs.TokenPrices[address] + if !ok { + return fmt.Errorf("no data returned for address %s", address) + } + + priceUSD, err := strconv.ParseFloat(priceStr, 64) + if err != nil { + return fmt.Errorf("parsing price: %w", err) + } + + var mcapUSD, volUSD, changeUSD, reserveUSD float64 + if v, ok := attrs.MarketCapUSD[address]; ok { + mcapUSD, _ = strconv.ParseFloat(v, 64) + } + if v, ok := attrs.H24VolumeUSD[address]; ok { + volUSD, _ = strconv.ParseFloat(v, 64) + } + if v, ok := attrs.H24PriceChangePct[address]; ok { + changeUSD, _ = strconv.ParseFloat(v, 64) + } + if v, ok := attrs.TotalReserveInUSD[address]; ok { + reserveUSD, _ = strconv.ParseFloat(v, 64) + } + + // 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) + } + } + + for _, cur := range currencies { + row := contractRow{ + currency: cur, + price: priceUSD, + marketCap: mcapUSD, + volume: volUSD, + change: changeUSD, + reserve: reserveUSD, + } + 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 { + 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}) + } + // 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: currencyData}) + } + + headers := []string{"Address", "Currency", "Price", "Market Cap", "24h Volume", "24h Change"} + if onchain { + headers = append(headers, "Reserve") + } + // 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") + } + } + + 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 { + tableRow = append(tableRow, display.FormatLargeNumber(r.reserve, r.currency)) + csvRow = append(csvRow, fmt.Sprintf("%.2f", r.reserve)) + } + tableRows = append(tableRows, tableRow) + csvRows = append(csvRows, csvRow) + } + display.PrintTable(headers, tableRows) + + if exportPath != "" { + 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..33bf0f2 --- /dev/null +++ b/cmd/contract_test.go @@ -0,0 +1,832 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "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_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(), "no pools found") +} + +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_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(), "no pools found") +} + +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) + assert.Equal(t, "coingecko-demo.json", out.OASSpec) +} + +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() + 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)) + 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, "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) +} + +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/")) + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "2289.33", "6692452895.78", "965988358.73", "3.387", "0")) + }) + defer srv.Close() + withTestClientDemo(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/"): + _ = 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{}{ + "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/"): + _ = 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{}{ + "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_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/") + _ = json.NewEncoder(w).Encode(onchainPriceJSON(addr, "100.5", "500000", "250000", "2.5", "0")) + }) + defer srv.Close() + withTestClientDemo(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, 100.5, result[addr]["price"], 0.01) +} + +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") + + // OAS spec should be present (both modes use demo-compatible endpoints). + assert.Equal(t, "coingecko-demo.json", contractInfo.OASSpec) + + // Not paid-only — onchain endpoint is available on both demo and paid tiers. + assert.False(t, contractInfo.PaidOnly) + + // 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) { + 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) +} + +// --------------------------------------------------------------------------- +// 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 r.URL.Path { + case "/onchain/search/pools": + _ = json.NewEncoder(w).Encode(poolSearchJSON("eth", "0xabc")) + case "/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/cmd/dryrun.go b/cmd/dryrun.go index 236760b..9bb28fd 100644 --- a/cmd/dryrun.go +++ b/cmd/dryrun.go @@ -94,10 +94,11 @@ func printDryRunFull(cfg *config.Config, cmdName, opKey, endpoint string, params if meta, ok := commandMeta[cmdName]; ok { out.OASSpec = meta.OASSpec + out.OASOperationID = meta.OASOperationID if opKey != "" && meta.OASOperationIDs != nil { - out.OASOperationID = meta.OASOperationIDs[opKey] - } else { - out.OASOperationID = meta.OASOperationID + 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..604deac --- /dev/null +++ b/cmd/dryrun_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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", + OASOperationIDs: map[string]string{ + "--modeA": "modeA-op", + }, + 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 + wantOperationID string + }{ + { + name: "opKey present in map", + opKey: "--modeA", + wantOperationID: "modeA-op", + }, + { + name: "opKey missing falls back to default", + opKey: "--modeB", + wantOperationID: "default-op", + }, + { + name: "empty opKey uses default", + opKey: "", + wantOperationID: "default-op", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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, "default-spec.json", out.OASSpec) + assert.Equal(t, tt.wantOperationID, out.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) { diff --git a/internal/api/coins.go b/internal/api/coins.go index 445d31a..d841545 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,65 @@ 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. +// 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) { + params := url.Values{ + "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", + url.PathEscape(network), strings.Join(addresses, ","), params.Encode()), &result) + 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) { + 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..cf20120 100644 --- a/internal/api/coins_test.go +++ b/internal/api/coins_test.go @@ -514,6 +514,217 @@ 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")) + 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(`{ + "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"}, + "total_reserve_in_usd": {"0xaddr": "12345678.90"} + } + } + }`)) + }) + 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"]) + assert.Equal(t, "12345678.90", result.Data.Attributes.TotalReserveInUSD["0xaddr"]) +} + +func TestOnchainSimpleTokenPrice_DemoTier(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + 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() + + result, err := c.OnchainSimpleTokenPrice(context.Background(), "eth", []string{"0xaddr"}) + require.NoError(t, err) + assert.Equal(t, "100.5", result.Data.Attributes.TokenPrices["0xaddr"]) +} + +// --------------------------------------------------------------------------- +// 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) +} + +// --------------------------------------------------------------------------- +// 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 ce9d509..06eef0d 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,82 @@ 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"` + TotalReserveInUSD map[string]string `json:"total_reserve_in_usd"` + } `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"` +} + +// 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"`