Skip to content

Commit 9daf3f4

Browse files
committed
Add evm defi-positions command for DeFi portfolio positions
1 parent 711c99d commit 9daf3f4

4 files changed

Lines changed: 507 additions & 0 deletions

File tree

cmd/sim/evm/defi_positions.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package evm
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/url"
8+
"sort"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/duneanalytics/cli/output"
15+
)
16+
17+
// NewDefiPositionsCmd returns the `sim evm defi-positions` command.
18+
func NewDefiPositionsCmd() *cobra.Command {
19+
cmd := &cobra.Command{
20+
Use: "defi-positions <address>",
21+
Short: "Get DeFi positions for a wallet address",
22+
Long: "Return DeFi positions for the given wallet address including USD values,\n" +
23+
"position-specific metadata, and aggregation summaries across supported protocols.\n\n" +
24+
"Supported position types: Erc4626 (vaults), Tokenized (lending, e.g. aTokens),\n" +
25+
"UniswapV2 (AMM LP), Nft (Uniswap V3 NFT), NftV4 (Uniswap V4 NFT).\n\n" +
26+
"Examples:\n" +
27+
" dune sim evm defi-positions 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" +
28+
" dune sim evm defi-positions 0xd8da... --chain-ids 1,8453\n" +
29+
" dune sim evm defi-positions 0xd8da... -o json",
30+
Args: cobra.ExactArgs(1),
31+
RunE: runDefiPositions,
32+
}
33+
34+
cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)")
35+
output.AddFormatFlag(cmd, "text")
36+
37+
return cmd
38+
}
39+
40+
// --- Response types ---
41+
42+
type defiPositionsResponse struct {
43+
Positions []defiPosition `json:"positions"`
44+
Aggregations *defiAggregations `json:"aggregations,omitempty"`
45+
Warnings []warningEntry `json:"warnings,omitempty"`
46+
}
47+
48+
type defiAggregations struct {
49+
TotalUSDValue float64 `json:"total_usd_value"`
50+
TotalByChain map[string]float64 `json:"total_by_chain,omitempty"`
51+
}
52+
53+
// defiPosition is a flat struct matching the polymorphic DefiPosition schema.
54+
// Fields are optional depending on the `type` discriminator.
55+
type defiPosition struct {
56+
Type string `json:"type"`
57+
ChainID int64 `json:"chain_id"`
58+
USDVal float64 `json:"usd_value"`
59+
Logo *string `json:"logo,omitempty"`
60+
61+
// Erc4626 / Tokenized fields
62+
TokenType string `json:"token_type,omitempty"`
63+
Token string `json:"token,omitempty"`
64+
TokenName string `json:"token_name,omitempty"`
65+
TokenSymbol string `json:"token_symbol,omitempty"`
66+
UnderlyingToken string `json:"underlying_token,omitempty"`
67+
UnderlyingTokenName string `json:"underlying_token_name,omitempty"`
68+
UnderlyingTokenSymbol string `json:"underlying_token_symbol,omitempty"`
69+
UnderlyingTokenDecimals int `json:"underlying_token_decimals,omitempty"`
70+
71+
// Erc4626 / Tokenized / UniswapV2 fields
72+
CalculatedBalance float64 `json:"calculated_balance,omitempty"`
73+
PriceInUSD float64 `json:"price_in_usd,omitempty"`
74+
75+
// UniswapV2 / Nft / NftV4 fields
76+
Protocol string `json:"protocol,omitempty"`
77+
Pool string `json:"pool,omitempty"`
78+
PoolID []int `json:"pool_id,omitempty"`
79+
PoolManager string `json:"pool_manager,omitempty"`
80+
Salt []int `json:"salt,omitempty"`
81+
Token0 string `json:"token0,omitempty"`
82+
Token0Name string `json:"token0_name,omitempty"`
83+
Token0Symbol string `json:"token0_symbol,omitempty"`
84+
Token0Decimals int `json:"token0_decimals,omitempty"`
85+
Token1 string `json:"token1,omitempty"`
86+
Token1Name string `json:"token1_name,omitempty"`
87+
Token1Symbol string `json:"token1_symbol,omitempty"`
88+
Token1Decimals int `json:"token1_decimals,omitempty"`
89+
LPBalance string `json:"lp_balance,omitempty"`
90+
Token0Price float64 `json:"token0_price,omitempty"`
91+
Token1Price float64 `json:"token1_price,omitempty"`
92+
93+
// Nft / NftV4 concentrated liquidity positions
94+
Positions []nftPositionDetails `json:"positions,omitempty"`
95+
}
96+
97+
type nftPositionDetails struct {
98+
TickLower int `json:"tick_lower"`
99+
TickUpper int `json:"tick_upper"`
100+
TokenID string `json:"token_id"`
101+
Token0Price float64 `json:"token0_price"`
102+
Token0Holdings float64 `json:"token0_holdings,omitempty"`
103+
Token0Rewards float64 `json:"token0_rewards,omitempty"`
104+
Token1Price float64 `json:"token1_price"`
105+
Token1Holdings float64 `json:"token1_holdings,omitempty"`
106+
Token1Rewards float64 `json:"token1_rewards,omitempty"`
107+
}
108+
109+
func runDefiPositions(cmd *cobra.Command, args []string) error {
110+
client, err := requireSimClient(cmd)
111+
if err != nil {
112+
return err
113+
}
114+
115+
address := args[0]
116+
params := url.Values{}
117+
118+
if v, _ := cmd.Flags().GetString("chain-ids"); v != "" {
119+
params.Set("chain_ids", v)
120+
}
121+
122+
data, err := client.Get(cmd.Context(), "/beta/evm/defi/positions/"+address, params)
123+
if err != nil {
124+
return err
125+
}
126+
127+
w := cmd.OutOrStdout()
128+
switch output.FormatFromCmd(cmd) {
129+
case output.FormatJSON:
130+
var raw json.RawMessage = data
131+
return output.PrintJSON(w, raw)
132+
default:
133+
var resp defiPositionsResponse
134+
if err := json.Unmarshal(data, &resp); err != nil {
135+
return fmt.Errorf("parsing response: %w", err)
136+
}
137+
138+
// Print warnings to stderr.
139+
printWarnings(cmd, resp.Warnings)
140+
141+
if len(resp.Positions) == 0 {
142+
fmt.Fprintln(w, "No DeFi positions found.")
143+
return nil
144+
}
145+
146+
columns := []string{"TYPE", "CHAIN_ID", "PROTOCOL", "USD_VALUE", "DETAILS"}
147+
rows := make([][]string, len(resp.Positions))
148+
for i, p := range resp.Positions {
149+
rows[i] = []string{
150+
p.Type,
151+
fmt.Sprintf("%d", p.ChainID),
152+
p.Protocol,
153+
formatUSD(p.USDVal),
154+
positionDetails(p),
155+
}
156+
}
157+
output.PrintTable(w, columns, rows)
158+
159+
// Print aggregation summary.
160+
printAggregations(w, resp.Aggregations)
161+
162+
return nil
163+
}
164+
}
165+
166+
// positionDetails returns a human-readable summary for a DeFi position,
167+
// varying by position type.
168+
func positionDetails(p defiPosition) string {
169+
switch p.Type {
170+
case "Erc4626":
171+
parts := []string{}
172+
if p.TokenSymbol != "" {
173+
parts = append(parts, p.TokenSymbol)
174+
}
175+
if p.UnderlyingTokenSymbol != "" {
176+
parts = append(parts, fmt.Sprintf("-> %s", p.UnderlyingTokenSymbol))
177+
}
178+
if p.CalculatedBalance != 0 {
179+
parts = append(parts, fmt.Sprintf("bal=%.6g", p.CalculatedBalance))
180+
}
181+
return strings.Join(parts, " ")
182+
183+
case "Tokenized":
184+
parts := []string{}
185+
if p.TokenType != "" {
186+
parts = append(parts, p.TokenType)
187+
}
188+
if p.TokenSymbol != "" {
189+
parts = append(parts, p.TokenSymbol)
190+
}
191+
if p.CalculatedBalance != 0 {
192+
parts = append(parts, fmt.Sprintf("bal=%.6g", p.CalculatedBalance))
193+
}
194+
return strings.Join(parts, " ")
195+
196+
case "UniswapV2":
197+
pair := formatPair(p.Token0Symbol, p.Token1Symbol)
198+
if p.CalculatedBalance != 0 {
199+
return fmt.Sprintf("%s bal=%.6g", pair, p.CalculatedBalance)
200+
}
201+
return pair
202+
203+
case "Nft", "NftV4":
204+
pair := formatPair(p.Token0Symbol, p.Token1Symbol)
205+
nPos := len(p.Positions)
206+
if nPos == 1 {
207+
return fmt.Sprintf("%s (1 position)", pair)
208+
}
209+
if nPos > 1 {
210+
return fmt.Sprintf("%s (%d positions)", pair, nPos)
211+
}
212+
return pair
213+
214+
default:
215+
return ""
216+
}
217+
}
218+
219+
// formatPair returns "SYM0/SYM1" or falls back to individual symbols.
220+
func formatPair(sym0, sym1 string) string {
221+
if sym0 != "" && sym1 != "" {
222+
return sym0 + "/" + sym1
223+
}
224+
if sym0 != "" {
225+
return sym0
226+
}
227+
return sym1
228+
}
229+
230+
// printAggregations prints the aggregation summary after the positions table.
231+
func printAggregations(w io.Writer, agg *defiAggregations) {
232+
if agg == nil {
233+
return
234+
}
235+
236+
fmt.Fprintf(w, "\nTotal USD Value: %s\n", formatUSD(agg.TotalUSDValue))
237+
238+
if len(agg.TotalByChain) > 0 {
239+
fmt.Fprintln(w, "Breakdown by chain:")
240+
241+
// Sort chain IDs numerically for natural display order.
242+
chainIDs := make([]string, 0, len(agg.TotalByChain))
243+
for k := range agg.TotalByChain {
244+
chainIDs = append(chainIDs, k)
245+
}
246+
sort.Slice(chainIDs, func(i, j int) bool {
247+
a, errA := strconv.Atoi(chainIDs[i])
248+
b, errB := strconv.Atoi(chainIDs[j])
249+
if errA != nil || errB != nil {
250+
return chainIDs[i] < chainIDs[j] // fallback to lexicographic
251+
}
252+
return a < b
253+
})
254+
255+
for _, cid := range chainIDs {
256+
fmt.Fprintf(w, " Chain %s: %s\n", cid, formatUSD(agg.TotalByChain[cid]))
257+
}
258+
}
259+
}

cmd/sim/evm/defi_positions_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package evm_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 TestEvmDefiPositions_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, "evm", "defi-positions", evmTestAddress})
19+
20+
require.NoError(t, root.Execute())
21+
22+
out := buf.String()
23+
// Should contain table headers.
24+
assert.Contains(t, out, "TYPE")
25+
assert.Contains(t, out, "CHAIN_ID")
26+
assert.Contains(t, out, "USD_VALUE")
27+
assert.Contains(t, out, "DETAILS")
28+
}
29+
30+
func TestEvmDefiPositions_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, "evm", "defi-positions", evmTestAddress, "-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, "positions")
43+
44+
positions, ok := resp["positions"].([]interface{})
45+
require.True(t, ok)
46+
if len(positions) > 0 {
47+
p, ok := positions[0].(map[string]interface{})
48+
require.True(t, ok)
49+
assert.Contains(t, p, "type")
50+
assert.Contains(t, p, "chain_id")
51+
assert.Contains(t, p, "usd_value")
52+
}
53+
}
54+
55+
func TestEvmDefiPositions_WithChainIDs(t *testing.T) {
56+
key := simAPIKey(t)
57+
58+
root := newSimTestRoot()
59+
var buf bytes.Buffer
60+
root.SetOut(&buf)
61+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "--chain-ids", "1", "-o", "json"})
62+
63+
require.NoError(t, root.Execute())
64+
65+
var resp map[string]interface{}
66+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
67+
assert.Contains(t, resp, "positions")
68+
69+
// All positions should be on chain 1.
70+
positions, ok := resp["positions"].([]interface{})
71+
require.True(t, ok)
72+
for _, pos := range positions {
73+
p, ok := pos.(map[string]interface{})
74+
require.True(t, ok)
75+
chainID, ok := p["chain_id"].(float64)
76+
if ok {
77+
assert.Equal(t, float64(1), chainID)
78+
}
79+
}
80+
}
81+
82+
func TestEvmDefiPositions_Aggregations(t *testing.T) {
83+
key := simAPIKey(t)
84+
85+
root := newSimTestRoot()
86+
var buf bytes.Buffer
87+
root.SetOut(&buf)
88+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "-o", "json"})
89+
90+
require.NoError(t, root.Execute())
91+
92+
var resp map[string]interface{}
93+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
94+
95+
// Check aggregations object.
96+
agg, ok := resp["aggregations"].(map[string]interface{})
97+
if ok {
98+
assert.Contains(t, agg, "total_usd_value")
99+
}
100+
}
101+
102+
func TestEvmDefiPositions_TextAggregationSummary(t *testing.T) {
103+
key := simAPIKey(t)
104+
105+
// First check via JSON whether aggregations are present for this address.
106+
jsonRoot := newSimTestRoot()
107+
var jsonBuf bytes.Buffer
108+
jsonRoot.SetOut(&jsonBuf)
109+
jsonRoot.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "-o", "json"})
110+
require.NoError(t, jsonRoot.Execute())
111+
112+
var resp map[string]interface{}
113+
require.NoError(t, json.Unmarshal(jsonBuf.Bytes(), &resp))
114+
if _, ok := resp["aggregations"]; !ok {
115+
t.Skip("API did not return aggregations for this address, skipping text aggregation test")
116+
}
117+
118+
root := newSimTestRoot()
119+
var buf bytes.Buffer
120+
root.SetOut(&buf)
121+
root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress})
122+
123+
require.NoError(t, root.Execute())
124+
125+
out := buf.String()
126+
// When aggregations are present, the summary should appear in text output.
127+
assert.Contains(t, out, "Total USD Value:")
128+
}

0 commit comments

Comments
 (0)