diff --git a/cmd/sim/evm/activity.go b/cmd/sim/evm/activity.go new file mode 100644 index 0000000..fdf81b1 --- /dev/null +++ b/cmd/sim/evm/activity.go @@ -0,0 +1,194 @@ +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 EVM activity feed for a wallet address", + Long: "Return a chronological feed of on-chain activity for the given wallet address\n" + + "including native transfers, ERC20 movements, NFT transfers, swaps, and contract calls.\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", + Args: cobra.ExactArgs(1), + RunE: runActivity, + } + + cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)") + cmd.Flags().String("token-address", "", "Filter by token contract address (comma-separated for multiple)") + cmd.Flags().String("activity-type", "", "Filter by type: send,receive,mint,burn,swap,approve,call") + cmd.Flags().String("asset-type", "", "Filter by asset standard: native,erc20,erc721,erc1155") + 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 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"` +} + +type functionInfo struct { + Signature string `json:"signature,omitempty"` + Name string `json:"name,omitempty"` +} + +type contractMetaObj struct { + Name string `json:"name,omitempty"` +} + +func runActivity(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().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), + 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 new file mode 100644 index 0000000..824b03c --- /dev/null +++ b/cmd/sim/evm/activity_test.go @@ -0,0 +1,120 @@ +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/evm.go b/cmd/sim/evm/evm.go index 2d797ab..381d9fe 100644 --- a/cmd/sim/evm/evm.go +++ b/cmd/sim/evm/evm.go @@ -42,6 +42,7 @@ func NewEvmCmd() *cobra.Command { cmd.AddCommand(NewBalancesCmd()) cmd.AddCommand(NewBalanceCmd()) cmd.AddCommand(NewStablecoinsCmd()) + cmd.AddCommand(NewActivityCmd()) return cmd }