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
161 changes: 161 additions & 0 deletions cmd/sim/evm/collectibles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package evm

import (
"encoding/json"
"fmt"
"net/url"

"github.com/spf13/cobra"

"github.com/duneanalytics/cli/output"
)

// NewCollectiblesCmd returns the `sim evm collectibles` command.
func NewCollectiblesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "collectibles <address>",
Short: "Get NFT collectibles for a wallet address",
Long: "Return ERC721 and ERC1155 collectibles (NFTs) held by the given wallet address\n" +
"across supported EVM chains. Spam filtering is enabled by default.\n\n" +
"Examples:\n" +
" dune sim evm collectibles 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" +
" dune sim evm collectibles 0xd8da... --chain-ids 1\n" +
" dune sim evm collectibles 0xd8da... --filter-spam=false --show-spam-scores -o json",
Args: cobra.ExactArgs(1),
RunE: runCollectibles,
}

cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)")
cmd.Flags().Bool("filter-spam", true, "Hide collectibles identified as spam")
cmd.Flags().Bool("show-spam-scores", false, "Include spam scoring details")
cmd.Flags().Int("limit", 0, "Max results per page (1-2500, default 250)")
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
output.AddFormatFlag(cmd, "text")

return cmd
}

type collectiblesResponse struct {
Address string `json:"address"`
Entries []collectibleEntry `json:"entries"`
Warnings []warningEntry `json:"warnings,omitempty"`
NextOffset string `json:"next_offset,omitempty"`
RequestTime string `json:"request_time,omitempty"`
ResponseTime string `json:"response_time,omitempty"`
}

type collectibleEntry struct {
ContractAddress string `json:"contract_address"`
TokenStandard string `json:"token_standard"`
TokenID string `json:"token_id"`
Chain string `json:"chain"`
ChainID int64 `json:"chain_id"`
Name string `json:"name,omitempty"`
Symbol string `json:"symbol,omitempty"`
Description string `json:"description,omitempty"`
ImageURL string `json:"image_url,omitempty"`
LastSalePrice string `json:"last_sale_price,omitempty"`
Metadata *collectibleMetadata `json:"metadata,omitempty"`
Balance string `json:"balance"`
LastAcquired string `json:"last_acquired"`
IsSpam bool `json:"is_spam"`
SpamScore int `json:"spam_score,omitempty"`
Explanations []spamExplanation `json:"explanations,omitempty"`
}

type collectibleMetadata struct {
URI string `json:"uri,omitempty"`
Attributes []collectibleAttribute `json:"attributes,omitempty"`
}

type collectibleAttribute struct {
Key string `json:"key"`
Value string `json:"value"`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attribute value typed as string, not RawMessage

Medium Severity

collectibleAttribute.Value is typed as string, but NFT metadata attribute values are commonly polymorphic (strings, numbers, booleans). The codebase consistently uses json.RawMessage for polymorphic API values — spamExplanation.Value in the same file and functionInput.Value in activity.go both do this. If the API returns a non-string attribute value, json.Unmarshal in the text-output path will fail, breaking the entire command for any NFT with numeric traits.

Fix in Cursor Fix in Web

Format string `json:"format,omitempty"`
}

type spamExplanation struct {
Feature string `json:"feature"`
Value json.RawMessage `json:"value,omitempty"`
FeatureScore int `json:"feature_score,omitempty"`
FeatureWeight float64 `json:"feature_weight,omitempty"`
}

func runCollectibles(cmd *cobra.Command, args []string) error {
client, err := requireSimClient(cmd)

Check failure on line 85 in cmd/sim/evm/collectibles.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient

Check failure on line 85 in cmd/sim/evm/collectibles.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient

Check failure on line 85 in cmd/sim/evm/collectibles.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient
if err != nil {
return err
}

address := args[0]
params := url.Values{}

if v, _ := cmd.Flags().GetString("chain-ids"); v != "" {
params.Set("chain_ids", v)
}
// filter_spam defaults to true on the API side, so only send when explicitly false.
if v, _ := cmd.Flags().GetBool("filter-spam"); !v {
params.Set("filter_spam", "false")
}
if v, _ := cmd.Flags().GetBool("show-spam-scores"); v {
params.Set("show_spam_scores", "true")
}
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/collectibles/"+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 collectiblesResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("parsing response: %w", err)
}

// Print warnings to stderr.
printWarnings(cmd, resp.Warnings)

showSpam, _ := cmd.Flags().GetBool("show-spam-scores")

columns := []string{"CHAIN", "NAME", "SYMBOL", "TOKEN_ID", "STANDARD", "BALANCE"}
if showSpam {
columns = append(columns, "SPAM", "SPAM_SCORE")
}
rows := make([][]string, len(resp.Entries))
for i, e := range resp.Entries {
row := []string{
e.Chain,
e.Name,
e.Symbol,
e.TokenID,
e.TokenStandard,
e.Balance,
}
if showSpam {
spam := "N"
if e.IsSpam {
spam = "Y"
}
row = append(row, spam, fmt.Sprintf("%d", e.SpamScore))
}
rows[i] = row
}
output.PrintTable(w, columns, rows)

if resp.NextOffset != "" {
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
}
return nil
}
}
148 changes: 148 additions & 0 deletions cmd/sim/evm/collectibles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package evm_test

import (
"bytes"
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEvmCollectibles_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", "collectibles", evmTestAddress, "--chain-ids", "1", "--limit", "5"})

require.NoError(t, root.Execute())

out := buf.String()
assert.Contains(t, out, "CHAIN")
assert.Contains(t, out, "NAME")
assert.Contains(t, out, "SYMBOL")
assert.Contains(t, out, "TOKEN_ID")
assert.Contains(t, out, "STANDARD")
assert.Contains(t, out, "BALANCE")
}

func TestEvmCollectibles_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", "collectibles", 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, "entries")
assert.Contains(t, resp, "address")
}

func TestEvmCollectibles_FilterSpamDisabled(t *testing.T) {
key := simAPIKey(t)

// Fetch with spam filtered (default) and without, compare counts.
rootFiltered := newSimTestRoot()
var bufFiltered bytes.Buffer
rootFiltered.SetOut(&bufFiltered)
rootFiltered.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--limit", "250", "-o", "json"})
require.NoError(t, rootFiltered.Execute())

var respFiltered map[string]interface{}
require.NoError(t, json.Unmarshal(bufFiltered.Bytes(), &respFiltered))
filteredEntries, ok := respFiltered["entries"].([]interface{})
require.True(t, ok)

rootUnfiltered := newSimTestRoot()
var bufUnfiltered bytes.Buffer
rootUnfiltered.SetOut(&bufUnfiltered)
rootUnfiltered.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--filter-spam=false", "--limit", "250", "-o", "json"})
require.NoError(t, rootUnfiltered.Execute())

var respUnfiltered map[string]interface{}
require.NoError(t, json.Unmarshal(bufUnfiltered.Bytes(), &respUnfiltered))
unfilteredEntries, ok := respUnfiltered["entries"].([]interface{})
require.True(t, ok)

// With spam filtering disabled we should get at least as many entries.
assert.GreaterOrEqual(t, len(unfilteredEntries), len(filteredEntries),
"disabling spam filter should return >= entries than with filter enabled")
}

func TestEvmCollectibles_SpamScores(t *testing.T) {
key := simAPIKey(t)

root := newSimTestRoot()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--filter-spam=false", "--show-spam-scores", "--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, "entries")

// When show_spam_scores is enabled, entries should contain spam_score.
entries, ok := resp["entries"].([]interface{})
require.True(t, ok)
if len(entries) > 0 {
entry, ok := entries[0].(map[string]interface{})
require.True(t, ok)
assert.Contains(t, entry, "spam_score", "spam_score should be present when --show-spam-scores is set")
assert.Contains(t, entry, "is_spam")
}
}

// TestEvmCollectibles_SpamScoresText verifies that --show-spam-scores
// adds SPAM and SPAM_SCORE columns in text mode.
func TestEvmCollectibles_SpamScoresText(t *testing.T) {
key := simAPIKey(t)

root := newSimTestRoot()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--show-spam-scores", "--limit", "5"})

require.NoError(t, root.Execute())

out := buf.String()
assert.Contains(t, out, "CHAIN")
assert.Contains(t, out, "SPAM")
assert.Contains(t, out, "SPAM_SCORE")
}

func TestEvmCollectibles_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", "collectibles", 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, "entries")

// 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", "collectibles", 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, "entries")
}
}
1 change: 1 addition & 0 deletions cmd/sim/evm/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func NewEvmCmd() *cobra.Command {
cmd.AddCommand(NewStablecoinsCmd())
cmd.AddCommand(NewActivityCmd())
cmd.AddCommand(NewTransactionsCmd())
cmd.AddCommand(NewCollectiblesCmd())

return cmd
}
Loading