From 26a1c39fdd906bb4a1f05a65fc943b3ed4eba4d1 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:01:08 +0100 Subject: [PATCH] Add evm token-info command for token metadata and pricing - GET /v1/evm/token-info/{address} with --chain-ids (required), --historical-prices, --limit, --offset - Key-value text output: chain, symbol, name, decimals, price, total supply, market cap, logo, historical prices - Full OpenAPI spec coverage, reuses historicalPrice from balances.go - 6 E2E tests: native text/JSON, ERC20, historical prices text/JSON, required flag validation --- cmd/sim/evm/evm.go | 1 + cmd/sim/evm/token_info.go | 137 +++++++++++++++++++++++++++++++++ cmd/sim/evm/token_info_test.go | 126 ++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 cmd/sim/evm/token_info.go create mode 100644 cmd/sim/evm/token_info_test.go diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go index 17b4f97..ab1b83b 100644 --- a/cmd/sim/evm/evm.go +++ b/cmd/sim/evm/evm.go @@ -45,6 +45,7 @@ func NewEvmCmd() *cobra.Command { cmd.AddCommand(NewActivityCmd()) cmd.AddCommand(NewTransactionsCmd()) cmd.AddCommand(NewCollectiblesCmd()) + cmd.AddCommand(NewTokenInfoCmd()) return cmd } diff --git a/cmd/sim/evm/token_info.go b/cmd/sim/evm/token_info.go new file mode 100644 index 0000000..66f0ba9 --- /dev/null +++ b/cmd/sim/evm/token_info.go @@ -0,0 +1,137 @@ +package evm + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/output" +) + +// NewTokenInfoCmd returns the `sim evm token-info` command. +func NewTokenInfoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "token-info
", + Short: "Get token metadata and pricing", + Long: "Return metadata and pricing for a token contract address (or \"native\" for the\n" + + "chain's native asset) on a specified chain.\n\n" + + "Examples:\n" + + " dune sim evm token-info native --chain-ids 1\n" + + " dune sim evm token-info 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --chain-ids 8453\n" + + " dune sim evm token-info native --chain-ids 1 --historical-prices 720,168,24 -o json", + Args: cobra.ExactArgs(1), + RunE: runTokenInfo, + } + + cmd.Flags().String("chain-ids", "", "Chain ID (required)") + cmd.Flags().String("historical-prices", "", "Hour offsets for historical prices (e.g. 720,168,24)") + cmd.Flags().Int("limit", 0, "Max results") + cmd.Flags().String("offset", "", "Pagination cursor from previous response") + _ = cmd.MarkFlagRequired("chain-ids") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +type tokensResponse struct { + ContractAddress string `json:"contract_address"` + Tokens []tokenInfo `json:"tokens"` + Warnings []warningEntry `json:"warnings,omitempty"` + NextOffset string `json:"next_offset,omitempty"` +} + +type tokenInfo struct { + Chain string `json:"chain"` + ChainID int64 `json:"chain_id"` + Symbol string `json:"symbol,omitempty"` + Name string `json:"name,omitempty"` + Decimals int `json:"decimals,omitempty"` + PriceUSD float64 `json:"price_usd"` + HistoricalPrices []historicalPrice `json:"historical_prices,omitempty"` + TotalSupply string `json:"total_supply,omitempty"` + MarketCap float64 `json:"market_cap,omitempty"` + Logo string `json:"logo,omitempty"` +} + +func runTokenInfo(cmd *cobra.Command, args []string) error { + client, err := requireSimClient(cmd) + if err != nil { + return err + } + + address := args[0] + params := url.Values{} + + if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { + params.Set("chain_ids", v) + } + if v, _ := cmd.Flags().GetString("historical-prices"); v != "" { + params.Set("historical_prices", v) + } + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + params.Set("limit", fmt.Sprintf("%d", v)) + } + if v, _ := cmd.Flags().GetString("offset"); v != "" { + params.Set("offset", v) + } + + data, err := client.Get(cmd.Context(), "/v1/evm/token-info/"+address, params) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + var raw json.RawMessage = data + return output.PrintJSON(w, raw) + default: + var resp tokensResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + // Print warnings to stderr. + printWarnings(cmd, resp.Warnings) + + if len(resp.Tokens) == 0 { + fmt.Fprintln(w, "No token info found.") + return nil + } + + // Key-value display for each token entry. + for i, t := range resp.Tokens { + if i > 0 { + fmt.Fprintln(w) + } + fmt.Fprintf(w, "Chain: %s (ID: %d)\n", t.Chain, t.ChainID) + if t.Symbol != "" { + fmt.Fprintf(w, "Symbol: %s\n", t.Symbol) + } + if t.Name != "" { + fmt.Fprintf(w, "Name: %s\n", t.Name) + } + fmt.Fprintf(w, "Decimals: %d\n", t.Decimals) + fmt.Fprintf(w, "Price USD: %s\n", formatUSD(t.PriceUSD)) + if t.TotalSupply != "" { + fmt.Fprintf(w, "Total Supply: %s\n", t.TotalSupply) + } + if t.MarketCap > 0 { + fmt.Fprintf(w, "Market Cap: %s\n", formatUSD(t.MarketCap)) + } + if t.Logo != "" { + fmt.Fprintf(w, "Logo: %s\n", t.Logo) + } + for _, hp := range t.HistoricalPrices { + fmt.Fprintf(w, "Price %dh ago: %s\n", hp.OffsetHours, formatUSD(hp.PriceUSD)) + } + } + + if resp.NextOffset != "" { + fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) + } + return nil + } +} diff --git a/cmd/sim/evm/token_info_test.go b/cmd/sim/evm/token_info_test.go new file mode 100644 index 0000000..8537381 --- /dev/null +++ b/cmd/sim/evm/token_info_test.go @@ -0,0 +1,126 @@ +package evm_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvmTokenInfo_Native_Text(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1"}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Chain:") + assert.Contains(t, out, "Symbol:") + assert.Contains(t, out, "Price USD:") +} + +func TestEvmTokenInfo_Native_JSON(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "contract_address") + assert.Contains(t, resp, "tokens") + + tokens, ok := resp["tokens"].([]interface{}) + require.True(t, ok) + require.NotEmpty(t, tokens) + + token, ok := tokens[0].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, token, "chain") + assert.Contains(t, token, "symbol") + assert.Contains(t, token, "price_usd") +} + +func TestEvmTokenInfo_ERC20(t *testing.T) { + key := simAPIKey(t) + + // USDC on Base + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "--chain-ids", "8453", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "tokens") + + tokens, ok := resp["tokens"].([]interface{}) + require.True(t, ok) + if len(tokens) > 0 { + token, ok := tokens[0].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, token, "symbol") + assert.Contains(t, token, "decimals") + } +} + +func TestEvmTokenInfo_HistoricalPrices(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1", "--historical-prices", "168,24", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + + tokens, ok := resp["tokens"].([]interface{}) + require.True(t, ok) + require.NotEmpty(t, tokens) + + token, ok := tokens[0].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, token, "historical_prices", "historical_prices should be present when --historical-prices is set") +} + +func TestEvmTokenInfo_HistoricalPrices_Text(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1", "--historical-prices", "168"}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Price 168h ago:") +} + +func TestEvmTokenInfo_RequiresChainIds(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "chain-ids") +}