Skip to content

Commit 0076e71

Browse files
committed
EVM activity
1 parent 3ab91b3 commit 0076e71

3 files changed

Lines changed: 315 additions & 0 deletions

File tree

cmd/sim/evm/activity.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package evm
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/duneanalytics/cli/output"
11+
)
12+
13+
// NewActivityCmd returns the `sim evm activity` command.
14+
func NewActivityCmd() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "activity <address>",
17+
Short: "Get EVM activity feed for a wallet address",
18+
Long: "Return a chronological feed of on-chain activity for the given wallet address\n" +
19+
"including native transfers, ERC20 movements, NFT transfers, swaps, and contract calls.\n\n" +
20+
"Examples:\n" +
21+
" dune sim evm activity 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" +
22+
" dune sim evm activity 0xd8da... --activity-type send,receive --chain-ids 1\n" +
23+
" dune sim evm activity 0xd8da... --asset-type erc20 --limit 50 -o json",
24+
Args: cobra.ExactArgs(1),
25+
RunE: runActivity,
26+
}
27+
28+
cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)")
29+
cmd.Flags().String("token-address", "", "Filter by token contract address (comma-separated for multiple)")
30+
cmd.Flags().String("activity-type", "", "Filter by type: send,receive,mint,burn,swap,approve,call")
31+
cmd.Flags().String("asset-type", "", "Filter by asset standard: native,erc20,erc721,erc1155")
32+
cmd.Flags().Int("limit", 0, "Max results (1-100)")
33+
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
34+
output.AddFormatFlag(cmd, "text")
35+
36+
return cmd
37+
}
38+
39+
type activityResponse struct {
40+
Activity []activityItem `json:"activity"`
41+
NextOffset string `json:"next_offset,omitempty"`
42+
Warnings []warningEntry `json:"warnings,omitempty"`
43+
}
44+
45+
type activityItem struct {
46+
ChainID int64 `json:"chain_id"`
47+
BlockNumber int64 `json:"block_number"`
48+
BlockTime string `json:"block_time"`
49+
TxHash string `json:"tx_hash"`
50+
Type string `json:"type"`
51+
AssetType string `json:"asset_type"`
52+
TokenAddress string `json:"token_address,omitempty"`
53+
From string `json:"from,omitempty"`
54+
To string `json:"to,omitempty"`
55+
Value string `json:"value,omitempty"`
56+
ValueUSD float64 `json:"value_usd"`
57+
ID string `json:"id,omitempty"` // ERC721/ERC1155 token ID
58+
Spender string `json:"spender,omitempty"`
59+
TokenMeta *tokenMetadata `json:"token_metadata,omitempty"`
60+
61+
// Swap-specific fields.
62+
FromTokenAddress string `json:"from_token_address,omitempty"`
63+
FromTokenValue string `json:"from_token_value,omitempty"`
64+
FromTokenMetadata *tokenMetadata `json:"from_token_metadata,omitempty"`
65+
ToTokenAddress string `json:"to_token_address,omitempty"`
66+
ToTokenValue string `json:"to_token_value,omitempty"`
67+
ToTokenMetadata *tokenMetadata `json:"to_token_metadata,omitempty"`
68+
69+
// Contract call fields.
70+
Function *functionInfo `json:"function,omitempty"`
71+
ContractMetadata *contractMetaObj `json:"contract_metadata,omitempty"`
72+
}
73+
74+
type tokenMetadata struct {
75+
Symbol string `json:"symbol"`
76+
Decimals int `json:"decimals"`
77+
Name string `json:"name,omitempty"`
78+
Logo string `json:"logo,omitempty"`
79+
PriceUSD float64 `json:"price_usd"`
80+
PoolSize float64 `json:"pool_size,omitempty"`
81+
}
82+
83+
type functionInfo struct {
84+
Signature string `json:"signature,omitempty"`
85+
Name string `json:"name,omitempty"`
86+
}
87+
88+
type contractMetaObj struct {
89+
Name string `json:"name,omitempty"`
90+
}
91+
92+
func runActivity(cmd *cobra.Command, args []string) error {
93+
client, err := requireSimClient(cmd)
94+
if err != nil {
95+
return err
96+
}
97+
98+
address := args[0]
99+
params := url.Values{}
100+
101+
if v, _ := cmd.Flags().GetString("chain-ids"); v != "" {
102+
params.Set("chain_ids", v)
103+
}
104+
if v, _ := cmd.Flags().GetString("token-address"); v != "" {
105+
params.Set("token_address", v)
106+
}
107+
if v, _ := cmd.Flags().GetString("activity-type"); v != "" {
108+
params.Set("activity_type", v)
109+
}
110+
if v, _ := cmd.Flags().GetString("asset-type"); v != "" {
111+
params.Set("asset_type", v)
112+
}
113+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
114+
params.Set("limit", fmt.Sprintf("%d", v))
115+
}
116+
if v, _ := cmd.Flags().GetString("offset"); v != "" {
117+
params.Set("offset", v)
118+
}
119+
120+
data, err := client.Get(cmd.Context(), "/v1/evm/activity/"+address, params)
121+
if err != nil {
122+
return err
123+
}
124+
125+
w := cmd.OutOrStdout()
126+
switch output.FormatFromCmd(cmd) {
127+
case output.FormatJSON:
128+
var raw json.RawMessage = data
129+
return output.PrintJSON(w, raw)
130+
default:
131+
var resp activityResponse
132+
if err := json.Unmarshal(data, &resp); err != nil {
133+
return fmt.Errorf("parsing response: %w", err)
134+
}
135+
136+
// Print warnings to stderr.
137+
printWarnings(cmd, resp.Warnings)
138+
139+
columns := []string{"CHAIN_ID", "TYPE", "ASSET_TYPE", "SYMBOL", "VALUE_USD", "TX_HASH", "BLOCK_TIME"}
140+
rows := make([][]string, len(resp.Activity))
141+
for i, a := range resp.Activity {
142+
rows[i] = []string{
143+
fmt.Sprintf("%d", a.ChainID),
144+
a.Type,
145+
a.AssetType,
146+
activitySymbol(a),
147+
formatUSD(a.ValueUSD),
148+
truncateHash(a.TxHash),
149+
a.BlockTime,
150+
}
151+
}
152+
output.PrintTable(w, columns, rows)
153+
154+
if resp.NextOffset != "" {
155+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
156+
}
157+
return nil
158+
}
159+
}
160+
161+
// activitySymbol returns the best symbol to display for the activity.
162+
// For swaps it shows "FROM -> TO", for regular activities it uses token_metadata.
163+
func activitySymbol(a activityItem) string {
164+
if a.Type == "swap" {
165+
from := ""
166+
to := ""
167+
if a.FromTokenMetadata != nil {
168+
from = a.FromTokenMetadata.Symbol
169+
}
170+
if a.ToTokenMetadata != nil {
171+
to = a.ToTokenMetadata.Symbol
172+
}
173+
if from != "" || to != "" {
174+
return from + " -> " + to
175+
}
176+
return ""
177+
}
178+
if a.TokenMeta != nil {
179+
return a.TokenMeta.Symbol
180+
}
181+
// Native transfers may not have token_metadata.
182+
if a.AssetType == "native" {
183+
return "ETH"
184+
}
185+
return ""
186+
}
187+
188+
// truncateHash shortens a hex hash for table display.
189+
func truncateHash(hash string) string {
190+
if len(hash) <= 14 {
191+
return hash
192+
}
193+
return hash[:8] + "..." + hash[len(hash)-4:]
194+
}

cmd/sim/evm/activity_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package evm_test
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestEvmActivity_Text(t *testing.T) {
13+
key := simAPIKey(t)
14+
15+
root := newSimTestRoot()
16+
var buf bytes.Buffer
17+
root.SetOut(&buf)
18+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--limit", "5"})
19+
20+
require.NoError(t, root.Execute())
21+
22+
out := buf.String()
23+
assert.Contains(t, out, "CHAIN_ID")
24+
assert.Contains(t, out, "TYPE")
25+
assert.Contains(t, out, "ASSET_TYPE")
26+
assert.Contains(t, out, "SYMBOL")
27+
assert.Contains(t, out, "VALUE_USD")
28+
assert.Contains(t, out, "TX_HASH")
29+
assert.Contains(t, out, "BLOCK_TIME")
30+
}
31+
32+
func TestEvmActivity_JSON(t *testing.T) {
33+
key := simAPIKey(t)
34+
35+
root := newSimTestRoot()
36+
var buf bytes.Buffer
37+
root.SetOut(&buf)
38+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--limit", "5", "-o", "json"})
39+
40+
require.NoError(t, root.Execute())
41+
42+
var resp map[string]interface{}
43+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
44+
assert.Contains(t, resp, "activity")
45+
}
46+
47+
func TestEvmActivity_ActivityTypeFilter(t *testing.T) {
48+
key := simAPIKey(t)
49+
50+
root := newSimTestRoot()
51+
var buf bytes.Buffer
52+
root.SetOut(&buf)
53+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--activity-type", "receive", "--limit", "5", "-o", "json"})
54+
55+
require.NoError(t, root.Execute())
56+
57+
var resp struct {
58+
Activity []struct {
59+
Type string `json:"type"`
60+
} `json:"activity"`
61+
}
62+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
63+
64+
// All returned activities should be of the filtered type.
65+
for _, a := range resp.Activity {
66+
assert.Equal(t, "receive", a.Type)
67+
}
68+
}
69+
70+
func TestEvmActivity_AssetTypeFilter(t *testing.T) {
71+
key := simAPIKey(t)
72+
73+
root := newSimTestRoot()
74+
var buf bytes.Buffer
75+
root.SetOut(&buf)
76+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--asset-type", "erc20", "--limit", "5", "-o", "json"})
77+
78+
require.NoError(t, root.Execute())
79+
80+
var resp struct {
81+
Activity []struct {
82+
AssetType string `json:"asset_type"`
83+
} `json:"activity"`
84+
}
85+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
86+
87+
for _, a := range resp.Activity {
88+
assert.Equal(t, "erc20", a.AssetType)
89+
}
90+
}
91+
92+
func TestEvmActivity_Pagination(t *testing.T) {
93+
key := simAPIKey(t)
94+
95+
// Fetch page 1 with a small limit to trigger pagination.
96+
root := newSimTestRoot()
97+
var buf bytes.Buffer
98+
root.SetOut(&buf)
99+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--limit", "2", "-o", "json"})
100+
101+
require.NoError(t, root.Execute())
102+
103+
var resp map[string]interface{}
104+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
105+
assert.Contains(t, resp, "activity")
106+
107+
// If next_offset is present, fetch page 2.
108+
if offset, ok := resp["next_offset"].(string); ok && offset != "" {
109+
root2 := newSimTestRoot()
110+
var buf2 bytes.Buffer
111+
root2.SetOut(&buf2)
112+
root2.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "activity", evmTestAddress, "--chain-ids", "1", "--limit", "2", "--offset", offset, "-o", "json"})
113+
114+
require.NoError(t, root2.Execute())
115+
116+
var resp2 map[string]interface{}
117+
require.NoError(t, json.Unmarshal(buf2.Bytes(), &resp2))
118+
assert.Contains(t, resp2, "activity")
119+
}
120+
}

cmd/sim/evm/evm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func NewEvmCmd() *cobra.Command {
4242
cmd.AddCommand(NewBalancesCmd())
4343
cmd.AddCommand(NewBalanceCmd())
4444
cmd.AddCommand(NewStablecoinsCmd())
45+
cmd.AddCommand(NewActivityCmd())
4546

4647
return cmd
4748
}

0 commit comments

Comments
 (0)