diff --git a/cmd/sim/evm/defi_positions.go b/cmd/sim/evm/defi_positions.go new file mode 100644 index 0000000..9099f18 --- /dev/null +++ b/cmd/sim/evm/defi_positions.go @@ -0,0 +1,259 @@ +package evm + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/output" +) + +// NewDefiPositionsCmd returns the `sim evm defi-positions` command. +func NewDefiPositionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "defi-positions
", + Short: "Get DeFi positions for a wallet address", + Long: "Return DeFi positions for the given wallet address including USD values,\n" + + "position-specific metadata, and aggregation summaries across supported protocols.\n\n" + + "Supported position types: Erc4626 (vaults), Tokenized (lending, e.g. aTokens),\n" + + "UniswapV2 (AMM LP), Nft (Uniswap V3 NFT), NftV4 (Uniswap V4 NFT).\n\n" + + "Examples:\n" + + " dune sim evm defi-positions 0xd8da6bf26964af9d7eed9e03e53415d37aa96045\n" + + " dune sim evm defi-positions 0xd8da... --chain-ids 1,8453\n" + + " dune sim evm defi-positions 0xd8da... -o json", + Args: cobra.ExactArgs(1), + RunE: runDefiPositions, + } + + cmd.Flags().String("chain-ids", "", "Comma-separated chain IDs or tags (default: all default chains)") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +// --- Response types --- + +type defiPositionsResponse struct { + Positions []defiPosition `json:"positions"` + Aggregations *defiAggregations `json:"aggregations,omitempty"` + Warnings []warningEntry `json:"warnings,omitempty"` +} + +type defiAggregations struct { + TotalUSDValue float64 `json:"total_usd_value"` + TotalByChain map[string]float64 `json:"total_by_chain,omitempty"` +} + +// defiPosition is a flat struct matching the polymorphic DefiPosition schema. +// Fields are optional depending on the `type` discriminator. +type defiPosition struct { + Type string `json:"type"` + ChainID int64 `json:"chain_id"` + USDVal float64 `json:"usd_value"` + Logo *string `json:"logo,omitempty"` + + // Erc4626 / Tokenized fields + TokenType string `json:"token_type,omitempty"` + Token string `json:"token,omitempty"` + TokenName string `json:"token_name,omitempty"` + TokenSymbol string `json:"token_symbol,omitempty"` + UnderlyingToken string `json:"underlying_token,omitempty"` + UnderlyingTokenName string `json:"underlying_token_name,omitempty"` + UnderlyingTokenSymbol string `json:"underlying_token_symbol,omitempty"` + UnderlyingTokenDecimals int `json:"underlying_token_decimals,omitempty"` + + // Erc4626 / Tokenized / UniswapV2 fields + CalculatedBalance float64 `json:"calculated_balance,omitempty"` + PriceInUSD float64 `json:"price_in_usd,omitempty"` + + // UniswapV2 / Nft / NftV4 fields + Protocol string `json:"protocol,omitempty"` + Pool string `json:"pool,omitempty"` + PoolID []int `json:"pool_id,omitempty"` + PoolManager string `json:"pool_manager,omitempty"` + Salt []int `json:"salt,omitempty"` + Token0 string `json:"token0,omitempty"` + Token0Name string `json:"token0_name,omitempty"` + Token0Symbol string `json:"token0_symbol,omitempty"` + Token0Decimals int `json:"token0_decimals,omitempty"` + Token1 string `json:"token1,omitempty"` + Token1Name string `json:"token1_name,omitempty"` + Token1Symbol string `json:"token1_symbol,omitempty"` + Token1Decimals int `json:"token1_decimals,omitempty"` + LPBalance string `json:"lp_balance,omitempty"` + Token0Price float64 `json:"token0_price,omitempty"` + Token1Price float64 `json:"token1_price,omitempty"` + + // Nft / NftV4 concentrated liquidity positions + Positions []nftPositionDetails `json:"positions,omitempty"` +} + +type nftPositionDetails struct { + TickLower int `json:"tick_lower"` + TickUpper int `json:"tick_upper"` + TokenID string `json:"token_id"` + Token0Price float64 `json:"token0_price"` + Token0Holdings float64 `json:"token0_holdings,omitempty"` + Token0Rewards float64 `json:"token0_rewards,omitempty"` + Token1Price float64 `json:"token1_price"` + Token1Holdings float64 `json:"token1_holdings,omitempty"` + Token1Rewards float64 `json:"token1_rewards,omitempty"` +} + +func runDefiPositions(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("chain-ids"); v != "" { + params.Set("chain_ids", v) + } + + data, err := client.Get(cmd.Context(), "/beta/evm/defi/positions/"+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 defiPositionsResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + // Print warnings to stderr. + printWarnings(cmd, resp.Warnings) + + if len(resp.Positions) == 0 { + fmt.Fprintln(w, "No DeFi positions found.") + return nil + } + + columns := []string{"TYPE", "CHAIN_ID", "PROTOCOL", "USD_VALUE", "DETAILS"} + rows := make([][]string, len(resp.Positions)) + for i, p := range resp.Positions { + rows[i] = []string{ + p.Type, + fmt.Sprintf("%d", p.ChainID), + p.Protocol, + formatUSD(p.USDVal), + positionDetails(p), + } + } + output.PrintTable(w, columns, rows) + + // Print aggregation summary. + printAggregations(w, resp.Aggregations) + + return nil + } +} + +// positionDetails returns a human-readable summary for a DeFi position, +// varying by position type. +func positionDetails(p defiPosition) string { + switch p.Type { + case "Erc4626": + parts := []string{} + if p.TokenSymbol != "" { + parts = append(parts, p.TokenSymbol) + } + if p.UnderlyingTokenSymbol != "" { + parts = append(parts, fmt.Sprintf("-> %s", p.UnderlyingTokenSymbol)) + } + if p.CalculatedBalance != 0 { + parts = append(parts, fmt.Sprintf("bal=%.6g", p.CalculatedBalance)) + } + return strings.Join(parts, " ") + + case "Tokenized": + parts := []string{} + if p.TokenType != "" { + parts = append(parts, p.TokenType) + } + if p.TokenSymbol != "" { + parts = append(parts, p.TokenSymbol) + } + if p.CalculatedBalance != 0 { + parts = append(parts, fmt.Sprintf("bal=%.6g", p.CalculatedBalance)) + } + return strings.Join(parts, " ") + + case "UniswapV2": + pair := formatPair(p.Token0Symbol, p.Token1Symbol) + if p.CalculatedBalance != 0 { + return fmt.Sprintf("%s bal=%.6g", pair, p.CalculatedBalance) + } + return pair + + case "Nft", "NftV4": + pair := formatPair(p.Token0Symbol, p.Token1Symbol) + nPos := len(p.Positions) + if nPos == 1 { + return fmt.Sprintf("%s (1 position)", pair) + } + if nPos > 1 { + return fmt.Sprintf("%s (%d positions)", pair, nPos) + } + return pair + + default: + return "" + } +} + +// formatPair returns "SYM0/SYM1" or falls back to individual symbols. +func formatPair(sym0, sym1 string) string { + if sym0 != "" && sym1 != "" { + return sym0 + "/" + sym1 + } + if sym0 != "" { + return sym0 + } + return sym1 +} + +// printAggregations prints the aggregation summary after the positions table. +func printAggregations(w io.Writer, agg *defiAggregations) { + if agg == nil { + return + } + + fmt.Fprintf(w, "\nTotal USD Value: %s\n", formatUSD(agg.TotalUSDValue)) + + if len(agg.TotalByChain) > 0 { + fmt.Fprintln(w, "Breakdown by chain:") + + // Sort chain IDs numerically for natural display order. + chainIDs := make([]string, 0, len(agg.TotalByChain)) + for k := range agg.TotalByChain { + chainIDs = append(chainIDs, k) + } + sort.Slice(chainIDs, func(i, j int) bool { + a, errA := strconv.Atoi(chainIDs[i]) + b, errB := strconv.Atoi(chainIDs[j]) + if errA != nil || errB != nil { + return chainIDs[i] < chainIDs[j] // fallback to lexicographic + } + return a < b + }) + + for _, cid := range chainIDs { + fmt.Fprintf(w, " Chain %s: %s\n", cid, formatUSD(agg.TotalByChain[cid])) + } + } +} diff --git a/cmd/sim/evm/defi_positions_test.go b/cmd/sim/evm/defi_positions_test.go new file mode 100644 index 0000000..df86b59 --- /dev/null +++ b/cmd/sim/evm/defi_positions_test.go @@ -0,0 +1,128 @@ +package evm_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvmDefiPositions_Text(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress}) + + require.NoError(t, root.Execute()) + + out := buf.String() + // Should contain table headers. + assert.Contains(t, out, "TYPE") + assert.Contains(t, out, "CHAIN_ID") + assert.Contains(t, out, "USD_VALUE") + assert.Contains(t, out, "DETAILS") +} + +func TestEvmDefiPositions_JSON(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "positions") + + positions, ok := resp["positions"].([]interface{}) + require.True(t, ok) + if len(positions) > 0 { + p, ok := positions[0].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, p, "type") + assert.Contains(t, p, "chain_id") + assert.Contains(t, p, "usd_value") + } +} + +func TestEvmDefiPositions_WithChainIDs(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "--chain-ids", "1", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Contains(t, resp, "positions") + + // All positions should be on chain 1. + positions, ok := resp["positions"].([]interface{}) + require.True(t, ok) + for _, pos := range positions { + p, ok := pos.(map[string]interface{}) + require.True(t, ok) + chainID, ok := p["chain_id"].(float64) + if ok { + assert.Equal(t, float64(1), chainID) + } + } +} + +func TestEvmDefiPositions_Aggregations(t *testing.T) { + key := simAPIKey(t) + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + + // Check aggregations object. + agg, ok := resp["aggregations"].(map[string]interface{}) + if ok { + assert.Contains(t, agg, "total_usd_value") + } +} + +func TestEvmDefiPositions_TextAggregationSummary(t *testing.T) { + key := simAPIKey(t) + + // First check via JSON whether aggregations are present for this address. + jsonRoot := newSimTestRoot() + var jsonBuf bytes.Buffer + jsonRoot.SetOut(&jsonBuf) + jsonRoot.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress, "-o", "json"}) + require.NoError(t, jsonRoot.Execute()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(jsonBuf.Bytes(), &resp)) + if _, ok := resp["aggregations"]; !ok { + t.Skip("API did not return aggregations for this address, skipping text aggregation test") + } + + root := newSimTestRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "--sim-api-key", key, "evm", "defi-positions", evmTestAddress}) + + require.NoError(t, root.Execute()) + + out := buf.String() + // When aggregations are present, the summary should appear in text output. + assert.Contains(t, out, "Total USD Value:") +} diff --git a/cmd/sim/evm/defi_positions_unit_test.go b/cmd/sim/evm/defi_positions_unit_test.go new file mode 100644 index 0000000..f596a58 --- /dev/null +++ b/cmd/sim/evm/defi_positions_unit_test.go @@ -0,0 +1,119 @@ +package evm + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPositionDetails_Erc4626(t *testing.T) { + p := defiPosition{ + Type: "Erc4626", + TokenSymbol: "alUSD", + UnderlyingTokenSymbol: "USDC", + CalculatedBalance: 0.0673736869415349, + } + got := positionDetails(p) + assert.Contains(t, got, "alUSD") + assert.Contains(t, got, "-> USDC") + assert.Contains(t, got, "bal=") +} + +func TestPositionDetails_Erc4626_NoBalance(t *testing.T) { + p := defiPosition{ + Type: "Erc4626", + TokenSymbol: "yvDAI", + UnderlyingTokenSymbol: "DAI", + } + assert.Equal(t, "yvDAI -> DAI", positionDetails(p)) +} + +func TestPositionDetails_Tokenized(t *testing.T) { + p := defiPosition{ + Type: "Tokenized", + TokenType: "AtokenV2", + TokenSymbol: "aWETH", + CalculatedBalance: 0.0496171604159423, + } + got := positionDetails(p) + assert.Contains(t, got, "AtokenV2") + assert.Contains(t, got, "aWETH") + assert.Contains(t, got, "bal=") +} + +func TestPositionDetails_Tokenized_NoBalance(t *testing.T) { + p := defiPosition{ + Type: "Tokenized", + TokenType: "AtokenV2", + TokenSymbol: "aWBTC", + } + assert.Equal(t, "AtokenV2 aWBTC", positionDetails(p)) +} + +func TestPositionDetails_UniswapV2(t *testing.T) { + p := defiPosition{ + Type: "UniswapV2", + Token0Symbol: "FWOG", + Token1Symbol: "WETH", + CalculatedBalance: 59754, + } + got := positionDetails(p) + assert.Contains(t, got, "FWOG/WETH") + assert.Contains(t, got, "bal=59754") +} + +func TestPositionDetails_UniswapV2_NoBalance(t *testing.T) { + p := defiPosition{ + Type: "UniswapV2", + Token0Symbol: "USDC", + Token1Symbol: "WETH", + } + assert.Equal(t, "USDC/WETH", positionDetails(p)) +} + +func TestPositionDetails_Nft(t *testing.T) { + p := defiPosition{ + Type: "Nft", + Token0Symbol: "WETH", + Token1Symbol: "USDC", + Positions: []nftPositionDetails{ + {TickLower: -100, TickUpper: 100, TokenID: "0x1"}, + {TickLower: -200, TickUpper: 200, TokenID: "0x2"}, + {TickLower: -300, TickUpper: 300, TokenID: "0x3"}, + }, + } + assert.Equal(t, "WETH/USDC (3 positions)", positionDetails(p)) +} + +func TestPositionDetails_NftV4(t *testing.T) { + p := defiPosition{ + Type: "NftV4", + Token0Symbol: "WBTC", + Token1Symbol: "WETH", + Positions: []nftPositionDetails{ + {TickLower: -50, TickUpper: 50, TokenID: "0xabc"}, + }, + } + assert.Equal(t, "WBTC/WETH (1 position)", positionDetails(p)) +} + +func TestPositionDetails_NftNoPositions(t *testing.T) { + p := defiPosition{ + Type: "Nft", + Token0Symbol: "DAI", + Token1Symbol: "USDC", + } + assert.Equal(t, "DAI/USDC", positionDetails(p)) +} + +func TestPositionDetails_Unknown(t *testing.T) { + p := defiPosition{Type: "SomeNewType"} + assert.Equal(t, "", positionDetails(p)) +} + +func TestFormatPair(t *testing.T) { + assert.Equal(t, "WETH/USDC", formatPair("WETH", "USDC")) + assert.Equal(t, "WETH", formatPair("WETH", "")) + assert.Equal(t, "USDC", formatPair("", "USDC")) + assert.Equal(t, "", formatPair("", "")) +} diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go index 69d8f2f..1329f2f 100644 --- a/cmd/sim/evm/evm.go +++ b/cmd/sim/evm/evm.go @@ -47,6 +47,7 @@ func NewEvmCmd() *cobra.Command { cmd.AddCommand(NewCollectiblesCmd()) cmd.AddCommand(NewTokenInfoCmd()) cmd.AddCommand(NewTokenHoldersCmd()) + cmd.AddCommand(NewDefiPositionsCmd()) return cmd }