Skip to content

Commit b3b1bb2

Browse files
authored
Add svm balances command with SimClient wiring (#38)
1 parent 1df3c1d commit b3b1bb2

10 files changed

Lines changed: 341 additions & 23 deletions

File tree

cmd/sim/evm/activity.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ func runActivity(cmd *cobra.Command, args []string) error {
152152
a.Type,
153153
a.AssetType,
154154
activitySymbol(a),
155-
formatUSD(a.ValueUSD),
155+
output.FormatUSD(a.ValueUSD),
156156
truncateHash(a.TxHash),
157157
a.BlockTime,
158158
}

cmd/sim/evm/balance.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ func runBalance(cmd *cobra.Command, args []string) error {
8989
}
9090
fmt.Fprintf(w, "Decimals: %d\n", b.Decimals)
9191
fmt.Fprintf(w, "Amount: %s\n", formatAmount(b.Amount, b.Decimals))
92-
fmt.Fprintf(w, "Price USD: %s\n", formatUSD(b.PriceUSD))
93-
fmt.Fprintf(w, "Value USD: %s\n", formatUSD(b.ValueUSD))
92+
fmt.Fprintf(w, "Price USD: %s\n", output.FormatUSD(b.PriceUSD))
93+
fmt.Fprintf(w, "Value USD: %s\n", output.FormatUSD(b.ValueUSD))
9494

9595
return nil
9696
}

cmd/sim/evm/balances.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ func runBalancesEndpoint(cmd *cobra.Command, args []string, pathPrefix, pathSuff
183183
b.Chain,
184184
b.Symbol,
185185
formatAmount(b.Amount, b.Decimals),
186-
formatUSD(b.PriceUSD),
187-
formatUSD(b.ValueUSD),
186+
output.FormatUSD(b.PriceUSD),
187+
output.FormatUSD(b.ValueUSD),
188188
}
189189
}
190190
output.PrintTable(w, columns, rows)
@@ -245,14 +245,6 @@ func formatAmount(raw string, decimals int) string {
245245
return intPart + "." + fracPart
246246
}
247247

248-
// formatUSD formats a USD value for display.
249-
func formatUSD(v float64) string {
250-
if v == 0 {
251-
return "0.00"
252-
}
253-
return fmt.Sprintf("%.2f", v)
254-
}
255-
256248
// printBalanceErrors writes balance-level errors to stderr.
257249
func printBalanceErrors(cmd *cobra.Command, errs *balanceErrors) {
258250
if errs == nil {

cmd/sim/evm/defi_positions.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func runDefiPositions(cmd *cobra.Command, args []string) error {
150150
p.Type,
151151
fmt.Sprintf("%d", p.ChainID),
152152
p.Protocol,
153-
formatUSD(p.USDVal),
153+
output.FormatUSD(p.USDVal),
154154
positionDetails(p),
155155
}
156156
}
@@ -233,7 +233,7 @@ func printAggregations(w io.Writer, agg *defiAggregations) {
233233
return
234234
}
235235

236-
fmt.Fprintf(w, "\nTotal USD Value: %s\n", formatUSD(agg.TotalUSDValue))
236+
fmt.Fprintf(w, "\nTotal USD Value: %s\n", output.FormatUSD(agg.TotalUSDValue))
237237

238238
if len(agg.TotalByChain) > 0 {
239239
fmt.Fprintln(w, "Breakdown by chain:")
@@ -253,7 +253,7 @@ func printAggregations(w io.Writer, agg *defiAggregations) {
253253
})
254254

255255
for _, cid := range chainIDs {
256-
fmt.Fprintf(w, " Chain %s: %s\n", cid, formatUSD(agg.TotalByChain[cid]))
256+
fmt.Fprintf(w, " Chain %s: %s\n", cid, output.FormatUSD(agg.TotalByChain[cid]))
257257
}
258258
}
259259
}

cmd/sim/evm/token_info.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,18 +111,18 @@ func runTokenInfo(cmd *cobra.Command, args []string) error {
111111
fmt.Fprintf(w, "Name: %s\n", t.Name)
112112
}
113113
fmt.Fprintf(w, "Decimals: %d\n", t.Decimals)
114-
fmt.Fprintf(w, "Price USD: %s\n", formatUSD(t.PriceUSD))
114+
fmt.Fprintf(w, "Price USD: %s\n", output.FormatUSD(t.PriceUSD))
115115
if t.TotalSupply != "" {
116116
fmt.Fprintf(w, "Total Supply: %s\n", t.TotalSupply)
117117
}
118118
if t.MarketCap > 0 {
119-
fmt.Fprintf(w, "Market Cap: %s\n", formatUSD(t.MarketCap))
119+
fmt.Fprintf(w, "Market Cap: %s\n", output.FormatUSD(t.MarketCap))
120120
}
121121
if t.Logo != "" {
122122
fmt.Fprintf(w, "Logo: %s\n", t.Logo)
123123
}
124124
for _, hp := range t.HistoricalPrices {
125-
fmt.Fprintf(w, "Price %dh ago: %s\n", hp.OffsetHours, formatUSD(hp.PriceUSD))
125+
fmt.Fprintf(w, "Price %dh ago: %s\n", hp.OffsetHours, output.FormatUSD(hp.PriceUSD))
126126
}
127127
}
128128

cmd/sim/svm/balances.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
bal := b.Balance
110+
if bal == "" {
111+
bal = b.Amount
112+
}
113+
rows[i] = []string{
114+
b.Chain,
115+
b.Symbol,
116+
bal,
117+
output.FormatUSD(b.PriceUSD),
118+
output.FormatUSD(b.ValueUSD),
119+
}
120+
}
121+
output.PrintTable(w, columns, rows)
122+
123+
if resp.NextOffset != "" {
124+
fmt.Fprintf(w, "\nNext offset: %s\n", resp.NextOffset)
125+
}
126+
return nil
127+
}
128+
}

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+
}

0 commit comments

Comments
 (0)