Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -32,4 +36,11 @@ vendor/
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Thumbs.db

# Local database / cache artifacts (not part of the app)
dump.rdb
store/
metadata/
preprocessed_configs/
uuid
32 changes: 28 additions & 4 deletions cmd/cosmoscope/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"sync"
"time"

"github.com/anilcse/cosmoscope/internal/config"
"github.com/anilcse/cosmoscope/internal/cosmos"
Expand All @@ -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
Expand Down Expand Up @@ -51,15 +52,19 @@ 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) {
defer wg.Done()
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
Expand All @@ -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()
}
133 changes: 102 additions & 31 deletions internal/cosmos/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 14 additions & 5 deletions internal/cosmos/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading