Skip to content

Commit 2035274

Browse files
committed
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
1 parent c380067 commit 2035274

File tree

3 files changed

+264
-0
lines changed

3 files changed

+264
-0
lines changed

cmd/sim/evm/evm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func NewEvmCmd() *cobra.Command {
4545
cmd.AddCommand(NewActivityCmd())
4646
cmd.AddCommand(NewTransactionsCmd())
4747
cmd.AddCommand(NewCollectiblesCmd())
48+
cmd.AddCommand(NewTokenInfoCmd())
4849

4950
return cmd
5051
}

cmd/sim/evm/token_info.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package evm
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/duneanalytics/cli/output"
11+
)
12+
13+
// NewTokenInfoCmd returns the `sim evm token-info` command.
14+
func NewTokenInfoCmd() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "token-info <address>",
17+
Short: "Get token metadata and pricing",
18+
Long: "Return metadata and pricing for a token contract address (or \"native\" for the\n" +
19+
"chain's native asset) on a specified chain.\n\n" +
20+
"Examples:\n" +
21+
" dune sim evm token-info native --chain-ids 1\n" +
22+
" dune sim evm token-info 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --chain-ids 8453\n" +
23+
" dune sim evm token-info native --chain-ids 1 --historical-prices 720,168,24 -o json",
24+
Args: cobra.ExactArgs(1),
25+
RunE: runTokenInfo,
26+
}
27+
28+
cmd.Flags().String("chain-ids", "", "Chain ID (required)")
29+
cmd.Flags().String("historical-prices", "", "Hour offsets for historical prices (e.g. 720,168,24)")
30+
cmd.Flags().Int("limit", 0, "Max results")
31+
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
32+
_ = cmd.MarkFlagRequired("chain-ids")
33+
output.AddFormatFlag(cmd, "text")
34+
35+
return cmd
36+
}
37+
38+
type tokensResponse struct {
39+
ContractAddress string `json:"contract_address"`
40+
Tokens []tokenInfo `json:"tokens"`
41+
Warnings []warningEntry `json:"warnings,omitempty"`
42+
NextOffset string `json:"next_offset,omitempty"`
43+
}
44+
45+
type tokenInfo struct {
46+
Chain string `json:"chain"`
47+
ChainID int64 `json:"chain_id"`
48+
Symbol string `json:"symbol,omitempty"`
49+
Name string `json:"name,omitempty"`
50+
Decimals int `json:"decimals,omitempty"`
51+
PriceUSD float64 `json:"price_usd"`
52+
HistoricalPrices []historicalPrice `json:"historical_prices,omitempty"`
53+
TotalSupply string `json:"total_supply,omitempty"`
54+
MarketCap float64 `json:"market_cap,omitempty"`
55+
Logo string `json:"logo,omitempty"`
56+
}
57+
58+
func runTokenInfo(cmd *cobra.Command, args []string) error {
59+
client, err := requireSimClient(cmd)
60+
if err != nil {
61+
return err
62+
}
63+
64+
address := args[0]
65+
params := url.Values{}
66+
67+
if v, _ := cmd.Flags().GetString("chain-ids"); v != "" {
68+
params.Set("chain_ids", v)
69+
}
70+
if v, _ := cmd.Flags().GetString("historical-prices"); v != "" {
71+
params.Set("historical_prices", v)
72+
}
73+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
74+
params.Set("limit", fmt.Sprintf("%d", v))
75+
}
76+
if v, _ := cmd.Flags().GetString("offset"); v != "" {
77+
params.Set("offset", v)
78+
}
79+
80+
data, err := client.Get(cmd.Context(), "/v1/evm/token-info/"+address, params)
81+
if err != nil {
82+
return err
83+
}
84+
85+
w := cmd.OutOrStdout()
86+
switch output.FormatFromCmd(cmd) {
87+
case output.FormatJSON:
88+
var raw json.RawMessage = data
89+
return output.PrintJSON(w, raw)
90+
default:
91+
var resp tokensResponse
92+
if err := json.Unmarshal(data, &resp); err != nil {
93+
return fmt.Errorf("parsing response: %w", err)
94+
}
95+
96+
// Print warnings to stderr.
97+
printWarnings(cmd, resp.Warnings)
98+
99+
if len(resp.Tokens) == 0 {
100+
fmt.Fprintln(w, "No token info found.")
101+
return nil
102+
}
103+
104+
// Key-value display for each token entry.
105+
for i, t := range resp.Tokens {
106+
if i > 0 {
107+
fmt.Fprintln(w)
108+
}
109+
fmt.Fprintf(w, "Chain: %s (ID: %d)\n", t.Chain, t.ChainID)
110+
if t.Symbol != "" {
111+
fmt.Fprintf(w, "Symbol: %s\n", t.Symbol)
112+
}
113+
if t.Name != "" {
114+
fmt.Fprintf(w, "Name: %s\n", t.Name)
115+
}
116+
fmt.Fprintf(w, "Decimals: %d\n", t.Decimals)
117+
fmt.Fprintf(w, "Price USD: %s\n", formatUSD(t.PriceUSD))
118+
if t.TotalSupply != "" {
119+
fmt.Fprintf(w, "Total Supply: %s\n", t.TotalSupply)
120+
}
121+
if t.MarketCap > 0 {
122+
fmt.Fprintf(w, "Market Cap: %s\n", formatUSD(t.MarketCap))
123+
}
124+
if t.Logo != "" {
125+
fmt.Fprintf(w, "Logo: %s\n", t.Logo)
126+
}
127+
for _, hp := range t.HistoricalPrices {
128+
fmt.Fprintf(w, "Price %dh ago: %s\n", hp.OffsetHours, formatUSD(hp.PriceUSD))
129+
}
130+
}
131+
132+
if resp.NextOffset != "" {
133+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
134+
}
135+
return nil
136+
}
137+
}

cmd/sim/evm/token_info_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package evm_test
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestEvmTokenInfo_Native_Text(t *testing.T) {
13+
key := simAPIKey(t)
14+
15+
root := newSimTestRoot()
16+
var buf bytes.Buffer
17+
root.SetOut(&buf)
18+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1"})
19+
20+
require.NoError(t, root.Execute())
21+
22+
out := buf.String()
23+
assert.Contains(t, out, "Chain:")
24+
assert.Contains(t, out, "Symbol:")
25+
assert.Contains(t, out, "Price USD:")
26+
}
27+
28+
func TestEvmTokenInfo_Native_JSON(t *testing.T) {
29+
key := simAPIKey(t)
30+
31+
root := newSimTestRoot()
32+
var buf bytes.Buffer
33+
root.SetOut(&buf)
34+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1", "-o", "json"})
35+
36+
require.NoError(t, root.Execute())
37+
38+
var resp map[string]interface{}
39+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
40+
assert.Contains(t, resp, "contract_address")
41+
assert.Contains(t, resp, "tokens")
42+
43+
tokens, ok := resp["tokens"].([]interface{})
44+
require.True(t, ok)
45+
require.NotEmpty(t, tokens)
46+
47+
token, ok := tokens[0].(map[string]interface{})
48+
require.True(t, ok)
49+
assert.Contains(t, token, "chain")
50+
assert.Contains(t, token, "symbol")
51+
assert.Contains(t, token, "price_usd")
52+
}
53+
54+
func TestEvmTokenInfo_ERC20(t *testing.T) {
55+
key := simAPIKey(t)
56+
57+
// USDC on Base
58+
root := newSimTestRoot()
59+
var buf bytes.Buffer
60+
root.SetOut(&buf)
61+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "--chain-ids", "8453", "-o", "json"})
62+
63+
require.NoError(t, root.Execute())
64+
65+
var resp map[string]interface{}
66+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
67+
assert.Contains(t, resp, "tokens")
68+
69+
tokens, ok := resp["tokens"].([]interface{})
70+
require.True(t, ok)
71+
if len(tokens) > 0 {
72+
token, ok := tokens[0].(map[string]interface{})
73+
require.True(t, ok)
74+
assert.Contains(t, token, "symbol")
75+
assert.Contains(t, token, "decimals")
76+
}
77+
}
78+
79+
func TestEvmTokenInfo_HistoricalPrices(t *testing.T) {
80+
key := simAPIKey(t)
81+
82+
root := newSimTestRoot()
83+
var buf bytes.Buffer
84+
root.SetOut(&buf)
85+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1", "--historical-prices", "168,24", "-o", "json"})
86+
87+
require.NoError(t, root.Execute())
88+
89+
var resp map[string]interface{}
90+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
91+
92+
tokens, ok := resp["tokens"].([]interface{})
93+
require.True(t, ok)
94+
require.NotEmpty(t, tokens)
95+
96+
token, ok := tokens[0].(map[string]interface{})
97+
require.True(t, ok)
98+
assert.Contains(t, token, "historical_prices", "historical_prices should be present when --historical-prices is set")
99+
}
100+
101+
func TestEvmTokenInfo_HistoricalPrices_Text(t *testing.T) {
102+
key := simAPIKey(t)
103+
104+
root := newSimTestRoot()
105+
var buf bytes.Buffer
106+
root.SetOut(&buf)
107+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native", "--chain-ids", "1", "--historical-prices", "168"})
108+
109+
require.NoError(t, root.Execute())
110+
111+
out := buf.String()
112+
assert.Contains(t, out, "Price 168h ago:")
113+
}
114+
115+
func TestEvmTokenInfo_RequiresChainIds(t *testing.T) {
116+
key := simAPIKey(t)
117+
118+
root := newSimTestRoot()
119+
var buf bytes.Buffer
120+
root.SetOut(&buf)
121+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "native"})
122+
123+
err := root.Execute()
124+
require.Error(t, err)
125+
assert.Contains(t, err.Error(), "chain-ids")
126+
}

0 commit comments

Comments
 (0)