From 090b6b0d58e877e17f196b0445d8dafb06399997 Mon Sep 17 00:00:00 2001 From: anilcse Date: Wed, 17 Jun 2026 12:29:19 +0530 Subject: [PATCH] Improve IBC token handling and filter unknown balances. Add a curated Osmosis-connected IBC registry, skip spam and unpriced tokens silently, cache EVM balances, and update gitignore to exclude local database artifacts. Co-authored-by: Cursor --- .gitignore | 19 +- cmd/cosmoscope/main.go | 32 ++- internal/cosmos/client.go | 133 +++++++-- internal/cosmos/client_test.go | 19 +- internal/cosmos/denom_filter.go | 61 ++++ internal/cosmos/denom_filter_test.go | 32 +++ internal/cosmos/ibc_tokens.go | 138 +++++++++ internal/cosmos/ibc_tokens_test.go | 98 +++++++ internal/evm/cache.go | 211 ++++++++++++++ internal/evm/client.go | 91 ++++-- internal/evm/debug.go | 99 +++++++ internal/portfolio/balance.go | 1 + internal/portfolio/display_optimized.go | 357 ++++++++++++++++++++++++ internal/price/coingecko.go | 189 ++++++++++++- 14 files changed, 1403 insertions(+), 77 deletions(-) create mode 100644 internal/cosmos/denom_filter.go create mode 100644 internal/cosmos/denom_filter_test.go create mode 100644 internal/cosmos/ibc_tokens.go create mode 100644 internal/cosmos/ibc_tokens_test.go create mode 100644 internal/evm/cache.go create mode 100644 internal/evm/debug.go create mode 100644 internal/portfolio/display_optimized.go diff --git a/.gitignore b/.gitignore index 1bda23a..b3b8063 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,22 @@ +# Local configuration (contains API keys and addresses) configs/config.json + +# Build output cosmoscope +bin/ +dist/ + # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib -bin/ -dist/ # Test binary, built with `go test -c` *.test -# Output of the go coverage tool, specifically when used with LiteIDE +# Output of the go coverage tool *.out coverage.html @@ -32,4 +36,11 @@ vendor/ .Spotlight-V100 .Trashes ehthumbs.db -Thumbs.db \ No newline at end of file +Thumbs.db + +# Local database / cache artifacts (not part of the app) +dump.rdb +store/ +metadata/ +preprocessed_configs/ +uuid diff --git a/cmd/cosmoscope/main.go b/cmd/cosmoscope/main.go index 32840ee..81f31d5 100644 --- a/cmd/cosmoscope/main.go +++ b/cmd/cosmoscope/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "sync" + "time" "github.com/anilcse/cosmoscope/internal/config" "github.com/anilcse/cosmoscope/internal/cosmos" @@ -18,7 +19,7 @@ func main() { // Load configuration cfg := config.Load() - // Initialize price and IBC data + // Initialize price data with retry logic price.InitializePrices(cfg.CoinGeckoURI) // Create channels for collecting balances @@ -51,8 +52,10 @@ func main() { } } - // Query EVM networks + // Query EVM networks with controlled concurrency to avoid rate limits + // Process one network at a time, but addresses in parallel for that network for _, network := range cfg.EVMNetworks { + fmt.Printf("Querying %s network...\n", network.Name) for _, address := range cfg.EVMAddresses { wg.Add(1) go func(net config.EVMNetwork, addr string) { @@ -60,6 +63,8 @@ func main() { evm.QueryBalances(net, addr, balanceChan) }(network, address) } + // Small delay between networks to respect rate limits + time.Sleep(500 * time.Millisecond) } // Close channel after all goroutines complete @@ -70,7 +75,26 @@ func main() { // Collect and display balances balances := portfolio.CollectBalances(balanceChan) + + // Print cache statistics + fmt.Printf("\n=== Performance Stats ===\n") + if cacheHits, cacheTotal := evm.GetCacheStats(); cacheTotal > 0 { + fmt.Printf("EVM Cache: %d hits out of %d entries\n", cacheHits, cacheTotal) + } + fmt.Println() - // Print the report - portfolio.PrintBalanceReport(balances) + // Print the report using optimized single-pass processing + if len(balances) > 0 { + portfolio.PrintOptimizedBalanceReport(balances) + } else { + fmt.Println("No balances found or all balances have zero USD value.") + fmt.Println("This might be due to:") + fmt.Println(" 1. Network connectivity issues") + fmt.Println(" 2. API rate limiting") + fmt.Println(" 3. Invalid addresses in configuration") + fmt.Println("\nPlease check your configuration and network connection.") + } + + // Cleanup + defer evm.CloseAllClients() } diff --git a/internal/cosmos/client.go b/internal/cosmos/client.go index fa391e5..b5537df 100644 --- a/internal/cosmos/client.go +++ b/internal/cosmos/client.go @@ -19,10 +19,12 @@ import ( // Cache for chain and asset information var ( - chainInfoCache = make(map[string]*ChainInfo) - assetListCache = make(map[string]AssetList) - registryBaseURL = "https://raw.githubusercontent.com/cosmos/chain-registry/master" - cacheMutex sync.RWMutex + chainInfoCache = make(map[string]*ChainInfo) + assetListCache = make(map[string]AssetList) + activeEndpointCache = make(map[string]string) + endpointMissLogged = make(map[string]struct{}) + registryBaseURL = "https://raw.githubusercontent.com/cosmos/chain-registry/master" + cacheMutex sync.RWMutex ) func FetchChainInfo(network string) (*ChainInfo, error) { @@ -86,38 +88,56 @@ func fetchAssetList(network string) (*AssetList, error) { return &assetList, nil } -func resolveSymbolForDenom(network, denom string) (string, int) { - assetList, err := fetchAssetList(network) +// resolveSymbolForDenom maps a chain denom to a display symbol and decimals. +// Returns ok=false for unknown IBC denoms that are not in the curated registry. +func resolveSymbolForDenom(network, denom string) (symbol string, decimals int, ok bool) { + if shouldSkipDenom(denom) { + return "", 0, false + } + + if symbol, _, decimals, found := lookupKnownDenom(denom); found { + return symbol, decimals, true + } + + if strings.HasPrefix(denom, "ibc/") { + return "", 0, false + } + + if symbol, decimals, found := resolveFactoryDenom(denom); found { + return symbol, decimals, true + } + + symbol, decimals = resolveNativeFromAssetList(network, denom) + if shouldSkipSymbol(symbol) { + return "", 0, false + } + return symbol, decimals, true +} +func resolveNativeFromAssetList(network, denom string) (string, int) { + assetList, err := fetchAssetList(network) if err != nil { - // Fallback to basic resolution if asset list fetch fails - if strings.HasPrefix(denom, "ibc/") { - return denom + " (Unknown IBC Asset)", 6 - } if strings.HasPrefix(denom, "u") { - return strings.ToUpper(strings.TrimLeft(denom, "u")), 6 + return strings.ToUpper(strings.TrimPrefix(denom, "u")), 6 } if strings.HasPrefix(denom, "a") { - return strings.ToUpper(strings.TrimLeft(denom, "a")), 18 + return strings.ToUpper(strings.TrimPrefix(denom, "a")), 18 } return denom, 6 } for _, asset := range assetList.Assets { - if asset.Base == denom { - // Find the decimal by looking for the display denom in denom_units - for _, denomUnit := range asset.DenomUnits { - if denomUnit.Denom == asset.Display { - return asset.Symbol, denomUnit.Exponent - } + if asset.Base != denom { + continue + } + for _, denomUnit := range asset.DenomUnits { + if denomUnit.Denom == asset.Display { + return asset.Symbol, denomUnit.Exponent } - - // Fallback to 6 decimals if no denom_units found - return asset.Symbol, 6 } + return asset.Symbol, 6 } - // Fallback if asset not found in registry return denom, 6 } @@ -134,18 +154,21 @@ func QueryBalances(networkName string, address string, balanceChan chan<- portfo return } - apiEndpoint := getActiveEndpoint(chainInfo.APIs.REST) + apiEndpoint := getCachedActiveEndpoint(networkName, chainInfo.APIs.REST) if apiEndpoint == "" { - fmt.Printf("No active REST endpoints found for %s\n", networkName) + logEndpointMissOnce(networkName) return } // Query bank balances bankBalances := getBalance(apiEndpoint, address, "/cosmos/bank/v1beta1/balances") for _, balance := range bankBalances { - symbol, decimals := resolveSymbolForDenom(networkName, balance.Denom) + symbol, decimals, include := resolveSymbolForDenom(networkName, balance.Denom) + if !include { + continue + } amount := utils.ParseAmount(balance.Amount, decimals) - usdValue := price.CalculateUSDValue(symbol, amount) + usdValue := price.CalculateUSDValue(priceSymbolForToken(symbol), amount) balanceChan <- portfolio.Balance{ Network: fmt.Sprintf("%s-bank", networkName), @@ -167,9 +190,12 @@ func QueryBalances(networkName string, address string, balanceChan chan<- portfo func queryStakingBalances(networkName, api, address string, balanceChan chan<- portfolio.Balance) { stakingBalances := getBalance(api, address, "/cosmos/staking/v1beta1/delegations") for _, balance := range stakingBalances { - symbol, decimals := resolveSymbolForDenom(networkName, balance.Denom) + symbol, decimals, include := resolveSymbolForDenom(networkName, balance.Denom) + if !include { + continue + } amount := utils.ParseAmount(balance.Amount, decimals) - usdValue := price.CalculateUSDValue(symbol, amount) + usdValue := price.CalculateUSDValue(priceSymbolForToken(symbol), amount) balanceChan <- portfolio.Balance{ Network: fmt.Sprintf("%s-staking", networkName), @@ -186,9 +212,12 @@ func queryStakingBalances(networkName, api, address string, balanceChan chan<- p func queryRewards(networkName, api, address string, balanceChan chan<- portfolio.Balance) { rewardBalances := getBalance(api, "", fmt.Sprintf("/cosmos/distribution/v1beta1/delegators/%s/rewards", address)) for _, balance := range rewardBalances { - symbol, decimals := resolveSymbolForDenom(networkName, balance.Denom) + symbol, decimals, include := resolveSymbolForDenom(networkName, balance.Denom) + if !include { + continue + } amount := utils.ParseAmount(balance.Amount, decimals) - usdValue := price.CalculateUSDValue(symbol, amount) + usdValue := price.CalculateUSDValue(priceSymbolForToken(symbol), amount) balanceChan <- portfolio.Balance{ Network: fmt.Sprintf("%s-rewards", networkName), @@ -229,7 +258,7 @@ func getBalance(api string, address string, endpoint string) []struct { case "/cosmos/bank/v1beta1/balances": var response BankBalanceResponse if err := json.Unmarshal(body, &response); err != nil { - fmt.Printf("Error unmarshaling bank balance response: %s - %s - %s\n", string(body), address, api) + fmt.Printf("Error fetching bank balances for %s: %s\n", address, summarizeHTTPBody(body, resp.StatusCode)) return nil } return response.Balances @@ -298,6 +327,48 @@ func getHexAddress(address string) string { return hex.EncodeToString(bz) } +func getCachedActiveEndpoint(network string, endpoints []RestEndpoint) string { + cacheMutex.RLock() + if ep, ok := activeEndpointCache[network]; ok { + cacheMutex.RUnlock() + return ep + } + cacheMutex.RUnlock() + + ep := getActiveEndpoint(endpoints) + if ep == "" { + return "" + } + + cacheMutex.Lock() + activeEndpointCache[network] = ep + cacheMutex.Unlock() + return ep +} + +func logEndpointMissOnce(network string) { + cacheMutex.Lock() + defer cacheMutex.Unlock() + if _, logged := endpointMissLogged[network]; logged { + return + } + endpointMissLogged[network] = struct{}{} + fmt.Printf("No active REST endpoints found for %s\n", network) +} + +func summarizeHTTPBody(body []byte, statusCode int) string { + if statusCode == http.StatusTooManyRequests { + return "429 Too Many Requests" + } + if len(body) > 0 && body[0] == '{' { + return string(body) + } + if statusCode > 0 { + return fmt.Sprintf("HTTP %d", statusCode) + } + return "invalid response" +} + // getActiveEndpoint tries each REST endpoint until it finds one that responds func getActiveEndpoint(endpoints []RestEndpoint) string { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) diff --git a/internal/cosmos/client_test.go b/internal/cosmos/client_test.go index afe962a..d92b484 100644 --- a/internal/cosmos/client_test.go +++ b/internal/cosmos/client_test.go @@ -58,20 +58,29 @@ func TestResolveSymbolForDenom(t *testing.T) { { name: "ibc token", denom: "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", - wantSymbol: "OSMO", + wantSymbol: "ATOM", wantDecimals: 6, }, { name: "unknown token", - denom: "unknown", - wantSymbol: "unknown", - wantDecimals: 6, + denom: "ibc/UNKNOWNHASH000000000000000000000000000000000000000000000000", + wantSymbol: "", + wantDecimals: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - symbol, decimals := resolveSymbolForDenom("cosmoshub", tt.denom) + symbol, decimals, ok := resolveSymbolForDenom("cosmoshub", tt.denom) + if tt.name == "unknown token" { + if ok { + t.Errorf("resolveSymbolForDenom() ok = true, want false for unknown ibc") + } + return + } + if !ok { + t.Fatalf("resolveSymbolForDenom() ok = false, want true") + } if symbol != tt.wantSymbol { t.Errorf("resolveSymbolForDenom() symbol = %v, want %v", symbol, tt.wantSymbol) } diff --git a/internal/cosmos/denom_filter.go b/internal/cosmos/denom_filter.go new file mode 100644 index 0000000..35a7754 --- /dev/null +++ b/internal/cosmos/denom_filter.go @@ -0,0 +1,61 @@ +package cosmos + +import ( + "strings" + + "github.com/anilcse/cosmoscope/internal/price" +) + +var skipDenomPrefixes = []string{ + "gamm/pool/", + "gamm/", + "cl/pool/", + "clpool/", + "osmo/pool/", + "superfluid/", + "poolshare/", + "lp/", +} + +func shouldSkipDenom(denom string) bool { + lower := strings.ToLower(denom) + for _, prefix := range skipDenomPrefixes { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func shouldSkipSymbol(symbol string) bool { + upper := strings.ToUpper(symbol) + if strings.Contains(upper, "/") || strings.Contains(upper, "POOL") { + return true + } + if len(upper) > 20 { + return true + } + return !price.HasPrice(priceSymbolForToken(symbol)) +} + +func resolveFactoryDenom(denom string) (symbol string, decimals int, ok bool) { + lower := strings.ToLower(denom) + if !strings.HasPrefix(lower, "factory/") { + return "", 0, false + } + + parts := strings.Split(denom, "/") + if len(parts) < 3 { + return "", 0, false + } + + subdenom := strings.ToLower(parts[len(parts)-1]) + switch { + case strings.HasPrefix(subdenom, "u") && len(subdenom) > 1: + return strings.ToUpper(subdenom[1:]), 6, true + case strings.HasPrefix(subdenom, "a") && len(subdenom) > 1: + return strings.ToUpper(subdenom[1:]), 18, true + default: + return strings.ToUpper(subdenom), 6, true + } +} diff --git a/internal/cosmos/denom_filter_test.go b/internal/cosmos/denom_filter_test.go new file mode 100644 index 0000000..cde22aa --- /dev/null +++ b/internal/cosmos/denom_filter_test.go @@ -0,0 +1,32 @@ +package cosmos + +import ( + "testing" + + "github.com/anilcse/cosmoscope/internal/price" +) + +func TestShouldSkipDenom(t *testing.T) { + if !shouldSkipDenom("gamm/pool/577") { + t.Error("expected gamm pool to be skipped") + } + if shouldSkipDenom("uatom") { + t.Error("native uatom should not be skipped") + } +} + +func TestResolveFactoryDenom(t *testing.T) { + symbol, decimals, ok := resolveFactoryDenom("factory/cosmos1s8qx0zvz8yd6e4x0mqmqf7fr9vvfn6226hkvrq/ustars") + if !ok || symbol != "STARS" || decimals != 6 { + t.Fatalf("got (%s, %d, %v), want (STARS, 6, true)", symbol, decimals, ok) + } +} + +func TestShouldSkipSymbol(t *testing.T) { + if !shouldSkipSymbol("GAMM/POOL/577") { + t.Error("expected LP symbol to be skipped") + } + if shouldSkipSymbol("ATOM") == price.HasPrice("ATOM") { + t.Error("ATOM skip should be inverse of HasPrice") + } +} diff --git a/internal/cosmos/ibc_tokens.go b/internal/cosmos/ibc_tokens.go new file mode 100644 index 0000000..4e5184e --- /dev/null +++ b/internal/cosmos/ibc_tokens.go @@ -0,0 +1,138 @@ +package cosmos + +import "strings" + +// knownToken defines a curated IBC token sourced from the Cosmos chain registry. +// IBC hashes are taken from osmosis assetlist entries with osmosis-connected paths. +type knownToken struct { + Symbol string + PriceSymbol string + Denoms map[string]int // denom -> decimals +} + +// wellKnownTokens lists major IBC assets with osmosis-connected representations. +// Denoms are sourced from https://github.com/cosmos/chain-registry (osmosis assetlist + native chains). +var wellKnownTokens = []knownToken{ + { + Symbol: "ATOM", + PriceSymbol: "ATOM", + Denoms: map[string]int{ + "uatom": 6, + "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2": 6, + "ibc/A4DB47A9D3CF9A068D454513891B526702455D3EF08FB9EB558C561F9DC2B701": 6, + }, + }, + { + Symbol: "USDC", + PriceSymbol: "USDC", + Denoms: map[string]int{ + "uusdc": 6, + "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4": 6, // noble via osmosis channel-750 + "ibc/65D0BEC6DAD96C7F5043D1E54E54B6BB5D5B3AEC3FF6CEBB75B9E059F3580EA3": 6, // noble via mantrachain channel-1 + }, + }, + { + Symbol: "AKT", + PriceSymbol: "AKT", + Denoms: map[string]int{ + "uakt": 6, + "ibc/1480B8FD20AD5FCAE81EA87584D269547DD4D436843C1D20F15E00EB64743EF4": 6, + }, + }, + { + Symbol: "TIA", + PriceSymbol: "TIA", + Denoms: map[string]int{ + "utia": 6, + "ibc/D79E7D83AB399BFFF93433E54FAA480C191248FC556924A2A8351AE2638B3877": 6, + }, + }, + { + Symbol: "MANTRA", + PriceSymbol: "MANTRA", + Denoms: map[string]int{ + "amantra": 18, + "ibc/4EDB04DED162ED1D45F86F8C7F92E011E8CF3AE9E5214BD3EA06F9D05B38F18D": 18, + }, + }, + { + Symbol: "OM", + PriceSymbol: "OM", + Denoms: map[string]int{ + "uom": 6, + "ibc/164807F6226F91990F358C6467EEE8B162E437BDCD3DADEC3F0CE20693720795": 6, + }, + }, + { + Symbol: "DYM", + PriceSymbol: "DYM", + Denoms: map[string]int{ + "adym": 18, + "ibc/9A76CDF0CBCEF37923F32518FA15E5DC92B9F56128292BC4D63C4AEA76CBB110": 18, + }, + }, + { + Symbol: "CHEQ", + PriceSymbol: "CHEQ", + Denoms: map[string]int{ + "ncheq": 9, + "ibc/7A08C6F11EF0F59EB841B9F788A87EC9F2361C7D9703157EC13D940DC53031FA": 9, + }, + }, + { + Symbol: "ATONE", + PriceSymbol: "ATONE", + Denoms: map[string]int{ + "uatone": 6, + "ibc/BC26A7A805ECD6822719472BCB7842A48EF09DF206182F8F259B2593EB5D23FB": 6, + }, + }, +} + +var ( + denomToToken map[string]knownToken + priceSymbolMap map[string]string +) + +func init() { + denomToToken = make(map[string]knownToken, 32) + priceSymbolMap = map[string]string{ + "ATOMONE": "ATONE", + "CHEQD": "CHEQ", + } + + for _, token := range wellKnownTokens { + priceSymbolMap[token.Symbol] = token.PriceSymbol + for denom, decimals := range token.Denoms { + denomToToken[normalizeDenomKey(denom)] = knownToken{ + Symbol: token.Symbol, + PriceSymbol: token.PriceSymbol, + Denoms: map[string]int{denom: decimals}, + } + } + } +} + +func normalizeDenomKey(denom string) string { + return strings.ToUpper(denom) +} + +func lookupKnownDenom(denom string) (symbol string, priceSymbol string, decimals int, ok bool) { + token, ok := denomToToken[normalizeDenomKey(denom)] + if !ok { + return "", "", 0, false + } + for _, dec := range token.Denoms { + decimals = dec + break + } + return token.Symbol, token.PriceSymbol, decimals, true +} + +func priceSymbolForToken(symbol string) string { + upper := strings.ToUpper(symbol) + if mapped, ok := priceSymbolMap[upper]; ok { + return mapped + } + return upper +} diff --git a/internal/cosmos/ibc_tokens_test.go b/internal/cosmos/ibc_tokens_test.go new file mode 100644 index 0000000..9c7d916 --- /dev/null +++ b/internal/cosmos/ibc_tokens_test.go @@ -0,0 +1,98 @@ +package cosmos + +import "testing" + +func TestLookupKnownDenom(t *testing.T) { + tests := []struct { + name string + denom string + wantSymbol string + wantPriceSymbol string + wantDecimals int + wantOK bool + }{ + { + name: "native atom", + denom: "uatom", + wantSymbol: "ATOM", + wantPriceSymbol: "ATOM", + wantDecimals: 6, + wantOK: true, + }, + { + name: "osmosis ibc atom", + denom: "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + wantSymbol: "ATOM", + wantPriceSymbol: "ATOM", + wantDecimals: 6, + wantOK: true, + }, + { + name: "noble usdc on osmosis", + denom: "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + wantSymbol: "USDC", + wantPriceSymbol: "USDC", + wantDecimals: 6, + wantOK: true, + }, + { + name: "dymension native", + denom: "adym", + wantSymbol: "DYM", + wantPriceSymbol: "DYM", + wantDecimals: 18, + wantOK: true, + }, + { + name: "cheqd native", + denom: "ncheq", + wantSymbol: "CHEQ", + wantPriceSymbol: "CHEQ", + wantDecimals: 9, + wantOK: true, + }, + { + name: "atomone native", + denom: "uatone", + wantSymbol: "ATONE", + wantPriceSymbol: "ATONE", + wantDecimals: 6, + wantOK: true, + }, + { + name: "unknown ibc hash", + denom: "ibc/DEADBEEF", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + symbol, priceSymbol, decimals, ok := lookupKnownDenom(tt.denom) + if ok != tt.wantOK { + t.Fatalf("lookupKnownDenom() ok = %v, want %v", ok, tt.wantOK) + } + if !tt.wantOK { + return + } + if symbol != tt.wantSymbol { + t.Errorf("symbol = %q, want %q", symbol, tt.wantSymbol) + } + if priceSymbol != tt.wantPriceSymbol { + t.Errorf("priceSymbol = %q, want %q", priceSymbol, tt.wantPriceSymbol) + } + if decimals != tt.wantDecimals { + t.Errorf("decimals = %d, want %d", decimals, tt.wantDecimals) + } + }) + } +} + +func TestPriceSymbolForToken(t *testing.T) { + if got := priceSymbolForToken("ATOMONE"); got != "ATONE" { + t.Errorf("priceSymbolForToken(ATOMONE) = %q, want ATONE", got) + } + if got := priceSymbolForToken("CHEQD"); got != "CHEQ" { + t.Errorf("priceSymbolForToken(CHEQD) = %q, want CHEQ", got) + } +} diff --git a/internal/evm/cache.go b/internal/evm/cache.go new file mode 100644 index 0000000..69de356 --- /dev/null +++ b/internal/evm/cache.go @@ -0,0 +1,211 @@ +package evm + +import ( + "fmt" + "sync" + "time" + + "github.com/anilcse/cosmoscope/internal/portfolio" + "github.com/ethereum/go-ethereum/ethclient" +) + +// BalanceCache stores cached balance data with TTL +type BalanceCache struct { + balances map[string]*CachedBalance + mu sync.RWMutex + ttl time.Duration +} + +// CachedBalance holds a cached balance entry +type CachedBalance struct { + Balance portfolio.Balance + Timestamp time.Time +} + +// EVMClientPool manages shared RPC clients with rate limiting +type EVMClientPool struct { + clients map[string]*ethclient.Client + lastRequest map[string]time.Time + requestDelay time.Duration // Minimum delay between requests + mu sync.Mutex +} + +var ( + // Global cache instance + balanceCache = &BalanceCache{ + balances: make(map[string]*CachedBalance), + ttl: 2 * time.Minute, // Cache balances for 2 minutes + } + + // Global client pool + clientPool = &EVMClientPool{ + clients: make(map[string]*ethclient.Client), + lastRequest: make(map[string]time.Time), + requestDelay: 100 * time.Millisecond, // 10 requests per second max per endpoint + } + + // Rate limiter for all RPC calls + rateLimiter = &RateLimiter{ + requestsPerSecond: 5, // Conservative rate limit + bucket: make(chan struct{}, 5), + } +) + +// RateLimiter implements a simple token bucket algorithm +type RateLimiter struct { + requestsPerSecond int + bucket chan struct{} + once sync.Once +} + +// Init initializes the rate limiter +func (rl *RateLimiter) Init() { + rl.once.Do(func() { + // Fill the bucket initially + for i := 0; i < rl.requestsPerSecond; i++ { + rl.bucket <- struct{}{} + } + + // Refill tokens periodically + go func() { + ticker := time.NewTicker(time.Second / time.Duration(rl.requestsPerSecond)) + defer ticker.Stop() + + for range ticker.C { + select { + case rl.bucket <- struct{}{}: + // Token added + default: + // Bucket full, skip + } + } + }() + }) +} + +// Wait waits for a token to become available +func (rl *RateLimiter) Wait() { + rl.Init() + <-rl.bucket // Wait for a token +} + +// GetCachedBalance retrieves a cached balance if valid +func (bc *BalanceCache) GetCachedBalance(key string) (*portfolio.Balance, bool) { + bc.mu.RLock() + defer bc.mu.RUnlock() + + cached, exists := bc.balances[key] + if !exists { + return nil, false + } + + // Check if cache is still valid + if time.Since(cached.Timestamp) > bc.ttl { + return nil, false + } + + return &cached.Balance, true +} + +// SetCachedBalance stores a balance in cache +func (bc *BalanceCache) SetCachedBalance(key string, balance portfolio.Balance) { + bc.mu.Lock() + defer bc.mu.Unlock() + + bc.balances[key] = &CachedBalance{ + Balance: balance, + Timestamp: time.Now(), + } +} + +// GetOrCreateClient gets an existing client or creates a new one with rate limiting +func (cp *EVMClientPool) GetOrCreateClient(rpcURL string) (*ethclient.Client, error) { + cp.mu.Lock() + defer cp.mu.Unlock() + + // Check if we have an existing client + if client, exists := cp.clients[rpcURL]; exists { + // Apply rate limiting per endpoint + if lastReq, ok := cp.lastRequest[rpcURL]; ok { + timeSince := time.Since(lastReq) + if timeSince < cp.requestDelay { + time.Sleep(cp.requestDelay - timeSince) + } + } + cp.lastRequest[rpcURL] = time.Now() + return client, nil + } + + // Create new client with timeout + fmt.Printf("Creating new RPC client for %s\n", rpcURL) + client, err := ethclient.Dial(rpcURL) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + cp.clients[rpcURL] = client + cp.lastRequest[rpcURL] = time.Now() + + return client, nil +} + +// MakeCacheKey creates a unique cache key for a balance query +func MakeCacheKey(network, address, tokenType string) string { + return fmt.Sprintf("%s:%s:%s", network, address, tokenType) +} + +// GetCachedOrFetch attempts to get cached balance or fetches if not cached +func GetCachedOrFetch( + cacheKey string, + fetchFunc func() (*portfolio.Balance, error), +) (*portfolio.Balance, error) { + // Check cache first + if cached, ok := balanceCache.GetCachedBalance(cacheKey); ok { + fmt.Printf("Using cached balance for %s\n", cacheKey) + return cached, nil + } + + // Apply global rate limiting + rateLimiter.Wait() + + // Fetch new data + balance, err := fetchFunc() + if err != nil { + return nil, err + } + + // Cache the result if successful + if balance != nil { + balanceCache.SetCachedBalance(cacheKey, *balance) + } + + return balance, nil +} + +// CloseAllClients closes all cached RPC clients +func CloseAllClients() { + clientPool.mu.Lock() + defer clientPool.mu.Unlock() + + for url, client := range clientPool.clients { + client.Close() + fmt.Printf("Closed RPC client for %s\n", url) + } + + clientPool.clients = make(map[string]*ethclient.Client) +} + +// GetCacheStats returns cache statistics +func GetCacheStats() (hits int, total int) { + balanceCache.mu.RLock() + defer balanceCache.mu.RUnlock() + + total = len(balanceCache.balances) + for _, cached := range balanceCache.balances { + if time.Since(cached.Timestamp) <= balanceCache.ttl { + hits++ + } + } + + return hits, total +} \ No newline at end of file diff --git a/internal/evm/client.go b/internal/evm/client.go index b1f0fb2..ed16733 100644 --- a/internal/evm/client.go +++ b/internal/evm/client.go @@ -13,34 +13,58 @@ import ( "github.com/anilcse/cosmoscope/internal/price" "github.com/anilcse/cosmoscope/pkg/utils" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" ) func QueryBalances(network config.EVMNetwork, address string, balanceChan chan<- portfolio.Balance) { - queryNativeBalance(network, address, balanceChan) - queryERC20Balances(network, address, balanceChan) + nativeCacheKey := MakeCacheKey(network.Name, address, "native") + if cached, ok := balanceCache.GetCachedBalance(nativeCacheKey); ok { + if cached.Amount > 0 || cached.USDValue > 0 { + balanceChan <- *cached + } + } else { + go func() { + queryNativeBalance(network, address, balanceChan) + }() + } + + erc20CacheKey := MakeCacheKey(network.Name, address, "erc20-check") + if _, ok := balanceCache.GetCachedBalance(erc20CacheKey); !ok { + go func() { + queryERC20Balances(network, address, balanceChan) + balanceCache.SetCachedBalance(erc20CacheKey, portfolio.Balance{}) + }() + } } func queryNativeBalance(network config.EVMNetwork, address string, balanceChan chan<- portfolio.Balance) { - client, err := ethclient.Dial(network.RPC) + cacheKey := MakeCacheKey(network.Name, address, "native") + + // Apply rate limiting + rateLimiter.Wait() + + // Get or create shared client + client, err := clientPool.GetOrCreateClient(network.RPC) if err != nil { - fmt.Printf("Error connecting to %s: %v\n", network.Name, err) + fmt.Printf("Error getting client for %s: %v\n", network.Name, err) return } - defer client.Close() - balance, err := client.BalanceAt(context.Background(), common.HexToAddress(address), nil) + // Query balance with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + balance, err := client.BalanceAt(ctx, common.HexToAddress(address), nil) if err != nil { + fmt.Printf("Error fetching %s balance for %s: %v\n", network.Name, address, err) return } amount := utils.ParseWeiToEther(balance) token := network.NativeToken - if token.Symbol == "POL" { - token.Symbol = "MATIC" - } - - balanceChan <- portfolio.Balance{ + // POL is now its own token, don't convert to MATIC + + // Create balance object + balanceObj := portfolio.Balance{ Network: network.Name, Account: address, Token: token.Symbol, @@ -48,9 +72,20 @@ func queryNativeBalance(network config.EVMNetwork, address string, balanceChan c USDValue: price.CalculateUSDValue(token.Symbol, amount), Decimals: token.Decimals, } + + // Cache the result + balanceCache.SetCachedBalance(cacheKey, balanceObj) + + // Only send balance if it's non-zero + if amount > 0 { + balanceChan <- balanceObj + } } func queryERC20Balances(network config.EVMNetwork, address string, balanceChan chan<- portfolio.Balance) { + // Apply rate limiting for Moralis API + rateLimiter.Wait() + url := fmt.Sprintf("https://deep-index.moralis.io/api/v2/%s/erc20?chain=%s", address, getChainName(network.ChainID)) @@ -58,17 +93,22 @@ func queryERC20Balances(network config.EVMNetwork, address string, balanceChan c req.Header.Add("Accept", "application/json") req.Header.Add("X-API-Key", config.GlobalConfig.MoralisAPIKey) - client := &http.Client{Timeout: time.Second * 10} + client := &http.Client{Timeout: time.Second * 15} // Increased timeout resp, err := client.Do(req) if err != nil { - fmt.Printf("Error querying Moralis API: %v\n", err) + fmt.Printf("Error querying Moralis API for %s on %s: %v\n", address, network.Name, err) return } defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Printf("Moralis API returned status %d for %s on %s\n", resp.StatusCode, address, network.Name) + return + } var tokens []MoralisTokenBalance if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { - fmt.Printf("Error decoding Moralis response: %v\n", err) + fmt.Printf("Error decoding Moralis response for %s: %v\n", network.Name, err) return } @@ -77,9 +117,7 @@ func queryERC20Balances(network config.EVMNetwork, address string, balanceChan c continue } - if token.Symbol == "POL" { - token.Symbol = "MATIC" - } + // POL is now its own token, don't convert to MATIC amount := utils.ParseAmount(token.Balance, token.Decimals) if amount == 0 { @@ -87,6 +125,10 @@ func queryERC20Balances(network config.EVMNetwork, address string, balanceChan c } symbol := sanitizeSymbol(token.Symbol) + if !price.HasPrice(symbol) { + continue + } + usdValue := price.CalculateUSDValue(symbol, amount) balanceChan <- portfolio.Balance{ @@ -108,17 +150,26 @@ func shouldSkipToken(token MoralisTokenBalance) bool { suspiciousTerms := []string{ "visit", "claim", "bonus", "reward", "gift", ".com", ".org", ".net", ".tech", "http", + "buy", "airdrop", "www", "nftfan", "justforfun", } symbolLower := strings.ToLower(token.Symbol) nameLower := strings.ToLower(token.Name) + if strings.Contains(symbolLower, " ") || strings.Contains(nameLower, " ") { + return true + } + for _, term := range suspiciousTerms { if strings.Contains(symbolLower, term) || strings.Contains(nameLower, term) { return true } } + if len(token.Symbol) > 15 { + return true + } + return !token.VerifiedContract && token.SecurityScore == nil } @@ -146,8 +197,12 @@ func getChainName(chainID int) string { 1: "eth", 137: "polygon", 56: "bsc", + 8453: "base", // Added Base network 42161: "arbitrum", 10: "optimism", + 43114: "avalanche", // Avalanche C-Chain + 250: "fantom", // Fantom + 25: "cronos", // Cronos } if name, ok := chainMap[chainID]; ok { diff --git a/internal/evm/debug.go b/internal/evm/debug.go new file mode 100644 index 0000000..231fdf5 --- /dev/null +++ b/internal/evm/debug.go @@ -0,0 +1,99 @@ +package evm + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/anilcse/cosmoscope/internal/config" + "github.com/anilcse/cosmoscope/internal/portfolio" + "github.com/anilcse/cosmoscope/internal/price" + "github.com/anilcse/cosmoscope/pkg/utils" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// DebugQueryETHBalance queries ETH balance with detailed logging for debugging +func DebugQueryETHBalance(network config.EVMNetwork, address string, balanceChan chan<- portfolio.Balance) { + fmt.Printf("\n=== DEBUG: Querying ETH Balance ===\n") + fmt.Printf("Network: %s\n", network.Name) + fmt.Printf("RPC URL: %s\n", network.RPC) + fmt.Printf("Address: %s\n", address) + fmt.Printf("Native Token: %s\n", network.NativeToken.Symbol) + + // Try to connect with detailed error logging + fmt.Printf("Connecting to RPC...\n") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client, err := ethclient.DialContext(ctx, network.RPC) + if err != nil { + fmt.Printf("ERROR: Failed to connect to %s: %v\n", network.Name, err) + return + } + defer client.Close() + fmt.Printf("✓ Connected successfully\n") + + // Get chain ID to verify we're on the right network + chainID, err := client.ChainID(ctx) + if err != nil { + fmt.Printf("ERROR: Failed to get chain ID: %v\n", err) + } else { + fmt.Printf("Chain ID from RPC: %s (expected: %d)\n", chainID.String(), network.ChainID) + } + + // Query balance + fmt.Printf("Querying balance for %s...\n", address) + ethAddress := common.HexToAddress(address) + balance, err := client.BalanceAt(ctx, ethAddress, nil) + if err != nil { + fmt.Printf("ERROR: Failed to get balance: %v\n", err) + return + } + + // Log raw balance + fmt.Printf("Raw balance (wei): %s\n", balance.String()) + + // Convert to ether + amount := utils.ParseWeiToEther(balance) + fmt.Printf("Balance in ETH: %.18f\n", amount) + + // Check if zero + if balance.Cmp(big.NewInt(0)) == 0 { + fmt.Printf("Balance is zero, skipping\n") + return + } + + // Calculate USD value + token := network.NativeToken + fmt.Printf("Calculating USD value for %s...\n", token.Symbol) + usdValue := price.CalculateUSDValue(token.Symbol, amount) + fmt.Printf("USD Value: $%.2f\n", usdValue) + + // Check if price is available + if usdValue == 0 && amount > 0 { + fmt.Printf("WARNING: Price not found for %s\n", token.Symbol) + fmt.Printf("Attempting to fetch price again...\n") + // Try different token names + alternativeNames := []string{"ETH", "ETHEREUM", "Ethereum", "ethereum", "Eth"} + for _, name := range alternativeNames { + testPrice := price.CalculateUSDValue(name, 1.0) + fmt.Printf("Price for '%s': $%.2f\n", name, testPrice) + } + } + + // Send to channel + balanceObj := portfolio.Balance{ + Network: network.Name, + Account: address, + Token: token.Symbol, + Amount: amount, + USDValue: usdValue, + Decimals: token.Decimals, + } + + fmt.Printf("Sending balance to channel: %+v\n", balanceObj) + balanceChan <- balanceObj + fmt.Printf("=== END DEBUG ===\n\n") +} \ No newline at end of file diff --git a/internal/portfolio/balance.go b/internal/portfolio/balance.go index 5edc917..c472569 100644 --- a/internal/portfolio/balance.go +++ b/internal/portfolio/balance.go @@ -25,6 +25,7 @@ type TokenSummary struct { func CollectBalances(balanceChan chan Balance) []Balance { var balances []Balance for balance := range balanceChan { + // Only show balances with meaningful USD value if balance.USDValue > 0.01 { balances = append(balances, balance) } diff --git a/internal/portfolio/display_optimized.go b/internal/portfolio/display_optimized.go new file mode 100644 index 0000000..3bf2d35 --- /dev/null +++ b/internal/portfolio/display_optimized.go @@ -0,0 +1,357 @@ +package portfolio + +import ( + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/olekukonko/tablewriter" +) + +// SinglePassStats holds all statistics computed in a single pass +type SinglePassStats struct { + TotalValue float64 + TokenSummaries map[string]*TokenSummary + NetworkTotals map[string]float64 + AssetTypeTotals map[string]float64 + SortedBalances []Balance + MinUSD float64 + MaxUSD float64 +} + +// ComputeAllStats processes all balances in a single pass +func ComputeAllStats(balances []Balance) *SinglePassStats { + stats := &SinglePassStats{ + TokenSummaries: make(map[string]*TokenSummary), + NetworkTotals: make(map[string]float64), + AssetTypeTotals: make(map[string]float64), + MinUSD: float64(^uint(0) >> 1), // Max float64 + MaxUSD: 0, + } + + // Single pass through all balances + for _, b := range balances { + // Update total value + stats.TotalValue += b.USDValue + + // Update min/max for gradient coloring + if b.USDValue < stats.MinUSD { + stats.MinUSD = b.USDValue + } + if b.USDValue > stats.MaxUSD { + stats.MaxUSD = b.USDValue + } + + // Update token summaries + if _, exists := stats.TokenSummaries[b.Token]; !exists { + stats.TokenSummaries[b.Token] = &TokenSummary{ + TokenName: b.Token, + } + } + stats.TokenSummaries[b.Token].Balance += b.Amount + stats.TokenSummaries[b.Token].USDValue += b.USDValue + + // Update network distribution (extract base network name) + networkBase := strings.Split(b.Network, "-")[0] + stats.NetworkTotals[networkBase] += b.USDValue + + // Update asset type distribution + assetType := getAssetType(b.Network) + stats.AssetTypeTotals[assetType] += b.USDValue + } + + // Calculate token shares + for _, summary := range stats.TokenSummaries { + summary.Share = (summary.USDValue / stats.TotalValue) * 100 + } + + // Sort balances once + stats.SortedBalances = make([]Balance, len(balances)) + copy(stats.SortedBalances, balances) + sort.Slice(stats.SortedBalances, func(i, j int) bool { + return stats.SortedBalances[i].USDValue > stats.SortedBalances[j].USDValue + }) + + return stats +} + +func getAssetType(network string) string { + if strings.Contains(network, "staking") { + return "Staking" + } else if strings.Contains(network, "rewards") { + return "Rewards" + } else if strings.Contains(network, "Exchange") || strings.Contains(network, "Staked") { + return "Fixed" + } + return "Bank" +} + +// PrintOptimizedBalanceReport prints all reports using pre-computed statistics +func PrintOptimizedBalanceReport(balances []Balance) { + // Compute all statistics in a single pass + stats := ComputeAllStats(balances) + + // Use pre-computed stats for all displays + printOptimizedDetailedView(stats) + printOptimizedPortfolioSummary(stats) + printOptimizedNetworkDistribution(stats) + printOptimizedAssetTypes(stats) + PrintOptimizedFooter(stats.TotalValue) +} + +func printOptimizedDetailedView(stats *SinglePassStats) { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Account", "Network", "Token", "Amount", "USD Value"}) + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + // Set all headers to bold + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) + + for _, b := range stats.SortedBalances { + row := []string{ + truncateString(b.Account, 20), + b.Network, + b.Token, + fmt.Sprintf("%.4f", b.Amount), + fmt.Sprintf("$%.2f", b.USDValue), + } + + // Calculate normalized value for coloring + norm := 0.0 + if stats.MaxUSD > stats.MinUSD { + norm = (b.USDValue - stats.MinUSD) / (stats.MaxUSD - stats.MinUSD) + } + + var color tablewriter.Colors + if norm >= 0.8 { + color = tablewriter.Colors{tablewriter.FgHiGreenColor, tablewriter.Bold} + } else if norm >= 0.5 { + color = tablewriter.Colors{tablewriter.FgGreenColor} + } else { + color = tablewriter.Colors{} + } + + table.Rich(row, []tablewriter.Colors{color, color, color, color, color}) + } + + titleColor.Println("Detailed Balance View:") + table.Render() + fmt.Println() +} + +func printOptimizedPortfolioSummary(stats *SinglePassStats) { + // Convert map to slice for sorting + var tokenRows []struct { + token string + amount float64 + usdValue float64 + share float64 + } + + for token, summary := range stats.TokenSummaries { + tokenRows = append(tokenRows, struct { + token string + amount float64 + usdValue float64 + share float64 + }{ + token: token, + amount: summary.Balance, + usdValue: summary.USDValue, + share: summary.Share, + }) + } + + // Sort by USD value descending + sort.Slice(tokenRows, func(i, j int) bool { + return tokenRows[i].usdValue > tokenRows[j].usdValue + }) + + // Find min/max for gradient + minTokenUSD, maxTokenUSD := 0.0, 0.0 + if len(tokenRows) > 0 { + minTokenUSD = tokenRows[len(tokenRows)-1].usdValue + maxTokenUSD = tokenRows[0].usdValue + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Token", "Amount", "USD Value", "Share %"}) + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) + + for _, row := range tokenRows { + rowData := []string{ + row.token, + fmt.Sprintf("%.4f", row.amount), + fmt.Sprintf("$%.2f", row.usdValue), + fmt.Sprintf("%.2f%%", row.share), + } + + // Calculate normalized value for coloring + norm := 0.0 + if maxTokenUSD > minTokenUSD { + norm = (row.usdValue - minTokenUSD) / (maxTokenUSD - minTokenUSD) + } + + var color tablewriter.Colors + if norm >= 0.8 { + color = tablewriter.Colors{tablewriter.FgHiBlueColor, tablewriter.Bold} + } else if norm >= 0.6 { + color = tablewriter.Colors{tablewriter.FgBlueColor} + } else if norm >= 0.4 { + color = tablewriter.Colors{tablewriter.FgHiBlueColor} + } else if norm >= 0.3 { + color = tablewriter.Colors{tablewriter.FgBlueColor} + } else { + color = tablewriter.Colors{} + } + + table.Rich(rowData, []tablewriter.Colors{color, color, color, color}) + } + + titleColor.Println("Portfolio Summary:") + table.Render() + fmt.Printf("Total Portfolio Value: ") + totalValueColor.Printf("$%.2f\n\n", stats.TotalValue) +} + +func printOptimizedNetworkDistribution(stats *SinglePassStats) { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Network", "USD Value", "Share %"}) + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) + + // Convert to slice for consistent ordering + var networkRows []struct { + network string + value float64 + share float64 + } + + for network, value := range stats.NetworkTotals { + share := (value / stats.TotalValue) * 100 + networkRows = append(networkRows, struct { + network string + value float64 + share float64 + }{network, value, share}) + } + + // Sort by value descending + sort.Slice(networkRows, func(i, j int) bool { + return networkRows[i].value > networkRows[j].value + }) + + for _, row := range networkRows { + table.Append([]string{ + row.network, + fmt.Sprintf("$%.2f", row.value), + fmt.Sprintf("%.2f%%", row.share), + }) + } + + titleColor.Println("Network Distribution:") + table.Render() + fmt.Println() +} + +func printOptimizedAssetTypes(stats *SinglePassStats) { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Type", "USD Value", "Share %"}) + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) + + // Convert to slice for consistent ordering + var assetRows []struct { + assetType string + value float64 + share float64 + } + + for assetType, value := range stats.AssetTypeTotals { + share := (value / stats.TotalValue) * 100 + assetRows = append(assetRows, struct { + assetType string + value float64 + share float64 + }{assetType, value, share}) + } + + // Sort by value descending + sort.Slice(assetRows, func(i, j int) bool { + return assetRows[i].value > assetRows[j].value + }) + + for _, row := range assetRows { + table.Append([]string{ + row.assetType, + fmt.Sprintf("$%.2f", row.value), + fmt.Sprintf("%.2f%%", row.share), + }) + } + + titleColor.Println("Asset Types:") + table.Render() +} + +func PrintOptimizedHeader() { + headerColor.Println("\n╔════════════════════════════════════════════════════════════╗") + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Printf("║ BALANCES REPORT - ") + timeColor.Printf("%s", time.Now().Format("2006-01-02 15:04:05")) + headerColor.Printf(" ║\n") + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Println("╚════════════════════════════════════════════════════════════╝") + fmt.Println("") +} + +func PrintOptimizedFooter(totalValue float64) { + headerColor.Println("\n╔════════════════════════════════════════════════════════════╗") + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Printf("║ Total USD value - ") + totalValueColor.Printf("$%.2f", totalValue) + + // Add proper spacing based on value length + valueStr := fmt.Sprintf("$%.2f", totalValue) + spacing := 17 - len(valueStr) + if spacing < 1 { + spacing = 1 + } + headerColor.Printf("%s║\n", strings.Repeat(" ", spacing)) + + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Println("╚════════════════════════════════════════════════════════════╝") + fmt.Println("") +} \ No newline at end of file diff --git a/internal/price/coingecko.go b/internal/price/coingecko.go index ea30aa4..568c99a 100644 --- a/internal/price/coingecko.go +++ b/internal/price/coingecko.go @@ -1,14 +1,48 @@ package price import ( + "context" "encoding/json" "fmt" "net/http" "strings" + "sync" "time" ) -var prices map[string]float64 +// PriceCache holds cached price data with TTL +type PriceCache struct { + prices map[string]float64 + timestamp time.Time + ttl time.Duration + mu sync.RWMutex +} + +var ( + prices map[string]float64 // Keep for backward compatibility + priceCache = &PriceCache{ + prices: make(map[string]float64), + ttl: 5 * time.Minute, + } + httpClient = &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + }, + } + // Fallback prices for common tokens + fallbackPrices = map[string]float64{ + "ETH": 2400.0, "BTC": 45000.0, "USDC": 1.0, "USDT": 1.0, + "MATIC": 0.8, "POL": 0.19, "ATOM": 9.5, "OSMO": 0.8, + "BNB": 300.0, + "JUNO": 0.3, "EVMOS": 0.06, "AKT": 3.5, "TIA": 5.0, + "DYDX": 2.0, "STARS": 0.01, "INJ": 20.0, "ROSE": 0.05, + "DOT": 7.0, "SOL": 100.0, "NOCK": 0.001, + "MANTRA": 0.5, "OM": 0.5, "DYM": 2.0, "CHEQ": 0.02, "ATONE": 1.0, + } +) type CoinGeckoResponse []struct { Symbol string `json:"symbol"` @@ -16,36 +50,161 @@ type CoinGeckoResponse []struct { } func InitializePrices(url string) { - prices = fetchPrices(url) - if prices == nil { - fmt.Println("Error: Failed to fetch prices. Proceeding with zero USD values.") - prices = make(map[string]float64) + InitializePricesWithRetry(url, 3) + prices = priceCache.prices // Set backward compatible variable +} + +// InitializePricesWithRetry initializes prices with retry logic +func InitializePricesWithRetry(url string, maxRetries int) { + var lastErr error + + for i := 0; i < maxRetries; i++ { + if i > 0 { + fmt.Printf("Retrying price fetch (attempt %d/%d)...\n", i+1, maxRetries) + time.Sleep(time.Duration(i) * time.Second) + } + + fetchedPrices, err := fetchPricesWithTimeout(url, 10*time.Second) + if err == nil && len(fetchedPrices) > 0 { + priceCache.mu.Lock() + priceCache.prices = fetchedPrices + priceCache.timestamp = time.Now() + prices = fetchedPrices // Update backward compatible variable + priceCache.mu.Unlock() + + fmt.Printf("Successfully fetched %d token prices\n", len(fetchedPrices)) + return + } + lastErr = err } + + fmt.Printf("Failed to fetch prices after %d attempts: %v\n", maxRetries, lastErr) + fmt.Println("Using fallback prices for common tokens") + + priceCache.mu.Lock() + priceCache.prices = mergePrices(priceCache.prices, fallbackPrices) + priceCache.timestamp = time.Now() + prices = priceCache.prices + priceCache.mu.Unlock() } func fetchPrices(url string) map[string]float64 { - client := &http.Client{Timeout: time.Second * 10} - resp, err := client.Get(url) + prices, _ := fetchPricesWithTimeout(url, 10*time.Second) + return prices +} + +func fetchPricesWithTimeout(url string, timeout time.Duration) (map[string]float64, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := httpClient.Do(req) if err != nil { - return nil + return nil, fmt.Errorf("fetching prices: %w", err) } defer resp.Body.Close() - + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + var response CoinGeckoResponse if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil + return nil, fmt.Errorf("decoding response: %w", err) } - - prices = make(map[string]float64) + + prices := make(map[string]float64) for _, coin := range response { - prices[strings.ToUpper(coin.Symbol)] = coin.CurrentPrice + symbol := strings.ToUpper(coin.Symbol) + prices[symbol] = coin.CurrentPrice } - return prices + + // POL is now separate from MATIC, no aliasing needed + + prices = mergePrices(prices, fallbackPrices) + return prices, nil +} + +func mergePrices(fetched, fallback map[string]float64) map[string]float64 { + result := make(map[string]float64) + for k, v := range fallback { + result[k] = v + } + for k, v := range fetched { + if v > 0 { + result[k] = v + } + } + return result +} + +func HasPrice(token string) bool { + if token == "" { + return false + } + + tokenUpper := normalizeTokenSymbol(strings.ToUpper(token)) + + priceCache.mu.RLock() + defer priceCache.mu.RUnlock() + + if price, ok := priceCache.prices[tokenUpper]; ok && price > 0 { + return true + } + if price, ok := prices[tokenUpper]; ok && price > 0 { + return true + } + if price, ok := fallbackPrices[tokenUpper]; ok && price > 0 { + return true + } + return false } func CalculateUSDValue(token string, amount float64) float64 { - if price, ok := prices[strings.ToUpper(token)]; ok { + if amount == 0 { + return 0 + } + + tokenUpper := normalizeTokenSymbol(strings.ToUpper(token)) + + priceCache.mu.RLock() + defer priceCache.mu.RUnlock() + + // Check cache first + if price, ok := priceCache.prices[tokenUpper]; ok { + return amount * price + } + + // Try backward compatible prices + if price, ok := prices[tokenUpper]; ok { + return amount * price + } + + // Try fallback + if price, ok := fallbackPrices[tokenUpper]; ok { return amount * price } + return 0 } + +func normalizeTokenSymbol(symbol string) string { + symbol = strings.TrimPrefix(symbol, "W") + symbol = strings.TrimPrefix(symbol, "ST") + symbol = strings.TrimSuffix(symbol, ".E") + + symbolMap := map[string]string{ + "WETH": "ETH", "WBTC": "BTC", "STETH": "ETH", + "USDC.E": "USDC", "USDT.E": "USDT", + "ATOMONE": "ATONE", "CHEQD": "CHEQ", + } + + if mapped, ok := symbolMap[symbol]; ok { + return mapped + } + return symbol +}