Skip to content

Commit 10c33da

Browse files
committed
Merge branch 'main' into sim/cli_descriptions
2 parents 4d17183 + 272151c commit 10c33da

10 files changed

Lines changed: 556 additions & 157 deletions

File tree

authconfig/identity.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package authconfig
2+
3+
import (
4+
"crypto/sha256"
5+
"errors"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
// UserIdentity holds the cached identity resolved from the API key.
15+
type UserIdentity struct {
16+
CustomerID string `yaml:"customer_id"` // e.g. "user_123" or "team_456"
17+
APIKeyHash string `yaml:"api_key_hash"` // SHA-256 of the API key for cache invalidation
18+
}
19+
20+
// identityFileName is the file storing the cached user identity.
21+
const identityFileName = "user_identity.yaml"
22+
23+
// LoadIdentity reads the cached user identity. Returns nil, nil if the file does not exist.
24+
func LoadIdentity() (*UserIdentity, error) {
25+
dir, err := Dir()
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
data, err := os.ReadFile(filepath.Join(dir, identityFileName))
31+
if err != nil {
32+
if errors.Is(err, fs.ErrNotExist) {
33+
return nil, nil
34+
}
35+
return nil, err
36+
}
37+
38+
var id UserIdentity
39+
if err := yaml.Unmarshal(data, &id); err != nil {
40+
return nil, err
41+
}
42+
return &id, nil
43+
}
44+
45+
// SaveIdentity writes the user identity cache to disk.
46+
func SaveIdentity(id *UserIdentity) error {
47+
dir, err := Dir()
48+
if err != nil {
49+
return err
50+
}
51+
52+
if err := os.MkdirAll(dir, 0o700); err != nil {
53+
return err
54+
}
55+
56+
data, err := yaml.Marshal(id)
57+
if err != nil {
58+
return err
59+
}
60+
61+
return os.WriteFile(filepath.Join(dir, identityFileName), data, 0o600)
62+
}
63+
64+
// HashAPIKey returns the hex-encoded SHA-256 hash of an API key.
65+
func HashAPIKey(apiKey string) string {
66+
h := sha256.Sum256([]byte(apiKey))
67+
return fmt.Sprintf("%x", h)
68+
}

authconfig/identity_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package authconfig_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/duneanalytics/cli/authconfig"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestSaveAndLoadIdentity(t *testing.T) {
12+
setupTempDir(t)
13+
14+
want := &authconfig.UserIdentity{
15+
CustomerID: "user_42",
16+
APIKeyHash: authconfig.HashAPIKey("test-api-key"),
17+
}
18+
require.NoError(t, authconfig.SaveIdentity(want))
19+
20+
got, err := authconfig.LoadIdentity()
21+
require.NoError(t, err)
22+
assert.Equal(t, want, got)
23+
}
24+
25+
func TestLoadIdentityNonExistent(t *testing.T) {
26+
setupTempDir(t)
27+
28+
id, err := authconfig.LoadIdentity()
29+
assert.NoError(t, err)
30+
assert.Nil(t, id)
31+
}
32+
33+
func TestHashAPIKeyDeterministic(t *testing.T) {
34+
h1 := authconfig.HashAPIKey("my-key")
35+
h2 := authconfig.HashAPIKey("my-key")
36+
assert.Equal(t, h1, h2)
37+
assert.NotEqual(t, h1, authconfig.HashAPIKey("other-key"))
38+
}

cli/root.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/duneanalytics/cli/cmd/query"
2323
"github.com/duneanalytics/cli/cmd/sim"
2424
"github.com/duneanalytics/cli/cmd/usage"
25+
"github.com/duneanalytics/cli/cmd/whoami"
2526
"github.com/duneanalytics/cli/cmdutil"
2627
"github.com/duneanalytics/cli/tracking"
2728
)
@@ -77,6 +78,13 @@ var rootCmd = &cobra.Command{
7778
client := dune.NewDuneClient(env)
7879
cmdutil.SetClient(cmd, client)
7980

81+
// Resolve customer identity for analytics (best-effort, never blocks the CLI).
82+
if tr := cmdutil.TrackerFromCmd(cmd); tr != nil {
83+
if customerID := resolveCustomerID(client, env.APIKey); customerID != "" {
84+
tr.SetUserID(customerID)
85+
}
86+
}
87+
8088
return nil
8189
},
8290
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
@@ -93,7 +101,8 @@ var rootCmd = &cobra.Command{
93101
commandPath = parts[1]
94102
}
95103

96-
tr.Track(commandPath, tracking.StatusSuccess, "", durationMs)
104+
isSim := strings.HasPrefix(commandPath, "sim")
105+
tr.Track(commandPath, tracking.StatusSuccess, "", durationMs, isSim)
97106
return nil
98107
},
99108
}
@@ -107,6 +116,7 @@ func init() {
107116
rootCmd.AddCommand(query.NewQueryCmd())
108117
rootCmd.AddCommand(execution.NewExecutionCmd())
109118
rootCmd.AddCommand(usage.NewUsageCmd())
119+
rootCmd.AddCommand(whoami.NewWhoAmICmd())
110120
rootCmd.AddCommand(sim.NewSimCmd())
111121
}
112122

@@ -115,11 +125,9 @@ func Execute(version, commit, date, amplitudeKey string) {
115125
versionStr := fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date)
116126

117127
telemetryEnabled := duneconfig.IsTelemetryEnabled()
118-
configDir, _ := authconfig.Dir()
119128
tracker := tracking.New(tracking.Config{
120129
AmplitudeKey: amplitudeKey,
121130
CLIVersion: version,
122-
ConfigDir: configDir,
123131
Enabled: telemetryEnabled,
124132
})
125133
defer tracker.Shutdown()
@@ -132,7 +140,8 @@ func Execute(version, commit, date, amplitudeKey string) {
132140
); err != nil {
133141
// Build best-effort command path from os.Args (strip flags).
134142
commandPath := commandPathFromArgs(os.Args)
135-
tracker.Track(commandPath, tracking.StatusError, err.Error(), 0)
143+
isSim := strings.HasPrefix(commandPath, "sim")
144+
tracker.Track(commandPath, tracking.StatusError, err.Error(), 0, isSim)
136145
// Flush the event before exiting — os.Exit does not run deferred funcs,
137146
// so defer tracker.Shutdown() above would never fire.
138147
tracker.Shutdown()
@@ -142,6 +151,34 @@ func Execute(version, commit, date, amplitudeKey string) {
142151
}
143152
}
144153

154+
// resolveCustomerID returns the customer_id associated with the given API key.
155+
// The customer_id may represent a user ("user_123") or a team ("team_456").
156+
// It uses a local cache to avoid calling /api/whoami on every invocation.
157+
// On any error it returns "" silently — analytics should never block the CLI.
158+
func resolveCustomerID(client dune.DuneClient, apiKey string) string {
159+
keyHash := authconfig.HashAPIKey(apiKey)
160+
161+
// Try the cache first.
162+
cached, err := authconfig.LoadIdentity()
163+
if err == nil && cached != nil && cached.APIKeyHash == keyHash && cached.CustomerID != "" {
164+
return cached.CustomerID
165+
}
166+
167+
// Cache miss or stale — call the API.
168+
resp, err := client.WhoAmI()
169+
if err != nil || resp == nil || resp.CustomerID == "" {
170+
return ""
171+
}
172+
173+
// Persist for next time (best-effort).
174+
_ = authconfig.SaveIdentity(&authconfig.UserIdentity{
175+
CustomerID: resp.CustomerID,
176+
APIKeyHash: keyHash,
177+
})
178+
179+
return resp.CustomerID
180+
}
181+
145182
// commandPathFromArgs extracts the subcommand path from os.Args, skipping
146183
// the binary name, flags, and flag values so the tracked path is e.g.
147184
// "query list" even when invoked as "dune --api-key KEY query list --limit 10".

cmd/sim/evm/defi_positions.go

Lines changed: 68 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -65,64 +65,67 @@ type defiPositionsResponse struct {
6565
}
6666

6767
type defiAggregations struct {
68-
TotalUSDValue float64 `json:"total_usd_value"`
68+
TotalValueUSD float64 `json:"total_value_usd"`
6969
TotalByChain map[string]float64 `json:"total_by_chain,omitempty"`
7070
}
7171

72-
// defiPosition is a flat struct matching the polymorphic DefiPosition schema.
72+
// defiTokenInfo represents a token object returned by the API with address,
73+
// name, symbol, and optional numeric fields depending on position type.
74+
type defiTokenInfo struct {
75+
Address string `json:"address,omitempty"`
76+
Name string `json:"name,omitempty"`
77+
Symbol string `json:"symbol,omitempty"`
78+
Decimals int `json:"decimals,omitempty"`
79+
Holdings float64 `json:"holdings,omitempty"`
80+
PriceUSD float64 `json:"price_usd,omitempty"`
81+
}
82+
83+
// nftTokenDetails holds per-token data inside an NFT concentrated-liquidity position.
84+
type nftTokenDetails struct {
85+
PriceUSD float64 `json:"price_usd"`
86+
Holdings float64 `json:"holdings,omitempty"`
87+
Rewards float64 `json:"rewards,omitempty"`
88+
}
89+
90+
// defiPosition matches the polymorphic DefiPosition schema returned by the API.
7391
// Fields are optional depending on the `type` discriminator.
7492
type defiPosition struct {
75-
Type string `json:"type"`
76-
ChainID int64 `json:"chain_id"`
77-
USDVal float64 `json:"usd_value"`
78-
Logo *string `json:"logo,omitempty"`
93+
Type string `json:"type"`
94+
Chain string `json:"chain,omitempty"`
95+
ChainID int64 `json:"chain_id"`
96+
ValueUSD float64 `json:"value_usd"`
97+
Logo *string `json:"logo,omitempty"`
7998

8099
// Erc4626 / Tokenized fields
81-
TokenType string `json:"token_type,omitempty"`
82-
Token string `json:"token,omitempty"`
83-
TokenName string `json:"token_name,omitempty"`
84-
TokenSymbol string `json:"token_symbol,omitempty"`
85-
UnderlyingToken string `json:"underlying_token,omitempty"`
86-
UnderlyingTokenName string `json:"underlying_token_name,omitempty"`
87-
UnderlyingTokenSymbol string `json:"underlying_token_symbol,omitempty"`
88-
UnderlyingTokenDecimals int `json:"underlying_token_decimals,omitempty"`
100+
TokenType string `json:"token_type,omitempty"`
101+
Token *defiTokenInfo `json:"token,omitempty"`
102+
UnderlyingToken *defiTokenInfo `json:"underlying_token,omitempty"`
103+
LendingPool string `json:"lending_pool,omitempty"`
89104

90105
// Erc4626 / Tokenized / UniswapV2 fields
91-
CalculatedBalance float64 `json:"calculated_balance,omitempty"`
92-
PriceInUSD float64 `json:"price_in_usd,omitempty"`
106+
Balance float64 `json:"balance,omitempty"`
107+
PriceUSD float64 `json:"price_usd,omitempty"`
93108

94109
// UniswapV2 / Nft / NftV4 fields
95-
Protocol string `json:"protocol,omitempty"`
96-
Pool string `json:"pool,omitempty"`
97-
PoolID []int `json:"pool_id,omitempty"`
98-
PoolManager string `json:"pool_manager,omitempty"`
99-
Salt []int `json:"salt,omitempty"`
100-
Token0 string `json:"token0,omitempty"`
101-
Token0Name string `json:"token0_name,omitempty"`
102-
Token0Symbol string `json:"token0_symbol,omitempty"`
103-
Token0Decimals int `json:"token0_decimals,omitempty"`
104-
Token1 string `json:"token1,omitempty"`
105-
Token1Name string `json:"token1_name,omitempty"`
106-
Token1Symbol string `json:"token1_symbol,omitempty"`
107-
Token1Decimals int `json:"token1_decimals,omitempty"`
108-
LPBalance string `json:"lp_balance,omitempty"`
109-
Token0Price float64 `json:"token0_price,omitempty"`
110-
Token1Price float64 `json:"token1_price,omitempty"`
110+
Protocol string `json:"protocol,omitempty"`
111+
Pool string `json:"pool,omitempty"`
112+
PoolID string `json:"pool_id,omitempty"`
113+
PoolManager string `json:"pool_manager,omitempty"`
114+
Salt string `json:"salt,omitempty"`
115+
Token0 *defiTokenInfo `json:"token0,omitempty"`
116+
Token1 *defiTokenInfo `json:"token1,omitempty"`
117+
LPBalance string `json:"lp_balance,omitempty"`
111118

112119
// Nft / NftV4 concentrated liquidity positions
113120
Positions []nftPositionDetails `json:"positions,omitempty"`
114121
}
115122

116123
type nftPositionDetails struct {
117-
TickLower int `json:"tick_lower"`
118-
TickUpper int `json:"tick_upper"`
119-
TokenID string `json:"token_id"`
120-
Token0Price float64 `json:"token0_price"`
121-
Token0Holdings float64 `json:"token0_holdings,omitempty"`
122-
Token0Rewards float64 `json:"token0_rewards,omitempty"`
123-
Token1Price float64 `json:"token1_price"`
124-
Token1Holdings float64 `json:"token1_holdings,omitempty"`
125-
Token1Rewards float64 `json:"token1_rewards,omitempty"`
124+
TickLower int `json:"tick_lower"`
125+
TickUpper int `json:"tick_upper"`
126+
TokenID string `json:"token_id"`
127+
Token0 *nftTokenDetails `json:"token0,omitempty"`
128+
Token1 *nftTokenDetails `json:"token1,omitempty"`
126129
}
127130

128131
func runDefiPositions(cmd *cobra.Command, args []string) error {
@@ -169,7 +172,7 @@ func runDefiPositions(cmd *cobra.Command, args []string) error {
169172
p.Type,
170173
fmt.Sprintf("%d", p.ChainID),
171174
p.Protocol,
172-
output.FormatUSD(p.USDVal),
175+
output.FormatUSD(p.ValueUSD),
173176
positionDetails(p),
174177
}
175178
}
@@ -184,18 +187,26 @@ func runDefiPositions(cmd *cobra.Command, args []string) error {
184187

185188
// positionDetails returns a human-readable summary for a DeFi position,
186189
// varying by position type.
190+
// tokenSymbol safely extracts the symbol from a token info pointer.
191+
func tokenSymbol(t *defiTokenInfo) string {
192+
if t == nil {
193+
return ""
194+
}
195+
return t.Symbol
196+
}
197+
187198
func positionDetails(p defiPosition) string {
188199
switch p.Type {
189200
case "Erc4626":
190201
parts := []string{}
191-
if p.TokenSymbol != "" {
192-
parts = append(parts, p.TokenSymbol)
202+
if sym := tokenSymbol(p.Token); sym != "" {
203+
parts = append(parts, sym)
193204
}
194-
if p.UnderlyingTokenSymbol != "" {
195-
parts = append(parts, fmt.Sprintf("-> %s", p.UnderlyingTokenSymbol))
205+
if sym := tokenSymbol(p.UnderlyingToken); sym != "" {
206+
parts = append(parts, fmt.Sprintf("-> %s", sym))
196207
}
197-
if p.CalculatedBalance != 0 {
198-
parts = append(parts, fmt.Sprintf("bal=%.6g", p.CalculatedBalance))
208+
if p.Balance != 0 {
209+
parts = append(parts, fmt.Sprintf("bal=%.6g", p.Balance))
199210
}
200211
return strings.Join(parts, " ")
201212

@@ -204,23 +215,23 @@ func positionDetails(p defiPosition) string {
204215
if p.TokenType != "" {
205216
parts = append(parts, p.TokenType)
206217
}
207-
if p.TokenSymbol != "" {
208-
parts = append(parts, p.TokenSymbol)
218+
if sym := tokenSymbol(p.Token); sym != "" {
219+
parts = append(parts, sym)
209220
}
210-
if p.CalculatedBalance != 0 {
211-
parts = append(parts, fmt.Sprintf("bal=%.6g", p.CalculatedBalance))
221+
if p.Balance != 0 {
222+
parts = append(parts, fmt.Sprintf("bal=%.6g", p.Balance))
212223
}
213224
return strings.Join(parts, " ")
214225

215226
case "UniswapV2":
216-
pair := formatPair(p.Token0Symbol, p.Token1Symbol)
217-
if p.CalculatedBalance != 0 {
218-
return fmt.Sprintf("%s bal=%.6g", pair, p.CalculatedBalance)
227+
pair := formatPair(tokenSymbol(p.Token0), tokenSymbol(p.Token1))
228+
if p.Balance != 0 {
229+
return fmt.Sprintf("%s bal=%.6g", pair, p.Balance)
219230
}
220231
return pair
221232

222233
case "Nft", "NftV4":
223-
pair := formatPair(p.Token0Symbol, p.Token1Symbol)
234+
pair := formatPair(tokenSymbol(p.Token0), tokenSymbol(p.Token1))
224235
nPos := len(p.Positions)
225236
if nPos == 1 {
226237
return fmt.Sprintf("%s (1 position)", pair)
@@ -252,7 +263,7 @@ func printAggregations(w io.Writer, agg *defiAggregations) {
252263
return
253264
}
254265

255-
fmt.Fprintf(w, "\nTotal USD Value: %s\n", output.FormatUSD(agg.TotalUSDValue))
266+
fmt.Fprintf(w, "\nTotal USD Value: %s\n", output.FormatUSD(agg.TotalValueUSD))
256267

257268
if len(agg.TotalByChain) > 0 {
258269
fmt.Fprintln(w, "Breakdown by chain:")

0 commit comments

Comments
 (0)