// Package pricer contains pricing logic for RFQ relayer quotes. package pricer import ( "context" "fmt" "math/big" "time" "github.com/jellydator/ttlcache/v3" "github.com/synapsecns/sanguine/core/metrics" "github.com/synapsecns/sanguine/ethergo/submitter" "github.com/synapsecns/sanguine/services/rfq/relayer/relconfig" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // FeePricer is the interface for the fee pricer. type FeePricer interface { // Start starts the fee pricer. Start(ctx context.Context) // GetOriginFee returns the total fee for a given chainID and gas limit, denominated in a given token. GetOriginFee(ctx context.Context, origin, destination uint32, denomToken string, isQuote bool) (*big.Int, error) // GetDestinationFee returns the total fee for a given chainID and gas limit, denominated in a given token. GetDestinationFee(ctx context.Context, origin, destination uint32, denomToken string, isQuote bool) (*big.Int, error) // GetTotalFee returns the total fee for a given origin and destination chainID, denominated in a given token. GetTotalFee(ctx context.Context, origin, destination uint32, denomToken string, isQuote bool) (*big.Int, error) // GetGasPrice returns the gas price for a given chainID in native units. GetGasPrice(ctx context.Context, chainID uint32) (*big.Int, error) // GetTokenPrice returns the price of a token in USD. GetTokenPrice(ctx context.Context, token string) (float64, error) } type feePricer struct { // config is the relayer config. config relconfig.Config // gasPriceCache maps chainID -> gas price gasPriceCache *ttlcache.Cache[uint32, *big.Int] // tokenPriceCache maps token name -> token price tokenPriceCache *ttlcache.Cache[string, float64] // clientFetcher is used to fetch clients. clientFetcher submitter.ClientFetcher // handler is the metrics handler. handler metrics.Handler // priceFetcher is used to fetch prices from coingecko. priceFetcher CoingeckoPriceFetcher } // NewFeePricer creates a new fee pricer. func NewFeePricer(config relconfig.Config, clientFetcher submitter.ClientFetcher, priceFetcher CoingeckoPriceFetcher, handler metrics.Handler) FeePricer { gasPriceCache := ttlcache.New[uint32, *big.Int]( ttlcache.WithTTL[uint32, *big.Int](time.Second*time.Duration(config.GetFeePricer().GasPriceCacheTTLSeconds)), ttlcache.WithDisableTouchOnHit[uint32, *big.Int](), ) tokenPriceCache := ttlcache.New[string, float64]( ttlcache.WithTTL[string, float64](time.Second*time.Duration(config.GetFeePricer().TokenPriceCacheTTLSeconds)), ttlcache.WithDisableTouchOnHit[string, float64](), ) return &feePricer{ config: config, gasPriceCache: gasPriceCache, tokenPriceCache: tokenPriceCache, clientFetcher: clientFetcher, handler: handler, priceFetcher: priceFetcher, } } func (f *feePricer) Start(ctx context.Context) { // Start the TTL caches. go f.gasPriceCache.Start() go f.tokenPriceCache.Start() go func() { <-ctx.Done() f.gasPriceCache.Stop() f.tokenPriceCache.Stop() }() } var nativeDecimalsFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(18)), nil) func (f *feePricer) GetOriginFee(parentCtx context.Context, origin, destination uint32, denomToken string, isQuote bool) (*big.Int, error) { var err error ctx, span := f.handler.Tracer().Start(parentCtx, "getOriginFee", trace.WithAttributes( attribute.Int(metrics.Origin, int(origin)), attribute.Int(metrics.Destination, int(destination)), attribute.String("denom_token", denomToken), attribute.Bool("is_quote", isQuote), )) defer func() { metrics.EndSpanWithErr(span, err) }() // Calculate the origin fee gasEstimate, err := f.config.GetOriginGasEstimate(int(origin)) if err != nil { return nil, fmt.Errorf("could not get origin gas estimate: %w", err) } fee, err := f.getFee(ctx, origin, destination, gasEstimate, denomToken, isQuote) if err != nil { return nil, err } // If specified, calculate and add the L1 fee l1ChainID, l1GasEstimate, useL1Fee := f.config.GetL1FeeParams(origin, true) if useL1Fee { l1Fee, err := f.getFee(ctx, l1ChainID, destination, l1GasEstimate, denomToken, isQuote) if err != nil { return nil, err } fee = new(big.Int).Add(fee, l1Fee) span.SetAttributes(attribute.String("l1_fee", l1Fee.String())) } span.SetAttributes(attribute.String("origin_fee", fee.String())) return fee, nil } func (f *feePricer) GetDestinationFee(parentCtx context.Context, _, destination uint32, denomToken string, isQuote bool) (*big.Int, error) { var err error ctx, span := f.handler.Tracer().Start(parentCtx, "getDestinationFee", trace.WithAttributes( attribute.Int(metrics.Destination, int(destination)), attribute.String("denom_token", denomToken), attribute.Bool("is_quote", isQuote), )) defer func() { metrics.EndSpanWithErr(span, err) }() // Calculate the destination fee gasEstimate, err := f.config.GetDestGasEstimate(int(destination)) if err != nil { return nil, fmt.Errorf("could not get dest gas estimate: %w", err) } fee, err := f.getFee(ctx, destination, destination, gasEstimate, denomToken, isQuote) if err != nil { return nil, err } // If specified, calculate and add the L1 fee l1ChainID, l1GasEstimate, useL1Fee := f.config.GetL1FeeParams(destination, false) if useL1Fee { l1Fee, err := f.getFee(ctx, l1ChainID, destination, l1GasEstimate, denomToken, isQuote) if err != nil { return nil, err } fee = new(big.Int).Add(fee, l1Fee) span.SetAttributes(attribute.String("l1_fee", l1Fee.String())) } span.SetAttributes(attribute.String("destination_fee", fee.String())) return fee, nil } func (f *feePricer) GetTotalFee(parentCtx context.Context, origin, destination uint32, denomToken string, isQuote bool) (_ *big.Int, err error) { ctx, span := f.handler.Tracer().Start(parentCtx, "getTotalFee", trace.WithAttributes( attribute.Int(metrics.Origin, int(origin)), attribute.Int(metrics.Destination, int(destination)), attribute.String("denom_token", denomToken), attribute.Bool("is_quote", isQuote), )) defer func() { metrics.EndSpanWithErr(span, err) }() originFee, err := f.GetOriginFee(ctx, origin, destination, denomToken, isQuote) if err != nil { span.AddEvent("could not get origin fee", trace.WithAttributes( attribute.String("error", err.Error()), )) return nil, err } destFee, err := f.GetDestinationFee(ctx, origin, destination, denomToken, isQuote) if err != nil { span.AddEvent("could not get destination fee", trace.WithAttributes( attribute.String("error", err.Error()), )) return nil, err } totalFee := new(big.Int).Add(originFee, destFee) span.SetAttributes( attribute.String("origin_fee", originFee.String()), attribute.String("dest_fee", destFee.String()), attribute.String("total_fee", totalFee.String()), ) return totalFee, nil } func (f *feePricer) getFee(parentCtx context.Context, gasChain, denomChain uint32, gasEstimate int, denomToken string, isQuote bool) (_ *big.Int, err error) { ctx, span := f.handler.Tracer().Start(parentCtx, "getFee", trace.WithAttributes( attribute.Int("gas_chain", int(gasChain)), attribute.Int("denom_chain", int(denomChain)), attribute.Int("gas_estimate", gasEstimate), attribute.String("denom_token", denomToken), )) defer func() { metrics.EndSpanWithErr(span, err) }() gasPrice, err := f.GetGasPrice(ctx, gasChain) if err != nil { return nil, err } nativeToken, err := f.config.GetNativeToken(int(gasChain)) if err != nil { return nil, err } nativeTokenPrice, err := f.GetTokenPrice(ctx, nativeToken) if err != nil { return nil, err } denomTokenPrice, err := f.GetTokenPrice(ctx, denomToken) if err != nil { return nil, err } denomTokenDecimals, err := f.config.GetTokenDecimals(denomChain, denomToken) if err != nil { return nil, err } denomDecimalsFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(denomTokenDecimals)), nil) // Compute the fee. var feeDenom *big.Float feeNativeWei := new(big.Float).Mul(new(big.Float).SetInt(gasPrice), new(big.Float).SetFloat64(float64(gasEstimate))) if denomToken == nativeToken { // Denomination token is native token, so no need for unit conversion. feeDenom = feeNativeWei } else { // The steps below convert a raw/wei value of our native gas units (feeNativeWei EG: 1234500000000000) into an equivalent amount in the "denom" Token // convert native gas fee raw/wei into units feeNativeUnits := new(big.Float).Quo(feeNativeWei, new(big.Float).SetInt(nativeDecimalsFactor)) // convert native gas fee units into USD value which can then be utilized as a normalizer between our native input and denominated output. feeUSD := new(big.Float).Mul(feeNativeUnits, new(big.Float).SetFloat64(nativeTokenPrice)) // convert USD value into "denomToken" units feeDenomUnits := new(big.Float).Quo(feeUSD, new(big.Float).SetFloat64(denomTokenPrice)) // convert denominated units into "denomToken" raw/wei value feeDenom = new(big.Float).Mul(feeDenomUnits, new(big.Float).SetInt(denomDecimalsFactor)) span.SetAttributes( attribute.String("fee_native_wei", feeNativeWei.String()), attribute.String("fee_native_units", feeNativeUnits.Text('f', -1)), attribute.String("fee_usd", feeUSD.Text('f', -1)), attribute.String("fee_denom_units", feeDenomUnits.Text('f', -1)), ) } var multiplier float64 if isQuote { multiplier, err = f.config.GetQuoteFixedFeeMultiplier(int(gasChain)) if err != nil { return nil, fmt.Errorf("could not get quote fixed fee multiplier: %w", err) } } else { multiplier, err = f.config.GetRelayFixedFeeMultiplier(int(gasChain)) if err != nil { return nil, fmt.Errorf("could not get relay fixed fee multiplier: %w", err) } } // Apply the fixed fee multiplier. // Note that this step rounds towards zero- we may need to apply rounding here if // we want to be conservative and lean towards overestimating fees. feeUSDCDecimalsScaled, _ := new(big.Float).Mul(feeDenom, new(big.Float).SetFloat64(multiplier)).Int(nil) span.SetAttributes( attribute.String("gas_price", gasPrice.String()), attribute.Float64("native_token_price", nativeTokenPrice), attribute.Float64("denom_token_price", denomTokenPrice), attribute.Float64("multplier", multiplier), attribute.Int("denom_token_decimals", int(denomTokenDecimals)), attribute.String("fee_native_wei", feeNativeWei.String()), attribute.String("fee_denom", feeDenom.Text('f', -1)), attribute.String("fee_usdc_decimals_scaled", feeUSDCDecimalsScaled.String()), ) return feeUSDCDecimalsScaled, nil } // getGasPrice returns the gas price for a given chainID in native units. func (f *feePricer) GetGasPrice(ctx context.Context, chainID uint32) (*big.Int, error) { // Attempt to fetch gas price from cache. gasPriceItem := f.gasPriceCache.Get(chainID) var gasPrice *big.Int if gasPriceItem == nil { // Fetch gas price from omnirpc. client, err := f.clientFetcher.GetClient(ctx, big.NewInt(int64(chainID))) if err != nil { return nil, err } gasPrice, err = client.SuggestGasPrice(ctx) if err != nil { return nil, fmt.Errorf("failed to suggest gas price on chain %d: %w", chainID, err) } if gasPrice == nil { return nil, fmt.Errorf("gas price is nil on chain %d", chainID) } f.gasPriceCache.Set(chainID, gasPrice, 0) } else { gasPrice = gasPriceItem.Value() } return gasPrice, nil } // getTokenPrice returns the price of a token in USD. func (f *feePricer) GetTokenPrice(ctx context.Context, token string) (price float64, err error) { ctx, span := f.handler.Tracer().Start(ctx, "GetTokenPrice", trace.WithAttributes( attribute.String("token", token), )) defer func() { span.SetAttributes(attribute.Float64("price", price)) metrics.EndSpanWithErr(span, err) }() // Attempt to fetch gas price from cache. tokenPriceItem := f.tokenPriceCache.Get(token) //nolint:nestif if tokenPriceItem == nil { // Try to get price from coingecko. price, err = f.priceFetcher.GetPrice(ctx, token) if err == nil { f.tokenPriceCache.Set(token, price, 0) span.SetAttributes(attribute.Float64("cg_price", price)) } else { span.SetAttributes( attribute.String("cg_error", err.Error()), ) // Fallback to configured token price. price, err = f.getTokenPriceFromConfig(token) if err != nil { return 0, err } } } else { price = tokenPriceItem.Value() span.SetAttributes(attribute.Float64("cache_price", price)) } return price, nil } func (f *feePricer) getTokenPriceFromConfig(token string) (float64, error) { for _, chainConfig := range f.config.GetChains() { for tokenName, tokenConfig := range chainConfig.Tokens { if token == tokenName { return tokenConfig.PriceUSD, nil } } } return 0, fmt.Errorf("could not get price for token: %s", token) }