Skip to content

Commit 857c295

Browse files
committed
Sim evm balances
1 parent 4669a46 commit 857c295

5 files changed

Lines changed: 346 additions & 16 deletions

File tree

cmd/sim/evm/balances.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package evm
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/duneanalytics/cli/output"
12+
)
13+
14+
// NewBalancesCmd returns the `sim evm balances` command.
15+
func NewBalancesCmd() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "balances <address>",
18+
Short: "Get EVM token balances for a wallet address",
19+
Long: "Return native and ERC20 token balances for the given wallet address\n" +
20+
"across supported EVM chains, including USD valuations.\n\n" +
21+
"Examples:\n" +
22+
" dune sim evm balances 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" +
23+
" dune sim evm balances 0xd8da... --chain-ids 1,8453 --exclude-spam\n" +
24+
" dune sim evm balances 0xd8da... --historical-prices 168 -o json",
25+
Args: cobra.ExactArgs(1),
26+
RunE: runBalances,
27+
}
28+
29+
cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)")
30+
cmd.Flags().String("filters", "", "Token filter: erc20 or native")
31+
cmd.Flags().String("asset-class", "", "Asset class filter: stablecoin")
32+
cmd.Flags().String("metadata", "", "Extra metadata fields: logo,url,pools")
33+
cmd.Flags().Bool("exclude-spam", false, "Exclude tokens with <100 USD liquidity")
34+
cmd.Flags().String("historical-prices", "", "Hour offsets for historical prices (e.g. 720,168,24)")
35+
cmd.Flags().Int("limit", 0, "Max results (1-1000)")
36+
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
37+
output.AddFormatFlag(cmd, "text")
38+
39+
return cmd
40+
}
41+
42+
type balancesResponse struct {
43+
WalletAddress string `json:"wallet_address"`
44+
Balances []balanceEntry `json:"balances"`
45+
NextOffset string `json:"next_offset,omitempty"`
46+
Warnings []warningEntry `json:"warnings,omitempty"`
47+
RequestTime string `json:"request_time,omitempty"`
48+
ResponseTime string `json:"response_time,omitempty"`
49+
}
50+
51+
type balanceEntry struct {
52+
Chain string `json:"chain"`
53+
ChainID int64 `json:"chain_id"`
54+
Address string `json:"address"`
55+
Amount string `json:"amount"`
56+
Symbol string `json:"symbol"`
57+
Name string `json:"name"`
58+
Decimals int `json:"decimals"`
59+
PriceUSD float64 `json:"price_usd"`
60+
ValueUSD float64 `json:"value_usd"`
61+
PoolSize float64 `json:"pool_size"`
62+
LowLiquidity bool `json:"low_liquidity"`
63+
}
64+
65+
type warningEntry struct {
66+
Code string `json:"code"`
67+
Message string `json:"message"`
68+
ChainIDs []int64 `json:"chain_ids,omitempty"`
69+
DocsURL string `json:"docs_url,omitempty"`
70+
}
71+
72+
func runBalances(cmd *cobra.Command, args []string) error {
73+
client, err := requireSimClient(cmd)
74+
if err != nil {
75+
return err
76+
}
77+
78+
address := args[0]
79+
params := url.Values{}
80+
81+
if v, _ := cmd.Flags().GetString("chain-ids"); v != "" {
82+
params.Set("chain_ids", v)
83+
}
84+
if v, _ := cmd.Flags().GetString("filters"); v != "" {
85+
params.Set("filters", v)
86+
}
87+
if v, _ := cmd.Flags().GetString("asset-class"); v != "" {
88+
params.Set("asset_class", v)
89+
}
90+
if v, _ := cmd.Flags().GetString("metadata"); v != "" {
91+
params.Set("metadata", v)
92+
}
93+
if v, _ := cmd.Flags().GetBool("exclude-spam"); v {
94+
params.Set("exclude_spam_tokens", "true")
95+
}
96+
if v, _ := cmd.Flags().GetString("historical-prices"); v != "" {
97+
params.Set("historical_prices", v)
98+
}
99+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
100+
params.Set("limit", fmt.Sprintf("%d", v))
101+
}
102+
if v, _ := cmd.Flags().GetString("offset"); v != "" {
103+
params.Set("offset", v)
104+
}
105+
106+
data, err := client.Get(cmd.Context(), "/v1/evm/balances/"+address, params)
107+
if err != nil {
108+
return err
109+
}
110+
111+
w := cmd.OutOrStdout()
112+
switch output.FormatFromCmd(cmd) {
113+
case output.FormatJSON:
114+
var raw json.RawMessage = data
115+
return output.PrintJSON(w, raw)
116+
default:
117+
var resp balancesResponse
118+
if err := json.Unmarshal(data, &resp); err != nil {
119+
return fmt.Errorf("parsing response: %w", err)
120+
}
121+
122+
// Print warnings to stderr.
123+
printWarnings(cmd, resp.Warnings)
124+
125+
columns := []string{"CHAIN", "SYMBOL", "AMOUNT", "PRICE_USD", "VALUE_USD"}
126+
rows := make([][]string, len(resp.Balances))
127+
for i, b := range resp.Balances {
128+
rows[i] = []string{
129+
b.Chain,
130+
b.Symbol,
131+
formatAmount(b.Amount, b.Decimals),
132+
formatUSD(b.PriceUSD),
133+
formatUSD(b.ValueUSD),
134+
}
135+
}
136+
output.PrintTable(w, columns, rows)
137+
138+
if resp.NextOffset != "" {
139+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
140+
}
141+
return nil
142+
}
143+
}
144+
145+
// printWarnings writes API warnings to stderr.
146+
func printWarnings(cmd *cobra.Command, warnings []warningEntry) {
147+
if len(warnings) == 0 {
148+
return
149+
}
150+
stderr := cmd.ErrOrStderr()
151+
for _, w := range warnings {
152+
fmt.Fprintf(stderr, "Warning: %s\n", w.Message)
153+
if len(w.ChainIDs) > 0 {
154+
ids := make([]string, len(w.ChainIDs))
155+
for i, id := range w.ChainIDs {
156+
ids[i] = fmt.Sprintf("%d", id)
157+
}
158+
fmt.Fprintf(stderr, " Unsupported chain IDs: %s\n", strings.Join(ids, ", "))
159+
}
160+
if w.DocsURL != "" {
161+
fmt.Fprintf(stderr, " See %s\n", w.DocsURL)
162+
}
163+
}
164+
fmt.Fprintln(stderr)
165+
}
166+
167+
// formatAmount converts a raw token amount string with decimals to a
168+
// human-readable decimal representation.
169+
func formatAmount(raw string, decimals int) string {
170+
if decimals <= 0 || raw == "" || raw == "0" {
171+
return raw
172+
}
173+
174+
// Pad with leading zeros if the raw string is shorter than decimals.
175+
for len(raw) <= decimals {
176+
raw = "0" + raw
177+
}
178+
179+
intPart := raw[:len(raw)-decimals]
180+
fracPart := raw[len(raw)-decimals:]
181+
182+
// Trim trailing zeros from the fractional part, keep up to 6 digits.
183+
if len(fracPart) > 6 {
184+
fracPart = fracPart[:6]
185+
}
186+
fracPart = strings.TrimRight(fracPart, "0")
187+
188+
if fracPart == "" {
189+
return intPart
190+
}
191+
return intPart + "." + fracPart
192+
}
193+
194+
// formatUSD formats a USD value for display.
195+
func formatUSD(v float64) string {
196+
if v == 0 {
197+
return "0.00"
198+
}
199+
return fmt.Sprintf("%.2f", v)
200+
}

cmd/sim/evm/balances_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 TestEvmBalances_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", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "5"})
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, "AMOUNT")
26+
assert.Contains(t, out, "PRICE_USD")
27+
assert.Contains(t, out, "VALUE_USD")
28+
}
29+
30+
func TestEvmBalances_JSON(t *testing.T) {
31+
key := simAPIKey(t)
32+
33+
root := newSimTestRoot()
34+
var buf bytes.Buffer
35+
root.SetOut(&buf)
36+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "5", "-o", "json"})
37+
38+
require.NoError(t, root.Execute())
39+
40+
var resp map[string]interface{}
41+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
42+
assert.Contains(t, resp, "wallet_address")
43+
assert.Contains(t, resp, "balances")
44+
}
45+
46+
func TestEvmBalances_WithFilters(t *testing.T) {
47+
key := simAPIKey(t)
48+
49+
root := newSimTestRoot()
50+
var buf bytes.Buffer
51+
root.SetOut(&buf)
52+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--filters", "native", "--limit", "5"})
53+
54+
require.NoError(t, root.Execute())
55+
56+
out := buf.String()
57+
assert.Contains(t, out, "CHAIN")
58+
assert.Contains(t, out, "ETH")
59+
}
60+
61+
func TestEvmBalances_ExcludeSpam(t *testing.T) {
62+
key := simAPIKey(t)
63+
64+
root := newSimTestRoot()
65+
var buf bytes.Buffer
66+
root.SetOut(&buf)
67+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--exclude-spam", "--limit", "5"})
68+
69+
require.NoError(t, root.Execute())
70+
71+
out := buf.String()
72+
assert.Contains(t, out, "CHAIN")
73+
}
74+
75+
func TestEvmBalances_Pagination(t *testing.T) {
76+
key := simAPIKey(t)
77+
78+
// Fetch page 1 with a small limit to trigger pagination.
79+
root := newSimTestRoot()
80+
var buf bytes.Buffer
81+
root.SetOut(&buf)
82+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "2", "-o", "json"})
83+
84+
require.NoError(t, root.Execute())
85+
86+
var resp map[string]interface{}
87+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
88+
assert.Contains(t, resp, "balances")
89+
90+
// If next_offset is present, pagination is working.
91+
if offset, ok := resp["next_offset"].(string); ok && offset != "" {
92+
// Fetch page 2 using the offset.
93+
root2 := newSimTestRoot()
94+
var buf2 bytes.Buffer
95+
root2.SetOut(&buf2)
96+
root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "2", "--offset", offset, "-o", "json"})
97+
98+
require.NoError(t, root2.Execute())
99+
100+
var resp2 map[string]interface{}
101+
require.NoError(t, json.Unmarshal(buf2.Bytes(), &resp2))
102+
assert.Contains(t, resp2, "balances")
103+
}
104+
}

cmd/sim/evm/evm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func NewEvmCmd() *cobra.Command {
4949
}
5050

5151
cmd.AddCommand(NewSupportedChainsCmd())
52+
cmd.AddCommand(NewBalancesCmd())
5253

5354
return cmd
5455
}

cmd/sim/evm/helpers_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package evm_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"testing"
7+
8+
"github.com/duneanalytics/cli/cmd/sim"
9+
"github.com/duneanalytics/cli/cmd/sim/evm"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
// simAPIKey returns the DUNE_SIM_API_KEY env var or skips the test.
14+
func simAPIKey(t *testing.T) string {
15+
t.Helper()
16+
key := os.Getenv("DUNE_SIM_API_KEY")
17+
if key == "" {
18+
t.Skip("DUNE_SIM_API_KEY not set, skipping e2e test")
19+
}
20+
return key
21+
}
22+
23+
const evmTestAddress = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
24+
25+
// newEvmTestRoot builds a minimal command tree: dune -> evm -> <subcommands>.
26+
// No sim parent — used for public endpoints that don't require auth.
27+
func newEvmTestRoot() *cobra.Command {
28+
root := &cobra.Command{Use: "dune"}
29+
root.SetContext(context.Background())
30+
root.AddCommand(evm.NewEvmCmd())
31+
return root
32+
}
33+
34+
// newSimTestRoot builds the full command tree: dune -> sim -> evm -> <subcommands>.
35+
// Used for authenticated E2E tests. Pass the API key via --sim-api-key in SetArgs.
36+
func newSimTestRoot() *cobra.Command {
37+
root := &cobra.Command{Use: "dune"}
38+
root.SetContext(context.Background())
39+
root.AddCommand(sim.NewSimCmd())
40+
return root
41+
}

cmd/sim/evm/supported_chains_test.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,13 @@ package evm_test
22

33
import (
44
"bytes"
5-
"context"
65
"encoding/json"
76
"testing"
87

9-
"github.com/duneanalytics/cli/cmd/sim/evm"
10-
"github.com/spf13/cobra"
118
"github.com/stretchr/testify/assert"
129
"github.com/stretchr/testify/require"
1310
)
1411

15-
// newEvmTestRoot builds a minimal command tree: dune -> evm -> <subcommands>.
16-
// No sim parent is needed because evm commands extract the client from context
17-
// directly; for public endpoints the command creates its own client.
18-
func newEvmTestRoot() *cobra.Command {
19-
root := &cobra.Command{Use: "dune"}
20-
root.SetContext(context.Background())
21-
22-
evmCmd := evm.NewEvmCmd()
23-
root.AddCommand(evmCmd)
24-
25-
return root
26-
}
27-
2812
// supported-chains is a public endpoint — no API key required.
2913

3014
func TestSupportedChains_Text(t *testing.T) {

0 commit comments

Comments
 (0)