From d9b5764defccb02772807be7bd94f7958a43f2ed Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:10:12 +0100 Subject: [PATCH] Add evm token-holders command with token address as positional arg - GET /v1/evm/token-holders/{chain_id}/{address} with --chain-id (required), --limit, --offset - Token address is positional arg, chain-id is a required flag for clarity - Table output: WALLET_ADDRESS, BALANCE, FIRST_ACQUIRED, HAS_TRANSFERRED (Y/N) - Chain ID validated as numeric - 5 E2E tests: text, JSON, pagination, invalid chain-id, required chain-id flag --- cmd/sim/evm/evm.go | 1 + cmd/sim/evm/token_holders.go | 116 ++++++++++++++++++++++++++++++ cmd/sim/evm/token_holders_test.go | 112 +++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 cmd/sim/evm/token_holders.go create mode 100644 cmd/sim/evm/token_holders_test.go diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go index ab1b83b..69d8f2f 100644 --- a/cmd/sim/evm/evm.go +++ b/cmd/sim/evm/evm.go @@ -46,6 +46,7 @@ func NewEvmCmd() *cobra.Command { cmd.AddCommand(NewTransactionsCmd()) cmd.AddCommand(NewCollectiblesCmd()) cmd.AddCommand(NewTokenInfoCmd()) + cmd.AddCommand(NewTokenHoldersCmd()) return cmd } diff --git a/cmd/sim/evm/token_holders.go b/cmd/sim/evm/token_holders.go new file mode 100644 index 0000000..b18b3d9 --- /dev/null +++ b/cmd/sim/evm/token_holders.go @@ -0,0 +1,116 @@ +package evm + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/output" +) + +// NewTokenHoldersCmd returns the `sim evm token-holders` command. +func NewTokenHoldersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "token-holders ", + Short: "Get token holders ranked by balance", + Long: "Return a list of holders for a given ERC20 token contract on a specified chain,\n" + + "ranked by balance descending.\n\n" + + "Examples:\n" + + " dune sim evm token-holders 0x63706e401c06ac8513145b7687A14804d17f814b --chain-id 8453\n" + + " dune sim evm token-holders 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --chain-id 1 --limit 50\n" + + " dune sim evm token-holders 0x63706e... --chain-id 8453 -o json", + Args: cobra.ExactArgs(1), + RunE: runTokenHolders, + } + + cmd.Flags().String("chain-id", "", "Numeric chain ID (required)") + cmd.Flags().Int("limit", 0, "Max results (1-500, default 500)") + cmd.Flags().String("offset", "", "Pagination cursor from previous response") + _ = cmd.MarkFlagRequired("chain-id") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +type tokenHoldersResponse struct { + TokenAddress string `json:"token_address"` + ChainID int64 `json:"chain_id"` + Holders []holder `json:"holders"` + NextOffset string `json:"next_offset,omitempty"` +} + +type holder struct { + WalletAddress string `json:"wallet_address"` + Balance string `json:"balance"` + FirstAcquired string `json:"first_acquired,omitempty"` + HasInitiatedTransfer bool `json:"has_initiated_transfer"` +} + +func runTokenHolders(cmd *cobra.Command, args []string) error { + client, err := requireSimClient(cmd) + if err != nil { + return err + } + + tokenAddress := args[0] + chainID, _ := cmd.Flags().GetString("chain-id") + // Validate chain_id is a valid integer. + if _, err := strconv.Atoi(chainID); err != nil { + return fmt.Errorf("--chain-id must be a numeric value, got %q", chainID) + } + + params := url.Values{} + 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) + } + + path := fmt.Sprintf("/v1/evm/token-holders/%s/%s", chainID, tokenAddress) + data, err := client.Get(cmd.Context(), path, 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 tokenHoldersResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + if len(resp.Holders) == 0 { + fmt.Fprintln(w, "No holders found.") + return nil + } + + columns := []string{"WALLET_ADDRESS", "BALANCE", "FIRST_ACQUIRED", "HAS_TRANSFERRED"} + rows := make([][]string, len(resp.Holders)) + for i, h := range resp.Holders { + transferred := "N" + if h.HasInitiatedTransfer { + transferred = "Y" + } + rows[i] = []string{ + h.WalletAddress, + h.Balance, + h.FirstAcquired, + transferred, + } + } + output.PrintTable(w, columns, rows) + + if resp.NextOffset != "" { + fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) + } + return nil + } +} diff --git a/cmd/sim/evm/token_holders_test.go b/cmd/sim/evm/token_holders_test.go new file mode 100644 index 0000000..d0605f3 --- /dev/null +++ b/cmd/sim/evm/token_holders_test.go @@ -0,0 +1,112 @@ +package evm_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test token: a token on Base with known holders. +const tokenHoldersChainID = "8453" +const tokenHoldersAddress = "0x63706e401c06ac8513145b7687A14804d17f814b" + +func TestEvmTokenHolders_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-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--limit", "5"}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "WALLET_ADDRESS") + assert.Contains(t, out, "BALANCE") + assert.Contains(t, out, "FIRST_ACQUIRED") + assert.Contains(t, out, "HAS_TRANSFERRED") +} + +func TestEvmTokenHolders_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-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--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, "token_address") + assert.Contains(t, resp, "chain_id") + assert.Contains(t, resp, "holders") + + holders, ok := resp["holders"].([]interface{}) + require.True(t, ok) + if len(holders) > 0 { + h, ok := holders[0].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, h, "wallet_address") + assert.Contains(t, h, "balance") + } +} + +func TestEvmTokenHolders_Pagination(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-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--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, "holders") + + // If next_offset is present, fetch page 2. + if offset, ok := resp["next_offset"].(string); ok && offset != "" { + root2 := newSimTestRoot() + var buf2 bytes.Buffer + root2.SetOut(&buf2) + root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--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, "holders") + } +} + +func TestEvmTokenHolders_InvalidChainID(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-holders", tokenHoldersAddress, "--chain-id", "notanumber"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--chain-id must be a numeric value") +} + +func TestEvmTokenHolders_RequiresChainID(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-holders", tokenHoldersAddress}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "chain-id") +}