diff --git a/cmd/sim/svm/balances.go b/cmd/sim/svm/balances.go new file mode 100644 index 0000000..d6ed0c3 --- /dev/null +++ b/cmd/sim/svm/balances.go @@ -0,0 +1,132 @@ +package svm + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/output" +) + +// NewBalancesCmd returns the `sim svm balances` command. +func NewBalancesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "balances
", + Short: "Get SVM token balances for a wallet address", + Long: "Return token balances for the given SVM wallet address across\n" + + "Solana and Eclipse chains, including USD valuations.\n\n" + + "Examples:\n" + + " dune sim svm balances 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY\n" + + " dune sim svm balances 86xCnPeV... --chains solana,eclipse\n" + + " dune sim svm balances 86xCnPeV... --limit 50 -o json", + Args: cobra.ExactArgs(1), + RunE: runBalances, + } + + cmd.Flags().String("chains", "", "Comma-separated chains: solana, eclipse (default: solana)") + cmd.Flags().Int("limit", 0, "Max results (1-1000, default 1000)") + cmd.Flags().String("offset", "", "Pagination cursor from previous response") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +// --- Response types --- + +type svmBalancesResponse struct { + ProcessingTimeMs float64 `json:"processing_time_ms,omitempty"` + WalletAddress string `json:"wallet_address"` + NextOffset string `json:"next_offset,omitempty"` + BalancesCount float64 `json:"balances_count,omitempty"` + Balances []svmBalanceEntry `json:"balances"` +} + +type svmBalanceEntry struct { + Chain string `json:"chain"` + Address string `json:"address"` + Amount string `json:"amount"` + Balance string `json:"balance,omitempty"` + RawBalance string `json:"raw_balance,omitempty"` + ValueUSD float64 `json:"value_usd,omitempty"` + ProgramID *string `json:"program_id,omitempty"` + Decimals float64 `json:"decimals,omitempty"` + TotalSupply string `json:"total_supply,omitempty"` + Name string `json:"name,omitempty"` + Symbol string `json:"symbol,omitempty"` + URI *string `json:"uri,omitempty"` + PriceUSD float64 `json:"price_usd,omitempty"` + LiquidityUSD float64 `json:"liquidity_usd,omitempty"` + PoolType *string `json:"pool_type,omitempty"` + PoolAddress *string `json:"pool_address,omitempty"` + MintAuthority *string `json:"mint_authority,omitempty"` +} + +func runBalances(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("chains"); v != "" { + params.Set("chains", 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(), "/beta/svm/balances/"+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 svmBalancesResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + if len(resp.Balances) == 0 { + fmt.Fprintln(w, "No balances found.") + return nil + } + + columns := []string{"CHAIN", "SYMBOL", "BALANCE", "PRICE_USD", "VALUE_USD"} + rows := make([][]string, len(resp.Balances)) + for i, b := range resp.Balances { + rows[i] = []string{ + b.Chain, + b.Symbol, + b.Balance, + formatUSD(b.PriceUSD), + formatUSD(b.ValueUSD), + } + } + output.PrintTable(w, columns, rows) + + if resp.NextOffset != "" { + fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset) + } + return nil + } +} + +// formatUSD formats a USD value for display. +func formatUSD(v float64) string { + if v == 0 { + return "0.00" + } + return fmt.Sprintf("%.2f", v) +} diff --git a/cmd/sim/svm/balances_test.go b/cmd/sim/svm/balances_test.go new file mode 100644 index 0000000..d9cfbbc --- /dev/null +++ b/cmd/sim/svm/balances_test.go @@ -0,0 +1,125 @@ +package svm_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSvmBalances_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", "balances", svmTestAddress}) + + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "CHAIN") + assert.Contains(t, out, "SYMBOL") + assert.Contains(t, out, "BALANCE") + assert.Contains(t, out, "PRICE_USD") + assert.Contains(t, out, "VALUE_USD") +} + +func TestSvmBalances_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", "balances", svmTestAddress, "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "wallet_address") + assert.Contains(t, resp, "balances") + + balances, ok := resp["balances"].([]interface{}) + require.True(t, ok) + if len(balances) > 0 { + b, ok := balances[0].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, b, "chain") + assert.Contains(t, b, "address") + assert.Contains(t, b, "amount") + } +} + +func TestSvmBalances_WithChains(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--chains", "solana", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "balances") + + // All balances should be on solana chain. + balances, ok := resp["balances"].([]interface{}) + require.True(t, ok) + for _, bal := range balances { + b, ok := bal.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "solana", b["chain"]) + } +} + +func TestSvmBalances_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", "balances", svmTestAddress, "--limit", "3", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + + balances, ok := resp["balances"].([]interface{}) + require.True(t, ok) + assert.LessOrEqual(t, len(balances), 3) +} + +func TestSvmBalances_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", "balances", 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, "balances") + + // 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", "balances", 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, "balances") + } +} diff --git a/cmd/sim/svm/helpers_test.go b/cmd/sim/svm/helpers_test.go new file mode 100644 index 0000000..376abb7 --- /dev/null +++ b/cmd/sim/svm/helpers_test.go @@ -0,0 +1,31 @@ +package svm_test + +import ( + "context" + "os" + "testing" + + "github.com/duneanalytics/cli/cmd/sim" + "github.com/spf13/cobra" +) + +// simAPIKey returns the DUNE_SIM_API_KEY env var or skips the test. +func simAPIKey(t *testing.T) string { + t.Helper() + key := os.Getenv("DUNE_SIM_API_KEY") + if key == "" { + t.Skip("DUNE_SIM_API_KEY not set, skipping e2e test") + } + return key +} + +const svmTestAddress = "86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY" + +// newSimTestRoot builds the full command tree: dune -> sim -> svm -> . +// Used for authenticated E2E tests. Pass the API key via --sim-api-key in SetArgs. +func newSimTestRoot() *cobra.Command { + root := &cobra.Command{Use: "dune"} + root.SetContext(context.Background()) + root.AddCommand(sim.NewSimCmd()) + return root +} diff --git a/cmd/sim/svm/svm.go b/cmd/sim/svm/svm.go index adc7078..70a0577 100644 --- a/cmd/sim/svm/svm.go +++ b/cmd/sim/svm/svm.go @@ -1,19 +1,53 @@ package svm import ( + "context" + "fmt" + "net/url" + + "github.com/duneanalytics/cli/cmdutil" "github.com/spf13/cobra" ) +// SimClient is the interface that svm commands use to talk to the Sim API. +// It is satisfied by *sim.SimClient (stored in the command context by +// the sim parent command's PersistentPreRunE). +type SimClient interface { + Get(ctx context.Context, path string, params url.Values) ([]byte, error) +} + +// SimClientFromCmd extracts the SimClient from the command context. +func SimClientFromCmd(cmd *cobra.Command) SimClient { + v := cmdutil.SimClientFromCmd(cmd) + if v == nil { + return nil + } + c, ok := v.(SimClient) + if !ok { + return nil + } + return c +} + +// requireSimClient extracts the SimClient or returns an error. +func requireSimClient(cmd *cobra.Command) (SimClient, error) { + c := SimClientFromCmd(cmd) + if c == nil { + return nil, fmt.Errorf("sim client not initialized") + } + return c, nil +} + // NewSvmCmd returns the `sim svm` parent command. func NewSvmCmd() *cobra.Command { cmd := &cobra.Command{ Use: "svm", - 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.", + Short: "Query SVM chain data (balances)", + Long: "Access real-time SVM blockchain data including token balances\n" + + "for Solana and Eclipse chains.", } - // Subcommands will be added here as they are implemented. + cmd.AddCommand(NewBalancesCmd()) return cmd }