diff --git a/cmd/sim/evm/activity.go b/cmd/sim/evm/activity.go index fdf81b1..e5c0758 100644 --- a/cmd/sim/evm/activity.go +++ b/cmd/sim/evm/activity.go @@ -78,11 +78,19 @@ type tokenMetadata struct { 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"` + 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 { diff --git a/cmd/sim/evm/balance.go b/cmd/sim/evm/balance.go index 4609ddc..3ddcaa9 100644 --- a/cmd/sim/evm/balance.go +++ b/cmd/sim/evm/balance.go @@ -27,6 +27,8 @@ func NewBalanceCmd() *cobra.Command { cmd.Flags().String("token", "", "Token contract address or \"native\" (required)") cmd.Flags().String("chain-ids", "", "Chain ID (required)") + cmd.Flags().String("metadata", "", "Extra metadata fields: logo,url,pools") + cmd.Flags().String("historical-prices", "", "Hour offsets for historical prices (e.g. 720,168,24)") _ = cmd.MarkFlagRequired("token") _ = cmd.MarkFlagRequired("chain-ids") output.AddFormatFlag(cmd, "text") @@ -47,6 +49,12 @@ func runBalance(cmd *cobra.Command, args []string) error { 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) diff --git a/cmd/sim/evm/balances.go b/cmd/sim/evm/balances.go index de791e8..9db002e 100644 --- a/cmd/sim/evm/balances.go +++ b/cmd/sim/evm/balances.go @@ -42,24 +42,56 @@ func NewBalancesCmd() *cobra.Command { 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 []balanceErrorInfo `json:"token_errors,omitempty"` +} + +type balanceErrorInfo 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"` + 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 { @@ -119,7 +151,8 @@ func runBalances(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing response: %w", err) } - // Print warnings to stderr. + // Print errors and warnings to stderr. + printBalanceErrors(cmd, resp.Errors) printWarnings(cmd, resp.Warnings) columns := []string{"CHAIN", "SYMBOL", "AMOUNT", "PRICE_USD", "VALUE_USD"} @@ -198,3 +231,24 @@ func formatUSD(v float64) string { } return fmt.Sprintf("%.2f", v) } + +// printBalanceErrors writes balance-level errors to stderr. +func printBalanceErrors(cmd *cobra.Command, errs *balanceErrors) { + if errs == nil { + return + } + stderr := cmd.ErrOrStderr() + if errs.ErrorMessage != "" { + fmt.Fprintf(stderr, "Error: %s\n", errs.ErrorMessage) + } + for _, e := range errs.TokenErrors { + fmt.Fprintf(stderr, " chain_id=%d address=%s", e.ChainID, e.Address) + if e.Description != "" { + fmt.Fprintf(stderr, " — %s", e.Description) + } + fmt.Fprintln(stderr) + } + if errs.ErrorMessage != "" || len(errs.TokenErrors) > 0 { + fmt.Fprintln(stderr) + } +} diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go index 381d9fe..30089d8 100644 --- a/cmd/sim/evm/evm.go +++ b/cmd/sim/evm/evm.go @@ -43,6 +43,7 @@ func NewEvmCmd() *cobra.Command { cmd.AddCommand(NewBalanceCmd()) cmd.AddCommand(NewStablecoinsCmd()) cmd.AddCommand(NewActivityCmd()) + cmd.AddCommand(NewTransactionsCmd()) return cmd } diff --git a/cmd/sim/evm/stablecoins.go b/cmd/sim/evm/stablecoins.go index 309f677..49e0928 100644 --- a/cmd/sim/evm/stablecoins.go +++ b/cmd/sim/evm/stablecoins.go @@ -84,6 +84,7 @@ func runStablecoins(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing response: %w", err) } + printBalanceErrors(cmd, resp.Errors) printWarnings(cmd, resp.Warnings) columns := []string{"CHAIN", "SYMBOL", "AMOUNT", "PRICE_USD", "VALUE_USD"} diff --git a/cmd/sim/evm/transactions.go b/cmd/sim/evm/transactions.go new file mode 100644 index 0000000..231ca47 --- /dev/null +++ b/cmd/sim/evm/transactions.go @@ -0,0 +1,187 @@ +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 EVM transactions for a wallet address", + Long: "Return transaction history for the given wallet address across supported EVM chains.\n" + + "Use --decode with -o json to include decoded function calls and event logs.\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", "", "Comma-separated chain IDs or tags (default: all default chains)") + cmd.Flags().Bool("decode", false, "Include decoded transaction data and logs (use with -o json)") + cmd.Flags().Int("limit", 0, "Max results (1-100)") + cmd.Flags().String("offset", "", "Pagination cursor from previous response") + 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 []transactionErrorInfo `json:"transaction_errors,omitempty"` +} + +type transactionErrorInfo struct { + ChainID int64 `json:"chain_id"` + Address string `json:"address"` + Description string `json:"description,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, err := requireSimClient(cmd) + if err != nil { + return err + } + + address := args[0] + params := url.Values{} + + if v, _ := cmd.Flags().GetString("chain-ids"); v != "" { + params.Set("chain_ids", v) + } + 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 + } + stderr := cmd.ErrOrStderr() + if errs.ErrorMessage != "" { + fmt.Fprintf(stderr, "Error: %s\n", errs.ErrorMessage) + } + for _, e := range errs.TransactionErrors { + fmt.Fprintf(stderr, " chain_id=%d address=%s", e.ChainID, e.Address) + if e.Description != "" { + fmt.Fprintf(stderr, " — %s", e.Description) + } + fmt.Fprintln(stderr) + } + if errs.ErrorMessage != "" || len(errs.TransactionErrors) > 0 { + fmt.Fprintln(stderr) + } +} diff --git a/cmd/sim/evm/transactions_test.go b/cmd/sim/evm/transactions_test.go new file mode 100644 index 0000000..b2c205d --- /dev/null +++ b/cmd/sim/evm/transactions_test.go @@ -0,0 +1,123 @@ +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") + } +}