diff --git a/cmd/commands.go b/cmd/commands.go index d87d69b..5753085 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -28,6 +28,12 @@ var commandMeta = map[string]commandAnnotation{ OASSpec: "coingecko-demo.json", RequiresAuth: true, }, + "token-price": { + APIEndpoint: "/simple/token_price/{platform}", + OASOperationID: "simple-token-price", + OASSpec: "coingecko-demo.json", + RequiresAuth: true, + }, "markets": { APIEndpoint: "/coins/markets", OASOperationID: "coins-markets", diff --git a/cmd/token_price.go b/cmd/token_price.go new file mode 100644 index 0000000..f6aeb7e --- /dev/null +++ b/cmd/token_price.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/coingecko/coingecko-cli/internal/display" + + "github.com/spf13/cobra" +) + +var tokenPriceCmd = &cobra.Command{ + Use: "token-price", + Short: "Get current price for tokens by contract address", + Long: "Fetch current prices by token contract addresses on a specific platform. Use --address for one or more contract addresses and --platform for the chain (e.g. ethereum, base, arbitrum-one).", + Example: ` cg token-price --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --platform ethereum + cg token-price --address 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7 --platform ethereum + cg token-price --address 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --platform base --vs eur + cg token-price --address 0x912CE59144191C1204E64559FE8253a0e49E6548 --platform arbitrum-one -o json`, + RunE: runTokenPrice, +} + +func init() { + tokenPriceCmd.Flags().String("address", "", "Comma-separated contract addresses") + tokenPriceCmd.Flags().String("platform", "", "Platform ID (e.g. ethereum, base, arbitrum-one, polygon-pos)") + tokenPriceCmd.Flags().String("vs", "usd", "Target currency") + rootCmd.AddCommand(tokenPriceCmd) +} + +func runTokenPrice(cmd *cobra.Command, args []string) error { + addressStr, _ := cmd.Flags().GetString("address") + platform, _ := cmd.Flags().GetString("platform") + vs, _ := cmd.Flags().GetString("vs") + jsonOut := outputJSON(cmd) + + if !jsonOut { + display.PrintBanner() + } + + if addressStr == "" { + return fmt.Errorf("provide --address") + } + + if platform == "" { + return fmt.Errorf("provide --platform (e.g. ethereum, base, arbitrum-one)") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + endpoint := fmt.Sprintf("/simple/token_price/%s", platform) + + // Short-circuit before any API calls in dry-run mode. + if isDryRun(cmd) { + params := map[string]string{ + "contract_addresses": addressStr, + "vs_currencies": vs, + "include_24hr_change": "true", + } + return printDryRun(cfg, "token-price", endpoint, params, nil) + } + + client := newAPIClient(cfg) + ctx := cmd.Context() + + addresses := splitTrim(addressStr) + prices, err := client.SimpleTokenPrice(ctx, platform, addresses, vs) + if err != nil { + return err + } + + if len(prices) == 0 { + return fmt.Errorf("no valid tokens found") + } + + if jsonOut { + return printJSONRaw(prices) + } + + // Warn about requested addresses that returned no data. + responseKeys := make(map[string]bool, len(prices)) + for k := range prices { + responseKeys[strings.ToLower(k)] = true + } + for _, addr := range addresses { + if !responseKeys[strings.ToLower(addr)] { + warnf("Warning: no data returned for %q\n", addr) + } + } + + // Sort response keys for deterministic table output. + keys := make([]string, 0, len(prices)) + for k := range prices { + keys = append(keys, k) + } + sort.Strings(keys) + + headers := []string{"Contract", "Price", "24h Change"} + var rows [][]string + for _, addr := range keys { + data := prices[addr] + rows = append(rows, []string{ + display.SanitizeCell(addr), + display.FormatPrice(data[vs], vs), + display.ColorPercent(data[vs + "_24h_change"]), + }) + } + + display.PrintTable(headers, rows) + return nil +} diff --git a/cmd/token_price_test.go b/cmd/token_price_test.go new file mode 100644 index 0000000..55fbd0a --- /dev/null +++ b/cmd/token_price_test.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/coingecko/coingecko-cli/internal/api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenPrice_MissingAddress(t *testing.T) { + _, _, err := executeCommand(t, "token-price", "--platform", "ethereum", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--address") +} + +func TestTokenPrice_MissingPlatform(t *testing.T) { + _, _, err := executeCommand(t, "token-price", "--address", "0x1234", "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--platform") +} + +func TestTokenPrice_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) + + stdout, _, err := executeCommand(t, "token-price", + "--address", "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "--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.Equal(t, "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", out.Params["contract_addresses"]) + assert.Contains(t, out.URL, "/simple/token_price/ethereum") +} + +func TestTokenPrice_JSONOutput(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/simple/token_price/ethereum", r.URL.Path) + assert.Equal(t, "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", r.URL.Query().Get("contract_addresses")) + resp := api.PriceResponse{ + "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {"usd": 7.42, "usd_24h_change": -1.3}, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, _, err := executeCommand(t, "token-price", + "--address", "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "--platform", "ethereum", + "-o", "json") + require.NoError(t, err) + + var prices api.PriceResponse + require.NoError(t, json.Unmarshal([]byte(stdout), &prices)) + assert.Equal(t, 7.42, prices["0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"]["usd"]) +} + +func TestTokenPrice_MultipleAddresses(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Query().Get("contract_addresses"), ",") + resp := api.PriceResponse{ + "0xaaa": {"usd": 1.0, "usd_24h_change": 0.5}, + "0xbbb": {"usd": 2.0, "usd_24h_change": -0.5}, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + stdout, _, err := executeCommand(t, "token-price", + "--address", "0xaaa,0xbbb", + "--platform", "ethereum", + "-o", "json") + require.NoError(t, err) + + var prices api.PriceResponse + require.NoError(t, json.Unmarshal([]byte(stdout), &prices)) + assert.Len(t, prices, 2) +} + +func TestTokenPrice_NoResults(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(api.PriceResponse{}) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, _, err := executeCommand(t, "token-price", + "--address", "0xnotreal", + "--platform", "ethereum", + "-o", "json") + require.Error(t, err) + assert.Contains(t, err.Error(), "no valid tokens found") +} + +func TestTokenPrice_PartialMiss_WarnsOnStderr(t *testing.T) { + srv := newTestServer(func(w http.ResponseWriter, r *http.Request) { + resp := api.PriceResponse{ + "0xaaa": {"usd": 1.0, "usd_24h_change": 0.5}, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + withTestClientDemo(t, srv) + + _, stderr, err := executeCommand(t, "token-price", + "--address", "0xaaa,0xmissing", + "--platform", "ethereum") + require.NoError(t, err) + assert.Contains(t, stderr, `no data returned for "0xmissing"`) +} diff --git a/internal/api/coins.go b/internal/api/coins.go index 445d31a..741b989 100644 --- a/internal/api/coins.go +++ b/internal/api/coins.go @@ -33,6 +33,19 @@ func (c *Client) SimplePriceBySymbols(ctx context.Context, symbols []string, vsC return result, err } +// SimpleTokenPrice fetches current prices for tokens by contract address on a given platform. +// https://docs.coingecko.com/v3.0.1/reference/simple-token-price +func (c *Client) SimpleTokenPrice(ctx context.Context, platform string, contractAddresses []string, vsCurrency string) (PriceResponse, error) { + params := url.Values{ + "contract_addresses": {strings.Join(contractAddresses, ",")}, + "vs_currencies": {vsCurrency}, + "include_24hr_change": {"true"}, + } + var result PriceResponse + err := c.get(ctx, fmt.Sprintf("/simple/token_price/%s?%s", url.PathEscape(platform), params.Encode()), &result) + return result, err +} + // CoinMarkets fetches a paginated list of coins with market data. // https://docs.coingecko.com/v3.0.1/reference/coins-markets func (c *Client) CoinMarkets(ctx context.Context, vsCurrency string, perPage, page int, order, category string) ([]MarketCoin, error) { diff --git a/internal/api/coins_test.go b/internal/api/coins_test.go index 39fc3fb..b40b84a 100644 --- a/internal/api/coins_test.go +++ b/internal/api/coins_test.go @@ -52,6 +52,62 @@ func TestSimplePrice(t *testing.T) { assert.Equal(t, -1.2, result["ethereum"]["usd_24h_change"]) } +// --------------------------------------------------------------------------- +// SimpleTokenPrice +// --------------------------------------------------------------------------- + +func TestSimpleTokenPrice(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, "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", q.Get("contract_addresses")) + assert.Equal(t, "usd", q.Get("vs_currencies")) + assert.Equal(t, "true", q.Get("include_24hr_change")) + + w.WriteHeader(200) + _, _ = w.Write([]byte(`{ + "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {"usd": 7.42, "usd_24h_change": -1.3} + }`)) + }) + defer srv.Close() + + result, err := c.SimpleTokenPrice(context.Background(), "ethereum", []string{"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"}, "usd") + require.NoError(t, err) + assert.Equal(t, 7.42, result["0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"]["usd"]) + assert.Equal(t, -1.3, result["0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"]["usd_24h_change"]) +} + +func TestSimpleTokenPrice_MultipleAddresses(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/simple/token_price/ethereum", r.URL.Path) + assert.Contains(t, r.URL.Query().Get("contract_addresses"), ",") + + w.WriteHeader(200) + _, _ = w.Write([]byte(`{ + "0xaaa": {"usd": 1.0}, + "0xbbb": {"usd": 2.0} + }`)) + }) + defer srv.Close() + + result, err := c.SimpleTokenPrice(context.Background(), "ethereum", []string{"0xaaa", "0xbbb"}, "usd") + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, 1.0, result["0xaaa"]["usd"]) + assert.Equal(t, 2.0, result["0xbbb"]["usd"]) +} + +func TestSimpleTokenPrice_PlatformEscaping(t *testing.T) { + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/simple/token_price/arbitrum-one", r.URL.Path) + _, _ = w.Write([]byte(`{}`)) + }) + defer srv.Close() + + _, err := c.SimpleTokenPrice(context.Background(), "arbitrum-one", []string{"0xaaa"}, "usd") + require.NoError(t, err) +} + // --------------------------------------------------------------------------- // CoinMarkets // ---------------------------------------------------------------------------