Skip to content

Commit 8a633b2

Browse files
committed
Add evm collectibles command for NFT listings with spam filtering
- GET /v1/evm/collectibles/{address} with --chain-ids, --filter-spam, --show-spam-scores, --limit, --offset - Text table shows CHAIN, NAME, SYMBOL, TOKEN_ID, STANDARD, BALANCE plus SPAM/SPAM_SCORE when --show-spam-scores is set - Full OpenAPI spec coverage including metadata, spam explanations with json.RawMessage for polymorphic values - 6 E2E tests: text, JSON, filter-spam disabled, spam scores JSON, spam scores text columns, pagination
1 parent bcff9e1 commit 8a633b2

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

cmd/sim/evm/collectibles.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package evm
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/duneanalytics/cli/output"
11+
)
12+
13+
// NewCollectiblesCmd returns the `sim evm collectibles` command.
14+
func NewCollectiblesCmd() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "collectibles <address>",
17+
Short: "Get NFT collectibles for a wallet address",
18+
Long: "Return ERC721 and ERC1155 collectibles (NFTs) held by the given wallet address\n" +
19+
"across supported EVM chains. Spam filtering is enabled by default.\n\n" +
20+
"Examples:\n" +
21+
" dune sim evm collectibles 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" +
22+
" dune sim evm collectibles 0xd8da... --chain-ids 1\n" +
23+
" dune sim evm collectibles 0xd8da... --filter-spam=false --show-spam-scores -o json",
24+
Args: cobra.ExactArgs(1),
25+
RunE: runCollectibles,
26+
}
27+
28+
cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)")
29+
cmd.Flags().Bool("filter-spam", true, "Hide collectibles identified as spam")
30+
cmd.Flags().Bool("show-spam-scores", false, "Include spam scoring details")
31+
cmd.Flags().Int("limit", 0, "Max results per page (1-2500, default 250)")
32+
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
33+
output.AddFormatFlag(cmd, "text")
34+
35+
return cmd
36+
}
37+
38+
type collectiblesResponse struct {
39+
Address string `json:"address"`
40+
Entries []collectibleEntry `json:"entries"`
41+
Warnings []warningEntry `json:"warnings,omitempty"`
42+
NextOffset string `json:"next_offset,omitempty"`
43+
RequestTime string `json:"request_time,omitempty"`
44+
ResponseTime string `json:"response_time,omitempty"`
45+
}
46+
47+
type collectibleEntry struct {
48+
ContractAddress string `json:"contract_address"`
49+
TokenStandard string `json:"token_standard"`
50+
TokenID string `json:"token_id"`
51+
Chain string `json:"chain"`
52+
ChainID int64 `json:"chain_id"`
53+
Name string `json:"name,omitempty"`
54+
Symbol string `json:"symbol,omitempty"`
55+
Description string `json:"description,omitempty"`
56+
ImageURL string `json:"image_url,omitempty"`
57+
LastSalePrice string `json:"last_sale_price,omitempty"`
58+
Metadata *collectibleMetadata `json:"metadata,omitempty"`
59+
Balance string `json:"balance"`
60+
LastAcquired string `json:"last_acquired"`
61+
IsSpam bool `json:"is_spam"`
62+
SpamScore int `json:"spam_score,omitempty"`
63+
Explanations []spamExplanation `json:"explanations,omitempty"`
64+
}
65+
66+
type collectibleMetadata struct {
67+
URI string `json:"uri,omitempty"`
68+
Attributes []collectibleAttribute `json:"attributes,omitempty"`
69+
}
70+
71+
type collectibleAttribute struct {
72+
Key string `json:"key"`
73+
Value string `json:"value"`
74+
Format string `json:"format,omitempty"`
75+
}
76+
77+
type spamExplanation struct {
78+
Feature string `json:"feature"`
79+
Value json.RawMessage `json:"value,omitempty"`
80+
FeatureScore int `json:"feature_score,omitempty"`
81+
FeatureWeight float64 `json:"feature_weight,omitempty"`
82+
}
83+
84+
func runCollectibles(cmd *cobra.Command, args []string) error {
85+
client, err := requireSimClient(cmd)
86+
if err != nil {
87+
return err
88+
}
89+
90+
address := args[0]
91+
params := url.Values{}
92+
93+
if v, _ := cmd.Flags().GetString("chain-ids"); v != "" {
94+
params.Set("chain_ids", v)
95+
}
96+
// filter_spam defaults to true on the API side, so only send when explicitly false.
97+
if v, _ := cmd.Flags().GetBool("filter-spam"); !v {
98+
params.Set("filter_spam", "false")
99+
}
100+
if v, _ := cmd.Flags().GetBool("show-spam-scores"); v {
101+
params.Set("show_spam_scores", "true")
102+
}
103+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
104+
params.Set("limit", fmt.Sprintf("%d", v))
105+
}
106+
if v, _ := cmd.Flags().GetString("offset"); v != "" {
107+
params.Set("offset", v)
108+
}
109+
110+
data, err := client.Get(cmd.Context(), "/v1/evm/collectibles/"+address, params)
111+
if err != nil {
112+
return err
113+
}
114+
115+
w := cmd.OutOrStdout()
116+
switch output.FormatFromCmd(cmd) {
117+
case output.FormatJSON:
118+
var raw json.RawMessage = data
119+
return output.PrintJSON(w, raw)
120+
default:
121+
var resp collectiblesResponse
122+
if err := json.Unmarshal(data, &resp); err != nil {
123+
return fmt.Errorf("parsing response: %w", err)
124+
}
125+
126+
// Print warnings to stderr.
127+
printWarnings(cmd, resp.Warnings)
128+
129+
showSpam, _ := cmd.Flags().GetBool("show-spam-scores")
130+
131+
columns := []string{"CHAIN", "NAME", "SYMBOL", "TOKEN_ID", "STANDARD", "BALANCE"}
132+
if showSpam {
133+
columns = append(columns, "SPAM", "SPAM_SCORE")
134+
}
135+
rows := make([][]string, len(resp.Entries))
136+
for i, e := range resp.Entries {
137+
row := []string{
138+
e.Chain,
139+
e.Name,
140+
e.Symbol,
141+
e.TokenID,
142+
e.TokenStandard,
143+
e.Balance,
144+
}
145+
if showSpam {
146+
spam := "N"
147+
if e.IsSpam {
148+
spam = "Y"
149+
}
150+
row = append(row, spam, fmt.Sprintf("%d", e.SpamScore))
151+
}
152+
rows[i] = row
153+
}
154+
output.PrintTable(w, columns, rows)
155+
156+
if resp.NextOffset != "" {
157+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
158+
}
159+
return nil
160+
}
161+
}

cmd/sim/evm/collectibles_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
func TestEvmCollectibles_Text(t *testing.T) {
13+
key := simAPIKey(t)
14+
15+
root := newSimTestRoot()
16+
var buf bytes.Buffer
17+
root.SetOut(&buf)
18+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--limit", "5"})
19+
20+
require.NoError(t, root.Execute())
21+
22+
out := buf.String()
23+
assert.Contains(t, out, "CHAIN")
24+
assert.Contains(t, out, "NAME")
25+
assert.Contains(t, out, "SYMBOL")
26+
assert.Contains(t, out, "TOKEN_ID")
27+
assert.Contains(t, out, "STANDARD")
28+
assert.Contains(t, out, "BALANCE")
29+
}
30+
31+
func TestEvmCollectibles_JSON(t *testing.T) {
32+
key := simAPIKey(t)
33+
34+
root := newSimTestRoot()
35+
var buf bytes.Buffer
36+
root.SetOut(&buf)
37+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--limit", "5", "-o", "json"})
38+
39+
require.NoError(t, root.Execute())
40+
41+
var resp map[string]interface{}
42+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
43+
assert.Contains(t, resp, "entries")
44+
assert.Contains(t, resp, "address")
45+
}
46+
47+
func TestEvmCollectibles_FilterSpamDisabled(t *testing.T) {
48+
key := simAPIKey(t)
49+
50+
// Fetch with spam filtered (default) and without, compare counts.
51+
rootFiltered := newSimTestRoot()
52+
var bufFiltered bytes.Buffer
53+
rootFiltered.SetOut(&bufFiltered)
54+
rootFiltered.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--limit", "250", "-o", "json"})
55+
require.NoError(t, rootFiltered.Execute())
56+
57+
var respFiltered map[string]interface{}
58+
require.NoError(t, json.Unmarshal(bufFiltered.Bytes(), &respFiltered))
59+
filteredEntries, ok := respFiltered["entries"].([]interface{})
60+
require.True(t, ok)
61+
62+
rootUnfiltered := newSimTestRoot()
63+
var bufUnfiltered bytes.Buffer
64+
rootUnfiltered.SetOut(&bufUnfiltered)
65+
rootUnfiltered.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--filter-spam=false", "--limit", "250", "-o", "json"})
66+
require.NoError(t, rootUnfiltered.Execute())
67+
68+
var respUnfiltered map[string]interface{}
69+
require.NoError(t, json.Unmarshal(bufUnfiltered.Bytes(), &respUnfiltered))
70+
unfilteredEntries, ok := respUnfiltered["entries"].([]interface{})
71+
require.True(t, ok)
72+
73+
// With spam filtering disabled we should get at least as many entries.
74+
assert.GreaterOrEqual(t, len(unfilteredEntries), len(filteredEntries),
75+
"disabling spam filter should return >= entries than with filter enabled")
76+
}
77+
78+
func TestEvmCollectibles_SpamScores(t *testing.T) {
79+
key := simAPIKey(t)
80+
81+
root := newSimTestRoot()
82+
var buf bytes.Buffer
83+
root.SetOut(&buf)
84+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--filter-spam=false", "--show-spam-scores", "--limit", "5", "-o", "json"})
85+
86+
require.NoError(t, root.Execute())
87+
88+
var resp map[string]interface{}
89+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
90+
assert.Contains(t, resp, "entries")
91+
92+
// When show_spam_scores is enabled, entries should contain spam_score.
93+
entries, ok := resp["entries"].([]interface{})
94+
require.True(t, ok)
95+
if len(entries) > 0 {
96+
entry, ok := entries[0].(map[string]interface{})
97+
require.True(t, ok)
98+
assert.Contains(t, entry, "spam_score", "spam_score should be present when --show-spam-scores is set")
99+
assert.Contains(t, entry, "is_spam")
100+
}
101+
}
102+
103+
// TestEvmCollectibles_SpamScoresText verifies that --show-spam-scores
104+
// adds SPAM and SPAM_SCORE columns in text mode.
105+
func TestEvmCollectibles_SpamScoresText(t *testing.T) {
106+
key := simAPIKey(t)
107+
108+
root := newSimTestRoot()
109+
var buf bytes.Buffer
110+
root.SetOut(&buf)
111+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--show-spam-scores", "--limit", "5"})
112+
113+
require.NoError(t, root.Execute())
114+
115+
out := buf.String()
116+
assert.Contains(t, out, "CHAIN")
117+
assert.Contains(t, out, "SPAM")
118+
assert.Contains(t, out, "SPAM_SCORE")
119+
}
120+
121+
func TestEvmCollectibles_Pagination(t *testing.T) {
122+
key := simAPIKey(t)
123+
124+
root := newSimTestRoot()
125+
var buf bytes.Buffer
126+
root.SetOut(&buf)
127+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--limit", "2", "-o", "json"})
128+
129+
require.NoError(t, root.Execute())
130+
131+
var resp map[string]interface{}
132+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
133+
assert.Contains(t, resp, "entries")
134+
135+
// If next_offset is present, fetch page 2.
136+
if offset, ok := resp["next_offset"].(string); ok && offset != "" {
137+
root2 := newSimTestRoot()
138+
var buf2 bytes.Buffer
139+
root2.SetOut(&buf2)
140+
root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "collectibles", evmTestAddress, "--chain-ids", "1", "--limit", "2", "--offset", offset, "-o", "json"})
141+
142+
require.NoError(t, root2.Execute())
143+
144+
var resp2 map[string]interface{}
145+
require.NoError(t, json.Unmarshal(buf2.Bytes(), &resp2))
146+
assert.Contains(t, resp2, "entries")
147+
}
148+
}

cmd/sim/evm/evm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ func NewEvmCmd() *cobra.Command {
5454
cmd.AddCommand(NewStablecoinsCmd())
5555
cmd.AddCommand(NewActivityCmd())
5656
cmd.AddCommand(NewTransactionsCmd())
57+
cmd.AddCommand(NewCollectiblesCmd())
5758

5859
return cmd
5960
}

0 commit comments

Comments
 (0)