// Package relconfig contains the config yaml object for the relayer. package relconfig import ( "context" "fmt" "math" "os" "path/filepath" "strconv" "strings" "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/jftuga/ellipsis" "github.com/synapsecns/sanguine/core/metrics" "github.com/synapsecns/sanguine/ethergo/signer/config" submitterConfig "github.com/synapsecns/sanguine/ethergo/submitter/config" cctpConfig "github.com/synapsecns/sanguine/services/cctp-relayer/config" "github.com/synapsecns/sanguine/services/rfq/contracts/ierc20" "github.com/synapsecns/sanguine/services/rfq/util" "gopkg.in/yaml.v2" omniClient "github.com/synapsecns/sanguine/services/omnirpc/client" ) // Config represents the configuration for the relayer. type Config struct { // Chains is a map of chainID -> chain config. Chains map[int]ChainConfig `yaml:"chains"` // BaseChainConfig applies to all chains except those values that are overridden in Chains. BaseChainConfig ChainConfig `yaml:"base_chain_config"` // OmniRPCURL is the URL of the OmniRPC server. OmniRPCURL string `yaml:"omnirpc_url"` // RFQAPIURL is the URL of the RFQ API. RFQAPIURL string `yaml:"rfq_url"` // RelayerAPIPort is the port of the relayer API. RelayerAPIPort string `yaml:"relayer_api_port"` // Database is the database config. Database DatabaseConfig `yaml:"database"` // QuotableTokens is a map of token -> list of quotable tokens. QuotableTokens map[string][]string `yaml:"quotable_tokens"` // Signer is the signer config. Signer config.SignerConfig `yaml:"signer"` // SubmitterConfig is the submitter config. SubmitterConfig submitterConfig.Config `yaml:"submitter_config"` // FeePricer is the fee pricer config. FeePricer FeePricerConfig `yaml:"fee_pricer"` // ScreenerAPIUrl is the TRM API url. ScreenerAPIUrl string `yaml:"screener_api_url"` // DBSelectorInterval is the interval for the db selector. DBSelectorInterval time.Duration `yaml:"db_selector_interval"` // RebalanceInterval is the interval for rebalancing. RebalanceInterval time.Duration `yaml:"rebalance_interval"` // QuoteSubmissionTimeout is the timeout for submitting a quote. QuoteSubmissionTimeout time.Duration `yaml:"quote_submission_timeout"` // CCTPRelayerConfig is the embedded cctp relayer config (optional). CCTPRelayerConfig *cctpConfig.Config `yaml:"cctp_relayer_config"` // EnableAPIWithdrawals enables withdrawals via the API. EnableAPIWithdrawals bool `yaml:"enable_api_withdrawals"` // WithdrawalWhitelist is a list of addresses that are allowed to withdraw. WithdrawalWhitelist []string `yaml:"withdrawal_whitelist"` // UseEmbeddedGuard enables the embedded guard. UseEmbeddedGuard bool `yaml:"enable_guard"` // SubmitSingleQuotes enables submitting single quotes. SubmitSingleQuotes bool `yaml:"submit_single_quotes"` // VolumeLimit is the maximum dollar value of relayed transactions in the BlockWindow. VolumeLimit float64 `yaml:"volume_limit"` // SupportActiveQuoting enables support for active quoting. SupportActiveQuoting bool `yaml:"support_active_quoting"` } // ChainConfig represents the configuration for a chain. type ChainConfig struct { // Bridge is the rfq bridge contract address. RFQAddress string `yaml:"rfq_address"` // Confirmations is the number of required confirmations. Confirmations uint64 `yaml:"confirmations"` // FinalityConfirmations is the number of required confirmations before proving. FinalityConfirmations uint64 `yaml:"prove_confirmations"` // Tokens is a map of token name -> token config. Tokens map[string]TokenConfig `yaml:"tokens"` // NativeToken is the native token of the chain (pays gas). NativeToken string `yaml:"native_token"` // DeadlineBufferSeconds is the deadline buffer for relaying a transaction. DeadlineBufferSeconds int `yaml:"deadline_buffer_seconds"` // OriginGasEstimate is the gas estimate to use for origin transactions (this will override base gas estimates). OriginGasEstimate int `yaml:"origin_gas_estimate"` // DestGasEstimate is the gas estimate to use for destination transactions (this will override base gas estimates). DestGasEstimate int `yaml:"dest_gas_estimate"` // L1FeeChainID indicates the chain ID for the L1 fee (if needed, for example on optimism). L1FeeChainID uint32 `yaml:"l1_fee_chain_id"` // L1FeeOriginGasEstimate is the gas estimate for the L1 fee on origin. L1FeeOriginGasEstimate int `yaml:"l1_fee_origin_gas_estimate"` // L1FeeDestGasEstimate is the gas estimate for the L1 fee on destination. L1FeeDestGasEstimate int `yaml:"l1_fee_dest_gas_estimate"` // MinGasToken is minimum amount of gas that should be leftover after bridging a gas token. MinGasToken string `yaml:"min_gas_token"` // QuotePct is the percent of balance to quote. QuotePct *float64 `yaml:"quote_pct"` // QuoteFixedFeeMultiplier is the multiplier for the fixed fee, applied when generating quotes. QuoteFixedFeeMultiplier *float64 `yaml:"quote_fixed_fee_multiplier"` // RelayFixedFeeMultiplier is the multiplier for the fixed fee, applied when relaying. RelayFixedFeeMultiplier *float64 `yaml:"relay_fixed_fee_multiplier"` // RebalanceStartBlock is the block at which the chain listener will listen for rebalance events. RebalanceStartBlock uint64 `yaml:"cctp_start_block"` // RebalanceConfigs is the rebalance configurations. RebalanceConfigs RebalanceConfigs `yaml:"rebalance_configs"` // LimitConfirmations is the number of confirmations to wait for before processing a quote. LimitConfirmations uint64 `yaml:"limit_confirmations"` } // TokenConfig represents the configuration for a token. type TokenConfig struct { // Address is the token address. Address string `yaml:"address"` // Decimals is the token decimals. Decimals uint8 `yaml:"decimals"` // For now, specify the USD price of the token in the config. PriceUSD float64 `yaml:"price_usd"` // MinQuoteAmount is the minimum amount to quote for this token in human-readable units. MinQuoteAmount string `yaml:"min_quote_amount"` // MaxRelayAmount is the maximum amount to quote and relay for this token in human-readable units. MaxRelayAmount string `yaml:"max_relay_amount"` // RebalanceMethods are the supported methods for rebalancing. RebalanceMethods []string `yaml:"rebalance_methods"` // MaintenanceBalancePct is the percentage of the total balance under which a rebalance will be triggered. MaintenanceBalancePct float64 `yaml:"maintenance_balance_pct"` // InitialBalancePct is the percentage of the total balance to retain when triggering a rebalance. InitialBalancePct float64 `yaml:"initial_balance_pct"` // MinRebalanceAmount is the minimum amount to rebalance in human-readable units. // For USDC-through-cctp pairs this defaults to $1,000. MinRebalanceAmount string `yaml:"min_rebalance_amount"` // MaxRebalanceAmount is the maximum amount to rebalance in human-readable units. MaxRebalanceAmount string `yaml:"max_rebalance_amount"` // QuoteOffsetBps is the number of basis points to deduct from the dest amount, // and add to the origin amount for a given token, // Note that this value can be positive or negative; if positive it effectively increases the quoted price // of the given token, and vice versa. QuoteOffsetBps float64 `yaml:"quote_offset_bps"` // QuoteWidthBps is the number of basis points to deduct from the dest amount. // Note that this parameter must be positive. QuoteWidthBps float64 `yaml:"quote_width_bps"` // MaxBalance is the maximum balance that should be accumulated for this token on this chain (human-readable units) MaxBalance *string `yaml:"max_balance"` } // DatabaseConfig represents the configuration for the database. type DatabaseConfig struct { Type string `yaml:"type"` DSN string `yaml:"dsn"` // Data Source Name } // FeePricerConfig represents the configuration for the fee pricer. type FeePricerConfig struct { // GasPriceCacheTTLSeconds is the TTL for the gas price cache. GasPriceCacheTTLSeconds int `yaml:"gas_price_cache_ttl"` // TokenPriceCacheTTLSeconds is the TTL for the token price cache. TokenPriceCacheTTLSeconds int `yaml:"token_price_cache_ttl"` // HTTPTimeoutMs is the number of milliseconds to timeout on a HTTP request. HTTPTimeoutMs int `yaml:"http_timeout_ms"` } // RebalanceConfigs represents the rebalance configurations. type RebalanceConfigs struct { Synapse *SynapseCCTPRebalanceConfig `yaml:"synapse"` Circle *CircleCCTPRebalanceConfig `yaml:"circle"` Scroll *ScrollRebalanceConfig `yaml:"scroll"` } // SynapseCCTPRebalanceConfig represents the configuration for the SynapseCCTP rebalance. type SynapseCCTPRebalanceConfig struct { // SynapseCCTPAddress is the SynapseCCTP address. SynapseCCTPAddress string `yaml:"synapse_cctp_address"` } // CircleCCTPRebalanceConfig represents the configuration for the CircleCCTP rebalance. type CircleCCTPRebalanceConfig struct { // TokenMessengerAddress is the TokenMessenger address. TokenMessengerAddress string `yaml:"token_messenger_address"` } // ScrollRebalanceConfig represents the configuration for the Scroll rebalance. type ScrollRebalanceConfig struct { // L1GatewayAddress is the L1Gateway address [scroll]. L1GatewayAddress string `yaml:"l1_gateway_address"` // L1ScrollMessengerAddress is the L1ScrollMessenger address [scroll]. L1ScrollMessengerAddress string `yaml:"l1_scroll_messenger_address"` // L2GatewayAddress is the L2Gateway address [scroll]. L2GatewayAddress string `yaml:"l2_gateway_address"` // ScrollMessageFee is the scroll message fee. ScrollMessageFee *string `yaml:"scroll_message_fee"` } // TokenIDDelimiter is the delimiter for token IDs. const TokenIDDelimiter = "-" // SanitizeTokenID takes a raw string, makes sure it is a valid token ID, // and returns the token ID as string with a checksummed address. func SanitizeTokenID(id string) (sanitized string, err error) { split := strings.Split(id, TokenIDDelimiter) if len(split) != 2 { return sanitized, fmt.Errorf("invalid token ID: %s", id) } chainID, err := strconv.Atoi(split[0]) if err != nil { return sanitized, fmt.Errorf("invalid chain ID: %s", split[0]) } addr := common.HexToAddress(split[1]) sanitized = fmt.Sprintf("%d%s%s", chainID, TokenIDDelimiter, addr.Hex()) return sanitized, nil } // DecodeTokenID decodes a token ID into a chain ID and address. func DecodeTokenID(id string) (chainID int, addr common.Address, err error) { // defensive coding, first check if the token ID is valid _, err = SanitizeTokenID(id) if err != nil { return chainID, addr, err } split := strings.Split(id, TokenIDDelimiter) if len(split) != 2 { return chainID, addr, fmt.Errorf("invalid token ID: %s", id) } chainID, err = strconv.Atoi(split[0]) if err != nil { return chainID, addr, fmt.Errorf("invalid chain ID: %s", split[0]) } if !common.IsHexAddress(split[1]) { return chainID, addr, fmt.Errorf("invalid address: %s", split[1]) } addr = common.HexToAddress(split[1]) return chainID, addr, nil } // LoadConfig loads the config from the given path. func LoadConfig(path string) (config Config, err error) { input, err := os.ReadFile(filepath.Clean(path)) if err != nil { return Config{}, fmt.Errorf("failed to read file: %w", err) } err = yaml.Unmarshal(input, &config) if err != nil { return Config{}, fmt.Errorf("could not unmarshall config %s: %w", ellipsis.Shorten(string(input), 30), err) } omniClient := omniClient.NewOmnirpcClient(config.OmniRPCURL, metrics.NewNullHandler(), omniClient.WithCaptureReqRes()) err = config.Validate(context.Background(), omniClient) if err != nil { return Config{}, fmt.Errorf("config validation failed: %w", err) } return config, nil } // Validate validates the config. Omniclient may be nil, but if not then it will also check the chain to see if the decimals // match the actual token decimals. func (c Config) Validate(ctx context.Context, omniclient omniClient.RPCClient) (err error) { maintenancePctSums := map[string]float64{} initialPctSums := map[string]float64{} for _, chainCfg := range c.Chains { for tokenName, tokenCfg := range chainCfg.Tokens { if len(tokenCfg.RebalanceMethods) != 0 { maintenancePctSums[tokenName] += tokenCfg.MaintenanceBalancePct initialPctSums[tokenName] += tokenCfg.InitialBalancePct } } } for token, sum := range maintenancePctSums { if sum > 100 { return fmt.Errorf("total maintenance percent exceeds 100 for %s: %f", token, sum) } } for token, sum := range initialPctSums { if math.Round(sum) != 100 { return fmt.Errorf("total initial percent does not total 100 for %s: %f", token, sum) } } if omniclient != nil { err = c.validateTokenDecimals(ctx, omniclient) if err != nil { return fmt.Errorf("error validating token decimals: %w", err) } } return nil } // ValidateTokenDecimals calls decimals() on the ERC20s to ensure that the decimals in the config match the actual token decimals. func (c Config) validateTokenDecimals(ctx context.Context, omniClient omniClient.RPCClient) (err error) { for chainID, chainCfg := range c.Chains { for tokenName, tokenCFG := range chainCfg.Tokens { chainClient, err := omniClient.GetChainClient(ctx, chainID) if err != nil { return fmt.Errorf("could not get chain client for chain %d: %w", chainID, err) } // Check if the token is the gas token. SHOULD BE 18. if tokenCFG.Address == util.EthAddress.String() { if tokenCFG.Decimals != 18 { return fmt.Errorf("decimals mismatch for token %s on chain %d: expected 18, got %d", tokenName, chainID, tokenCFG.Decimals) } continue } ierc20, err := ierc20.NewIERC20(common.HexToAddress(tokenCFG.Address), chainClient) if err != nil { return fmt.Errorf("could not create caller for token %s at address %s on chain %d: %w", tokenName, tokenCFG.Address, chainID, err) } actualDecimals, err := ierc20.Decimals(&bind.CallOpts{Context: ctx}) if err != nil { return fmt.Errorf("could not get decimals for token %s on chain %d: %w", tokenName, chainID, err) } if actualDecimals != tokenCFG.Decimals { return fmt.Errorf("decimals mismatch for token %s on chain %d: expected %d, got %d", tokenName, chainID, tokenCFG.Decimals, actualDecimals) } } } return nil }