Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/sim/evm/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func NewEvmCmd() *cobra.Command {
cmd.AddCommand(NewTransactionsCmd())
cmd.AddCommand(NewCollectiblesCmd())
cmd.AddCommand(NewTokenInfoCmd())
cmd.AddCommand(NewTokenHoldersCmd())

return cmd
}
116 changes: 116 additions & 0 deletions cmd/sim/evm/token_holders.go
Original file line number Diff line number Diff line change
@@ -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 <token_address>",
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)

Check failure on line 53 in cmd/sim/evm/token_holders.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient

Check failure on line 53 in cmd/sim/evm/token_holders.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient
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
}
}
112 changes: 112 additions & 0 deletions cmd/sim/evm/token_holders_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading