Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions cmd/sim/evm/activity.go
Original file line number Diff line number Diff line change
@@ -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 <address>",
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)

Check failure on line 93 in cmd/sim/evm/activity.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient

Check failure on line 93 in cmd/sim/evm/activity.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient

Check failure on line 93 in cmd/sim/evm/activity.go

View workflow job for this annotation

GitHub Actions / test

undefined: requireSimClient
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"
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded "ETH" symbol wrong for non-Ethereum chains

Medium Severity

The activitySymbol fallback for AssetType == "native" always returns "ETH", but the activity command supports querying any EVM chain via --chain-ids. For chains like BSC (BNB), Polygon (MATIC/POL), or Avalanche (AVAX), native transfers without token_metadata will incorrectly display "ETH" as the symbol in the text table output.

Fix in Cursor Fix in Web

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:]
}
120 changes: 120 additions & 0 deletions cmd/sim/evm/activity_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions cmd/sim/evm/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func NewEvmCmd() *cobra.Command {
cmd.AddCommand(NewBalancesCmd())
cmd.AddCommand(NewBalanceCmd())
cmd.AddCommand(NewStablecoinsCmd())
cmd.AddCommand(NewActivityCmd())

return cmd
}
Loading