diff --git a/cmd/sim/svm/svm.go b/cmd/sim/svm/svm.go index 70a0577..cc1b03b 100644 --- a/cmd/sim/svm/svm.go +++ b/cmd/sim/svm/svm.go @@ -42,12 +42,13 @@ func requireSimClient(cmd *cobra.Command) (SimClient, error) { func NewSvmCmd() *cobra.Command { cmd := &cobra.Command{ Use: "svm", - Short: "Query SVM chain data (balances)", - Long: "Access real-time SVM blockchain data including token balances\n" + - "for Solana and Eclipse chains.", + Short: "Query SVM chain data (balances, transactions)", + Long: "Access real-time SVM blockchain data including token balances and\n" + + "transaction history for Solana and Eclipse chains.", } cmd.AddCommand(NewBalancesCmd()) + cmd.AddCommand(NewTransactionsCmd()) return cmd } diff --git a/cmd/sim/svm/transactions.go b/cmd/sim/svm/transactions.go new file mode 100644 index 0000000..d5f7888 --- /dev/null +++ b/cmd/sim/svm/transactions.go @@ -0,0 +1,138 @@ +package svm + +import ( + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/output" +) + +// NewTransactionsCmd returns the `sim svm transactions` command. +func NewTransactionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "transactions
", + Short: "Get SVM transactions for a wallet address", + Long: "Return transactions for the given SVM wallet address.\n" + + "Raw transaction data is available in JSON output.\n\n" + + "Examples:\n" + + " dune sim svm transactions 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY\n" + + " dune sim svm transactions 86xCnPeV... --limit 20\n" + + " dune sim svm transactions 86xCnPeV... -o json", + Args: cobra.ExactArgs(1), + RunE: runTransactions, + } + + cmd.Flags().Int("limit", 0, "Max results (1-1000, default 100)") + cmd.Flags().String("offset", "", "Pagination cursor from previous response") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +// --- Response types --- + +type svmTransactionsResponse struct { + NextOffset string `json:"next_offset,omitempty"` + Transactions []svmTransaction `json:"transactions"` +} + +type svmTransaction struct { + Address string `json:"address"` + BlockSlot json.Number `json:"block_slot"` + BlockTime json.Number `json:"block_time"` + Chain string `json:"chain"` + RawTransaction json.RawMessage `json:"raw_transaction,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().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(), "/beta/svm/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 svmTransactionsResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + if len(resp.Transactions) == 0 { + fmt.Fprintln(w, "No transactions found.") + return nil + } + + columns := []string{"CHAIN", "BLOCK_SLOT", "BLOCK_TIME", "TX_SIGNATURE"} + rows := make([][]string, len(resp.Transactions)) + for i, tx := range resp.Transactions { + rows[i] = []string{ + tx.Chain, + tx.BlockSlot.String(), + formatBlockTime(tx.BlockTime), + extractSignature(tx.RawTransaction), + } + } + output.PrintTable(w, columns, rows) + + if resp.NextOffset != "" { + fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) + } + return nil + } +} + +// formatBlockTime converts a block_time (microseconds since epoch) to a +// human-readable UTC timestamp. +func formatBlockTime(bt json.Number) string { + us, err := bt.Int64() + if err != nil { + return bt.String() + } + // block_time is in microseconds. + t := time.Unix(0, us*int64(time.Microsecond)) + return t.UTC().Format("2006-01-02 15:04:05") +} + +// extractSignature pulls the first transaction signature from raw_transaction. +// Returns the signature string or an empty string if unavailable. +func extractSignature(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + + var rt struct { + Transaction struct { + Signatures []string `json:"signatures"` + } `json:"transaction"` + } + if err := json.Unmarshal(raw, &rt); err != nil { + return "" + } + if len(rt.Transaction.Signatures) > 0 { + return rt.Transaction.Signatures[0] + } + return "" +} diff --git a/cmd/sim/svm/transactions_test.go b/cmd/sim/svm/transactions_test.go new file mode 100644 index 0000000..88945da --- /dev/null +++ b/cmd/sim/svm/transactions_test.go @@ -0,0 +1,132 @@ +package svm_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSvmTransactions_Text(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--limit", "5"}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "CHAIN") + assert.Contains(t, out, "BLOCK_SLOT") + assert.Contains(t, out, "BLOCK_TIME") + assert.Contains(t, out, "TX_SIGNATURE") +} + +func TestSvmTransactions_JSON(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--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") + + txns, ok := resp["transactions"].([]interface{}) + require.True(t, ok) + if len(txns) > 0 { + tx, ok := txns[0].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, tx, "address") + assert.Contains(t, tx, "block_slot") + assert.Contains(t, tx, "block_time") + assert.Contains(t, tx, "chain") + assert.Contains(t, tx, "raw_transaction") + } +} + +func TestSvmTransactions_Limit(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--limit", "3", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + + txns, ok := resp["transactions"].([]interface{}) + require.True(t, ok) + assert.LessOrEqual(t, len(txns), 3) +} + +func TestSvmTransactions_Pagination(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--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, "svm", "transactions", svmTestAddress, "--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") + } +} + +func TestSvmTransactions_RawTransactionInJSON(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "transactions", svmTestAddress, "--limit", "1", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + + txns, ok := resp["transactions"].([]interface{}) + require.True(t, ok) + if len(txns) > 0 { + tx, ok := txns[0].(map[string]interface{}) + require.True(t, ok) + + // raw_transaction should be a nested object with transaction data. + rawTx, ok := tx["raw_transaction"].(map[string]interface{}) + if ok { + // Should contain transaction with signatures. + txData, ok := rawTx["transaction"].(map[string]interface{}) + if ok { + assert.Contains(t, txData, "signatures") + } + } + } +}