From 853c8c16dbba6c997ce465003b800a3c04d6b433 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:42:31 +0100 Subject: [PATCH] Sim evm balances --- cmd/sim/evm/balances.go | 200 +++++++++++++++++++++++++++ cmd/sim/evm/balances_test.go | 104 ++++++++++++++ cmd/sim/evm/evm.go | 1 + cmd/sim/evm/helpers_test.go | 41 ++++++ cmd/sim/evm/supported_chains_test.go | 29 +--- 5 files changed, 350 insertions(+), 25 deletions(-) create mode 100644 cmd/sim/evm/balances.go create mode 100644 cmd/sim/evm/balances_test.go create mode 100644 cmd/sim/evm/helpers_test.go diff --git a/cmd/sim/evm/balances.go b/cmd/sim/evm/balances.go new file mode 100644 index 0000000..de791e8 --- /dev/null +++ b/cmd/sim/evm/balances.go @@ -0,0 +1,200 @@ +package evm + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/output" +) + +// NewBalancesCmd returns the `sim evm balances` command. +func NewBalancesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "balances
", + Short: "Get EVM token balances for a wallet address", + Long: "Return native and ERC20 token balances for the given wallet address\n" + + "across supported EVM chains, including USD valuations.\n\n" + + "Examples:\n" + + " dune sim evm balances 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" + + " dune sim evm balances 0xd8da... --chain-ids 1,8453 --exclude-spam\n" + + " dune sim evm balances 0xd8da... --historical-prices 168 -o json", + Args: cobra.ExactArgs(1), + RunE: runBalances, + } + + cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)") + cmd.Flags().String("filters", "", "Token filter: erc20 or native") + cmd.Flags().String("asset-class", "", "Asset class filter: stablecoin") + cmd.Flags().String("metadata", "", "Extra metadata fields: logo,url,pools") + cmd.Flags().Bool("exclude-spam", false, "Exclude tokens with <100 USD liquidity") + cmd.Flags().String("historical-prices", "", "Hour offsets for historical prices (e.g. 720,168,24)") + cmd.Flags().Int("limit", 0, "Max results (1-1000)") + cmd.Flags().String("offset", "", "Pagination cursor from previous response") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +type balancesResponse struct { + WalletAddress string `json:"wallet_address"` + Balances []balanceEntry `json:"balances"` + NextOffset string `json:"next_offset,omitempty"` + Warnings []warningEntry `json:"warnings,omitempty"` + RequestTime string `json:"request_time,omitempty"` + ResponseTime string `json:"response_time,omitempty"` +} + +type balanceEntry struct { + Chain string `json:"chain"` + ChainID int64 `json:"chain_id"` + Address string `json:"address"` + Amount string `json:"amount"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Decimals int `json:"decimals"` + PriceUSD float64 `json:"price_usd"` + ValueUSD float64 `json:"value_usd"` + PoolSize float64 `json:"pool_size"` + LowLiquidity bool `json:"low_liquidity"` +} + +type warningEntry struct { + Code string `json:"code"` + Message string `json:"message"` + ChainIDs []int64 `json:"chain_ids,omitempty"` + DocsURL string `json:"docs_url,omitempty"` +} + +func runBalances(cmd *cobra.Command, args []string) error { + client := SimClientFromCmd(cmd) + if client == nil { + return fmt.Errorf("sim client not initialized") + } + + address := args[0] + params := url.Values{} + + if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { + params.Set("chain_ids", v) + } + if v, _ := cmd.Flags().GetString("filters"); v != "" { + params.Set("filters", v) + } + if v, _ := cmd.Flags().GetString("asset-class"); v != "" { + params.Set("asset_class", v) + } + if v, _ := cmd.Flags().GetString("metadata"); v != "" { + params.Set("metadata", v) + } + if v, _ := cmd.Flags().GetBool("exclude-spam"); v { + params.Set("exclude_spam_tokens", "true") + } + 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/balances/"+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 balancesResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + // Print warnings to stderr. + printWarnings(cmd, resp.Warnings) + + columns := []string{"CHAIN", "SYMBOL", "AMOUNT", "PRICE_USD", "VALUE_USD"} + rows := make([][]string, len(resp.Balances)) + for i, b := range resp.Balances { + rows[i] = []string{ + b.Chain, + b.Symbol, + formatAmount(b.Amount, b.Decimals), + formatUSD(b.PriceUSD), + formatUSD(b.ValueUSD), + } + } + output.PrintTable(w, columns, rows) + + if resp.NextOffset != "" { + fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) + } + return nil + } +} + +// printWarnings writes API warnings to stderr. +func printWarnings(cmd *cobra.Command, warnings []warningEntry) { + if len(warnings) == 0 { + return + } + stderr := cmd.ErrOrStderr() + for _, w := range warnings { + fmt.Fprintf(stderr, "Warning: %s\n", w.Message) + if len(w.ChainIDs) > 0 { + ids := make([]string, len(w.ChainIDs)) + for i, id := range w.ChainIDs { + ids[i] = fmt.Sprintf("%d", id) + } + fmt.Fprintf(stderr, " Unsupported chain IDs: %s\n", strings.Join(ids, ", ")) + } + if w.DocsURL != "" { + fmt.Fprintf(stderr, " See %s\n", w.DocsURL) + } + } + fmt.Fprintln(stderr) +} + +// formatAmount converts a raw token amount string with decimals to a +// human-readable decimal representation. +func formatAmount(raw string, decimals int) string { + if decimals <= 0 || raw == "" || raw == "0" { + return raw + } + + // Pad with leading zeros if the raw string is shorter than decimals. + for len(raw) <= decimals { + raw = "0" + raw + } + + intPart := raw[:len(raw)-decimals] + fracPart := raw[len(raw)-decimals:] + + // Trim trailing zeros from the fractional part, keep up to 6 digits. + if len(fracPart) > 6 { + fracPart = fracPart[:6] + } + fracPart = strings.TrimRight(fracPart, "0") + + if fracPart == "" { + return intPart + } + return intPart + "." + fracPart +} + +// formatUSD formats a USD value for display. +func formatUSD(v float64) string { + if v == 0 { + return "0.00" + } + return fmt.Sprintf("%.2f", v) +} diff --git a/cmd/sim/evm/balances_test.go b/cmd/sim/evm/balances_test.go new file mode 100644 index 0000000..227b74e --- /dev/null +++ b/cmd/sim/evm/balances_test.go @@ -0,0 +1,104 @@ +package evm_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvmBalances_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", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "5"}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "CHAIN") + assert.Contains(t, out, "SYMBOL") + assert.Contains(t, out, "AMOUNT") + assert.Contains(t, out, "PRICE_USD") + assert.Contains(t, out, "VALUE_USD") +} + +func TestEvmBalances_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", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "5", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "wallet_address") + assert.Contains(t, resp, "balances") +} + +func TestEvmBalances_WithFilters(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--filters", "native", "--limit", "5"}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "CHAIN") + assert.Contains(t, out, "ETH") +} + +func TestEvmBalances_ExcludeSpam(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--exclude-spam", "--limit", "5"}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "CHAIN") +} + +func TestEvmBalances_Pagination(t *testing.T) { + key := simAPIKey(t) + + // Fetch page 1 with a small limit to trigger pagination. + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "2", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "balances") + + // If next_offset is present, pagination is working. + if offset, ok := resp["next_offset"].(string); ok && offset != "" { + // Fetch page 2 using the offset. + root2 := newSimTestRoot() + var buf2 bytes.Buffer + root2.SetOut(&buf2) + root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "2", "--offset", offset, "-o", "json"}) + + require.NoError(t, root2.Execute()) + + var resp2 map[string]interface{} + require.NoError(t, json.Unmarshal(buf2.Bytes(), &resp2)) + assert.Contains(t, resp2, "balances") + } +} diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go index e86dbc1..b7821da 100644 --- a/cmd/sim/evm/evm.go +++ b/cmd/sim/evm/evm.go @@ -39,6 +39,7 @@ func NewEvmCmd() *cobra.Command { } cmd.AddCommand(NewSupportedChainsCmd()) + cmd.AddCommand(NewBalancesCmd()) return cmd } diff --git a/cmd/sim/evm/helpers_test.go b/cmd/sim/evm/helpers_test.go new file mode 100644 index 0000000..1a5454c --- /dev/null +++ b/cmd/sim/evm/helpers_test.go @@ -0,0 +1,41 @@ +package evm_test + +import ( + "context" + "os" + "testing" + + "github.com/duneanalytics/cli/cmd/sim" + "github.com/duneanalytics/cli/cmd/sim/evm" + "github.com/spf13/cobra" +) + +// simAPIKey returns the DUNE_SIM_API_KEY env var or skips the test. +func simAPIKey(t *testing.T) string { + t.Helper() + key := os.Getenv("DUNE_SIM_API_KEY") + if key == "" { + t.Skip("DUNE_SIM_API_KEY not set, skipping e2e test") + } + return key +} + +const evmTestAddress = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" + +// newEvmTestRoot builds a minimal command tree: dune -> evm ->