diff --git a/example-config.yml b/example-config.yml index f8749a4..b232a41 100644 --- a/example-config.yml +++ b/example-config.yml @@ -165,6 +165,12 @@ chains: # the name/slug of this chain, used by CoinMarketCap API to convert the price slug: osmosis + # The chain name as it appears on cosmos.directory (e.g., "osmosis", "babylon", "cosmoshub") + # This enables automatic fetching of chain parameters (denom metadata, APR, etc.) from cosmos.directory + # and provides RPC fallback when configured nodes fail. + # If not specified, defaults to lowercase of the chain display name (e.g., "Osmosis" -> "osmosis") + chain_name: osmosis + # Without specifying this option, the inflationRate is queried from a RPC call, but it may not be available for some chains # If the inflation rate cannot be queried, you can use this option to explicitly set the value inflationRate: 0.04 diff --git a/td2/chain-details.go b/td2/chain-details.go index fa8d380..da9bd5f 100644 --- a/td2/chain-details.go +++ b/td2/chain-details.go @@ -3,11 +3,14 @@ package tenderduty import ( "encoding/json" "errors" + "fmt" "io" "net/http" "strings" "sync" "time" + + bank "github.com/cosmos/cosmos-sdk/x/bank/types" ) // altValopers is used to get a bech32 prefix for chains using non-standard naming @@ -109,8 +112,10 @@ var cosmosPaths = map[string]string{ } var pathMux sync.Mutex -const registryJson = "https://chains.cosmos.directory/" -const publicRpcUrl = "https://rpc.cosmos.directory:443/" +const ( + registryJson = "https://chains.cosmos.directory/" + publicRpcUrl = "https://rpc.cosmos.directory:443/" +) // a trimmed down version only holding the info we need to create a lookup map type registryResults struct { @@ -155,3 +160,258 @@ func getRegistryUrl(chainid string) (url string, ok bool) { defer pathMux.Unlock() return publicRpcUrl + cosmosPaths[chainid], cosmosPaths[chainid] != "" } + +// getRegistryUrlByChainName returns the cosmos.directory RPC proxy URL for a given chain name +func getRegistryUrlByChainName(chainName string) string { + return publicRpcUrl + chainName +} + +// CosmosDirectoryResponse wraps the top-level API response from cosmos.directory +type CosmosDirectoryResponse struct { + Chain CosmosDirectoryChainData `json:"chain"` +} + +// CosmosDirectoryChainData holds chain information from cosmos.directory API +type CosmosDirectoryChainData struct { + ChainID string `json:"chain_id"` + Path string `json:"path"` + ChainName string `json:"chain_name"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + Denom string `json:"denom"` + Params CDParams `json:"params"` + Assets []CDAsset `json:"assets"` +} + +// CDParams holds chain parameters from cosmos.directory +type CDParams struct { + // Top-level params fields + Authz bool `json:"authz"` + ActualBlockTime float64 `json:"actual_block_time"` + ActualBlocksPerYear float64 `json:"actual_blocks_per_year"` + CurrentBlockHeight string `json:"current_block_height"` + UnbondingTime int64 `json:"unbonding_time"` + MaxValidators int `json:"max_validators"` + CommunityTax float64 `json:"community_tax"` + BondedTokens string `json:"bonded_tokens"` + AnnualProvision string `json:"annual_provision"` + EstimatedAPR float64 `json:"estimated_apr"` + CalculatedAPR float64 `json:"calculated_apr"` + + // Nested parameter objects + Staking CDStakingParams `json:"staking"` + Slashing CDSlashingParams `json:"slashing"` + Distribution CDDistributionParams `json:"distribution"` +} + +// CDStakingParams holds staking parameters +type CDStakingParams struct { + UnbondingTime string `json:"unbonding_time"` + MaxValidators int `json:"max_validators"` + MaxEntries int `json:"max_entries"` + HistoricalEntries int `json:"historical_entries"` + BondDenom string `json:"bond_denom"` + MinCommissionRate string `json:"min_commission_rate"` +} + +// CDSlashingParams holds slashing parameters +type CDSlashingParams struct { + SignedBlocksWindow string `json:"signed_blocks_window"` + MinSignedPerWindow string `json:"min_signed_per_window"` + DowntimeJailDuration string `json:"downtime_jail_duration"` + SlashFractionDoubleSign string `json:"slash_fraction_double_sign"` + SlashFractionDowntime string `json:"slash_fraction_downtime"` +} + +// CDDistributionParams holds distribution parameters +type CDDistributionParams struct { + CommunityTax string `json:"community_tax"` + BaseProposerReward string `json:"base_proposer_reward"` + BonusProposerReward string `json:"bonus_proposer_reward"` + WithdrawAddrEnabled bool `json:"withdraw_addr_enabled"` +} + +// CDAssetDenomInfo holds denomination info for base/display in assets +type CDAssetDenomInfo struct { + Denom string `json:"denom"` + Exponent int `json:"exponent"` +} + +// CDAsset holds asset information from cosmos.directory +type CDAsset struct { + Base CDAssetDenomInfo `json:"base"` + Symbol string `json:"symbol"` + Display CDAssetDenomInfo `json:"display"` + Name string `json:"name"` + Description string `json:"description"` + DenomUnits []CDDenomUnit `json:"denom_units"` +} + +// CDDenomUnit holds denomination unit information +type CDDenomUnit struct { + Denom string `json:"denom"` + Exponent int `json:"exponent"` + Aliases []string `json:"aliases"` +} + +const ( + chainDataCacheKey = "cosmos_directory_chain_data_" + chainDataCacheTTL = 30 * time.Minute +) + +// fetchCosmosDirectoryChainData fetches chain data from cosmos.directory API +// chainName is the cosmos.directory path (e.g., "babylon", "osmosis") +func fetchCosmosDirectoryChainData(chainName string) (*CosmosDirectoryChainData, error) { + cacheKey := chainDataCacheKey + chainName + + // Try to get from cache first + if cached, ok := td.tenderdutyCache.Get(cacheKey); ok { + if data, ok := cached.(*CosmosDirectoryChainData); ok { + return data, nil + } + } + + // Fetch from cosmos.directory API with timeout + url := registryJson + chainName + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("cosmos.directory returned status: " + resp.Status + " for chain: " + chainName) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var response CosmosDirectoryResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + chainData := response.Chain + + // Verify we got valid data + if chainData.ChainID == "" { + return nil, errors.New("cosmos.directory returned empty chain data for: " + chainName) + } + + // Cache the result + td.tenderdutyCache.Set(cacheKey, &chainData, chainDataCacheTTL) + + return &chainData, nil +} + +// getEffectiveChainName returns the chain name to use for cosmos.directory lookups +// It uses chain_name if set, otherwise falls back to lowercase of the display name +func (cc *ChainConfig) getEffectiveChainName() string { + if cc.ChainName != "" { + return cc.ChainName + } + // Fall back to lowercase of the display name (cc.name) + return strings.ToLower(cc.name) +} + +// loadCosmosDirectoryData attempts to load chain data from cosmos.directory +// and caches it in the ChainConfig. Returns nil if the chain is not found. +func (cc *ChainConfig) loadCosmosDirectoryData() error { + chainName := cc.getEffectiveChainName() + data, err := fetchCosmosDirectoryChainData(chainName) + if err != nil { + return err + } else { + if data.ChainID != cc.ChainId { + return fmt.Errorf("configured chain ID (%s) does not match the chain ID from CosmosDirectory (%s), you can ignore this error if the validator is running in testnet", cc.ChainId, data.ChainID) + } else { + cc.cosmosDirectoryData = data + } + } + return nil +} + +// hasCosmosDirectoryData returns true if cosmos.directory data is available +func (cc *ChainConfig) hasCosmosDirectoryData() bool { + return cc.cosmosDirectoryData != nil +} + +// getCosmosDirectoryRPCUrl returns the cosmos.directory RPC proxy URL for this chain +func (cc *ChainConfig) getCosmosDirectoryRPCUrl() string { + return getRegistryUrlByChainName(cc.getEffectiveChainName()) +} + +// getDenomMetadataFromCosmosDirectory returns bank metadata from cosmos.directory data +// Returns nil if the chain doesn't have cosmos.directory data or no matching asset is found +func (cc *ChainConfig) getDenomMetadataFromCosmosDirectory(denom string) *CDAsset { + if cc.cosmosDirectoryData == nil { + return nil + } + + // First try to find an exact match for the denom + for _, asset := range cc.cosmosDirectoryData.Assets { + if asset.Base.Denom == denom { + return &asset + } + } + + // If no exact match, return the first asset (usually the native token) + if len(cc.cosmosDirectoryData.Assets) > 0 { + return &cc.cosmosDirectoryData.Assets[0] + } + + return nil +} + +// getChainInfoFromCosmosDirectory returns chain info from cosmos.directory data +// Returns (communityTax, calculatedAPR, ok) +func (cc *ChainConfig) getChainInfoFromCosmosDirectory() (communityTax float64, calculatedAPR float64, ok bool) { + if cc.cosmosDirectoryData == nil { + return 0, 0, false + } + + // Get community tax from params (top-level, as float64) + communityTax = cc.cosmosDirectoryData.Params.CommunityTax + + // Use calculated APR from cosmos.directory params + calculatedAPR = cc.cosmosDirectoryData.Params.CalculatedAPR + + ok = true + return +} + +// getBankMetadataFromCosmosDirectory converts cosmos.directory asset data to bank.Metadata +// Returns nil if no matching asset is found +func (cc *ChainConfig) getBankMetadataFromCosmosDirectory(denom string) *bank.Metadata { + cdAsset := cc.getDenomMetadataFromCosmosDirectory(denom) + if cdAsset == nil { + return nil + } + + // Convert CDDenomUnit to bank.DenomUnit + denomUnits := make([]*bank.DenomUnit, len(cdAsset.DenomUnits)) + for i, unit := range cdAsset.DenomUnits { + // Ensure exponent is within valid uint32 range to avoid integer overflow + // Denom exponents are typically small (0-18), but we check the full range for safety + exponent := uint32(0) + if unit.Exponent >= 0 && unit.Exponent <= 255 { + exponent = uint32(unit.Exponent) + } + denomUnits[i] = &bank.DenomUnit{ + Denom: unit.Denom, + Exponent: exponent, + Aliases: unit.Aliases, + } + } + + return &bank.Metadata{ + Description: cdAsset.Description, + DenomUnits: denomUnits, + Base: cdAsset.Base.Denom, + Display: cdAsset.Display.Denom, + Name: cdAsset.Name, + Symbol: cdAsset.Symbol, + } +} diff --git a/td2/rpc.go b/td2/rpc.go index 0b19a03..c1a758d 100644 --- a/td2/rpc.go +++ b/td2/rpc.go @@ -19,6 +19,16 @@ import ( // newRpc sets up the rpc client used for monitoring. It will try nodes in order until a working node is found. // it will also get some initial info on the validator's status. func (cc *ChainConfig) newRpc() error { + // Try to load cosmos.directory data for this chain + // This is done early so we can use it for RPC fallback and chain params + if cc.cosmosDirectoryData == nil { + if err := cc.loadCosmosDirectoryData(); err != nil { + l("ℹ️ cosmos.directory data not available for", cc.name, "(chain_name: "+cc.getEffectiveChainName()+", chain_id: "+cc.ChainId+")", "-", err) + } else { + l("✅ loaded cosmos.directory data for", cc.name, "(chain_name: "+cc.getEffectiveChainName()+", chain_id: "+cc.ChainId+")") + } + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() var anyWorking bool // if healthchecks are running, we will skip to the first known good node. @@ -91,11 +101,26 @@ func (cc *ChainConfig) newRpc() error { } return nil } + // Try cosmos.directory RPC proxy using chain_name (or lowercase display name) + // This is tried before the legacy PublicFallback to use the more reliable chain_name lookup + { + chainName := cc.getEffectiveChainName() + u := getRegistryUrlByChainName(chainName) + node := guessPublicEndpoint(u) + l(cc.ChainId, "⛑ attempting to use cosmos.directory fallback node (chain_name:", chainName+")", node) + if _, failed, _ := tryUrl(node); !failed { + l(cc.ChainId, "⛑ connected to cosmos.directory endpoint", node) + return nil + } + l("⚠️ could not connect to cosmos.directory fallback for chain_name:", chainName) + } + + // Legacy fallback using chain_id lookup (when PublicFallback is explicitly enabled) if cc.PublicFallback { if u, ok := getRegistryUrl(cc.ChainId); ok { node := guessPublicEndpoint(u) - l(cc.ChainId, "⛑ attemtping to use public fallback node", node) - if _, kk, _ := tryUrl(node); !kk { + l(cc.ChainId, "⛑ attempting to use public fallback node (chain_id lookup)", node) + if _, failed, _ := tryUrl(node); !failed { l(cc.ChainId, "⛑ connected to public endpoint", node) return nil } diff --git a/td2/types.go b/td2/types.go index 6957852..d81aa38 100644 --- a/td2/types.go +++ b/td2/types.go @@ -184,7 +184,8 @@ type ChainConfig struct { inflationRate float64 // inflation rate of the chain, used for calculating APR baseAPR float64 // the base APR of a chain denomMetadata *bank.Metadata // chain denom metadata - cryptoPrice *utils.CryptoPrice // coin price in a fiat currency + cryptoPrice *utils.CryptoPrice // coin price in a fiat currency + cosmosDirectoryData *CosmosDirectoryChainData // cached chain data from cosmos.directory minSignedPerWindow float64 // instantly see the validator risk level blocksResults []int @@ -230,6 +231,10 @@ type ChainConfig struct { Slug string `yaml:"slug"` // The inflation rate of the chain, if specified the value overrides the query result InflationRateOverriding float64 `yaml:"inflationRate"` + // ChainName is the cosmos.directory path (e.g., "babylon", "osmosis"). + // If not set, defaults to lowercase of the chain's display name. + // Used to fetch chain params from cosmos.directory and as RPC fallback. + ChainName string `yaml:"chain_name"` } // mkUpdate returns the info needed by prometheus for a gauge. diff --git a/td2/validator.go b/td2/validator.go index c8ecc38..c5a4752 100644 --- a/td2/validator.go +++ b/td2/validator.go @@ -201,12 +201,20 @@ func (cc *ChainConfig) GetValInfo(first bool) (err error) { if err == nil { cc.denomMetadata = bankMeta } else { - l(fmt.Errorf("cannot query bank metadata for chain %s, err: %w, now fallback to query the GitHub JSON file", cc.name, err)) - bankMeta, err = cc.fetchBankMetadataFromGitHub() - if err == nil { + l(fmt.Errorf("cannot query bank metadata for chain %s via ABCI, err: %w, trying cosmos.directory fallback", cc.name, err)) + // Try cosmos.directory fallback first + bankMeta = cc.getBankMetadataFromCosmosDirectory((*rewards)[0].Denom) + if bankMeta != nil { cc.denomMetadata = bankMeta + l(fmt.Sprintf("✅ loaded bank metadata for chain %s from cosmos.directory", cc.name)) } else { - l(fmt.Errorf("cannot find bank metadata for chain %s in the GitHub JSON file, err: %w", cc.name, err)) + l(fmt.Sprintf("ℹ️ cosmos.directory bank metadata not available for chain %s, trying GitHub fallback", cc.name)) + bankMeta, err = cc.fetchBankMetadataFromGitHub() + if err == nil { + cc.denomMetadata = bankMeta + } else { + l(fmt.Errorf("cannot find bank metadata for chain %s in the GitHub JSON file, err: %w", cc.name, err)) + } } } } @@ -247,39 +255,54 @@ func (cc *ChainConfig) GetValInfo(first bool) (err error) { if cc.InflationRateOverriding != 0 { // Override the base APR with the configured value inflationRate = cc.InflationRateOverriding + l(fmt.Sprintf("based on the config, inflation rate of chain %s has been overriden to %f", cc.name, cc.InflationRateOverriding)) } cc.inflationRate = inflationRate cc.baseAPR = inflationRate * (1 - communityTax) * totalSupply / cc.totalBondedTokens cc.valInfo.ValidatorAPR = cc.baseAPR * (1 - cc.valInfo.CommissionRate) + } else { + l(fmt.Errorf("failed to query APR-related data for chain %s via ABCI (err: %w)", cc.name, err)) + } - if cc.cryptoPrice != nil { - // Calculate the validator's projected 30-day rewards in base units - projected30DRewardsBaseUnits := cc.valInfo.DelegatedTokens * cc.valInfo.ValidatorAPR * cc.valInfo.CommissionRate * 30 / 365 - - // Convert from base units to display units using exponent - var displayExponent uint32 = 0 - if cc.denomMetadata.Display != "" { - // Find the exponent for the display denomination - for _, unit := range cc.denomMetadata.DenomUnits { - if unit.Denom == cc.denomMetadata.Display { - displayExponent = unit.Exponent - break - } - } - } + if err != nil || cc.baseAPR == 0 { + // Try cosmos.directory fallback for APR data, when it failed to query via ABCI, or the previously calculated base APR is zero + cdCommunityTax, cdAPR, ok := cc.getChainInfoFromCosmosDirectory() + if ok && cdAPR > 0 { + l(fmt.Sprintf("✅ using cosmos.directory APR data for chain %s (APR: %.4f)", cc.name, cdAPR)) + cc.communityTax = cdCommunityTax + // Use the pre-calculated APR from cosmos.directory as the base APR + cc.baseAPR = cdAPR + cc.valInfo.ValidatorAPR = cc.baseAPR * (1 - cc.valInfo.CommissionRate) + } else { + l(fmt.Sprintf("APR-related data for chain %s from cosmos.directory fallback not available", cc.name)) + } + } - // Convert to display units by dividing by 10^exponent - projected30DRewardsDisplayUnits := projected30DRewardsBaseUnits - for i := uint32(0); i < displayExponent; i++ { - projected30DRewardsDisplayUnits = projected30DRewardsDisplayUnits / 10 + // Calculate projected rewards if we have APR and price data + if cc.baseAPR > 0 && cc.cryptoPrice != nil { + // Calculate the validator's projected 30-day rewards in base units + projected30DRewardsBaseUnits := cc.valInfo.DelegatedTokens * cc.valInfo.ValidatorAPR * cc.valInfo.CommissionRate * 30 / 365 + + // Convert from base units to display units using exponent + var displayExponent uint32 = 0 + if cc.denomMetadata.Display != "" { + // Find the exponent for the display denomination + for _, unit := range cc.denomMetadata.DenomUnits { + if unit.Denom == cc.denomMetadata.Display { + displayExponent = unit.Exponent + break + } } + } - // Convert to fiat currency - cc.valInfo.Projected30DRewards = projected30DRewardsDisplayUnits * cc.cryptoPrice.Price + // Convert to display units by dividing by 10^exponent + projected30DRewardsDisplayUnits := projected30DRewardsBaseUnits + for i := uint32(0); i < displayExponent; i++ { + projected30DRewardsDisplayUnits = projected30DRewardsDisplayUnits / 10 } - } else { - l(fmt.Errorf("failed to query APR-related data such as total supply, community tax and inflation rate for chain %s, err: %w", cc.name, err)) + // Convert to fiat currency + cc.valInfo.Projected30DRewards = projected30DRewardsDisplayUnits * cc.cryptoPrice.Price } }