-
Notifications
You must be signed in to change notification settings - Fork 1
Add evm collectibles command for NFT listings with spam filtering #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| 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"` | ||
| 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 := SimClientFromCmd(cmd) | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.