Skip to content

Commit c6867f4

Browse files
authored
Add evm transactions (#33)
* Add evm transactions command and fix OpenAPI spec gaps across all EVM commands - Add dune sim evm transactions with --chain-ids, --decode, --limit, --offset flags - Add --decode text-mode stderr hint and E2E tests (text, JSON, decode-text, decode-JSON, pagination) - Fix spec gaps: add missing struct fields across all EVM commands - transactions: block_version, max_fee_per_gas, max_priority_fee_per_gas, decoded, logs, errors - balances/balance/stablecoins: historical_prices, token_metadata, pool, errors + printBalanceErrors - activity: tokenMetadata.standard, functionInfo.inputs - balance: --metadata and --historical-prices CLI flags - Use json.RawMessage for decoded input values to handle non-string ABI types safely * Fix CI build error and address PR review comments - Fix undefined requireSimClient: use SimClientFromCmd pattern matching other EVM commands - Add missing printBalanceErrors call in balance.go text-mode path - Deduplicate error types: extract shared apiChainError and printAPIChainErrors helper
1 parent 7b613d5 commit c6867f4

6 files changed

Lines changed: 388 additions & 14 deletions

File tree

cmd/sim/evm/activity.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,19 @@ type tokenMetadata struct {
7878
Logo string `json:"logo,omitempty"`
7979
PriceUSD float64 `json:"price_usd"`
8080
PoolSize float64 `json:"pool_size,omitempty"`
81+
Standard string `json:"standard,omitempty"`
8182
}
8283

8384
type functionInfo struct {
84-
Signature string `json:"signature,omitempty"`
85-
Name string `json:"name,omitempty"`
85+
Signature string `json:"signature,omitempty"`
86+
Name string `json:"name,omitempty"`
87+
Inputs []functionInput `json:"inputs,omitempty"`
88+
}
89+
90+
type functionInput struct {
91+
Name string `json:"name,omitempty"`
92+
Type string `json:"type,omitempty"`
93+
Value json.RawMessage `json:"value,omitempty"`
8694
}
8795

8896
type contractMetaObj struct {

cmd/sim/evm/balance.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ func NewBalanceCmd() *cobra.Command {
2727

2828
cmd.Flags().String("token", "", "Token contract address or \"native\" (required)")
2929
cmd.Flags().String("chain-ids", "", "Chain ID (required)")
30+
cmd.Flags().String("metadata", "", "Extra metadata fields: logo,url,pools")
31+
cmd.Flags().String("historical-prices", "", "Hour offsets for historical prices (e.g. 720,168,24)")
3032
_ = cmd.MarkFlagRequired("token")
3133
_ = cmd.MarkFlagRequired("chain-ids")
3234
output.AddFormatFlag(cmd, "text")
@@ -47,6 +49,12 @@ func runBalance(cmd *cobra.Command, args []string) error {
4749
if v, _ := cmd.Flags().GetString("chain-ids"); v != "" {
4850
params.Set("chain_ids", v)
4951
}
52+
if v, _ := cmd.Flags().GetString("metadata"); v != "" {
53+
params.Set("metadata", v)
54+
}
55+
if v, _ := cmd.Flags().GetString("historical-prices"); v != "" {
56+
params.Set("historical_prices", v)
57+
}
5058

5159
path := fmt.Sprintf("/v1/evm/balances/%s/token/%s", address, tokenAddress)
5260
data, err := client.Get(cmd.Context(), path, params)
@@ -65,6 +73,8 @@ func runBalance(cmd *cobra.Command, args []string) error {
6573
return fmt.Errorf("parsing response: %w", err)
6674
}
6775

76+
printBalanceErrors(cmd, resp.Errors)
77+
6878
if len(resp.Balances) == 0 {
6979
fmt.Fprintln(w, "No balance found.")
7080
return nil

cmd/sim/evm/balances.go

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,58 @@ func NewBalancesCmd() *cobra.Command {
3535
type balancesResponse struct {
3636
WalletAddress string `json:"wallet_address"`
3737
Balances []balanceEntry `json:"balances"`
38+
Errors *balanceErrors `json:"errors,omitempty"`
3839
NextOffset string `json:"next_offset,omitempty"`
3940
Warnings []warningEntry `json:"warnings,omitempty"`
4041
RequestTime string `json:"request_time,omitempty"`
4142
ResponseTime string `json:"response_time,omitempty"`
4243
}
4344

45+
type balanceErrors struct {
46+
ErrorMessage string `json:"error_message,omitempty"`
47+
TokenErrors []apiChainError `json:"token_errors,omitempty"`
48+
}
49+
50+
// apiChainError is a per-chain error returned by several Sim API endpoints
51+
// (balances, transactions, etc.). It is intentionally shared across commands.
52+
type apiChainError struct {
53+
ChainID int64 `json:"chain_id"`
54+
Address string `json:"address"`
55+
Description string `json:"description,omitempty"`
56+
}
57+
4458
type balanceEntry struct {
45-
Chain string `json:"chain"`
46-
ChainID int64 `json:"chain_id"`
47-
Address string `json:"address"`
48-
Amount string `json:"amount"`
49-
Symbol string `json:"symbol"`
50-
Name string `json:"name"`
51-
Decimals int `json:"decimals"`
52-
PriceUSD float64 `json:"price_usd"`
53-
ValueUSD float64 `json:"value_usd"`
54-
PoolSize float64 `json:"pool_size"`
55-
LowLiquidity bool `json:"low_liquidity"`
59+
Chain string `json:"chain"`
60+
ChainID int64 `json:"chain_id"`
61+
Address string `json:"address"`
62+
Amount string `json:"amount"`
63+
Symbol string `json:"symbol"`
64+
Name string `json:"name"`
65+
Decimals int `json:"decimals"`
66+
PriceUSD float64 `json:"price_usd"`
67+
ValueUSD float64 `json:"value_usd"`
68+
PoolSize float64 `json:"pool_size"`
69+
LowLiquidity bool `json:"low_liquidity"`
70+
HistoricalPrices []historicalPrice `json:"historical_prices,omitempty"`
71+
TokenMetadata *balanceTokenMeta `json:"token_metadata,omitempty"`
72+
Pool *poolMetadata `json:"pool,omitempty"`
73+
}
74+
75+
type historicalPrice struct {
76+
OffsetHours int `json:"offset_hours"`
77+
PriceUSD float64 `json:"price_usd"`
78+
}
79+
80+
type balanceTokenMeta struct {
81+
Logo string `json:"logo,omitempty"`
82+
URL string `json:"url,omitempty"`
83+
}
84+
85+
type poolMetadata struct {
86+
PoolType string `json:"pool_type"`
87+
Address string `json:"address"`
88+
Token0 string `json:"token0"`
89+
Token1 string `json:"token1"`
5690
}
5791

5892
type warningEntry struct {
@@ -138,7 +172,8 @@ func runBalancesEndpoint(cmd *cobra.Command, args []string, pathPrefix, pathSuff
138172
return fmt.Errorf("parsing response: %w", err)
139173
}
140174

141-
// Print warnings to stderr.
175+
// Print errors and warnings to stderr.
176+
printBalanceErrors(cmd, resp.Errors)
142177
printWarnings(cmd, resp.Warnings)
143178

144179
columns := []string{"CHAIN", "SYMBOL", "AMOUNT", "PRICE_USD", "VALUE_USD"}
@@ -217,3 +252,32 @@ func formatUSD(v float64) string {
217252
}
218253
return fmt.Sprintf("%.2f", v)
219254
}
255+
256+
// printBalanceErrors writes balance-level errors to stderr.
257+
func printBalanceErrors(cmd *cobra.Command, errs *balanceErrors) {
258+
if errs == nil {
259+
return
260+
}
261+
printAPIChainErrors(cmd, errs.ErrorMessage, errs.TokenErrors)
262+
}
263+
264+
// printAPIChainErrors is a shared helper that writes per-chain API errors to
265+
// stderr. It is used by both balance and transaction commands to avoid
266+
// duplicating the same formatting logic.
267+
func printAPIChainErrors(cmd *cobra.Command, msg string, errs []apiChainError) {
268+
if msg == "" && len(errs) == 0 {
269+
return
270+
}
271+
stderr := cmd.ErrOrStderr()
272+
if msg != "" {
273+
fmt.Fprintf(stderr, "Error: %s\n", msg)
274+
}
275+
for _, e := range errs {
276+
fmt.Fprintf(stderr, " chain_id=%d address=%s", e.ChainID, e.Address)
277+
if e.Description != "" {
278+
fmt.Fprintf(stderr, " — %s", e.Description)
279+
}
280+
fmt.Fprintln(stderr)
281+
}
282+
fmt.Fprintln(stderr)
283+
}

cmd/sim/evm/evm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func NewEvmCmd() *cobra.Command {
4343
cmd.AddCommand(NewBalanceCmd())
4444
cmd.AddCommand(NewStablecoinsCmd())
4545
cmd.AddCommand(NewActivityCmd())
46+
cmd.AddCommand(NewTransactionsCmd())
4647

4748
return cmd
4849
}

cmd/sim/evm/transactions.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
// NewTransactionsCmd returns the `sim evm transactions` command.
14+
func NewTransactionsCmd() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "transactions <address>",
17+
Short: "Get EVM transactions for a wallet address",
18+
Long: "Return transaction history for the given wallet address across supported EVM chains.\n" +
19+
"Use --decode with -o json to include decoded function calls and event logs.\n\n" +
20+
"Examples:\n" +
21+
" dune sim evm transactions 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" +
22+
" dune sim evm transactions 0xd8da... --chain-ids 1 --decode -o json\n" +
23+
" dune sim evm transactions 0xd8da... --limit 50 -o json",
24+
Args: cobra.ExactArgs(1),
25+
RunE: runTransactions,
26+
}
27+
28+
cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)")
29+
cmd.Flags().Bool("decode", false, "Include decoded transaction data and logs (use with -o json)")
30+
cmd.Flags().Int("limit", 0, "Max results (1-100)")
31+
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
32+
output.AddFormatFlag(cmd, "text")
33+
34+
return cmd
35+
}
36+
37+
type transactionsResponse struct {
38+
WalletAddress string `json:"wallet_address"`
39+
Transactions []transactionTx `json:"transactions"`
40+
Errors *transactionErrors `json:"errors,omitempty"`
41+
NextOffset string `json:"next_offset,omitempty"`
42+
Warnings []warningEntry `json:"warnings,omitempty"`
43+
RequestTime string `json:"request_time,omitempty"`
44+
ResponseTime string `json:"response_time,omitempty"`
45+
}
46+
47+
type transactionErrors struct {
48+
ErrorMessage string `json:"error_message,omitempty"`
49+
TransactionErrors []apiChainError `json:"transaction_errors,omitempty"`
50+
}
51+
52+
type transactionTx struct {
53+
Address string `json:"address"`
54+
BlockHash string `json:"block_hash"`
55+
BlockNumber json.Number `json:"block_number"`
56+
BlockTime string `json:"block_time"`
57+
BlockVersion int `json:"block_version,omitempty"`
58+
Chain string `json:"chain"`
59+
From string `json:"from"`
60+
To string `json:"to"`
61+
Data string `json:"data,omitempty"`
62+
GasPrice string `json:"gas_price,omitempty"`
63+
Hash string `json:"hash"`
64+
Index json.Number `json:"index,omitempty"`
65+
MaxFeePerGas string `json:"max_fee_per_gas,omitempty"`
66+
MaxPriorityFeePerGas string `json:"max_priority_fee_per_gas,omitempty"`
67+
Nonce string `json:"nonce,omitempty"`
68+
TransactionType string `json:"transaction_type,omitempty"`
69+
Value string `json:"value"`
70+
Decoded *decodedCall `json:"decoded,omitempty"`
71+
Logs []transactionLog `json:"logs,omitempty"`
72+
}
73+
74+
type decodedCall struct {
75+
Name string `json:"name,omitempty"`
76+
Inputs []decodedInput `json:"inputs,omitempty"`
77+
}
78+
79+
type decodedInput struct {
80+
Name string `json:"name,omitempty"`
81+
Type string `json:"type,omitempty"`
82+
Value json.RawMessage `json:"value,omitempty"`
83+
}
84+
85+
type transactionLog struct {
86+
Address string `json:"address,omitempty"`
87+
Data string `json:"data,omitempty"`
88+
Topics []string `json:"topics,omitempty"`
89+
Decoded *decodedCall `json:"decoded,omitempty"`
90+
}
91+
92+
func runTransactions(cmd *cobra.Command, args []string) error {
93+
client := SimClientFromCmd(cmd)
94+
if client == nil {
95+
return fmt.Errorf("sim client not initialized")
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().GetBool("decode"); v {
105+
params.Set("decode", "true")
106+
}
107+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
108+
params.Set("limit", fmt.Sprintf("%d", v))
109+
}
110+
if v, _ := cmd.Flags().GetString("offset"); v != "" {
111+
params.Set("offset", v)
112+
}
113+
114+
data, err := client.Get(cmd.Context(), "/v1/evm/transactions/"+address, params)
115+
if err != nil {
116+
return err
117+
}
118+
119+
w := cmd.OutOrStdout()
120+
switch output.FormatFromCmd(cmd) {
121+
case output.FormatJSON:
122+
var raw json.RawMessage = data
123+
return output.PrintJSON(w, raw)
124+
default:
125+
var resp transactionsResponse
126+
if err := json.Unmarshal(data, &resp); err != nil {
127+
return fmt.Errorf("parsing response: %w", err)
128+
}
129+
130+
// Warn if --decode is used in text mode since the table can't show decoded data.
131+
if decode, _ := cmd.Flags().GetBool("decode"); decode {
132+
fmt.Fprintln(cmd.ErrOrStderr(), "Note: --decode data is only visible in JSON output. Use -o json to see decoded fields.")
133+
}
134+
135+
// Print errors to stderr.
136+
printTransactionErrors(cmd, resp.Errors)
137+
138+
// Print warnings to stderr.
139+
printWarnings(cmd, resp.Warnings)
140+
141+
columns := []string{"CHAIN", "HASH", "FROM", "TO", "VALUE", "BLOCK_TIME"}
142+
rows := make([][]string, len(resp.Transactions))
143+
for i, tx := range resp.Transactions {
144+
rows[i] = []string{
145+
tx.Chain,
146+
truncateHash(tx.Hash),
147+
truncateHash(tx.From),
148+
truncateHash(tx.To),
149+
tx.Value,
150+
tx.BlockTime,
151+
}
152+
}
153+
output.PrintTable(w, columns, rows)
154+
155+
if resp.NextOffset != "" {
156+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
157+
}
158+
return nil
159+
}
160+
}
161+
162+
// printTransactionErrors writes transaction-level errors to stderr.
163+
func printTransactionErrors(cmd *cobra.Command, errs *transactionErrors) {
164+
if errs == nil {
165+
return
166+
}
167+
printAPIChainErrors(cmd, errs.ErrorMessage, errs.TransactionErrors)
168+
}

0 commit comments

Comments
 (0)