diff --git a/cmd/sim/evm/collectibles.go b/cmd/sim/evm/collectibles.go new file mode 100644 index 0000000..0fcf94b --- /dev/null +++ b/cmd/sim/evm/collectibles.go @@ -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
", + 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"` + 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) + 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 + } +} diff --git a/cmd/sim/evm/collectibles_test.go b/cmd/sim/evm/collectibles_test.go new file mode 100644 index 0000000..c4f9f27 --- /dev/null +++ b/cmd/sim/evm/collectibles_test.go @@ -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") + } +} diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go index 30089d8..17b4f97 100644 --- a/cmd/sim/evm/evm.go +++ b/cmd/sim/evm/evm.go @@ -44,6 +44,7 @@ func NewEvmCmd() *cobra.Command { cmd.AddCommand(NewStablecoinsCmd()) cmd.AddCommand(NewActivityCmd()) cmd.AddCommand(NewTransactionsCmd()) + cmd.AddCommand(NewCollectiblesCmd()) return cmd }