synapsecns/sanguine

View on GitHub
services/rfq/relayer/pricer/fee_pricer.go

Summary

Maintainability
A
0 mins
Test Coverage
// 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)
}