Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
114 changes: 114 additions & 0 deletions cmd/token_price.go
Original file line number Diff line number Diff line change
@@ -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
}
122 changes: 122 additions & 0 deletions cmd/token_price_test.go
Original file line number Diff line number Diff line change
@@ -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"`)
}
13 changes: 13 additions & 0 deletions internal/api/coins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
56 changes: 56 additions & 0 deletions internal/api/coins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down