diff --git a/authconfig/authconfig.go b/authconfig/authconfig.go index c8857bd..640f753 100644 --- a/authconfig/authconfig.go +++ b/authconfig/authconfig.go @@ -13,7 +13,6 @@ import ( // Config holds the persisted CLI configuration. type Config struct { APIKey string `yaml:"api_key"` - SimAPIKey string `yaml:"sim_api_key,omitempty"` Telemetry *bool `yaml:"telemetry,omitempty"` } diff --git a/cli/root.go b/cli/root.go index 0a35809..cf8fd90 100644 --- a/cli/root.go +++ b/cli/root.go @@ -22,7 +22,6 @@ import ( "github.com/duneanalytics/cli/cmd/execution" "github.com/duneanalytics/cli/cmd/matview" "github.com/duneanalytics/cli/cmd/query" - "github.com/duneanalytics/cli/cmd/sim" "github.com/duneanalytics/cli/cmd/usage" "github.com/duneanalytics/cli/cmd/visualization" "github.com/duneanalytics/cli/cmd/whoami" @@ -45,7 +44,6 @@ var rootCmd = &cobra.Command{ " - Create and manage visualizations (charts, tables, counters) on query results\n" + " - Create and manage dashboards with visualizations and text widgets\n" + " - Browse Dune documentation for DuneSQL syntax, API references, and guides\n" + - " - Query real-time wallet and token data via the Sim API\n" + " - Monitor credit usage, storage consumption, and billing periods\n\n" + "Authenticate with an API key via --api-key, the DUNE_API_KEY environment variable,\n" + "or by running `dune auth`.", @@ -110,8 +108,7 @@ var rootCmd = &cobra.Command{ commandPath = parts[1] } - isSim := strings.HasPrefix(commandPath, "sim") - tr.Track(commandPath, tracking.StatusSuccess, "", durationMs, isSim) + tr.Track(commandPath, tracking.StatusSuccess, "", durationMs) return nil }, } @@ -127,7 +124,6 @@ func init() { rootCmd.AddCommand(execution.NewExecutionCmd()) rootCmd.AddCommand(usage.NewUsageCmd()) rootCmd.AddCommand(whoami.NewWhoAmICmd()) - rootCmd.AddCommand(sim.NewSimCmd()) rootCmd.AddCommand(visualization.NewVisualizationCmd()) rootCmd.AddCommand(dashboard.NewDashboardCmd()) } @@ -152,8 +148,7 @@ func Execute(version, commit, date, amplitudeKey string) { ); err != nil { // Build best-effort command path from os.Args (strip flags). commandPath := commandPathFromArgs(os.Args) - isSim := strings.HasPrefix(commandPath, "sim") - tracker.Track(commandPath, tracking.StatusError, err.Error(), 0, isSim) + tracker.Track(commandPath, tracking.StatusError, err.Error(), 0) // Flush the event before exiting — os.Exit does not run deferred funcs, // so defer tracker.Shutdown() above would never fire. tracker.Shutdown() diff --git a/cmd/sim/auth.go b/cmd/sim/auth.go deleted file mode 100644 index 767a275..0000000 --- a/cmd/sim/auth.go +++ /dev/null @@ -1,74 +0,0 @@ -package sim - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/authconfig" -) - -// NewAuthCmd returns the `sim auth` command. -func NewAuthCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "auth", - Short: "Save your Sim API key to the local configuration file", - Long: "Persist your Sim API key to ~/.config/dune/config.yaml so subsequent\n" + - "'dune sim' commands authenticate automatically without requiring\n" + - "--sim-api-key or the DUNE_SIM_API_KEY environment variable.\n\n" + - "The key can be provided via:\n" + - " 1. --api-key flag\n" + - " 2. DUNE_SIM_API_KEY environment variable\n" + - " 3. Interactive prompt (if neither of the above is set)\n\n" + - "The saved key is used as the lowest-priority fallback; --sim-api-key and\n" + - "DUNE_SIM_API_KEY always take precedence when set.\n\n" + - "Examples:\n" + - " dune sim auth\n" + - " dune sim auth --api-key sim_abc123...", - Annotations: map[string]string{"skipSimAuth": "true"}, - RunE: runSimAuth, - } - - cmd.Flags().String("api-key", "", "Sim API key to save (prefixed 'sim_'); if omitted, reads from DUNE_SIM_API_KEY or prompts interactively") - - return cmd -} - -func runSimAuth(cmd *cobra.Command, _ []string) error { - key, _ := cmd.Flags().GetString("api-key") - - if key == "" { - key = os.Getenv("DUNE_SIM_API_KEY") - } - - if key == "" { - fmt.Fprint(cmd.ErrOrStderr(), "Enter your Sim API key: ") - scanner := bufio.NewScanner(cmd.InOrStdin()) - if scanner.Scan() { - key = strings.TrimSpace(scanner.Text()) - } - } - - if key == "" { - return fmt.Errorf("no API key provided") - } - - cfg, err := authconfig.Load() - if err != nil { - return fmt.Errorf("loading existing config: %w", err) - } - if cfg == nil { - cfg = &authconfig.Config{} - } - cfg.SimAPIKey = key - if err := authconfig.Save(cfg); err != nil { - return fmt.Errorf("saving config: %w", err) - } - - p, _ := authconfig.Path() - fmt.Fprintf(cmd.OutOrStdout(), "Sim API key saved to %s\n", p) - return nil -} diff --git a/cmd/sim/auth_test.go b/cmd/sim/auth_test.go deleted file mode 100644 index ad950a5..0000000 --- a/cmd/sim/auth_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package sim_test - -import ( - "bytes" - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/duneanalytics/cli/authconfig" - "github.com/duneanalytics/cli/cmd/sim" - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -func setupAuthTest(t *testing.T) string { - t.Helper() - dir := t.TempDir() - authconfig.SetDirFunc(func() (string, error) { return dir, nil }) - t.Cleanup(authconfig.ResetDirFunc) - // Clear env var so it doesn't interfere with tests. - t.Setenv("DUNE_SIM_API_KEY", "") - return dir -} - -func newSimAuthRoot() *cobra.Command { - root := &cobra.Command{Use: "dune"} - root.SetContext(context.Background()) - - simCmd := sim.NewSimCmd() - root.AddCommand(simCmd) - - return root -} - -func TestSimAuth_WithFlag(t *testing.T) { - dir := setupAuthTest(t) - - root := newSimAuthRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_flag_key"}) - require.NoError(t, root.Execute()) - - data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) - require.NoError(t, err) - assert.Contains(t, string(data), "sk_sim_flag_key") - assert.Contains(t, buf.String(), "Sim API key saved to") -} - -func TestSimAuth_WithEnvVar(t *testing.T) { - dir := setupAuthTest(t) - - t.Setenv("DUNE_SIM_API_KEY", "sk_sim_env_key") - - root := newSimAuthRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "auth"}) - require.NoError(t, root.Execute()) - - data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) - require.NoError(t, err) - assert.Contains(t, string(data), "sk_sim_env_key") -} - -func TestSimAuth_WithPrompt(t *testing.T) { - dir := setupAuthTest(t) - - root := newSimAuthRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetIn(strings.NewReader("sk_sim_prompt_key\n")) - root.SetArgs([]string{"sim", "auth"}) - require.NoError(t, root.Execute()) - - data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) - require.NoError(t, err) - assert.Contains(t, string(data), "sk_sim_prompt_key") -} - -func TestSimAuth_EmptyInput(t *testing.T) { - setupAuthTest(t) - - root := newSimAuthRoot() - root.SetIn(strings.NewReader("\n")) - root.SetArgs([]string{"sim", "auth"}) - err := root.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "no API key provided") -} - -func TestSimAuth_PreservesExistingConfig(t *testing.T) { - dir := setupAuthTest(t) - - // Pre-populate config with existing fields. - existing := &authconfig.Config{ - APIKey: "existing_dune_key", - } - telemetryTrue := true - existing.Telemetry = &telemetryTrue - require.NoError(t, authconfig.Save(existing)) - - root := newSimAuthRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_new"}) - require.NoError(t, root.Execute()) - - // Verify all fields are preserved. - data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) - require.NoError(t, err) - - var cfg authconfig.Config - require.NoError(t, yaml.Unmarshal(data, &cfg)) - - assert.Equal(t, "existing_dune_key", cfg.APIKey, "existing api_key should be preserved") - assert.Equal(t, "sk_sim_new", cfg.SimAPIKey, "sim_api_key should be set") - require.NotNil(t, cfg.Telemetry, "telemetry should be preserved") - assert.True(t, *cfg.Telemetry, "telemetry value should be preserved") -} diff --git a/cmd/sim/client.go b/cmd/sim/client.go deleted file mode 100644 index 493e202..0000000 --- a/cmd/sim/client.go +++ /dev/null @@ -1,123 +0,0 @@ -package sim - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" -) - -const defaultBaseURL = "https://api.sim.dune.com" - -// SimClient is a lightweight HTTP client for the Sim API. -type SimClient struct { - baseURL string - apiKey string - httpClient *http.Client -} - -// NewSimClient creates a new Sim API client with the given API key. -func NewSimClient(apiKey string) *SimClient { - return &SimClient{ - baseURL: defaultBaseURL, - apiKey: apiKey, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// NewBareSimClient creates a Sim API client without authentication. -// Use this for public endpoints that don't require an API key. -func NewBareSimClient() *SimClient { - return &SimClient{ - baseURL: defaultBaseURL, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// Get performs a GET request to the Sim API and returns the raw JSON response body. -// The path should include the leading slash (e.g. "/v1/evm/supported-chains"). -// Query parameters are appended from params. -func (c *SimClient) Get(ctx context.Context, path string, params url.Values) ([]byte, error) { - u, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("invalid URL: %w", err) - } - if params != nil { - u.RawQuery = params.Encode() - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - if c.apiKey != "" { - req.Header.Set("X-Sim-Api-Key", c.apiKey) - } - req.Header.Set("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response: %w", err) - } - - if resp.StatusCode >= 400 { - return nil, httpError(resp.StatusCode, body) - } - - return body, nil -} - -// httpError returns a user-friendly error for HTTP error status codes. -func httpError(status int, body []byte) error { - // Try to extract a message from the JSON error response. - var errResp struct { - Error string `json:"error"` - Message string `json:"message"` - } - msg := "" - if json.Unmarshal(body, &errResp) == nil { - if errResp.Error != "" { - msg = errResp.Error - } else if errResp.Message != "" { - msg = errResp.Message - } - } - - switch status { - case http.StatusBadRequest: - if msg != "" { - return fmt.Errorf("bad request: %s", msg) - } - return fmt.Errorf("bad request") - case http.StatusUnauthorized: - return fmt.Errorf("authentication failed: check your Sim API key") - case http.StatusNotFound: - if msg != "" { - return fmt.Errorf("not found: %s", msg) - } - return fmt.Errorf("not found") - case http.StatusTooManyRequests: - return fmt.Errorf("rate limit exceeded: try again later") - default: - if status >= 500 { - return fmt.Errorf("Sim API server error (HTTP %d): try again later", status) - } - if msg != "" { - return fmt.Errorf("Sim API error (HTTP %d): %s", status, msg) - } - return fmt.Errorf("Sim API error (HTTP %d)", status) - } -} diff --git a/cmd/sim/evm/activity.go b/cmd/sim/evm/activity.go deleted file mode 100644 index 9ca52a0..0000000 --- a/cmd/sim/evm/activity.go +++ /dev/null @@ -1,218 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewActivityCmd returns the `sim evm activity` command. -func NewActivityCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "activity
", - Short: "Get a decoded activity feed for a wallet address across EVM chains", - Long: "Return a reverse-chronological feed of human-readable on-chain activity for\n" + - "the given wallet address. Unlike raw transactions, activity entries are decoded\n" + - "and classified into semantic types: sends, receives, mints, burns, token swaps,\n" + - "approvals, and contract calls.\n\n" + - "Activity types:\n" + - " - send/receive: native or token transfers to/from the wallet\n" + - " - mint/burn: token creation or destruction involving the wallet\n" + - " - swap: DEX token exchanges (includes from/to token details)\n" + - " - approve: ERC20/ERC721 spending approvals\n" + - " - call: contract interactions with decoded function name and inputs\n\n" + - "Asset types: native, erc20, erc721, erc1155.\n\n" + - "Each activity item includes the transaction context, transfer amounts with\n" + - "USD values, and token metadata. Swap entries include both sides of the trade.\n" + - "Call entries include the decoded function name and inputs.\n\n" + - "By default, returns all activity types across all default chains.\n" + - "Run 'dune sim evm supported-chains' to see which chains support activity.\n\n" + - "For raw transaction data (hashes, gas, calldata), use 'dune sim evm transactions'.\n\n" + - "Examples:\n" + - " dune sim evm activity 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" + - " dune sim evm activity 0xd8da... --activity-type send,receive --chain-ids 1\n" + - " dune sim evm activity 0xd8da... --asset-type erc20 --limit 50 -o json\n" + - " dune sim evm activity 0xd8da... --token-address 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - Args: cobra.ExactArgs(1), - RunE: runActivity, - } - - cmd.Flags().String("chain-ids", "", "Restrict to specific chains by numeric ID or tag name (comma-separated, e.g. '1,8453' or 'default'); defaults to all chains tagged 'default'") - cmd.Flags().String("token-address", "", "Filter activities involving specific token contracts (comma-separated ERC20/ERC721/ERC1155 addresses)") - cmd.Flags().String("activity-type", "", "Filter by activity classification (comma-separated): send, receive, mint, burn, swap, approve, call; defaults to all types") - cmd.Flags().String("asset-type", "", "Filter by token standard (comma-separated): native, erc20, erc721, erc1155; defaults to all standards") - cmd.Flags().Int("limit", 0, "Maximum number of activity items to return per page (1-100, default: server-determined)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -type activityResponse struct { - Activity []activityItem `json:"activity"` - NextOffset string `json:"next_offset,omitempty"` - Warnings []warningEntry `json:"warnings,omitempty"` -} - -type activityItem struct { - ChainID int64 `json:"chain_id"` - BlockNumber int64 `json:"block_number"` - BlockTime string `json:"block_time"` - TxHash string `json:"tx_hash"` - Type string `json:"type"` - AssetType string `json:"asset_type"` - TokenAddress string `json:"token_address,omitempty"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Value string `json:"value,omitempty"` - ValueUSD float64 `json:"value_usd"` - ID string `json:"id,omitempty"` // ERC721/ERC1155 token ID - Spender string `json:"spender,omitempty"` - TokenMeta *tokenMetadata `json:"token_metadata,omitempty"` - - // Swap-specific fields. - FromTokenAddress string `json:"from_token_address,omitempty"` - FromTokenValue string `json:"from_token_value,omitempty"` - FromTokenMetadata *tokenMetadata `json:"from_token_metadata,omitempty"` - ToTokenAddress string `json:"to_token_address,omitempty"` - ToTokenValue string `json:"to_token_value,omitempty"` - ToTokenMetadata *tokenMetadata `json:"to_token_metadata,omitempty"` - - // Contract call fields. - Function *functionInfo `json:"function,omitempty"` - ContractMetadata *contractMetaObj `json:"contract_metadata,omitempty"` -} - -type tokenMetadata struct { - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` - Name string `json:"name,omitempty"` - Logo string `json:"logo,omitempty"` - PriceUSD float64 `json:"price_usd"` - PoolSize float64 `json:"pool_size,omitempty"` - Standard string `json:"standard,omitempty"` -} - -type functionInfo struct { - Signature string `json:"signature,omitempty"` - Name string `json:"name,omitempty"` - Inputs []functionInput `json:"inputs,omitempty"` -} - -type functionInput struct { - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Value json.RawMessage `json:"value,omitempty"` -} - -type contractMetaObj struct { - Name string `json:"name,omitempty"` -} - -func runActivity(cmd *cobra.Command, args []string) error { - client := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - address := args[0] - params := url.Values{} - - if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { - params.Set("chain_ids", v) - } - if v, _ := cmd.Flags().GetString("token-address"); v != "" { - params.Set("token_address", v) - } - if v, _ := cmd.Flags().GetString("activity-type"); v != "" { - params.Set("activity_type", v) - } - if v, _ := cmd.Flags().GetString("asset-type"); v != "" { - params.Set("asset_type", v) - } - 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/activity/"+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 activityResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - // Print warnings to stderr. - printWarnings(cmd, resp.Warnings) - - columns := []string{"CHAIN_ID", "TYPE", "ASSET_TYPE", "SYMBOL", "VALUE_USD", "TX_HASH", "BLOCK_TIME"} - rows := make([][]string, len(resp.Activity)) - for i, a := range resp.Activity { - rows[i] = []string{ - fmt.Sprintf("%d", a.ChainID), - a.Type, - a.AssetType, - activitySymbol(a), - output.FormatUSD(a.ValueUSD), - truncateHash(a.TxHash), - a.BlockTime, - } - } - output.PrintTable(w, columns, rows) - - if resp.NextOffset != "" { - fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) - } - return nil - } -} - -// activitySymbol returns the best symbol to display for the activity. -// For swaps it shows "FROM -> TO", for regular activities it uses token_metadata. -func activitySymbol(a activityItem) string { - if a.Type == "swap" { - from := "" - to := "" - if a.FromTokenMetadata != nil { - from = a.FromTokenMetadata.Symbol - } - if a.ToTokenMetadata != nil { - to = a.ToTokenMetadata.Symbol - } - if from != "" || to != "" { - return from + " -> " + to - } - return "" - } - if a.TokenMeta != nil { - return a.TokenMeta.Symbol - } - // Native transfers may not have token_metadata. - if a.AssetType == "native" { - return "ETH" - } - return "" -} - -// truncateHash shortens a hex hash for table display. -func truncateHash(hash string) string { - if len(hash) <= 14 { - return hash - } - return hash[:8] + "..." + hash[len(hash)-4:] -} diff --git a/cmd/sim/evm/activity_test.go b/cmd/sim/evm/activity_test.go deleted file mode 100644 index 824b03c..0000000 --- a/cmd/sim/evm/activity_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmActivity_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", "activity", evmTestAddress, "--chain-ids", "1", "--limit", "5"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN_ID") - assert.Contains(t, out, "TYPE") - assert.Contains(t, out, "ASSET_TYPE") - assert.Contains(t, out, "SYMBOL") - assert.Contains(t, out, "VALUE_USD") - assert.Contains(t, out, "TX_HASH") - assert.Contains(t, out, "BLOCK_TIME") -} - -func TestEvmActivity_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", "activity", 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, "activity") -} - -func TestEvmActivity_ActivityTypeFilter(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--activity-type", "receive", "--limit", "5", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp struct { - Activity []struct { - Type string `json:"type"` - } `json:"activity"` - } - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - - // All returned activities should be of the filtered type. - for _, a := range resp.Activity { - assert.Equal(t, "receive", a.Type) - } -} - -func TestEvmActivity_AssetTypeFilter(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--asset-type", "erc20", "--limit", "5", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp struct { - Activity []struct { - AssetType string `json:"asset_type"` - } `json:"activity"` - } - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - - for _, a := range resp.Activity { - assert.Equal(t, "erc20", a.AssetType) - } -} - -func TestEvmActivity_Pagination(t *testing.T) { - key := simAPIKey(t) - - // Fetch page 1 with a small limit to trigger pagination. - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", 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, "activity") - - // 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", "activity", 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, "activity") - } -} diff --git a/cmd/sim/evm/balance.go b/cmd/sim/evm/balance.go deleted file mode 100644 index 01d36c2..0000000 --- a/cmd/sim/evm/balance.go +++ /dev/null @@ -1,102 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewBalanceCmd returns the `sim evm balance` command (single token). -func NewBalanceCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "balance ", - Short: "Get the balance of a single token for a wallet on one chain", - Long: "Return the balance of a specific token for the given wallet address on a\n" + - "single EVM chain. This is a targeted lookup that returns exactly one balance\n" + - "entry, unlike 'dune sim evm balances' which returns all tokens across chains.\n\n" + - "Both --token and --chain-ids are required. Use the literal string 'native'\n" + - "as the --token value to query the chain's native asset (e.g. ETH on Ethereum,\n" + - "MATIC on Polygon), or pass an ERC20 contract address.\n\n" + - "For multi-token or multi-chain lookups, use 'dune sim evm balances' instead.\n\n" + - "Examples:\n" + - " dune sim evm balance 0xd8da... --token native --chain-ids 1\n" + - " dune sim evm balance 0xd8da... --token 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --chain-ids 8453\n" + - " dune sim evm balance 0xd8da... --token native --chain-ids 1 --historical-prices 168,24 -o json", - Args: cobra.ExactArgs(1), - RunE: runBalance, - } - - cmd.Flags().String("token", "", "Token to query: an ERC20 contract address (0x...) or the literal string 'native' for the chain's native asset (required)") - cmd.Flags().String("chain-ids", "", "Numeric EVM chain ID to query (required, single value, e.g. '1' for Ethereum, '8453' for Base)") - cmd.Flags().String("metadata", "", "Request additional metadata fields in the response (comma-separated): 'logo' (token icon URL), 'url' (project website), 'pools' (liquidity pool details)") - cmd.Flags().String("historical-prices", "", "Include historical USD prices at the specified hour offsets from now (comma-separated, e.g. '720,168,24' for 30d, 7d, 1d ago)") - _ = cmd.MarkFlagRequired("token") - _ = cmd.MarkFlagRequired("chain-ids") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -func runBalance(cmd *cobra.Command, args []string) error { - client := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - address := args[0] - tokenAddress, _ := cmd.Flags().GetString("token") - - params := url.Values{} - if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { - params.Set("chain_ids", v) - } - if v, _ := cmd.Flags().GetString("metadata"); v != "" { - params.Set("metadata", v) - } - if v, _ := cmd.Flags().GetString("historical-prices"); v != "" { - params.Set("historical_prices", v) - } - - path := fmt.Sprintf("/v1/evm/balances/%s/token/%s", address, 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 balancesResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - printBalanceErrors(cmd, resp.Errors) - - if len(resp.Balances) == 0 { - fmt.Fprintln(w, "No balance found.") - return nil - } - - b := resp.Balances[0] - fmt.Fprintf(w, "Chain: %s (ID: %d)\n", b.Chain, b.ChainID) - fmt.Fprintf(w, "Token: %s\n", b.Address) - fmt.Fprintf(w, "Symbol: %s\n", b.Symbol) - if b.Name != "" { - fmt.Fprintf(w, "Name: %s\n", b.Name) - } - fmt.Fprintf(w, "Decimals: %d\n", b.Decimals) - fmt.Fprintf(w, "Amount: %s\n", formatAmount(b.Amount, b.Decimals)) - fmt.Fprintf(w, "Price USD: %s\n", output.FormatUSD(b.PriceUSD)) - fmt.Fprintf(w, "Value USD: %s\n", output.FormatUSD(b.ValueUSD)) - - return nil - } -} diff --git a/cmd/sim/evm/balance_test.go b/cmd/sim/evm/balance_test.go deleted file mode 100644 index 1d01025..0000000 --- a/cmd/sim/evm/balance_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmBalance_NativeText(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balance", evmTestAddress, "--token", "native", "--chain-ids", "1"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "Chain:") - assert.Contains(t, out, "Symbol:") - assert.Contains(t, out, "ETH") - assert.Contains(t, out, "Amount:") - assert.Contains(t, out, "Price USD:") - assert.Contains(t, out, "Value USD:") -} - -func TestEvmBalance_NativeJSON(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balance", evmTestAddress, "--token", "native", "--chain-ids", "1", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "wallet_address") - assert.Contains(t, resp, "balances") - - balances, ok := resp["balances"].([]interface{}) - require.True(t, ok) - require.Len(t, balances, 1) - - first, ok := balances[0].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "native", first["address"]) -} - -func TestEvmBalance_MissingRequiredFlags(t *testing.T) { - key := simAPIKey(t) - - // Missing --token - root := newSimTestRoot() - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balance", evmTestAddress, "--chain-ids", "1"}) - err := root.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "token") - - // Missing --chain-ids - root2 := newSimTestRoot() - root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balance", evmTestAddress, "--token", "native"}) - err2 := root2.Execute() - assert.Error(t, err2) - assert.Contains(t, err2.Error(), "chain-ids") -} diff --git a/cmd/sim/evm/balances.go b/cmd/sim/evm/balances.go deleted file mode 100644 index 8857107..0000000 --- a/cmd/sim/evm/balances.go +++ /dev/null @@ -1,290 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "net/url" - "strings" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewBalancesCmd returns the `sim evm balances` command. -func NewBalancesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "balances
", - Short: "Get EVM token balances for a wallet address across multiple chains", - Long: "Return native and ERC20 token balances for the given wallet address across\n" + - "supported EVM chains. Each balance entry includes the token identity, raw\n" + - "balance amount, current USD price, total USD value, and liquidity indicators.\n" + - "Data comes from Dune's real-time index.\n\n" + - "By default, queries all chains tagged 'default'. Use --chain-ids to restrict\n" + - "to specific networks. Run 'dune sim evm supported-chains' to see valid IDs.\n\n" + - "For a single-token balance lookup, use 'dune sim evm balance' instead.\n" + - "For stablecoin-only balances, use 'dune sim evm stablecoins'.\n\n" + - "Results are paginated; use --offset with the next_offset value from a\n" + - "previous response to retrieve additional pages.\n\n" + - "Examples:\n" + - " dune sim evm balances 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" + - " dune sim evm balances 0xd8da... --chain-ids 1,8453 --exclude-spam\n" + - " dune sim evm balances 0xd8da... --filters erc20 --metadata logo,url\n" + - " dune sim evm balances 0xd8da... --historical-prices 720,168,24 -o json", - Args: cobra.ExactArgs(1), - RunE: runBalances, - } - - addBalanceFlags(cmd) - cmd.Flags().String("asset-class", "", "Filter by asset classification: 'stablecoin' (returns only stablecoins like USDC, USDT, DAI); prefer 'dune sim evm stablecoins' as a shorthand") - - return cmd -} - -type balancesResponse struct { - WalletAddress string `json:"wallet_address"` - Balances []balanceEntry `json:"balances"` - Errors *balanceErrors `json:"errors,omitempty"` - NextOffset string `json:"next_offset,omitempty"` - Warnings []warningEntry `json:"warnings,omitempty"` - RequestTime string `json:"request_time,omitempty"` - ResponseTime string `json:"response_time,omitempty"` -} - -type balanceErrors struct { - ErrorMessage string `json:"error_message,omitempty"` - TokenErrors []apiChainError `json:"token_errors,omitempty"` -} - -// apiChainError is a per-chain error returned by several Sim API endpoints -// (balances, transactions, etc.). It is intentionally shared across commands. -type apiChainError struct { - ChainID int64 `json:"chain_id"` - Address string `json:"address"` - Description string `json:"description,omitempty"` -} - -type balanceEntry struct { - Chain string `json:"chain"` - ChainID int64 `json:"chain_id"` - Address string `json:"address"` - Amount string `json:"amount"` - Symbol string `json:"symbol"` - Name string `json:"name"` - Decimals int `json:"decimals"` - PriceUSD float64 `json:"price_usd"` - ValueUSD float64 `json:"value_usd"` - PoolSize float64 `json:"pool_size"` - LowLiquidity bool `json:"low_liquidity"` - HistoricalPrices []historicalPrice `json:"historical_prices,omitempty"` - TokenMetadata *balanceTokenMeta `json:"token_metadata,omitempty"` - Pool *poolMetadata `json:"pool,omitempty"` -} - -type historicalPrice struct { - OffsetHours int `json:"offset_hours"` - PriceUSD float64 `json:"price_usd"` -} - -type balanceTokenMeta struct { - Logo string `json:"logo,omitempty"` - URL string `json:"url,omitempty"` -} - -type poolMetadata struct { - PoolType string `json:"pool_type"` - Address string `json:"address"` - Token0 string `json:"token0"` - Token1 string `json:"token1"` -} - -type warningEntry struct { - Code string `json:"code"` - Message string `json:"message"` - ChainIDs []int64 `json:"chain_ids,omitempty"` - DocsURL string `json:"docs_url,omitempty"` -} - -func runBalances(cmd *cobra.Command, args []string) error { - return runBalancesEndpoint(cmd, args, "/v1/evm/balances/", "") -} - -// addBalanceFlags registers the common flags shared by the balances and -// stablecoins commands. -func addBalanceFlags(cmd *cobra.Command) { - cmd.Flags().String("chain-ids", "", "Restrict to specific chains by numeric ID or tag name (comma-separated, e.g. '1,8453' or 'default'); defaults to all chains tagged 'default'. Run 'dune sim evm supported-chains' for valid values") - cmd.Flags().String("filters", "", "Filter by token standard: 'erc20' (only ERC20 tokens) or 'native' (only native chain assets like ETH)") - cmd.Flags().String("metadata", "", "Request additional metadata fields in the response (comma-separated): 'logo' (token icon URL), 'url' (project website), 'pools' (liquidity pool details)") - cmd.Flags().Bool("exclude-spam", false, "Exclude low-liquidity tokens (less than $100 USD pool size) commonly associated with spam airdrops") - cmd.Flags().Bool("exclude-unpriced", true, "Exclude tokens without a USD price (default: true); pass --exclude-unpriced=false to include them") - cmd.Flags().String("historical-prices", "", "Include historical USD prices at the specified hour offsets from now (comma-separated, e.g. '720,168,24' for 30d, 7d, 1d ago)") - cmd.Flags().Int("limit", 0, "Maximum number of balance entries to return per page (1-1000, default: server-determined)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - output.AddFormatFlag(cmd, "text") -} - -// runBalancesEndpoint is the shared run implementation for the balances and -// stablecoins commands. The final API path is built as: -// -// pathPrefix + address + pathSuffix -// -// For example "/v1/evm/balances/" + addr + "" for balances, -// or "/v1/evm/balances/" + addr + "/stablecoins" for stablecoins. -func runBalancesEndpoint(cmd *cobra.Command, args []string, pathPrefix, pathSuffix string) error { - client := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - address := args[0] - params := url.Values{} - - if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { - params.Set("chain_ids", v) - } - if v, _ := cmd.Flags().GetString("filters"); v != "" { - params.Set("filters", v) - } - // asset-class is only registered on the balances command; silently ignored - // when the flag is absent. - if v, _ := cmd.Flags().GetString("asset-class"); v != "" { - params.Set("asset_class", v) - } - if v, _ := cmd.Flags().GetString("metadata"); v != "" { - params.Set("metadata", v) - } - if v, _ := cmd.Flags().GetBool("exclude-spam"); v { - params.Set("exclude_spam_tokens", "true") - } - if v, _ := cmd.Flags().GetBool("exclude-unpriced"); v { - params.Set("exclude_unpriced", "true") - } else { - params.Set("exclude_unpriced", "false") - } - if v, _ := cmd.Flags().GetString("historical-prices"); v != "" { - params.Set("historical_prices", v) - } - 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(), pathPrefix+address+pathSuffix, 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 balancesResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - // Print errors and warnings to stderr. - printBalanceErrors(cmd, resp.Errors) - printWarnings(cmd, resp.Warnings) - - columns := []string{"CHAIN", "SYMBOL", "AMOUNT", "PRICE_USD", "VALUE_USD"} - rows := make([][]string, len(resp.Balances)) - for i, b := range resp.Balances { - rows[i] = []string{ - b.Chain, - b.Symbol, - formatAmount(b.Amount, b.Decimals), - output.FormatUSD(b.PriceUSD), - output.FormatUSD(b.ValueUSD), - } - } - output.PrintTable(w, columns, rows) - - if resp.NextOffset != "" { - fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) - } - return nil - } -} - -// printWarnings writes API warnings to stderr. -func printWarnings(cmd *cobra.Command, warnings []warningEntry) { - if len(warnings) == 0 { - return - } - stderr := cmd.ErrOrStderr() - for _, w := range warnings { - fmt.Fprintf(stderr, "Warning: %s\n", w.Message) - if len(w.ChainIDs) > 0 { - ids := make([]string, len(w.ChainIDs)) - for i, id := range w.ChainIDs { - ids[i] = fmt.Sprintf("%d", id) - } - fmt.Fprintf(stderr, " Unsupported chain IDs: %s\n", strings.Join(ids, ", ")) - } - if w.DocsURL != "" { - fmt.Fprintf(stderr, " See %s\n", w.DocsURL) - } - } - fmt.Fprintln(stderr) -} - -// formatAmount converts a raw token amount string with decimals to a -// human-readable decimal representation. -func formatAmount(raw string, decimals int) string { - if decimals <= 0 || raw == "" || raw == "0" { - return raw - } - - // Pad with leading zeros if the raw string is shorter than decimals. - for len(raw) <= decimals { - raw = "0" + raw - } - - intPart := raw[:len(raw)-decimals] - fracPart := raw[len(raw)-decimals:] - - // Trim trailing zeros from the fractional part, keep up to 6 digits. - if len(fracPart) > 6 { - fracPart = fracPart[:6] - } - fracPart = strings.TrimRight(fracPart, "0") - - if fracPart == "" { - return intPart - } - return intPart + "." + fracPart -} - -// printBalanceErrors writes balance-level errors to stderr. -func printBalanceErrors(cmd *cobra.Command, errs *balanceErrors) { - if errs == nil { - return - } - printAPIChainErrors(cmd, errs.ErrorMessage, errs.TokenErrors) -} - -// printAPIChainErrors is a shared helper that writes per-chain API errors to -// stderr. It is used by both balance and transaction commands to avoid -// duplicating the same formatting logic. -func printAPIChainErrors(cmd *cobra.Command, msg string, errs []apiChainError) { - if msg == "" && len(errs) == 0 { - return - } - stderr := cmd.ErrOrStderr() - if msg != "" { - fmt.Fprintf(stderr, "Error: %s\n", msg) - } - for _, e := range errs { - fmt.Fprintf(stderr, " chain_id=%d address=%s", e.ChainID, e.Address) - if e.Description != "" { - fmt.Fprintf(stderr, " — %s", e.Description) - } - fmt.Fprintln(stderr) - } - fmt.Fprintln(stderr) -} diff --git a/cmd/sim/evm/balances_test.go b/cmd/sim/evm/balances_test.go deleted file mode 100644 index 227b74e..0000000 --- a/cmd/sim/evm/balances_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmBalances_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", "balances", evmTestAddress, "--chain-ids", "1", "--limit", "5"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") - assert.Contains(t, out, "SYMBOL") - assert.Contains(t, out, "AMOUNT") - assert.Contains(t, out, "PRICE_USD") - assert.Contains(t, out, "VALUE_USD") -} - -func TestEvmBalances_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", "balances", 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, "wallet_address") - assert.Contains(t, resp, "balances") -} - -func TestEvmBalances_WithFilters(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--filters", "native", "--limit", "5"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") - assert.Contains(t, out, "ETH") -} - -func TestEvmBalances_ExcludeSpam(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", evmTestAddress, "--chain-ids", "1", "--exclude-spam", "--limit", "5"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") -} - -func TestEvmBalances_Pagination(t *testing.T) { - key := simAPIKey(t) - - // Fetch page 1 with a small limit to trigger pagination. - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", 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, "balances") - - // If next_offset is present, pagination is working. - if offset, ok := resp["next_offset"].(string); ok && offset != "" { - // Fetch page 2 using the offset. - root2 := newSimTestRoot() - var buf2 bytes.Buffer - root2.SetOut(&buf2) - root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "balances", 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, "balances") - } -} diff --git a/cmd/sim/evm/collectibles.go b/cmd/sim/evm/collectibles.go deleted file mode 100644 index 31b52d1..0000000 --- a/cmd/sim/evm/collectibles.go +++ /dev/null @@ -1,167 +0,0 @@ -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 (ERC721/ERC1155) held by a wallet address", - Long: "Return ERC721 and ERC1155 collectibles (NFTs) held by the given wallet\n" + - "address across supported EVM chains. Each entry includes the collection and\n" + - "token identity, image URL, quantity held, acquisition timestamp, and spam\n" + - "classification when requested. Spam filtering is enabled by default to hide\n" + - "airdropped junk NFTs.\n\n" + - "Spam filtering uses a scoring model based on collection traits (holder count,\n" + - "transfer patterns, metadata quality). Disable with --filter-spam=false to see\n" + - "all NFTs including suspected spam.\n\n" + - "By default, queries all chains tagged 'default'. Run 'dune sim evm\n" + - "supported-chains' to see which chains support collectibles.\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\n" + - " dune sim evm collectibles 0xd8da... --limit 100 -o json", - Args: cobra.ExactArgs(1), - RunE: runCollectibles, - } - - cmd.Flags().String("chain-ids", "", "Restrict to specific chains by numeric ID or tag name (comma-separated, e.g. '1,8453' or 'default'); defaults to all chains tagged 'default'") - cmd.Flags().Bool("filter-spam", true, "Hide collectibles identified as spam by the scoring model (default: true); set --filter-spam=false to include all NFTs") - cmd.Flags().Bool("show-spam-scores", false, "Include spam classification details in the response: is_spam flag, numeric spam_score, and per-feature explanations with weights") - cmd.Flags().Int("limit", 0, "Maximum number of collectibles to return per page (1-2500, default: 250)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - 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 - } -} diff --git a/cmd/sim/evm/collectibles_test.go b/cmd/sim/evm/collectibles_test.go deleted file mode 100644 index c4f9f27..0000000 --- a/cmd/sim/evm/collectibles_test.go +++ /dev/null @@ -1,148 +0,0 @@ -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/defi_positions.go b/cmd/sim/evm/defi_positions.go deleted file mode 100644 index 6cf9342..0000000 --- a/cmd/sim/evm/defi_positions.go +++ /dev/null @@ -1,282 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "io" - "net/url" - "sort" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewDefiPositionsCmd returns the `sim evm defi-positions` command. -func NewDefiPositionsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "defi-positions
", - Short: "Get DeFi positions (lending, LPs, vaults) for a wallet address", - Long: "Return DeFi positions for the given wallet address across supported EVM\n" + - "chains and protocols. Each position includes USD valuation and protocol-\n" + - "specific metadata. The response also includes aggregation summaries with\n" + - "total USD value and per-chain breakdowns.\n\n" + - "Supported position types:\n" + - " - Erc4626: ERC-4626 vault positions (e.g. yield vaults, staking wrappers)\n" + - " - Tokenized: lending protocol positions with receipt tokens (e.g. Aave\n" + - " aTokens, Compound cTokens)\n" + - " - UniswapV2: AMM liquidity provider positions (Uniswap V2 and forks)\n" + - " - Nft: Uniswap V3 concentrated liquidity positions (NFT-based)\n" + - " - NftV4: Uniswap V4 concentrated liquidity positions (NFT-based)\n\n" + - "Each position includes the chain, USD value, protocol name, and type-\n" + - "specific details (token identities, pool info, balances). The response\n" + - "also includes aggregation summaries with total value and per-chain\n" + - "breakdowns. Use -o json for the full structured response.\n\n" + - "Run 'dune sim evm supported-chains' to see which chains support defi-positions.\n\n" + - "Examples:\n" + - " dune sim evm defi-positions 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" + - " dune sim evm defi-positions 0xd8da... --chain-ids 1,8453\n" + - " dune sim evm defi-positions 0xd8da... -o json", - Args: cobra.ExactArgs(1), - RunE: runDefiPositions, - } - - cmd.Flags().String("chain-ids", "", "Restrict to specific chains by numeric ID or tag name (comma-separated, e.g. '1,8453' or 'default'); defaults to all chains tagged 'default'") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -// --- Response types --- - -type defiPositionsResponse struct { - Positions []defiPosition `json:"positions"` - Aggregations *defiAggregations `json:"aggregations,omitempty"` - Warnings []warningEntry `json:"warnings,omitempty"` -} - -type defiAggregations struct { - TotalValueUSD float64 `json:"total_value_usd"` - TotalByChain map[string]float64 `json:"total_by_chain,omitempty"` -} - -// defiTokenInfo represents a token object returned by the API with address, -// name, symbol, and optional numeric fields depending on position type. -type defiTokenInfo struct { - Address string `json:"address,omitempty"` - Name string `json:"name,omitempty"` - Symbol string `json:"symbol,omitempty"` - Decimals int `json:"decimals,omitempty"` - Holdings float64 `json:"holdings,omitempty"` - PriceUSD float64 `json:"price_usd,omitempty"` -} - -// nftTokenDetails holds per-token data inside an NFT concentrated-liquidity position. -type nftTokenDetails struct { - PriceUSD float64 `json:"price_usd"` - Holdings float64 `json:"holdings,omitempty"` - Rewards float64 `json:"rewards,omitempty"` -} - -// defiPosition matches the polymorphic DefiPosition schema returned by the API. -// Fields are optional depending on the `type` discriminator. -type defiPosition struct { - Type string `json:"type"` - Chain string `json:"chain,omitempty"` - ChainID int64 `json:"chain_id"` - ValueUSD float64 `json:"value_usd"` - Logo *string `json:"logo,omitempty"` - - // Erc4626 / Tokenized fields - TokenType string `json:"token_type,omitempty"` - Token *defiTokenInfo `json:"token,omitempty"` - UnderlyingToken *defiTokenInfo `json:"underlying_token,omitempty"` - LendingPool string `json:"lending_pool,omitempty"` - - // Erc4626 / Tokenized / UniswapV2 fields - Balance float64 `json:"balance,omitempty"` - PriceUSD float64 `json:"price_usd,omitempty"` - - // UniswapV2 / Nft / NftV4 fields - Protocol string `json:"protocol,omitempty"` - Pool string `json:"pool,omitempty"` - PoolID string `json:"pool_id,omitempty"` - PoolManager string `json:"pool_manager,omitempty"` - Salt string `json:"salt,omitempty"` - Token0 *defiTokenInfo `json:"token0,omitempty"` - Token1 *defiTokenInfo `json:"token1,omitempty"` - LPBalance string `json:"lp_balance,omitempty"` - - // Nft / NftV4 concentrated liquidity positions - Positions []nftPositionDetails `json:"positions,omitempty"` -} - -type nftPositionDetails struct { - TickLower int `json:"tick_lower"` - TickUpper int `json:"tick_upper"` - TokenID string `json:"token_id"` - Token0 *nftTokenDetails `json:"token0,omitempty"` - Token1 *nftTokenDetails `json:"token1,omitempty"` -} - -func runDefiPositions(cmd *cobra.Command, args []string) error { - client := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - address := args[0] - params := url.Values{} - - if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { - params.Set("chain_ids", v) - } - - data, err := client.Get(cmd.Context(), "/v1/evm/defi/positions/"+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 defiPositionsResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - // Print warnings to stderr. - printWarnings(cmd, resp.Warnings) - - if len(resp.Positions) == 0 { - fmt.Fprintln(w, "No DeFi positions found.") - return nil - } - - columns := []string{"TYPE", "CHAIN_ID", "PROTOCOL", "USD_VALUE", "DETAILS"} - rows := make([][]string, len(resp.Positions)) - for i, p := range resp.Positions { - rows[i] = []string{ - p.Type, - fmt.Sprintf("%d", p.ChainID), - p.Protocol, - output.FormatUSD(p.ValueUSD), - positionDetails(p), - } - } - output.PrintTable(w, columns, rows) - - // Print aggregation summary. - printAggregations(w, resp.Aggregations) - - return nil - } -} - -// positionDetails returns a human-readable summary for a DeFi position, -// varying by position type. -// tokenSymbol safely extracts the symbol from a token info pointer. -func tokenSymbol(t *defiTokenInfo) string { - if t == nil { - return "" - } - return t.Symbol -} - -func positionDetails(p defiPosition) string { - switch p.Type { - case "Erc4626": - parts := []string{} - if sym := tokenSymbol(p.Token); sym != "" { - parts = append(parts, sym) - } - if sym := tokenSymbol(p.UnderlyingToken); sym != "" { - parts = append(parts, fmt.Sprintf("-> %s", sym)) - } - if p.Balance != 0 { - parts = append(parts, fmt.Sprintf("bal=%.6g", p.Balance)) - } - return strings.Join(parts, " ") - - case "Tokenized": - parts := []string{} - if p.TokenType != "" { - parts = append(parts, p.TokenType) - } - if sym := tokenSymbol(p.Token); sym != "" { - parts = append(parts, sym) - } - if p.Balance != 0 { - parts = append(parts, fmt.Sprintf("bal=%.6g", p.Balance)) - } - return strings.Join(parts, " ") - - case "UniswapV2": - pair := formatPair(tokenSymbol(p.Token0), tokenSymbol(p.Token1)) - if p.Balance != 0 { - return fmt.Sprintf("%s bal=%.6g", pair, p.Balance) - } - return pair - - case "Nft", "NftV4": - pair := formatPair(tokenSymbol(p.Token0), tokenSymbol(p.Token1)) - nPos := len(p.Positions) - if nPos == 1 { - return fmt.Sprintf("%s (1 position)", pair) - } - if nPos > 1 { - return fmt.Sprintf("%s (%d positions)", pair, nPos) - } - return pair - - default: - return "" - } -} - -// formatPair returns "SYM0/SYM1" or falls back to individual symbols. -func formatPair(sym0, sym1 string) string { - if sym0 != "" && sym1 != "" { - return sym0 + "/" + sym1 - } - if sym0 != "" { - return sym0 - } - return sym1 -} - -// printAggregations prints the aggregation summary after the positions table. -func printAggregations(w io.Writer, agg *defiAggregations) { - if agg == nil { - return - } - - fmt.Fprintf(w, "\nTotal USD Value: %s\n", output.FormatUSD(agg.TotalValueUSD)) - - if len(agg.TotalByChain) > 0 { - fmt.Fprintln(w, "Breakdown by chain:") - - // Sort chain IDs numerically for natural display order. - chainIDs := make([]string, 0, len(agg.TotalByChain)) - for k := range agg.TotalByChain { - chainIDs = append(chainIDs, k) - } - sort.Slice(chainIDs, func(i, j int) bool { - a, errA := strconv.Atoi(chainIDs[i]) - b, errB := strconv.Atoi(chainIDs[j]) - if errA != nil || errB != nil { - return chainIDs[i] < chainIDs[j] // fallback to lexicographic - } - return a < b - }) - - for _, cid := range chainIDs { - fmt.Fprintf(w, " Chain %s: %s\n", cid, output.FormatUSD(agg.TotalByChain[cid])) - } - } -} diff --git a/cmd/sim/evm/defi_positions_test.go b/cmd/sim/evm/defi_positions_test.go deleted file mode 100644 index df86b59..0000000 --- a/cmd/sim/evm/defi_positions_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmDefiPositions_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", "defi-positions", evmTestAddress}) - - require.NoError(t, root.Execute()) - - out := buf.String() - // Should contain table headers. - assert.Contains(t, out, "TYPE") - assert.Contains(t, out, "CHAIN_ID") - assert.Contains(t, out, "USD_VALUE") - assert.Contains(t, out, "DETAILS") -} - -func TestEvmDefiPositions_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", "defi-positions", evmTestAddress, "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "positions") - - positions, ok := resp["positions"].([]interface{}) - require.True(t, ok) - if len(positions) > 0 { - p, ok := positions[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, p, "type") - assert.Contains(t, p, "chain_id") - assert.Contains(t, p, "usd_value") - } -} - -func TestEvmDefiPositions_WithChainIDs(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "--chain-ids", "1", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "positions") - - // All positions should be on chain 1. - positions, ok := resp["positions"].([]interface{}) - require.True(t, ok) - for _, pos := range positions { - p, ok := pos.(map[string]interface{}) - require.True(t, ok) - chainID, ok := p["chain_id"].(float64) - if ok { - assert.Equal(t, float64(1), chainID) - } - } -} - -func TestEvmDefiPositions_Aggregations(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - - // Check aggregations object. - agg, ok := resp["aggregations"].(map[string]interface{}) - if ok { - assert.Contains(t, agg, "total_usd_value") - } -} - -func TestEvmDefiPositions_TextAggregationSummary(t *testing.T) { - key := simAPIKey(t) - - // First check via JSON whether aggregations are present for this address. - jsonRoot := newSimTestRoot() - var jsonBuf bytes.Buffer - jsonRoot.SetOut(&jsonBuf) - jsonRoot.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "-o", "json"}) - require.NoError(t, jsonRoot.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(jsonBuf.Bytes(), &resp)) - if _, ok := resp["aggregations"]; !ok { - t.Skip("API did not return aggregations for this address, skipping text aggregation test") - } - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress}) - - require.NoError(t, root.Execute()) - - out := buf.String() - // When aggregations are present, the summary should appear in text output. - assert.Contains(t, out, "Total USD Value:") -} diff --git a/cmd/sim/evm/defi_positions_unit_test.go b/cmd/sim/evm/defi_positions_unit_test.go deleted file mode 100644 index 55f3f1d..0000000 --- a/cmd/sim/evm/defi_positions_unit_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package evm - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// apiResponseJSON is the exact JSON returned by the defi-positions API endpoint, -// used to verify that our structs can unmarshal every field correctly. -const apiResponseJSON = `{"positions":[{"type":"Erc4626","chain":"ethereum","chain_id":1,"token":{"address":"0xa3931d71877c0e7a3148cb7eb4463524fec27fbd","name":"Savings USDS","symbol":"sUSDS"},"underlying_token":{"address":"0xdc035d45d973e3ec169d2276ddab16f1e407384f","name":"USDS Stablecoin","symbol":"USDS","decimals":18,"holdings":47.00372505463423},"balance":43.091714709517426,"price_usd":1.0906557333153313,"value_usd":46.99822570632377,"logo":"https://api.sim.dune.com/beta/token/logo/1/0xdc035d45d973e3ec169d2276ddab16f1e407384f"},{"type":"Tokenized","chain":"ethereum","chain_id":1,"token_type":"AtokenV2","token":{"address":"0x030ba81f1c18d280636f32af80b9aad02cf0854e","name":"Aave interest bearing WETH","symbol":"aWETH"},"underlying_token":{"address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","holdings":0.0515820177322061},"lending_pool":"0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9","balance":0.04961716041594229,"price_usd":2257.7211358982086,"value_usd":112.02171177432484,"logo":"https://api.sim.dune.com/beta/token/logo/1/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"},{"type":"UniswapV2","chain":"ethereum","chain_id":1,"protocol":"ShibaSwapV2","pool":"0x76ec974feaf0293f64cf8643e0f42dea5b71689b","token0":{"address":"0x198065e69a86cb8a9154b333aad8efe7a3c256f8","name":"KOYO","symbol":"KOY","decimals":18,"price_usd":0.00009108374058938265,"holdings":72267.17195098454},"token1":{"address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","name":"Wrapped Ether","symbol":"WETH","decimals":18,"price_usd":2171.720237,"holdings":0.00297532741832308},"lp_balance":"0x8ac7230489e80000","balance":10.0,"price_usd":1.3043943109184983,"value_usd":13.043943109184983,"logo":"https://api.sim.dune.com/beta/token/logo/1/0x198065e69a86cb8a9154b333aad8efe7a3c256f8"},{"type":"UniswapV2","chain":"ethereum","chain_id":1,"protocol":"UniswapV2","pool":"0x09c29277d081a1b347f41277ff53116a30d4ddff","token0":{"address":"0x4206975c6d7135ad73129476ebe2b06e42f41f50","name":"FWOG","symbol":"FWOG","decimals":18,"price_usd":2.3754275952306002e-11,"holdings":609179876998.8339},"token1":{"address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","name":"Wrapped Ether","symbol":"WETH","decimals":18,"price_usd":2171.720237,"holdings":0.006683389542586471},"lp_balance":"0xca7455529bd53680000","balance":59754.0,"price_usd":0.00048507345490195365,"value_usd":28.98507922421134,"logo":null},{"type":"Nft","chain":"ethereum","chain_id":1,"protocol":"UniswapV3","pool":"0x7625d7f67e4e44341ddfb1e698801fd5a1574b48","token0":{"address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","name":"Wrapped Ether","symbol":"WETH","decimals":18},"token1":{"address":"0xd78959df1ff28b45e6b4ea234bdcf9d6609d16e1","name":"Moneda de Caca","symbol":"Mierda","decimals":18},"positions":[{"tick_lower":0,"tick_upper":184200,"token_id":"0xe8ac0","token0":{"price_usd":2171.720237,"holdings":0.0,"rewards":0.000201014351146556},"token1":{"price_usd":0.0,"holdings":100000000.0,"rewards":19678.323702556045}}],"logo":"https://api.sim.dune.com/beta/token/logo/1/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","value_usd":0.43654693431239977},{"type":"NftV4","chain":"ethereum","chain_id":1,"protocol":"UniswapV4","pool_id":"0x21fb293b9dc53b42fa6e63fa24e1212de76c88eb7a15b94cd220fc66274851bf","pool_manager":"0x000000000004444c5dc75cb358380d2e3de08a90","salt":"0x0000000000000000000000000000000000000000000000000000000000002118","token0":{"address":"0x0000000000000000000000000000000000000000","name":"Ether","symbol":"ETH","decimals":18},"token1":{"address":"0xf9c8631fba291bac14ed549a2dde7c7f2ddff1a8","name":"Mighty Morphin Power Rangers","symbol":"GoGo","decimals":18},"positions":[{"tick_lower":-184220,"tick_upper":207220,"token_id":"0x2118","token0":{"price_usd":2171.720237,"holdings":0.000252141460675072,"rewards":8.852246853764e-6},"token1":{"price_usd":0.0,"holdings":479748570.0393271,"rewards":8399.799985973925}}],"logo":"https://api.sim.dune.com/beta/token/logo/1/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","value_usd":0.5668053163700324}],"aggregations":{"total_value_usd":6153.428535761592,"total_by_chain":{"1":4206.858757536072,"8453":1946.5539365060883,"42161":0.015841719431772122}}}` - -func TestUnmarshal_FullAPIResponse(t *testing.T) { - var resp defiPositionsResponse - err := json.Unmarshal([]byte(apiResponseJSON), &resp) - require.NoError(t, err, "unmarshal must not fail on real API response") - - require.Len(t, resp.Positions, 6) - - // --- Erc4626 --- - erc := resp.Positions[0] - assert.Equal(t, "Erc4626", erc.Type) - assert.Equal(t, "ethereum", erc.Chain) - assert.Equal(t, int64(1), erc.ChainID) - assert.InDelta(t, 46.998, erc.ValueUSD, 0.01) - assert.InDelta(t, 43.0917, erc.Balance, 0.001) - assert.InDelta(t, 1.0906, erc.PriceUSD, 0.001) - require.NotNil(t, erc.Logo) - assert.Contains(t, *erc.Logo, "api.sim.dune.com") - // token - require.NotNil(t, erc.Token) - assert.Equal(t, "0xa3931d71877c0e7a3148cb7eb4463524fec27fbd", erc.Token.Address) - assert.Equal(t, "Savings USDS", erc.Token.Name) - assert.Equal(t, "sUSDS", erc.Token.Symbol) - // underlying_token - require.NotNil(t, erc.UnderlyingToken) - assert.Equal(t, "0xdc035d45d973e3ec169d2276ddab16f1e407384f", erc.UnderlyingToken.Address) - assert.Equal(t, "USDS Stablecoin", erc.UnderlyingToken.Name) - assert.Equal(t, "USDS", erc.UnderlyingToken.Symbol) - assert.Equal(t, 18, erc.UnderlyingToken.Decimals) - assert.InDelta(t, 47.003, erc.UnderlyingToken.Holdings, 0.001) - - // --- Tokenized --- - tok := resp.Positions[1] - assert.Equal(t, "Tokenized", tok.Type) - assert.Equal(t, "AtokenV2", tok.TokenType) - assert.Equal(t, "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", tok.LendingPool) - assert.InDelta(t, 0.04961, tok.Balance, 0.0001) - assert.InDelta(t, 2257.72, tok.PriceUSD, 0.01) - assert.InDelta(t, 112.02, tok.ValueUSD, 0.01) - require.NotNil(t, tok.Token) - assert.Equal(t, "aWETH", tok.Token.Symbol) - assert.Equal(t, "Aave interest bearing WETH", tok.Token.Name) - require.NotNil(t, tok.UnderlyingToken) - assert.Equal(t, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", tok.UnderlyingToken.Address) - assert.InDelta(t, 0.05158, tok.UnderlyingToken.Holdings, 0.0001) - - // --- UniswapV2 (with logo) --- - uni := resp.Positions[2] - assert.Equal(t, "UniswapV2", uni.Type) - assert.Equal(t, "ShibaSwapV2", uni.Protocol) - assert.Equal(t, "0x76ec974feaf0293f64cf8643e0f42dea5b71689b", uni.Pool) - assert.Equal(t, "0x8ac7230489e80000", uni.LPBalance) - assert.InDelta(t, 10.0, uni.Balance, 0.001) - require.NotNil(t, uni.Token0) - assert.Equal(t, "KOY", uni.Token0.Symbol) - assert.Equal(t, 18, uni.Token0.Decimals) - assert.InDelta(t, 0.0000911, uni.Token0.PriceUSD, 0.00001) - assert.InDelta(t, 72267.17, uni.Token0.Holdings, 0.01) - require.NotNil(t, uni.Token1) - assert.Equal(t, "WETH", uni.Token1.Symbol) - assert.InDelta(t, 2171.72, uni.Token1.PriceUSD, 0.01) - - // --- UniswapV2 (logo: null) --- - uniNull := resp.Positions[3] - assert.Equal(t, "UniswapV2", uniNull.Type) - assert.Nil(t, uniNull.Logo, "null logo should be nil pointer") - assert.InDelta(t, 59754.0, uniNull.Balance, 0.1) - - // --- Nft (UniswapV3) --- - nft := resp.Positions[4] - assert.Equal(t, "Nft", nft.Type) - assert.Equal(t, "UniswapV3", nft.Protocol) - assert.Equal(t, "0x7625d7f67e4e44341ddfb1e698801fd5a1574b48", nft.Pool) - require.NotNil(t, nft.Token0) - assert.Equal(t, "WETH", nft.Token0.Symbol) - require.NotNil(t, nft.Token1) - assert.Equal(t, "Mierda", nft.Token1.Symbol) - assert.InDelta(t, 0.4365, nft.ValueUSD, 0.001) - // NFT position details - require.Len(t, nft.Positions, 1) - nftPos := nft.Positions[0] - assert.Equal(t, 0, nftPos.TickLower) - assert.Equal(t, 184200, nftPos.TickUpper) - assert.Equal(t, "0xe8ac0", nftPos.TokenID) - require.NotNil(t, nftPos.Token0) - assert.InDelta(t, 2171.72, nftPos.Token0.PriceUSD, 0.01) - assert.InDelta(t, 0.0, nftPos.Token0.Holdings, 0.001) - assert.InDelta(t, 0.000201, nftPos.Token0.Rewards, 0.00001) - require.NotNil(t, nftPos.Token1) - assert.InDelta(t, 0.0, nftPos.Token1.PriceUSD, 0.001) - assert.InDelta(t, 100000000.0, nftPos.Token1.Holdings, 1.0) - assert.InDelta(t, 19678.32, nftPos.Token1.Rewards, 0.01) - - // --- NftV4 (UniswapV4) --- - nft4 := resp.Positions[5] - assert.Equal(t, "NftV4", nft4.Type) - assert.Equal(t, "UniswapV4", nft4.Protocol) - assert.Equal(t, "0x21fb293b9dc53b42fa6e63fa24e1212de76c88eb7a15b94cd220fc66274851bf", nft4.PoolID) - assert.Equal(t, "0x000000000004444c5dc75cb358380d2e3de08a90", nft4.PoolManager) - assert.Equal(t, "0x0000000000000000000000000000000000000000000000000000000000002118", nft4.Salt) - require.NotNil(t, nft4.Token0) - assert.Equal(t, "ETH", nft4.Token0.Symbol) - assert.Equal(t, "0x0000000000000000000000000000000000000000", nft4.Token0.Address) - require.NotNil(t, nft4.Token1) - assert.Equal(t, "GoGo", nft4.Token1.Symbol) - assert.InDelta(t, 0.5668, nft4.ValueUSD, 0.001) - require.Len(t, nft4.Positions, 1) - nft4Pos := nft4.Positions[0] - assert.Equal(t, -184220, nft4Pos.TickLower) - assert.Equal(t, 207220, nft4Pos.TickUpper) - assert.Equal(t, "0x2118", nft4Pos.TokenID) - require.NotNil(t, nft4Pos.Token0) - assert.InDelta(t, 0.000252, nft4Pos.Token0.Holdings, 0.00001) - assert.InDelta(t, 8.852e-6, nft4Pos.Token0.Rewards, 1e-7) - - // --- Aggregations --- - require.NotNil(t, resp.Aggregations) - assert.InDelta(t, 6153.43, resp.Aggregations.TotalValueUSD, 0.01) - assert.Len(t, resp.Aggregations.TotalByChain, 3) - assert.InDelta(t, 4206.86, resp.Aggregations.TotalByChain["1"], 0.01) - assert.InDelta(t, 1946.55, resp.Aggregations.TotalByChain["8453"], 0.01) - assert.InDelta(t, 0.01584, resp.Aggregations.TotalByChain["42161"], 0.0001) -} - -func TestPositionDetails_Erc4626(t *testing.T) { - p := defiPosition{ - Type: "Erc4626", - Token: &defiTokenInfo{Symbol: "alUSD"}, - UnderlyingToken: &defiTokenInfo{Symbol: "USDC"}, - Balance: 0.0673736869415349, - } - got := positionDetails(p) - assert.Contains(t, got, "alUSD") - assert.Contains(t, got, "-> USDC") - assert.Contains(t, got, "bal=") -} - -func TestPositionDetails_Erc4626_NoBalance(t *testing.T) { - p := defiPosition{ - Type: "Erc4626", - Token: &defiTokenInfo{Symbol: "yvDAI"}, - UnderlyingToken: &defiTokenInfo{Symbol: "DAI"}, - } - assert.Equal(t, "yvDAI -> DAI", positionDetails(p)) -} - -func TestPositionDetails_Tokenized(t *testing.T) { - p := defiPosition{ - Type: "Tokenized", - TokenType: "AtokenV2", - Token: &defiTokenInfo{Symbol: "aWETH"}, - Balance: 0.0496171604159423, - } - got := positionDetails(p) - assert.Contains(t, got, "AtokenV2") - assert.Contains(t, got, "aWETH") - assert.Contains(t, got, "bal=") -} - -func TestPositionDetails_Tokenized_NoBalance(t *testing.T) { - p := defiPosition{ - Type: "Tokenized", - TokenType: "AtokenV2", - Token: &defiTokenInfo{Symbol: "aWBTC"}, - } - assert.Equal(t, "AtokenV2 aWBTC", positionDetails(p)) -} - -func TestPositionDetails_UniswapV2(t *testing.T) { - p := defiPosition{ - Type: "UniswapV2", - Token0: &defiTokenInfo{Symbol: "FWOG"}, - Token1: &defiTokenInfo{Symbol: "WETH"}, - Balance: 59754, - } - got := positionDetails(p) - assert.Contains(t, got, "FWOG/WETH") - assert.Contains(t, got, "bal=59754") -} - -func TestPositionDetails_UniswapV2_NoBalance(t *testing.T) { - p := defiPosition{ - Type: "UniswapV2", - Token0: &defiTokenInfo{Symbol: "USDC"}, - Token1: &defiTokenInfo{Symbol: "WETH"}, - } - assert.Equal(t, "USDC/WETH", positionDetails(p)) -} - -func TestPositionDetails_Nft(t *testing.T) { - p := defiPosition{ - Type: "Nft", - Token0: &defiTokenInfo{Symbol: "WETH"}, - Token1: &defiTokenInfo{Symbol: "USDC"}, - Positions: []nftPositionDetails{ - {TickLower: -100, TickUpper: 100, TokenID: "0x1"}, - {TickLower: -200, TickUpper: 200, TokenID: "0x2"}, - {TickLower: -300, TickUpper: 300, TokenID: "0x3"}, - }, - } - assert.Equal(t, "WETH/USDC (3 positions)", positionDetails(p)) -} - -func TestPositionDetails_NftV4(t *testing.T) { - p := defiPosition{ - Type: "NftV4", - Token0: &defiTokenInfo{Symbol: "WBTC"}, - Token1: &defiTokenInfo{Symbol: "WETH"}, - Positions: []nftPositionDetails{ - {TickLower: -50, TickUpper: 50, TokenID: "0xabc"}, - }, - } - assert.Equal(t, "WBTC/WETH (1 position)", positionDetails(p)) -} - -func TestPositionDetails_NftNoPositions(t *testing.T) { - p := defiPosition{ - Type: "Nft", - Token0: &defiTokenInfo{Symbol: "DAI"}, - Token1: &defiTokenInfo{Symbol: "USDC"}, - } - assert.Equal(t, "DAI/USDC", positionDetails(p)) -} - -func TestPositionDetails_Unknown(t *testing.T) { - p := defiPosition{Type: "SomeNewType"} - assert.Equal(t, "", positionDetails(p)) -} - -func TestFormatPair(t *testing.T) { - assert.Equal(t, "WETH/USDC", formatPair("WETH", "USDC")) - assert.Equal(t, "WETH", formatPair("WETH", "")) - assert.Equal(t, "USDC", formatPair("", "USDC")) - assert.Equal(t, "", formatPair("", "")) -} - -func TestTokenSymbol_Nil(t *testing.T) { - assert.Equal(t, "", tokenSymbol(nil)) -} - -func TestTokenSymbol_NonNil(t *testing.T) { - assert.Equal(t, "ETH", tokenSymbol(&defiTokenInfo{Symbol: "ETH"})) -} diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go deleted file mode 100644 index d64cbda..0000000 --- a/cmd/sim/evm/evm.go +++ /dev/null @@ -1,70 +0,0 @@ -package evm - -import ( - "context" - "net/url" - - "github.com/duneanalytics/cli/cmdutil" - "github.com/spf13/cobra" -) - -// SimClient is the interface that evm commands use to talk to the Sim API. -// It is satisfied by *sim.SimClient (stored in the command context by -// the sim parent command's PersistentPreRunE). -type SimClient interface { - Get(ctx context.Context, path string, params url.Values) ([]byte, error) -} - -// SimClientFromCmd extracts the SimClient from the command context. -func SimClientFromCmd(cmd *cobra.Command) SimClient { - v := cmdutil.SimClientFromCmd(cmd) - if v == nil { - return nil - } - c, ok := v.(SimClient) - if !ok { - return nil - } - return c -} - -// NewEvmCmd returns the `sim evm` parent command. -func NewEvmCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "evm", - Short: "Query EVM chain data (balances, activity, transactions, tokens, NFTs, DeFi)", - Long: "Access real-time, indexed EVM blockchain data. All commands accept an\n" + - "Ethereum-style address (0x...) as the primary argument and return data\n" + - "across multiple EVM chains simultaneously.\n\n" + - "Available subcommands:\n" + - " supported-chains - List all supported EVM chains and endpoint availability (public, no auth)\n" + - " balances - Native + ERC20 token balances with USD valuations\n" + - " balance - Single-token balance lookup on one chain\n" + - " stablecoins - Stablecoin-only balances (USDC, USDT, DAI, etc.)\n" + - " activity - Chronological feed of transfers, swaps, mints, burns, approvals\n" + - " transactions - Raw transaction history with optional ABI decoding\n" + - " collectibles - ERC721 and ERC1155 NFT holdings with spam filtering\n" + - " token-info - Token metadata, pricing, supply, and market cap\n" + - " token-holders - Top holders of an ERC20 token ranked by balance\n" + - " defi-positions - DeFi positions across lending, AMM, and vault protocols (beta)\n" + - " supported-protocols - DeFi protocol families and chains covered by defi-positions\n\n" + - "Most commands support --chain-ids to restrict results to specific networks.\n" + - "Run 'dune sim evm supported-chains' to discover valid chain IDs, tags, and\n" + - "which endpoints are available per chain.\n\n" + - "All commands except 'supported-chains' require a Sim API key.", - } - - cmd.AddCommand(NewSupportedChainsCmd()) - cmd.AddCommand(NewBalancesCmd()) - cmd.AddCommand(NewBalanceCmd()) - cmd.AddCommand(NewStablecoinsCmd()) - cmd.AddCommand(NewActivityCmd()) - cmd.AddCommand(NewTransactionsCmd()) - cmd.AddCommand(NewCollectiblesCmd()) - cmd.AddCommand(NewTokenInfoCmd()) - cmd.AddCommand(NewTokenHoldersCmd()) - cmd.AddCommand(NewDefiPositionsCmd()) - cmd.AddCommand(NewSupportedProtocolsCmd()) - - return cmd -} diff --git a/cmd/sim/evm/helpers_test.go b/cmd/sim/evm/helpers_test.go deleted file mode 100644 index 1a5454c..0000000 --- a/cmd/sim/evm/helpers_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package evm_test - -import ( - "context" - "os" - "testing" - - "github.com/duneanalytics/cli/cmd/sim" - "github.com/duneanalytics/cli/cmd/sim/evm" - "github.com/spf13/cobra" -) - -// simAPIKey returns the DUNE_SIM_API_KEY env var or skips the test. -func simAPIKey(t *testing.T) string { - t.Helper() - key := os.Getenv("DUNE_SIM_API_KEY") - if key == "" { - t.Skip("DUNE_SIM_API_KEY not set, skipping e2e test") - } - return key -} - -const evmTestAddress = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" - -// newEvmTestRoot builds a minimal command tree: dune -> evm -> . -// No sim parent — used for public endpoints that don't require auth. -func newEvmTestRoot() *cobra.Command { - root := &cobra.Command{Use: "dune"} - root.SetContext(context.Background()) - root.AddCommand(evm.NewEvmCmd()) - return root -} - -// newSimTestRoot builds the full command tree: dune -> sim -> evm -> . -// Used for authenticated E2E tests. Pass the API key via --sim-api-key in SetArgs. -func newSimTestRoot() *cobra.Command { - root := &cobra.Command{Use: "dune"} - root.SetContext(context.Background()) - root.AddCommand(sim.NewSimCmd()) - return root -} diff --git a/cmd/sim/evm/stablecoins.go b/cmd/sim/evm/stablecoins.go deleted file mode 100644 index 9cdfde7..0000000 --- a/cmd/sim/evm/stablecoins.go +++ /dev/null @@ -1,36 +0,0 @@ -package evm - -import ( - "github.com/spf13/cobra" -) - -// NewStablecoinsCmd returns the `sim evm stablecoins` command. -func NewStablecoinsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "stablecoins
", - Short: "Get stablecoin balances for a wallet address across multiple chains", - Long: "Return only stablecoin balances (USDC, USDT, DAI, FRAX, etc.) for the given\n" + - "wallet address across supported EVM chains. This is a convenience shorthand\n" + - "for 'dune sim evm balances
--asset-class stablecoin'.\n\n" + - "Each balance entry includes the token amount, current USD price, and total\n" + - "USD value. The same response format and pagination as 'dune sim evm balances'\n" + - "applies.\n\n" + - "By default, queries all chains tagged 'default'. Use --chain-ids to restrict\n" + - "to specific networks. Run 'dune sim evm supported-chains' for valid IDs.\n\n" + - "For all token balances (not just stablecoins), use 'dune sim evm balances'.\n\n" + - "Examples:\n" + - " dune sim evm stablecoins 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" + - " dune sim evm stablecoins 0xd8da... --chain-ids 1,8453\n" + - " dune sim evm stablecoins 0xd8da... --exclude-spam -o json", - Args: cobra.ExactArgs(1), - RunE: runStablecoins, - } - - addBalanceFlags(cmd) - - return cmd -} - -func runStablecoins(cmd *cobra.Command, args []string) error { - return runBalancesEndpoint(cmd, args, "/v1/evm/balances/", "/stablecoins") -} diff --git a/cmd/sim/evm/stablecoins_test.go b/cmd/sim/evm/stablecoins_test.go deleted file mode 100644 index 7dd4888..0000000 --- a/cmd/sim/evm/stablecoins_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmStablecoins_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", "stablecoins", evmTestAddress, "--chain-ids", "1"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") - assert.Contains(t, out, "SYMBOL") - assert.Contains(t, out, "VALUE_USD") -} - -func TestEvmStablecoins_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", "stablecoins", evmTestAddress, "--chain-ids", "1", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "wallet_address") - assert.Contains(t, resp, "balances") -} diff --git a/cmd/sim/evm/supported_chains.go b/cmd/sim/evm/supported_chains.go deleted file mode 100644 index 9d6df7e..0000000 --- a/cmd/sim/evm/supported_chains.go +++ /dev/null @@ -1,110 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewSupportedChainsCmd returns the `sim evm supported-chains` command. -func NewSupportedChainsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "supported-chains", - Short: "List supported EVM chains and their endpoint availability", - Long: "Display all EVM chains supported by the Sim API along with a capability\n" + - "matrix showing which endpoints are available for each chain. Each chain\n" + - "entry includes the chain name, numeric ID, tags, and boolean flags\n" + - "indicating which Sim API endpoints are supported.\n\n" + - "This is a public endpoint and does not require a Sim API key.\n\n" + - "Use the chain IDs or tag names from this output as --chain-ids\n" + - "arguments in other 'dune sim evm' commands.\n\n" + - "Examples:\n" + - " dune sim evm supported-chains\n" + - " dune sim evm supported-chains -o json", - Annotations: map[string]string{"skipSimAuth": "true"}, - RunE: runSupportedChains, - } - - output.AddFormatFlag(cmd, "text") - - return cmd -} - -type supportedChainsResponse struct { - Chains []chainEntry `json:"chains"` -} - -type chainEntry struct { - Name string `json:"name"` - ChainID json.Number `json:"chain_id"` - Tags []string `json:"tags"` - Balances endpointSupport `json:"balances"` - Activity endpointSupport `json:"activity"` - Transactions endpointSupport `json:"transactions"` - TokenInfo endpointSupport `json:"token_info"` - TokenHolders endpointSupport `json:"token_holders"` - Collectibles endpointSupport `json:"collectibles"` - DefiPositions endpointSupport `json:"defi_positions"` -} - -type endpointSupport struct { - Supported bool `json:"supported"` -} - -func runSupportedChains(cmd *cobra.Command, _ []string) error { - client := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - data, err := client.Get(cmd.Context(), "/v1/evm/supported-chains", nil) - 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 supportedChainsResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - columns := []string{ - "NAME", "CHAIN_ID", "TAGS", - "BALANCES", "ACTIVITY", "TXS", - "TOKEN_INFO", "HOLDERS", "COLLECTIBLES", "DEFI", - } - rows := make([][]string, len(resp.Chains)) - for i, c := range resp.Chains { - rows[i] = []string{ - c.Name, - c.ChainID.String(), - strings.Join(c.Tags, ","), - boolYN(c.Balances.Supported), - boolYN(c.Activity.Supported), - boolYN(c.Transactions.Supported), - boolYN(c.TokenInfo.Supported), - boolYN(c.TokenHolders.Supported), - boolYN(c.Collectibles.Supported), - boolYN(c.DefiPositions.Supported), - } - } - output.PrintTable(w, columns, rows) - return nil - } -} - -func boolYN(b bool) string { - if b { - return "Y" - } - return "N" -} diff --git a/cmd/sim/evm/supported_chains_test.go b/cmd/sim/evm/supported_chains_test.go deleted file mode 100644 index adaf9ae..0000000 --- a/cmd/sim/evm/supported_chains_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// supported-chains is a public endpoint — no API key required. - -func TestSupportedChains_Text(t *testing.T) { - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "evm", "supported-chains"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "NAME") - assert.Contains(t, out, "CHAIN_ID") - assert.Contains(t, out, "BALANCES") - assert.Contains(t, out, "ethereum") -} - -func TestSupportedChains_JSON(t *testing.T) { - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "evm", "supported-chains", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "chains") - - chains, ok := resp["chains"].([]interface{}) - require.True(t, ok, "chains should be an array") - require.NotEmpty(t, chains, "should have at least one chain") - - first, ok := chains[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, first, "name") - assert.Contains(t, first, "chain_id") -} diff --git a/cmd/sim/evm/supported_protocols.go b/cmd/sim/evm/supported_protocols.go deleted file mode 100644 index d372bc5..0000000 --- a/cmd/sim/evm/supported_protocols.go +++ /dev/null @@ -1,115 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "net/url" - "strings" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// Chain-status values returned by the API are lowercase strings. -const protocolStatusPreview = "preview" - -// NewSupportedProtocolsCmd returns the `sim evm supported-protocols` command. -func NewSupportedProtocolsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "supported-protocols", - Short: "List DeFi protocol families and chains supported by defi-positions", - Long: "Display DeFi protocol families covered by the Sim defi-positions\n" + - "endpoint, the chains each family is available on, and the sub-protocols\n" + - "(forks) recognized under each family. Each chain entry has a status of\n" + - "Stable or Preview.\n\n" + - "Use this to discover which protocols and chains 'dune sim evm defi-positions'\n" + - "can return data for.\n\n" + - "Examples:\n" + - " dune sim evm supported-protocols\n" + - " dune sim evm supported-protocols --include-preview-chains\n" + - " dune sim evm supported-protocols --include-preview-protocols\n" + - " dune sim evm supported-protocols -o json", - RunE: runSupportedProtocols, - } - - cmd.Flags().Bool("include-preview-chains", false, "Include chains that are marked as preview (not yet publicly available)") - cmd.Flags().Bool("include-preview-protocols", false, "Include protocols that are marked as preview on the requested chains") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -type supportedProtocolsResponse struct { - ProtocolFamilies []supportedProtocolFamily `json:"protocol_families"` -} - -type supportedProtocolFamily struct { - Family string `json:"family"` - Chains []supportedProtocolChain `json:"chains"` - SubProtocols []string `json:"sub_protocols"` -} - -type supportedProtocolChain struct { - ChainID json.Number `json:"chain_id"` - ChainName string `json:"chain_name"` - Status string `json:"status"` -} - -func runSupportedProtocols(cmd *cobra.Command, _ []string) error { - client := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - params := url.Values{} - if v, _ := cmd.Flags().GetBool("include-preview-chains"); v { - params.Set("include_preview_chains", "true") - } - if v, _ := cmd.Flags().GetBool("include-preview-protocols"); v { - params.Set("include_preview_protocols", "true") - } - - data, err := client.Get(cmd.Context(), "/v1/evm/defi/supported-protocols", 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 supportedProtocolsResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - columns := []string{"FAMILY", "CHAINS", "SUB_PROTOCOLS"} - rows := make([][]string, len(resp.ProtocolFamilies)) - for i, f := range resp.ProtocolFamilies { - rows[i] = []string{ - f.Family, - formatProtocolChains(f.Chains), - strings.Join(f.SubProtocols, ","), - } - } - output.PrintTable(w, columns, rows) - return nil - } -} - -// formatProtocolChains renders chains as "name(id)" with a "*" suffix for -// preview-status entries, joined by commas. -func formatProtocolChains(chains []supportedProtocolChain) string { - parts := make([]string, len(chains)) - for i, c := range chains { - entry := fmt.Sprintf("%s(%s)", c.ChainName, c.ChainID.String()) - if strings.EqualFold(c.Status, protocolStatusPreview) { - entry += "*" - } - parts[i] = entry - } - return strings.Join(parts, ",") -} diff --git a/cmd/sim/evm/supported_protocols_test.go b/cmd/sim/evm/supported_protocols_test.go deleted file mode 100644 index 56697d5..0000000 --- a/cmd/sim/evm/supported_protocols_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmSupportedProtocols_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", "supported-protocols"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "FAMILY") - assert.Contains(t, out, "CHAINS") - assert.Contains(t, out, "SUB_PROTOCOLS") -} - -func TestEvmSupportedProtocols_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", "supported-protocols", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "protocol_families") - - families, ok := resp["protocol_families"].([]interface{}) - require.True(t, ok, "protocol_families should be an array") - require.NotEmpty(t, families, "should have at least one protocol family") - - first, ok := families[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, first, "family") - assert.Contains(t, first, "chains") - assert.Contains(t, first, "sub_protocols") - - chains, ok := first["chains"].([]interface{}) - require.True(t, ok, "chains should be an array") - if len(chains) > 0 { - c, ok := chains[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, c, "chain_id") - assert.Contains(t, c, "chain_name") - assert.Contains(t, c, "status") - } -} - -func TestEvmSupportedProtocols_IncludePreviewChainsFlag(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{ - "sim", "--sim-api-key", key, "evm", "supported-protocols", - "--include-preview-chains", "-o", "json", - }) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "protocol_families") -} diff --git a/cmd/sim/evm/token_holders.go b/cmd/sim/evm/token_holders.go deleted file mode 100644 index acf221a..0000000 --- a/cmd/sim/evm/token_holders.go +++ /dev/null @@ -1,126 +0,0 @@ -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 ", - Short: "Get the top holders of an ERC20 token ranked by balance", - Long: "Return a leaderboard of holders for a given ERC20 token contract on a single\n" + - "EVM chain, ranked by balance in descending order. Useful for analyzing token\n" + - "distribution, identifying whales, and checking concentration.\n\n" + - "Both --chain-id (single numeric chain ID) and the token address argument are\n" + - "required. Unlike other sim evm commands that accept --chain-ids (plural),\n" + - "this command uses --chain-id (singular) and queries exactly one chain.\n\n" + - "Each holder entry includes the wallet address, token balance, earliest\n" + - "acquisition timestamp, and whether they have ever initiated an outgoing\n" + - "transfer (useful for distinguishing active holders from airdrop recipients).\n\n" + - "Results are paginated; use --offset with the next_offset value from a\n" + - "previous response to retrieve additional pages.\n\n" + - "Run 'dune sim evm supported-chains' to see which chains support token-holders.\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 EVM chain ID to query (required, single value, e.g. '1' for Ethereum, '8453' for Base); note: singular --chain-id, not --chain-ids") - cmd.Flags().Int("limit", 0, "Maximum number of holders to return per page (1-500, default: 500)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - _ = 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 := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - 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 - } -} diff --git a/cmd/sim/evm/token_holders_test.go b/cmd/sim/evm/token_holders_test.go deleted file mode 100644 index d0605f3..0000000 --- a/cmd/sim/evm/token_holders_test.go +++ /dev/null @@ -1,112 +0,0 @@ -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") -} diff --git a/cmd/sim/evm/token_info.go b/cmd/sim/evm/token_info.go deleted file mode 100644 index 77669d9..0000000 --- a/cmd/sim/evm/token_info.go +++ /dev/null @@ -1,142 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewTokenInfoCmd returns the `sim evm token-info` command. -func NewTokenInfoCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "token-info
", - Short: "Get token metadata, pricing, supply, and market cap for a token address", - Long: "Return metadata and real-time pricing for a token contract address on a\n" + - "specified EVM chain. Use the literal string 'native' as the address to query\n" + - "the chain's native asset (e.g. ETH on chain 1, MATIC on chain 137).\n\n" + - "The --chain-ids flag is required and should specify a single chain ID.\n\n" + - "Returns the token identity, current USD price (from on-chain DEX pools),\n" + - "total supply, estimated market cap, and logo. Use --historical-prices to\n" + - "include past USD prices at specified hour offsets.\n\n" + - "This command is useful for looking up token details before querying\n" + - "balances or activity. For wallet-scoped token data, use\n" + - "'dune sim evm balances' or 'dune sim evm balance'.\n\n" + - "Examples:\n" + - " dune sim evm token-info native --chain-ids 1\n" + - " dune sim evm token-info 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --chain-ids 8453\n" + - " dune sim evm token-info native --chain-ids 1 --historical-prices 720,168,24 -o json", - Args: cobra.ExactArgs(1), - RunE: runTokenInfo, - } - - cmd.Flags().String("chain-ids", "", "Numeric EVM chain ID to query (required, single value, e.g. '1' for Ethereum, '8453' for Base)") - cmd.Flags().String("historical-prices", "", "Include historical USD prices at the specified hour offsets from now (comma-separated, e.g. '720,168,24' for 30d, 7d, 1d ago)") - cmd.Flags().Int("limit", 0, "Maximum number of token entries to return (default: server-determined)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - _ = cmd.MarkFlagRequired("chain-ids") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -type tokensResponse struct { - ContractAddress string `json:"contract_address"` - Tokens []tokenInfo `json:"tokens"` - Warnings []warningEntry `json:"warnings,omitempty"` - NextOffset string `json:"next_offset,omitempty"` -} - -type tokenInfo struct { - Chain string `json:"chain"` - ChainID int64 `json:"chain_id"` - Symbol string `json:"symbol,omitempty"` - Name string `json:"name,omitempty"` - Decimals int `json:"decimals,omitempty"` - PriceUSD float64 `json:"price_usd"` - HistoricalPrices []historicalPrice `json:"historical_prices,omitempty"` - TotalSupply string `json:"total_supply,omitempty"` - MarketCap float64 `json:"market_cap,omitempty"` - Logo string `json:"logo,omitempty"` -} - -func runTokenInfo(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) - } - if v, _ := cmd.Flags().GetString("historical-prices"); v != "" { - params.Set("historical_prices", v) - } - 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/token-info/"+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 tokensResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - // Print warnings to stderr. - printWarnings(cmd, resp.Warnings) - - if len(resp.Tokens) == 0 { - fmt.Fprintln(w, "No token info found.") - return nil - } - - // Key-value display for each token entry. - for i, t := range resp.Tokens { - if i > 0 { - fmt.Fprintln(w) - } - fmt.Fprintf(w, "Chain: %s (ID: %d)\n", t.Chain, t.ChainID) - if t.Symbol != "" { - fmt.Fprintf(w, "Symbol: %s\n", t.Symbol) - } - if t.Name != "" { - fmt.Fprintf(w, "Name: %s\n", t.Name) - } - fmt.Fprintf(w, "Decimals: %d\n", t.Decimals) - fmt.Fprintf(w, "Price USD: %s\n", output.FormatUSD(t.PriceUSD)) - if t.TotalSupply != "" { - fmt.Fprintf(w, "Total Supply: %s\n", t.TotalSupply) - } - if t.MarketCap > 0 { - fmt.Fprintf(w, "Market Cap: %s\n", output.FormatUSD(t.MarketCap)) - } - if t.Logo != "" { - fmt.Fprintf(w, "Logo: %s\n", t.Logo) - } - for _, hp := range t.HistoricalPrices { - fmt.Fprintf(w, "Price %dh ago: %s\n", hp.OffsetHours, output.FormatUSD(hp.PriceUSD)) - } - } - - if resp.NextOffset != "" { - fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) - } - return nil - } -} diff --git a/cmd/sim/evm/token_info_test.go b/cmd/sim/evm/token_info_test.go deleted file mode 100644 index 8537381..0000000 --- a/cmd/sim/evm/token_info_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmTokenInfo_Native_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-info", "native", "--chain-ids", "1"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "Chain:") - assert.Contains(t, out, "Symbol:") - assert.Contains(t, out, "Price USD:") -} - -func TestEvmTokenInfo_Native_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-info", "native", "--chain-ids", "1", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "contract_address") - assert.Contains(t, resp, "tokens") - - tokens, ok := resp["tokens"].([]interface{}) - require.True(t, ok) - require.NotEmpty(t, tokens) - - token, ok := tokens[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, token, "chain") - assert.Contains(t, token, "symbol") - assert.Contains(t, token, "price_usd") -} - -func TestEvmTokenInfo_ERC20(t *testing.T) { - key := simAPIKey(t) - - // USDC on Base - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "token-info", "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "--chain-ids", "8453", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "tokens") - - tokens, ok := resp["tokens"].([]interface{}) - require.True(t, ok) - if len(tokens) > 0 { - token, ok := tokens[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, token, "symbol") - assert.Contains(t, token, "decimals") - } -} - -func TestEvmTokenInfo_HistoricalPrices(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-info", "native", "--chain-ids", "1", "--historical-prices", "168,24", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - - tokens, ok := resp["tokens"].([]interface{}) - require.True(t, ok) - require.NotEmpty(t, tokens) - - token, ok := tokens[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, token, "historical_prices", "historical_prices should be present when --historical-prices is set") -} - -func TestEvmTokenInfo_HistoricalPrices_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-info", "native", "--chain-ids", "1", "--historical-prices", "168"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "Price 168h ago:") -} - -func TestEvmTokenInfo_RequiresChainIds(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-info", "native"}) - - err := root.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "chain-ids") -} diff --git a/cmd/sim/evm/transactions.go b/cmd/sim/evm/transactions.go deleted file mode 100644 index 1e5e6da..0000000 --- a/cmd/sim/evm/transactions.go +++ /dev/null @@ -1,178 +0,0 @@ -package evm - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewTransactionsCmd returns the `sim evm transactions` command. -func NewTransactionsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "transactions
", - Short: "Get raw EVM transaction history for a wallet address across chains", - Long: "Return raw transaction history for the given wallet address across supported\n" + - "EVM chains. Transactions are returned in reverse-chronological order. Each\n" + - "transaction includes the hash, sender/recipient, value, block context, gas\n" + - "parameters, and raw calldata.\n\n" + - "Use --decode with -o json to include ABI-decoded function calls and event\n" + - "logs. Decoded data adds 'decoded' fields with function name and typed inputs,\n" + - "plus 'logs' with decoded event emissions. Note: --decode data is only visible\n" + - "in JSON output mode; the text table cannot display nested decoded structures.\n\n" + - "For human-readable, classified activity (sends, swaps, approvals, etc.),\n" + - "use 'dune sim evm activity' instead.\n\n" + - "By default, queries all chains tagged 'default'. Run 'dune sim evm\n" + - "supported-chains' to see which chains support transactions.\n\n" + - "Examples:\n" + - " dune sim evm transactions 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" + - " dune sim evm transactions 0xd8da... --chain-ids 1 --decode -o json\n" + - " dune sim evm transactions 0xd8da... --limit 50 -o json", - Args: cobra.ExactArgs(1), - RunE: runTransactions, - } - - cmd.Flags().String("chain-ids", "", "Restrict to specific chains by numeric ID or tag name (comma-separated, e.g. '1,8453' or 'default'); defaults to all chains tagged 'default'") - cmd.Flags().Bool("decode", false, "Include ABI-decoded function calls and event logs in the response; only visible in JSON output (-o json), ignored in text table mode") - cmd.Flags().Int("limit", 0, "Maximum number of transactions to return per page (1-100, default: server-determined)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -type transactionsResponse struct { - WalletAddress string `json:"wallet_address"` - Transactions []transactionTx `json:"transactions"` - Errors *transactionErrors `json:"errors,omitempty"` - NextOffset string `json:"next_offset,omitempty"` - Warnings []warningEntry `json:"warnings,omitempty"` - RequestTime string `json:"request_time,omitempty"` - ResponseTime string `json:"response_time,omitempty"` -} - -type transactionErrors struct { - ErrorMessage string `json:"error_message,omitempty"` - TransactionErrors []apiChainError `json:"transaction_errors,omitempty"` -} - -type transactionTx struct { - Address string `json:"address"` - BlockHash string `json:"block_hash"` - BlockNumber json.Number `json:"block_number"` - BlockTime string `json:"block_time"` - BlockVersion int `json:"block_version,omitempty"` - Chain string `json:"chain"` - From string `json:"from"` - To string `json:"to"` - Data string `json:"data,omitempty"` - GasPrice string `json:"gas_price,omitempty"` - Hash string `json:"hash"` - Index json.Number `json:"index,omitempty"` - MaxFeePerGas string `json:"max_fee_per_gas,omitempty"` - MaxPriorityFeePerGas string `json:"max_priority_fee_per_gas,omitempty"` - Nonce string `json:"nonce,omitempty"` - TransactionType string `json:"transaction_type,omitempty"` - Value string `json:"value"` - Decoded *decodedCall `json:"decoded,omitempty"` - Logs []transactionLog `json:"logs,omitempty"` -} - -type decodedCall struct { - Name string `json:"name,omitempty"` - Inputs []decodedInput `json:"inputs,omitempty"` -} - -type decodedInput struct { - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Value json.RawMessage `json:"value,omitempty"` -} - -type transactionLog struct { - Address string `json:"address,omitempty"` - Data string `json:"data,omitempty"` - Topics []string `json:"topics,omitempty"` - Decoded *decodedCall `json:"decoded,omitempty"` -} - -func runTransactions(cmd *cobra.Command, args []string) error { - client := SimClientFromCmd(cmd) - if client == nil { - return fmt.Errorf("sim client not initialized") - } - - address := args[0] - params := url.Values{} - - if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { - params.Set("chain_ids", v) - } - if v, _ := cmd.Flags().GetBool("decode"); v { - params.Set("decode", "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/transactions/"+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 transactionsResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - // Warn if --decode is used in text mode since the table can't show decoded data. - if decode, _ := cmd.Flags().GetBool("decode"); decode { - fmt.Fprintln(cmd.ErrOrStderr(), "Note: --decode data is only visible in JSON output. Use -o json to see decoded fields.") - } - - // Print errors to stderr. - printTransactionErrors(cmd, resp.Errors) - - // Print warnings to stderr. - printWarnings(cmd, resp.Warnings) - - columns := []string{"CHAIN", "HASH", "FROM", "TO", "VALUE", "BLOCK_TIME"} - rows := make([][]string, len(resp.Transactions)) - for i, tx := range resp.Transactions { - rows[i] = []string{ - tx.Chain, - truncateHash(tx.Hash), - truncateHash(tx.From), - truncateHash(tx.To), - tx.Value, - tx.BlockTime, - } - } - output.PrintTable(w, columns, rows) - - if resp.NextOffset != "" { - fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) - } - return nil - } -} - -// printTransactionErrors writes transaction-level errors to stderr. -func printTransactionErrors(cmd *cobra.Command, errs *transactionErrors) { - if errs == nil { - return - } - printAPIChainErrors(cmd, errs.ErrorMessage, errs.TransactionErrors) -} diff --git a/cmd/sim/evm/transactions_test.go b/cmd/sim/evm/transactions_test.go deleted file mode 100644 index b2c205d..0000000 --- a/cmd/sim/evm/transactions_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package evm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvmTransactions_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", "transactions", evmTestAddress, "--chain-ids", "1", "--limit", "5"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") - assert.Contains(t, out, "HASH") - assert.Contains(t, out, "FROM") - assert.Contains(t, out, "TO") - assert.Contains(t, out, "VALUE") - assert.Contains(t, out, "BLOCK_TIME") -} - -func TestEvmTransactions_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", "transactions", 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, "transactions") -} - -func TestEvmTransactions_DecodeJSON(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "transactions", evmTestAddress, "--chain-ids", "1", "--decode", "--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, "transactions") - - // When decode is enabled, transactions may contain decoded and logs fields. - // We just verify the response is valid JSON with transactions. - txs, ok := resp["transactions"].([]interface{}) - require.True(t, ok) - if len(txs) > 0 { - tx, ok := txs[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, tx, "hash") - assert.Contains(t, tx, "chain") - } -} - -// TestEvmTransactions_DecodeText exercises --decode in text mode (the default). -// This is the code path that unmarshals decoded inputs into Go structs. If -// Value were typed as string rather than json.RawMessage, non-string ABI -// arguments (numbers, booleans, arrays) would cause an unmarshal error here. -func TestEvmTransactions_DecodeText(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - var errBuf bytes.Buffer - root.SetOut(&buf) - root.SetErr(&errBuf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "transactions", evmTestAddress, "--chain-ids", "1", "--decode", "--limit", "5"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") - assert.Contains(t, out, "HASH") - - // Text mode should print the stderr hint about --decode being JSON-only. - assert.Contains(t, errBuf.String(), "--decode data is only visible in JSON output") -} - -func TestEvmTransactions_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", "transactions", 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, "transactions") - - // 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", "transactions", 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, "transactions") - } -} diff --git a/cmd/sim/sim.go b/cmd/sim/sim.go deleted file mode 100644 index f7d252d..0000000 --- a/cmd/sim/sim.go +++ /dev/null @@ -1,121 +0,0 @@ -package sim - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/authconfig" - "github.com/duneanalytics/cli/cmd/sim/evm" - "github.com/duneanalytics/cli/cmd/sim/svm" - "github.com/duneanalytics/cli/cmdutil" -) - -var simAPIKeyFlag string - -// NewSimCmd returns the `sim` parent command. -func NewSimCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "sim", - Short: "Query real-time blockchain data via the Dune Sim API", - Long: "Access real-time, indexed blockchain data through the Dune Sim API. Unlike\n" + - "'dune query run' which executes SQL against historical data warehouses, Sim API\n" + - "endpoints return pre-indexed, low-latency responses for common wallet and token\n" + - "lookups.\n\n" + - "Available subcommands:\n" + - " evm - Query EVM chains: balances, activity, transactions, collectibles,\n" + - " token-info, token-holders, defi-positions, supported-chains,\n" + - " supported-protocols\n" + - " svm - Query SVM chains (Solana, Eclipse): balances, transactions\n" + - " auth - Save your Sim API key to the local config file\n\n" + - "Authentication:\n" + - " Most commands require a Sim API key. The key is resolved in priority order:\n" + - " 1. --sim-api-key flag\n" + - " 2. DUNE_SIM_API_KEY environment variable\n" + - " 3. Saved key in ~/.config/dune/config.yaml (set via 'dune sim auth')\n\n" + - " Exception: 'dune sim evm supported-chains' is a public endpoint and does\n" + - " not require authentication.\n\n" + - "Each API call consumes compute units based on the number of chains queried\n" + - "and the complexity of the request. Use -o json on any command to get the\n" + - "full structured response for programmatic consumption.", - Annotations: map[string]string{"skipAuth": "true"}, - PersistentPreRunE: simPreRun, - } - - cmd.PersistentFlags().StringVar( - &simAPIKeyFlag, "sim-api-key", "", - "Sim API key for authentication (overrides DUNE_SIM_API_KEY env var and saved config); keys are prefixed 'sim_'", - ) - - cmd.AddCommand(NewAuthCmd()) - cmd.AddCommand(evm.NewEvmCmd()) - cmd.AddCommand(svm.NewSvmCmd()) - - return cmd -} - -// simPreRun resolves the Sim API key and stores a SimClient in the command context. -// Commands annotated with "skipSimAuth": "true" bypass this step. -func simPreRun(cmd *cobra.Command, _ []string) error { - // The sim command's PersistentPreRunE overrides the root command's hook - // (cobra does not chain PersistentPreRunE without EnableTraverseRunHooks). - // Record the start time here so the root's PersistentPostRunE computes a - // correct duration for telemetry. - cmdutil.SetStartTime(cmd, time.Now()) - - // Commands like `sim evm supported-chains` that hit public endpoints - // don't require an API key. Provide a bare (unauthenticated) client so - // they can still use the shared HTTP infrastructure and error handling. - if cmd.Annotations["skipSimAuth"] == "true" { - cmdutil.SetSimClient(cmd, NewBareSimClient()) - return nil - } - - apiKey := resolveSimAPIKey() - if apiKey == "" { - return fmt.Errorf( - "missing Sim API key: set DUNE_SIM_API_KEY, pass --sim-api-key, or run `dune sim auth`", - ) - } - - client := NewSimClient(apiKey) - cmdutil.SetSimClient(cmd, client) - - return nil -} - -// resolveSimAPIKey resolves the Sim API key from (in priority order): -// 1. --sim-api-key flag -// 2. DUNE_SIM_API_KEY environment variable -// 3. sim_api_key from ~/.config/dune/config.yaml -func resolveSimAPIKey() string { - // 1. Flag - if simAPIKeyFlag != "" { - return strings.TrimSpace(simAPIKeyFlag) - } - - // 2. Environment variable - if key := os.Getenv("DUNE_SIM_API_KEY"); key != "" { - return strings.TrimSpace(key) - } - - // 3. Config file - cfg, err := authconfig.Load() - if err != nil || cfg == nil { - return "" - } - return strings.TrimSpace(cfg.SimAPIKey) -} - -// SimClientFromCmd is a convenience helper that extracts and type-asserts the -// SimClient from the command context. -func SimClientFromCmd(cmd *cobra.Command) *SimClient { - v := cmdutil.SimClientFromCmd(cmd) - if v == nil { - return nil - } - return v.(*SimClient) -} diff --git a/cmd/sim/svm/balances.go b/cmd/sim/svm/balances.go deleted file mode 100644 index eb813a4..0000000 --- a/cmd/sim/svm/balances.go +++ /dev/null @@ -1,134 +0,0 @@ -package svm - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewBalancesCmd returns the `sim svm balances` command. -func NewBalancesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "balances
", - Short: "Get SPL token balances for an SVM wallet address with USD valuations", - Long: "Return SPL token balances for the given SVM (Solana Virtual Machine) wallet\n" + - "address. Each balance entry includes the token identity, balance (both raw\n" + - "and human-readable), current USD price, total USD value, and liquidity data.\n" + - "Data comes from Dune's real-time index.\n\n" + - "Supported chains: Solana, Eclipse (default: Solana only).\n\n" + - "Note: This endpoint is in beta (served under /beta/svm/balances/*).\n\n" + - "Results are paginated; use --offset with the next_offset value from a\n" + - "previous response to retrieve additional pages.\n\n" + - "Examples:\n" + - " dune sim svm balances 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY\n" + - " dune sim svm balances 86xCnPeV... --chains solana,eclipse\n" + - " dune sim svm balances 86xCnPeV... --limit 50 -o json", - Args: cobra.ExactArgs(1), - RunE: runBalances, - } - - cmd.Flags().String("chains", "", "Restrict to specific SVM chains (comma-separated): 'solana', 'eclipse' (default: solana only)") - cmd.Flags().Int("limit", 0, "Maximum number of balance entries to return per page (1-1000, default: 1000)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -// --- Response types --- - -type svmBalancesResponse struct { - ProcessingTimeMs float64 `json:"processing_time_ms,omitempty"` - WalletAddress string `json:"wallet_address"` - NextOffset string `json:"next_offset,omitempty"` - BalancesCount float64 `json:"balances_count,omitempty"` - Balances []svmBalanceEntry `json:"balances"` -} - -type svmBalanceEntry struct { - Chain string `json:"chain"` - Address string `json:"address"` - Amount string `json:"amount"` - Balance string `json:"balance,omitempty"` - RawBalance string `json:"raw_balance,omitempty"` - ValueUSD float64 `json:"value_usd,omitempty"` - ProgramID *string `json:"program_id,omitempty"` - Decimals float64 `json:"decimals,omitempty"` - TotalSupply string `json:"total_supply,omitempty"` - Name string `json:"name,omitempty"` - Symbol string `json:"symbol,omitempty"` - URI *string `json:"uri,omitempty"` - PriceUSD float64 `json:"price_usd,omitempty"` - LiquidityUSD float64 `json:"liquidity_usd,omitempty"` - PoolType *string `json:"pool_type,omitempty"` - PoolAddress *string `json:"pool_address,omitempty"` - MintAuthority *string `json:"mint_authority,omitempty"` -} - -func runBalances(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("chains"); v != "" { - params.Set("chains", v) - } - 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(), "/beta/svm/balances/"+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 svmBalancesResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - if len(resp.Balances) == 0 { - fmt.Fprintln(w, "No balances found.") - return nil - } - - columns := []string{"CHAIN", "SYMBOL", "BALANCE", "PRICE_USD", "VALUE_USD"} - rows := make([][]string, len(resp.Balances)) - for i, b := range resp.Balances { - bal := b.Balance - if bal == "" { - bal = b.Amount - } - rows[i] = []string{ - b.Chain, - b.Symbol, - bal, - output.FormatUSD(b.PriceUSD), - output.FormatUSD(b.ValueUSD), - } - } - output.PrintTable(w, columns, rows) - - if resp.NextOffset != "" { - fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) - } - return nil - } -} diff --git a/cmd/sim/svm/balances_test.go b/cmd/sim/svm/balances_test.go deleted file mode 100644 index d9cfbbc..0000000 --- a/cmd/sim/svm/balances_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package svm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSvmBalances_Text(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") - assert.Contains(t, out, "SYMBOL") - assert.Contains(t, out, "BALANCE") - assert.Contains(t, out, "PRICE_USD") - assert.Contains(t, out, "VALUE_USD") -} - -func TestSvmBalances_JSON(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "wallet_address") - assert.Contains(t, resp, "balances") - - balances, ok := resp["balances"].([]interface{}) - require.True(t, ok) - if len(balances) > 0 { - b, ok := balances[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, b, "chain") - assert.Contains(t, b, "address") - assert.Contains(t, b, "amount") - } -} - -func TestSvmBalances_WithChains(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--chains", "solana", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - assert.Contains(t, resp, "balances") - - // All balances should be on solana chain. - balances, ok := resp["balances"].([]interface{}) - require.True(t, ok) - for _, bal := range balances { - b, ok := bal.(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "solana", b["chain"]) - } -} - -func TestSvmBalances_Limit(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--limit", "3", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - - balances, ok := resp["balances"].([]interface{}) - require.True(t, ok) - assert.LessOrEqual(t, len(balances), 3) -} - -func TestSvmBalances_Pagination(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--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, "balances") - - // 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, "svm", "balances", svmTestAddress, "--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, "balances") - } -} diff --git a/cmd/sim/svm/helpers_test.go b/cmd/sim/svm/helpers_test.go deleted file mode 100644 index 376abb7..0000000 --- a/cmd/sim/svm/helpers_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package svm_test - -import ( - "context" - "os" - "testing" - - "github.com/duneanalytics/cli/cmd/sim" - "github.com/spf13/cobra" -) - -// simAPIKey returns the DUNE_SIM_API_KEY env var or skips the test. -func simAPIKey(t *testing.T) string { - t.Helper() - key := os.Getenv("DUNE_SIM_API_KEY") - if key == "" { - t.Skip("DUNE_SIM_API_KEY not set, skipping e2e test") - } - return key -} - -const svmTestAddress = "86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY" - -// newSimTestRoot builds the full command tree: dune -> sim -> svm -> . -// Used for authenticated E2E tests. Pass the API key via --sim-api-key in SetArgs. -func newSimTestRoot() *cobra.Command { - root := &cobra.Command{Use: "dune"} - root.SetContext(context.Background()) - root.AddCommand(sim.NewSimCmd()) - return root -} diff --git a/cmd/sim/svm/svm.go b/cmd/sim/svm/svm.go deleted file mode 100644 index d81e0da..0000000 --- a/cmd/sim/svm/svm.go +++ /dev/null @@ -1,60 +0,0 @@ -package svm - -import ( - "context" - "fmt" - "net/url" - - "github.com/duneanalytics/cli/cmdutil" - "github.com/spf13/cobra" -) - -// SimClient is the interface that svm commands use to talk to the Sim API. -// It is satisfied by *sim.SimClient (stored in the command context by -// the sim parent command's PersistentPreRunE). -type SimClient interface { - Get(ctx context.Context, path string, params url.Values) ([]byte, error) -} - -// SimClientFromCmd extracts the SimClient from the command context. -func SimClientFromCmd(cmd *cobra.Command) SimClient { - v := cmdutil.SimClientFromCmd(cmd) - if v == nil { - return nil - } - c, ok := v.(SimClient) - if !ok { - return nil - } - return c -} - -// requireSimClient extracts the SimClient or returns an error. -func requireSimClient(cmd *cobra.Command) (SimClient, error) { - c := SimClientFromCmd(cmd) - if c == nil { - return nil, fmt.Errorf("sim client not initialized") - } - return c, nil -} - -// NewSvmCmd returns the `sim svm` parent command. -func NewSvmCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "svm", - Short: "Query SVM chain data (Solana, Eclipse) for balances and transactions", - Long: "Access real-time, indexed SVM (Solana Virtual Machine) blockchain data.\n" + - "Commands accept a Solana-style base58 wallet address as the primary argument.\n\n" + - "Supported chains: Solana, Eclipse.\n\n" + - "Available subcommands:\n" + - " balances - SPL token balances with USD valuations and liquidity data\n" + - " transactions - Raw transaction history with block slot and signature\n\n" + - "Note: SVM endpoints are currently in beta (served under /beta/svm/*).\n" + - "All commands require a Sim API key.", - } - - cmd.AddCommand(NewBalancesCmd()) - cmd.AddCommand(NewTransactionsCmd()) - - return cmd -} diff --git a/cmd/sim/svm/transactions.go b/cmd/sim/svm/transactions.go deleted file mode 100644 index 45106d8..0000000 --- a/cmd/sim/svm/transactions.go +++ /dev/null @@ -1,143 +0,0 @@ -package svm - -import ( - "encoding/json" - "fmt" - "net/url" - "time" - - "github.com/spf13/cobra" - - "github.com/duneanalytics/cli/output" -) - -// NewTransactionsCmd returns the `sim svm transactions` command. -func NewTransactionsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "transactions
", - Short: "Get Solana transaction history for an SVM wallet address", - Long: "Return transaction history for the given SVM (Solana Virtual Machine) wallet\n" + - "address. Transactions are returned in reverse-chronological order by block slot.\n" + - "Each entry includes the chain, block slot, block time, and in JSON output the\n" + - "full raw Solana transaction with signatures, instructions, and account keys.\n\n" + - "Note: This endpoint is in beta (served under /beta/svm/transactions/*).\n\n" + - "Results are paginated; use --offset with the next_offset value from a\n" + - "previous response to retrieve additional pages.\n\n" + - "Examples:\n" + - " dune sim svm transactions 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY\n" + - " dune sim svm transactions 86xCnPeV... --limit 20\n" + - " dune sim svm transactions 86xCnPeV... -o json", - Args: cobra.ExactArgs(1), - RunE: runTransactions, - } - - cmd.Flags().Int("limit", 0, "Maximum number of transactions to return per page (1-1000, default: 100)") - cmd.Flags().String("offset", "", "Pagination cursor returned as next_offset in a previous response; use to fetch the next page of results") - output.AddFormatFlag(cmd, "text") - - return cmd -} - -// --- Response types --- - -type svmTransactionsResponse struct { - NextOffset string `json:"next_offset,omitempty"` - Transactions []svmTransaction `json:"transactions"` -} - -type svmTransaction struct { - Address string `json:"address"` - BlockSlot json.Number `json:"block_slot"` - BlockTime json.Number `json:"block_time"` - Chain string `json:"chain"` - RawTransaction json.RawMessage `json:"raw_transaction,omitempty"` -} - -func runTransactions(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().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(), "/beta/svm/transactions/"+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 svmTransactionsResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parsing response: %w", err) - } - - if len(resp.Transactions) == 0 { - fmt.Fprintln(w, "No transactions found.") - return nil - } - - columns := []string{"CHAIN", "BLOCK_SLOT", "BLOCK_TIME", "TX_SIGNATURE"} - rows := make([][]string, len(resp.Transactions)) - for i, tx := range resp.Transactions { - rows[i] = []string{ - tx.Chain, - tx.BlockSlot.String(), - formatBlockTime(tx.BlockTime), - extractSignature(tx.RawTransaction), - } - } - output.PrintTable(w, columns, rows) - - if resp.NextOffset != "" { - fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) - } - return nil - } -} - -// formatBlockTime converts a block_time (microseconds since epoch) to a -// human-readable UTC timestamp. -func formatBlockTime(bt json.Number) string { - us, err := bt.Int64() - if err != nil { - return bt.String() - } - // block_time is in microseconds. - t := time.Unix(0, us*int64(time.Microsecond)) - return t.UTC().Format("2006-01-02 15:04:05") -} - -// extractSignature pulls the first transaction signature from raw_transaction. -// Returns the signature string or an empty string if unavailable. -func extractSignature(raw json.RawMessage) string { - if len(raw) == 0 { - return "" - } - - var rt struct { - Transaction struct { - Signatures []string `json:"signatures"` - } `json:"transaction"` - } - if err := json.Unmarshal(raw, &rt); err != nil { - return "" - } - if len(rt.Transaction.Signatures) > 0 { - return rt.Transaction.Signatures[0] - } - return "" -} diff --git a/cmd/sim/svm/transactions_test.go b/cmd/sim/svm/transactions_test.go deleted file mode 100644 index 88945da..0000000 --- a/cmd/sim/svm/transactions_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package svm_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSvmTransactions_Text(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--limit", "5"}) - - require.NoError(t, root.Execute()) - - out := buf.String() - assert.Contains(t, out, "CHAIN") - assert.Contains(t, out, "BLOCK_SLOT") - assert.Contains(t, out, "BLOCK_TIME") - assert.Contains(t, out, "TX_SIGNATURE") -} - -func TestSvmTransactions_JSON(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--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, "transactions") - - txns, ok := resp["transactions"].([]interface{}) - require.True(t, ok) - if len(txns) > 0 { - tx, ok := txns[0].(map[string]interface{}) - require.True(t, ok) - assert.Contains(t, tx, "address") - assert.Contains(t, tx, "block_slot") - assert.Contains(t, tx, "block_time") - assert.Contains(t, tx, "chain") - assert.Contains(t, tx, "raw_transaction") - } -} - -func TestSvmTransactions_Limit(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--limit", "3", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - - txns, ok := resp["transactions"].([]interface{}) - require.True(t, ok) - assert.LessOrEqual(t, len(txns), 3) -} - -func TestSvmTransactions_Pagination(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--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, "transactions") - - // 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, "svm", "transactions", svmTestAddress, "--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, "transactions") - } -} - -func TestSvmTransactions_RawTransactionInJSON(t *testing.T) { - key := simAPIKey(t) - - root := newSimTestRoot() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--limit", "1", "-o", "json"}) - - require.NoError(t, root.Execute()) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - - txns, ok := resp["transactions"].([]interface{}) - require.True(t, ok) - if len(txns) > 0 { - tx, ok := txns[0].(map[string]interface{}) - require.True(t, ok) - - // raw_transaction should be a nested object with transaction data. - rawTx, ok := tx["raw_transaction"].(map[string]interface{}) - if ok { - // Should contain transaction with signatures. - txData, ok := rawTx["transaction"].(map[string]interface{}) - if ok { - assert.Contains(t, txData, "signatures") - } - } - } -} diff --git a/cmdutil/client.go b/cmdutil/client.go index b611856..ad2adb8 100644 --- a/cmdutil/client.go +++ b/cmdutil/client.go @@ -10,7 +10,6 @@ import ( ) type clientKey struct{} -type simClientKey struct{} type trackerKey struct{} type startTimeKey struct{} @@ -28,22 +27,6 @@ func ClientFromCmd(cmd *cobra.Command) dune.DuneClient { return cmd.Context().Value(clientKey{}).(dune.DuneClient) } -// SetSimClient stores a Sim API client in the command's context. -// The value is stored as any to avoid a circular import with cmd/sim. -func SetSimClient(cmd *cobra.Command, client any) { - ctx := cmd.Context() - if ctx == nil { - ctx = context.Background() - } - cmd.SetContext(context.WithValue(ctx, simClientKey{}, client)) -} - -// SimClientFromCmd extracts the Sim API client stored in the command's context. -// Callers should type-assert the result to *sim.SimClient. -func SimClientFromCmd(cmd *cobra.Command) any { - return cmd.Context().Value(simClientKey{}) -} - // SetTracker stores a Tracker in the command's context. func SetTracker(cmd *cobra.Command, t *tracking.Tracker) { ctx := cmd.Context() diff --git a/install.sh b/install.sh index 8a49dcc..c8bf0a7 100755 --- a/install.sh +++ b/install.sh @@ -91,7 +91,6 @@ main() { echo "" >&2 log "Tip: Run 'npx skills add duneanalytics/skills' to install Dune AI skills." log "Tip: Run 'dune auth' to authenticate with your Dune account." - log "Tip: Run 'dune sim auth' to access real-time blockchain data (balances, tokens, NFTs)." fi } @@ -103,7 +102,7 @@ post_install() { # --- Skill install --- if has npx; then log "Dune skills let AI agents (Cursor, Claude Code, etc.) query" - log "blockchain data and access real-time wallet/token info on your behalf." + log "blockchain data on your behalf." printf " Install Dune skills for your AI coding agent? [Y/n] " >&2 read -r answer < /dev/tty || answer="" case "$answer" in @@ -145,32 +144,6 @@ post_install() { esac fi - echo "" >&2 - - # --- Sim API Authentication --- - if "$dune_bin" sim evm token-info native --chain-ids 1 > /dev/null 2>&1; then - log "Already authenticated with Sim API." - else - log "Authenticate with the Sim API to access real-time blockchain data" - log "(wallet balances, token prices, NFTs, DeFi positions, etc.)." - printf " Authenticate with Sim API now? [Y/n] " >&2 - read -r answer < /dev/tty || answer="" - case "$answer" in - [nN]*) - log "Skipped. You can authenticate later with: dune sim auth" - ;; - *) - echo "" >&2 - log "You'll need a Sim API key. To create one:" - log " 1. Go to https://sim.dune.com/ and login or create an account" - log " 2. Click 'Keys'" - log " 3. Click 'New' to create a new API key" - echo "" >&2 - "$dune_bin" sim auth < /dev/tty || log "Authentication failed. You can retry with: dune sim auth" - ;; - esac - fi - echo "" >&2 log "Dune CLI ${dune_version} installed successfully!" } diff --git a/tracking/tracking.go b/tracking/tracking.go index 1b4ef7d..662371f 100644 --- a/tracking/tracking.go +++ b/tracking/tracking.go @@ -76,8 +76,7 @@ func toAmplitudeUserID(customerID string) string { } // Track sends a "CLI Command Executed" event to Amplitude. -// Set isSim to true for commands under `dune sim`. -func (t *Tracker) Track(commandPath, status, errMsg string, durationMs int64, isSim bool) { +func (t *Tracker) Track(commandPath, status, errMsg string, durationMs int64) { if !t.enabled || t.client == nil { return } @@ -95,7 +94,6 @@ func (t *Tracker) Track(commandPath, status, errMsg string, durationMs int64, is "cli_version": t.version, "os": runtime.GOOS, "arch": runtime.GOARCH, - "is_sim": isSim, }, }) } diff --git a/tracking/tracking_test.go b/tracking/tracking_test.go index 9df02b4..04f276d 100644 --- a/tracking/tracking_test.go +++ b/tracking/tracking_test.go @@ -32,8 +32,7 @@ func TestTracker_DisabledNoOp(t *testing.T) { tr := New(Config{Enabled: false}) assert.False(t, tr.enabled) // Should not panic. - tr.Track("test cmd", StatusSuccess, "", 100, false) - tr.Track("sim evm balances", StatusSuccess, "", 100, true) + tr.Track("test cmd", StatusSuccess, "", 100) tr.Shutdown() } @@ -80,31 +79,28 @@ func TestToAmplitudeUserID(t *testing.T) { func TestTracker_TrackWithoutSetUserID(t *testing.T) { tr := New(Config{Enabled: true, AmplitudeKey: "test-key"}) // Should not panic — events are sent with "cli" UserID. - tr.Track("test cmd", StatusSuccess, "", 100, false) + tr.Track("test cmd", StatusSuccess, "", 100) tr.Shutdown() } -func TestTrack_IsSim(t *testing.T) { +func TestTrack_EventProperties(t *testing.T) { spy := &spyClient{} tr := newTestTracker(spy) - tr.Track("query list", StatusSuccess, "", 42, false) - tr.Track("sim evm balances", StatusSuccess, "", 99, true) + tr.Track("query list", StatusSuccess, "boom", 42) - require.Len(t, spy.events, 2) + require.Len(t, spy.events, 1) - // Non-sim event props0 := spy.events[0].EventProperties assert.Equal(t, "CLI Command Executed", spy.events[0].EventType) assert.Equal(t, "cli", spy.events[0].UserID) assert.Equal(t, "query list", props0["command_path"]) - assert.Equal(t, false, props0["is_sim"]) - - // Sim event - props1 := spy.events[1].EventProperties - assert.Equal(t, "CLI Command Executed", spy.events[1].EventType) - assert.Equal(t, "sim evm balances", props1["command_path"]) - assert.Equal(t, true, props1["is_sim"]) + assert.Equal(t, StatusSuccess, props0["status"]) + assert.Equal(t, int64(42), props0["duration_ms"]) + assert.Equal(t, "boom", props0["error_message"]) + assert.Equal(t, "test", props0["cli_version"]) + assert.NotEmpty(t, props0["os"]) + assert.NotEmpty(t, props0["arch"]) } func TestTrack_SetUserIDReflectedInEvents(t *testing.T) { @@ -112,7 +108,7 @@ func TestTrack_SetUserIDReflectedInEvents(t *testing.T) { tr := newTestTracker(spy) tr.SetUserID("user_42") - tr.Track("query run", StatusSuccess, "", 10, false) + tr.Track("query run", StatusSuccess, "", 10) require.Len(t, spy.events, 1) assert.Equal(t, "42", spy.events[0].UserID)