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
6 changes: 6 additions & 0 deletions example-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
264 changes: 262 additions & 2 deletions td2/chain-details.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}
29 changes: 27 additions & 2 deletions td2/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
7 changes: 6 additions & 1 deletion td2/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading