Skip to content

Commit 2ee6e94

Browse files
committed
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
1 parent ede65a1 commit 2ee6e94

File tree

3 files changed

+229
-0
lines changed

3 files changed

+229
-0
lines changed

cmd/sim/evm/evm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func NewEvmCmd() *cobra.Command {
4646
cmd.AddCommand(NewTransactionsCmd())
4747
cmd.AddCommand(NewCollectiblesCmd())
4848
cmd.AddCommand(NewTokenInfoCmd())
49+
cmd.AddCommand(NewTokenHoldersCmd())
4950

5051
return cmd
5152
}

cmd/sim/evm/token_holders.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package evm
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
"strconv"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/duneanalytics/cli/output"
12+
)
13+
14+
// NewTokenHoldersCmd returns the `sim evm token-holders` command.
15+
func NewTokenHoldersCmd() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "token-holders <token_address>",
18+
Short: "Get token holders ranked by balance",
19+
Long: "Return a list of holders for a given ERC20 token contract on a specified chain,\n" +
20+
"ranked by balance descending.\n\n" +
21+
"Examples:\n" +
22+
" dune sim evm token-holders 0x63706e401c06ac8513145b7687A14804d17f814b --chain-id 8453\n" +
23+
" dune sim evm token-holders 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --chain-id 1 --limit 50\n" +
24+
" dune sim evm token-holders 0x63706e... --chain-id 8453 -o json",
25+
Args: cobra.ExactArgs(1),
26+
RunE: runTokenHolders,
27+
}
28+
29+
cmd.Flags().String("chain-id", "", "Numeric chain ID (required)")
30+
cmd.Flags().Int("limit", 0, "Max results (1-500, default 500)")
31+
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
32+
_ = cmd.MarkFlagRequired("chain-id")
33+
output.AddFormatFlag(cmd, "text")
34+
35+
return cmd
36+
}
37+
38+
type tokenHoldersResponse struct {
39+
TokenAddress string `json:"token_address"`
40+
ChainID int64 `json:"chain_id"`
41+
Holders []holder `json:"holders"`
42+
NextOffset string `json:"next_offset,omitempty"`
43+
}
44+
45+
type holder struct {
46+
WalletAddress string `json:"wallet_address"`
47+
Balance string `json:"balance"`
48+
FirstAcquired string `json:"first_acquired,omitempty"`
49+
HasInitiatedTransfer bool `json:"has_initiated_transfer"`
50+
}
51+
52+
func runTokenHolders(cmd *cobra.Command, args []string) error {
53+
client, err := requireSimClient(cmd)
54+
if err != nil {
55+
return err
56+
}
57+
58+
tokenAddress := args[0]
59+
chainID, _ := cmd.Flags().GetString("chain-id")
60+
// Validate chain_id is a valid integer.
61+
if _, err := strconv.Atoi(chainID); err != nil {
62+
return fmt.Errorf("--chain-id must be a numeric value, got %q", chainID)
63+
}
64+
65+
params := url.Values{}
66+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
67+
params.Set("limit", fmt.Sprintf("%d", v))
68+
}
69+
if v, _ := cmd.Flags().GetString("offset"); v != "" {
70+
params.Set("offset", v)
71+
}
72+
73+
path := fmt.Sprintf("/v1/evm/token-holders/%s/%s", chainID, tokenAddress)
74+
data, err := client.Get(cmd.Context(), path, params)
75+
if err != nil {
76+
return err
77+
}
78+
79+
w := cmd.OutOrStdout()
80+
switch output.FormatFromCmd(cmd) {
81+
case output.FormatJSON:
82+
var raw json.RawMessage = data
83+
return output.PrintJSON(w, raw)
84+
default:
85+
var resp tokenHoldersResponse
86+
if err := json.Unmarshal(data, &resp); err != nil {
87+
return fmt.Errorf("parsing response: %w", err)
88+
}
89+
90+
if len(resp.Holders) == 0 {
91+
fmt.Fprintln(w, "No holders found.")
92+
return nil
93+
}
94+
95+
columns := []string{"WALLET_ADDRESS", "BALANCE", "FIRST_ACQUIRED", "HAS_TRANSFERRED"}
96+
rows := make([][]string, len(resp.Holders))
97+
for i, h := range resp.Holders {
98+
transferred := "N"
99+
if h.HasInitiatedTransfer {
100+
transferred = "Y"
101+
}
102+
rows[i] = []string{
103+
h.WalletAddress,
104+
h.Balance,
105+
h.FirstAcquired,
106+
transferred,
107+
}
108+
}
109+
output.PrintTable(w, columns, rows)
110+
111+
if resp.NextOffset != "" {
112+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
113+
}
114+
return nil
115+
}
116+
}

cmd/sim/evm/token_holders_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
// Test token: a token on Base with known holders.
13+
const tokenHoldersChainID = "8453"
14+
const tokenHoldersAddress = "0x63706e401c06ac8513145b7687A14804d17f814b"
15+
16+
func TestEvmTokenHolders_Text(t *testing.T) {
17+
key := simAPIKey(t)
18+
19+
root := newSimTestRoot()
20+
var buf bytes.Buffer
21+
root.SetOut(&buf)
22+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--limit", "5"})
23+
24+
require.NoError(t, root.Execute())
25+
26+
out := buf.String()
27+
assert.Contains(t, out, "WALLET_ADDRESS")
28+
assert.Contains(t, out, "BALANCE")
29+
assert.Contains(t, out, "FIRST_ACQUIRED")
30+
assert.Contains(t, out, "HAS_TRANSFERRED")
31+
}
32+
33+
func TestEvmTokenHolders_JSON(t *testing.T) {
34+
key := simAPIKey(t)
35+
36+
root := newSimTestRoot()
37+
var buf bytes.Buffer
38+
root.SetOut(&buf)
39+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--limit", "5", "-o", "json"})
40+
41+
require.NoError(t, root.Execute())
42+
43+
var resp map[string]interface{}
44+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
45+
assert.Contains(t, resp, "token_address")
46+
assert.Contains(t, resp, "chain_id")
47+
assert.Contains(t, resp, "holders")
48+
49+
holders, ok := resp["holders"].([]interface{})
50+
require.True(t, ok)
51+
if len(holders) > 0 {
52+
h, ok := holders[0].(map[string]interface{})
53+
require.True(t, ok)
54+
assert.Contains(t, h, "wallet_address")
55+
assert.Contains(t, h, "balance")
56+
}
57+
}
58+
59+
func TestEvmTokenHolders_Pagination(t *testing.T) {
60+
key := simAPIKey(t)
61+
62+
root := newSimTestRoot()
63+
var buf bytes.Buffer
64+
root.SetOut(&buf)
65+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--limit", "2", "-o", "json"})
66+
67+
require.NoError(t, root.Execute())
68+
69+
var resp map[string]interface{}
70+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
71+
assert.Contains(t, resp, "holders")
72+
73+
// If next_offset is present, fetch page 2.
74+
if offset, ok := resp["next_offset"].(string); ok && offset != "" {
75+
root2 := newSimTestRoot()
76+
var buf2 bytes.Buffer
77+
root2.SetOut(&buf2)
78+
root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-holders", tokenHoldersAddress, "--chain-id", tokenHoldersChainID, "--limit", "2", "--offset", offset, "-o", "json"})
79+
80+
require.NoError(t, root2.Execute())
81+
82+
var resp2 map[string]interface{}
83+
require.NoError(t, json.Unmarshal(buf2.Bytes(), &resp2))
84+
assert.Contains(t, resp2, "holders")
85+
}
86+
}
87+
88+
func TestEvmTokenHolders_InvalidChainID(t *testing.T) {
89+
key := simAPIKey(t)
90+
91+
root := newSimTestRoot()
92+
var buf bytes.Buffer
93+
root.SetOut(&buf)
94+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-holders", tokenHoldersAddress, "--chain-id", "notanumber"})
95+
96+
err := root.Execute()
97+
require.Error(t, err)
98+
assert.Contains(t, err.Error(), "--chain-id must be a numeric value")
99+
}
100+
101+
func TestEvmTokenHolders_RequiresChainID(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-holders", tokenHoldersAddress})
108+
109+
err := root.Execute()
110+
require.Error(t, err)
111+
assert.Contains(t, err.Error(), "chain-id")
112+
}

0 commit comments

Comments
 (0)