Skip to content

Commit bee2c11

Browse files
committed
Add svm balances command with SimClient wiring
1 parent 9daf3f4 commit bee2c11

File tree

4 files changed

+326
-4
lines changed

4 files changed

+326
-4
lines changed

cmd/sim/svm/balances.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package svm
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+
// NewBalancesCmd returns the `sim svm balances` command.
14+
func NewBalancesCmd() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "balances <address>",
17+
Short: "Get SVM token balances for a wallet address",
18+
Long: "Return token balances for the given SVM wallet address across\n" +
19+
"Solana and Eclipse chains, including USD valuations.\n\n" +
20+
"Examples:\n" +
21+
" dune sim svm balances 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY\n" +
22+
" dune sim svm balances 86xCnPeV... --chains solana,eclipse\n" +
23+
" dune sim svm balances 86xCnPeV... --limit 50 -o json",
24+
Args: cobra.ExactArgs(1),
25+
RunE: runBalances,
26+
}
27+
28+
cmd.Flags().String("chains", "", "Comma-separated chains: solana, eclipse (default: solana)")
29+
cmd.Flags().Int("limit", 0, "Max results (1-1000, default 1000)")
30+
cmd.Flags().String("offset", "", "Pagination cursor from previous response")
31+
output.AddFormatFlag(cmd, "text")
32+
33+
return cmd
34+
}
35+
36+
// --- Response types ---
37+
38+
type svmBalancesResponse struct {
39+
ProcessingTimeMs float64 `json:"processing_time_ms,omitempty"`
40+
WalletAddress string `json:"wallet_address"`
41+
NextOffset string `json:"next_offset,omitempty"`
42+
BalancesCount float64 `json:"balances_count,omitempty"`
43+
Balances []svmBalanceEntry `json:"balances"`
44+
}
45+
46+
type svmBalanceEntry struct {
47+
Chain string `json:"chain"`
48+
Address string `json:"address"`
49+
Amount string `json:"amount"`
50+
Balance string `json:"balance,omitempty"`
51+
RawBalance string `json:"raw_balance,omitempty"`
52+
ValueUSD float64 `json:"value_usd,omitempty"`
53+
ProgramID *string `json:"program_id,omitempty"`
54+
Decimals float64 `json:"decimals,omitempty"`
55+
TotalSupply string `json:"total_supply,omitempty"`
56+
Name string `json:"name,omitempty"`
57+
Symbol string `json:"symbol,omitempty"`
58+
URI *string `json:"uri,omitempty"`
59+
PriceUSD float64 `json:"price_usd,omitempty"`
60+
LiquidityUSD float64 `json:"liquidity_usd,omitempty"`
61+
PoolType *string `json:"pool_type,omitempty"`
62+
PoolAddress *string `json:"pool_address,omitempty"`
63+
MintAuthority *string `json:"mint_authority,omitempty"`
64+
}
65+
66+
func runBalances(cmd *cobra.Command, args []string) error {
67+
client, err := requireSimClient(cmd)
68+
if err != nil {
69+
return err
70+
}
71+
72+
address := args[0]
73+
params := url.Values{}
74+
75+
if v, _ := cmd.Flags().GetString("chains"); v != "" {
76+
params.Set("chains", v)
77+
}
78+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
79+
params.Set("limit", fmt.Sprintf("%d", v))
80+
}
81+
if v, _ := cmd.Flags().GetString("offset"); v != "" {
82+
params.Set("offset", v)
83+
}
84+
85+
data, err := client.Get(cmd.Context(), "/beta/svm/balances/"+address, params)
86+
if err != nil {
87+
return err
88+
}
89+
90+
w := cmd.OutOrStdout()
91+
switch output.FormatFromCmd(cmd) {
92+
case output.FormatJSON:
93+
var raw json.RawMessage = data
94+
return output.PrintJSON(w, raw)
95+
default:
96+
var resp svmBalancesResponse
97+
if err := json.Unmarshal(data, &resp); err != nil {
98+
return fmt.Errorf("parsing response: %w", err)
99+
}
100+
101+
if len(resp.Balances) == 0 {
102+
fmt.Fprintln(w, "No balances found.")
103+
return nil
104+
}
105+
106+
columns := []string{"CHAIN", "SYMBOL", "BALANCE", "PRICE_USD", "VALUE_USD"}
107+
rows := make([][]string, len(resp.Balances))
108+
for i, b := range resp.Balances {
109+
rows[i] = []string{
110+
b.Chain,
111+
b.Symbol,
112+
b.Balance,
113+
formatUSD(b.PriceUSD),
114+
formatUSD(b.ValueUSD),
115+
}
116+
}
117+
output.PrintTable(w, columns, rows)
118+
119+
if resp.NextOffset != "" {
120+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
121+
}
122+
return nil
123+
}
124+
}
125+
126+
// formatUSD formats a USD value for display.
127+
func formatUSD(v float64) string {
128+
if v == 0 {
129+
return "0.00"
130+
}
131+
return fmt.Sprintf("%.2f", v)
132+
}

cmd/sim/svm/balances_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package svm_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 TestSvmBalances_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, "svm", "balances", svmTestAddress})
19+
20+
require.NoError(t, root.Execute())
21+
22+
out := buf.String()
23+
assert.Contains(t, out, "CHAIN")
24+
assert.Contains(t, out, "SYMBOL")
25+
assert.Contains(t, out, "BALANCE")
26+
assert.Contains(t, out, "PRICE_USD")
27+
assert.Contains(t, out, "VALUE_USD")
28+
}
29+
30+
func TestSvmBalances_JSON(t *testing.T) {
31+
key := simAPIKey(t)
32+
33+
root := newSimTestRoot()
34+
var buf bytes.Buffer
35+
root.SetOut(&buf)
36+
root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "-o", "json"})
37+
38+
require.NoError(t, root.Execute())
39+
40+
var resp map[string]interface{}
41+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
42+
assert.Contains(t, resp, "wallet_address")
43+
assert.Contains(t, resp, "balances")
44+
45+
balances, ok := resp["balances"].([]interface{})
46+
require.True(t, ok)
47+
if len(balances) > 0 {
48+
b, ok := balances[0].(map[string]interface{})
49+
require.True(t, ok)
50+
assert.Contains(t, b, "chain")
51+
assert.Contains(t, b, "address")
52+
assert.Contains(t, b, "amount")
53+
}
54+
}
55+
56+
func TestSvmBalances_WithChains(t *testing.T) {
57+
key := simAPIKey(t)
58+
59+
root := newSimTestRoot()
60+
var buf bytes.Buffer
61+
root.SetOut(&buf)
62+
root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--chains", "solana", "-o", "json"})
63+
64+
require.NoError(t, root.Execute())
65+
66+
var resp map[string]interface{}
67+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
68+
assert.Contains(t, resp, "balances")
69+
70+
// All balances should be on solana chain.
71+
balances, ok := resp["balances"].([]interface{})
72+
require.True(t, ok)
73+
for _, bal := range balances {
74+
b, ok := bal.(map[string]interface{})
75+
require.True(t, ok)
76+
assert.Equal(t, "solana", b["chain"])
77+
}
78+
}
79+
80+
func TestSvmBalances_Limit(t *testing.T) {
81+
key := simAPIKey(t)
82+
83+
root := newSimTestRoot()
84+
var buf bytes.Buffer
85+
root.SetOut(&buf)
86+
root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--limit", "3", "-o", "json"})
87+
88+
require.NoError(t, root.Execute())
89+
90+
var resp map[string]interface{}
91+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
92+
93+
balances, ok := resp["balances"].([]interface{})
94+
require.True(t, ok)
95+
assert.LessOrEqual(t, len(balances), 3)
96+
}
97+
98+
func TestSvmBalances_Pagination(t *testing.T) {
99+
key := simAPIKey(t)
100+
101+
root := newSimTestRoot()
102+
var buf bytes.Buffer
103+
root.SetOut(&buf)
104+
root.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--limit", "2", "-o", "json"})
105+
106+
require.NoError(t, root.Execute())
107+
108+
var resp map[string]interface{}
109+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
110+
assert.Contains(t, resp, "balances")
111+
112+
// If next_offset is present, fetch page 2.
113+
if offset, ok := resp["next_offset"].(string); ok && offset != "" {
114+
root2 := newSimTestRoot()
115+
var buf2 bytes.Buffer
116+
root2.SetOut(&buf2)
117+
root2.SetArgs([]string{"sim", "--sim-api-key", key, "svm", "balances", svmTestAddress, "--limit", "2", "--offset", offset, "-o", "json"})
118+
119+
require.NoError(t, root2.Execute())
120+
121+
var resp2 map[string]interface{}
122+
require.NoError(t, json.Unmarshal(buf2.Bytes(), &resp2))
123+
assert.Contains(t, resp2, "balances")
124+
}
125+
}

cmd/sim/svm/helpers_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package svm_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"testing"
7+
8+
"github.com/duneanalytics/cli/cmd/sim"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// simAPIKey returns the DUNE_SIM_API_KEY env var or skips the test.
13+
func simAPIKey(t *testing.T) string {
14+
t.Helper()
15+
key := os.Getenv("DUNE_SIM_API_KEY")
16+
if key == "" {
17+
t.Skip("DUNE_SIM_API_KEY not set, skipping e2e test")
18+
}
19+
return key
20+
}
21+
22+
const svmTestAddress = "86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY"
23+
24+
// newSimTestRoot builds the full command tree: dune -> sim -> svm -> <subcommands>.
25+
// Used for authenticated E2E tests. Pass the API key via --sim-api-key in SetArgs.
26+
func newSimTestRoot() *cobra.Command {
27+
root := &cobra.Command{Use: "dune"}
28+
root.SetContext(context.Background())
29+
root.AddCommand(sim.NewSimCmd())
30+
return root
31+
}

cmd/sim/svm/svm.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,53 @@
11
package svm
22

33
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
8+
"github.com/duneanalytics/cli/cmdutil"
49
"github.com/spf13/cobra"
510
)
611

12+
// SimClient is the interface that svm commands use to talk to the Sim API.
13+
// It is satisfied by *sim.SimClient (stored in the command context by
14+
// the sim parent command's PersistentPreRunE).
15+
type SimClient interface {
16+
Get(ctx context.Context, path string, params url.Values) ([]byte, error)
17+
}
18+
19+
// SimClientFromCmd extracts the SimClient from the command context.
20+
func SimClientFromCmd(cmd *cobra.Command) SimClient {
21+
v := cmdutil.SimClientFromCmd(cmd)
22+
if v == nil {
23+
return nil
24+
}
25+
c, ok := v.(SimClient)
26+
if !ok {
27+
return nil
28+
}
29+
return c
30+
}
31+
32+
// requireSimClient extracts the SimClient or returns an error.
33+
func requireSimClient(cmd *cobra.Command) (SimClient, error) {
34+
c := SimClientFromCmd(cmd)
35+
if c == nil {
36+
return nil, fmt.Errorf("sim client not initialized")
37+
}
38+
return c, nil
39+
}
40+
741
// NewSvmCmd returns the `sim svm` parent command.
842
func NewSvmCmd() *cobra.Command {
943
cmd := &cobra.Command{
1044
Use: "svm",
11-
Short: "Query SVM chain data (balances, transactions)",
12-
Long: "Access real-time SVM blockchain data including token balances and\n" +
13-
"transaction history for Solana and Eclipse chains.",
45+
Short: "Query SVM chain data (balances)",
46+
Long: "Access real-time SVM blockchain data including token balances\n" +
47+
"for Solana and Eclipse chains.",
1448
}
1549

16-
// Subcommands will be added here as they are implemented.
50+
cmd.AddCommand(NewBalancesCmd())
1751

1852
return cmd
1953
}

0 commit comments

Comments
 (0)