// Package quoter submits quotes to the RFQ API for which assets the relayer is willing to relay. package quoter import ( "context" "encoding/json" "errors" "fmt" "math/big" "strconv" "strings" "sync" "sync/atomic" "github.com/synapsecns/sanguine/contrib/screener-api/client" "github.com/ethereum/go-ethereum/common/hexutil" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" "github.com/ipfs/go-log" "github.com/synapsecns/sanguine/core/metrics" "github.com/synapsecns/sanguine/services/rfq/relayer/pricer" "github.com/synapsecns/sanguine/services/rfq/relayer/relconfig" "github.com/synapsecns/sanguine/services/rfq/relayer/reldb" "github.com/synapsecns/sanguine/services/rfq/util" "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" "github.com/ethereum/go-ethereum/common" "github.com/synapsecns/sanguine/ethergo/signer/signer" rfqAPIClient "github.com/synapsecns/sanguine/services/rfq/api/client" "github.com/synapsecns/sanguine/services/rfq/api/model" "github.com/synapsecns/sanguine/services/rfq/api/rest" "github.com/synapsecns/sanguine/services/rfq/relayer/inventory" ) var logger = log.Logger("quoter") const ( base10 = 10 ) // Quoter submits quotes to the RFQ API. // //go:generate go run github.com/vektra/mockery/v2 --name Quoter --output ./mocks --case=underscore type Quoter interface { // SubmitAllQuotes submits all quotes to the RFQ API. SubmitAllQuotes(ctx context.Context) (err error) // SubscribeActiveRFQ subscribes to the RFQ websocket API. SubscribeActiveRFQ(ctx context.Context) (err error) // ShouldProcess determines if a quote should be processed. // We do this by either saving all quotes in-memory, and refreshing via GetSelfQuotes() through the API // The first comparison is does bridge transaction OriginChainID+TokenAddr match with a quote + DestChainID+DestTokenAddr, then we look to see if we have enough amount to relay it + if the price fits our bounds (based on that the Relayer is relaying the destination token for the origin) // validateQuote(BridgeEvent) ShouldProcess(ctx context.Context, quote reldb.QuoteRequest) (bool, error) // IsProfitable determines if a quote is profitable, i.e. we will not lose money on it, net of fees. IsProfitable(ctx context.Context, quote reldb.QuoteRequest) (bool, error) // GetPrice gets the price of a token. GetPrice(ctx context.Context, tokenName string) (float64, error) } // Manager submits quotes to the RFQ API. // TODO: should be unexported. type Manager struct { // config is the relayer's config. config relconfig.Config // inventoryManager is used to get the relayer's inventory. inventoryManager inventory.Manager // rfqClient is used to communicate with the RFQ API. rfqClient rfqAPIClient.AuthenticatedClient // relayerSigner is the signer used by the relayer to interact on chain relayerSigner signer.Signer // feePricer is used to price fees. feePricer pricer.FeePricer // metricsHandler handles traces, etc metricsHandler metrics.Handler // quotableTokens is a map of token -> list of quotable tokens. // should be removed in config overhaul quotableTokens map[string][]string // screener is used to screen addresses. screener client.ScreenerClient // relayPaused is set when the RFQ API is found to be offline, which // lets the quoter indicate that quotes should not be relayed. relayPaused atomic.Bool // meter is the meter used by this package. meter metric.Meter // quoteAmountGauge stores a histogram of quote amounts. quoteAmountGauge metric.Float64ObservableGauge // currentQuotes is used for recording quote metrics. currentQuotes []model.PutRelayerQuoteRequest } // NewQuoterManager creates a new QuoterManager. func NewQuoterManager(config relconfig.Config, metricsHandler metrics.Handler, inventoryManager inventory.Manager, relayerSigner signer.Signer, feePricer pricer.FeePricer, apiClient rfqAPIClient.AuthenticatedClient) (Quoter, error) { qt := make(map[string][]string) // fix any casing issues. var err error for tokenID, destTokenIDs := range config.QuotableTokens { processedDestTokens := make([]string, len(destTokenIDs)) for i := range destTokenIDs { processedDestTokens[i], err = relconfig.SanitizeTokenID(destTokenIDs[i]) if err != nil { return nil, fmt.Errorf("error sanitizing dest token ID: %w", err) } } sanitizedID, err := relconfig.SanitizeTokenID(tokenID) if err != nil { return nil, fmt.Errorf("error sanitizing token ID: %w", err) } qt[sanitizedID] = processedDestTokens } var ss client.ScreenerClient if config.ScreenerAPIUrl != "" { ss, err = client.NewClient(metricsHandler, config.ScreenerAPIUrl) if err != nil { return nil, fmt.Errorf("error creating screener client: %w", err) } } m := &Manager{ config: config, inventoryManager: inventoryManager, rfqClient: apiClient, quotableTokens: qt, relayerSigner: relayerSigner, metricsHandler: metricsHandler, feePricer: feePricer, screener: ss, meter: metricsHandler.Meter(meterName), currentQuotes: []model.PutRelayerQuoteRequest{}, } m.quoteAmountGauge, err = m.meter.Float64ObservableGauge("quote_amount") if err != nil { return nil, fmt.Errorf("error creating quote amount gauge: %w", err) } _, err = m.meter.RegisterCallback(m.recordQuoteAmounts, m.quoteAmountGauge) if err != nil { return nil, fmt.Errorf("could not register callback: %w", err) } return m, nil } // ShouldProcess determines if a quote should be processed. func (m *Manager) ShouldProcess(parentCtx context.Context, quote reldb.QuoteRequest) (res bool, err error) { ctx, span := m.metricsHandler.Tracer().Start(parentCtx, "shouldProcess", trace.WithAttributes( attribute.String("transaction_id", hexutil.Encode(quote.TransactionID[:])), )) defer func() { span.AddEvent("result", trace.WithAttributes(attribute.Bool("result", res))) metrics.EndSpanWithErr(span, err) }() if m.relayPaused.Load() { span.AddEvent("relayPaused is set due to RFQ API being offline") return false, nil } if m.screener != nil { // screen sender and recipient in parallel g, gctx := errgroup.WithContext(ctx) var senderBlocked, recipientBlocked bool g.Go(func() error { senderBlocked, err = m.screener.ScreenAddress(gctx, quote.Transaction.OriginSender.String()) if err != nil { span.RecordError(fmt.Errorf("error screening address: %w", err)) return fmt.Errorf("error screening address: %w", err) } return nil }) g.Go(func() error { recipientBlocked, err = m.screener.ScreenAddress(gctx, quote.Transaction.DestRecipient.String()) if err != nil { span.RecordError(fmt.Errorf("error screening address: %w", err)) return fmt.Errorf("error screening address: %w", err) } return nil }) err = g.Wait() if err != nil { return false, fmt.Errorf("error screening addresses: %w", err) } if senderBlocked || recipientBlocked { span.SetAttributes( attribute.Bool("sender_blocked", senderBlocked), attribute.Bool("recipient_blocked", recipientBlocked), ) return false, nil } } // allowed pairs for this origin token on the destination destPairs := m.quotableTokens[quote.GetOriginIDPair()] if !(slices.Contains(destPairs, quote.GetDestIDPair())) { span.AddEvent(fmt.Sprintf("%s not in %s or %s not found", quote.GetDestIDPair(), strings.Join(destPairs, ", "), quote.GetOriginIDPair())) return false, nil } // handle decimals. // this will never get hit if we're operating correctly. if quote.OriginTokenDecimals != quote.DestTokenDecimals { span.AddEvent("Pairing tokens with two different decimals is disabled as a safety feature right now.") return false, nil } // check relay amount maxRelayAmount := m.config.GetMaxRelayAmount(int(quote.Transaction.OriginChainId), quote.Transaction.OriginToken) if maxRelayAmount != nil { if quote.Transaction.OriginAmount.Cmp(maxRelayAmount) > 0 { span.AddEvent("origin amount is greater than max relay amount") return false, nil } } // all checks have passed return true, nil } // IsProfitable determines if a quote is profitable, i.e. we will not lose money on it, net of fees. func (m *Manager) IsProfitable(parentCtx context.Context, quote reldb.QuoteRequest) (isProfitable bool, err error) { ctx, span := m.metricsHandler.Tracer().Start(parentCtx, "IsProfitable") defer func() { span.AddEvent("result", trace.WithAttributes(attribute.Bool("result", isProfitable))) metrics.EndSpanWithErr(span, err) }() destTokenID, err := m.config.GetTokenName(quote.Transaction.DestChainId, quote.Transaction.DestToken.String()) if err != nil { return false, fmt.Errorf("error getting dest token ID: %w", err) } fee, err := m.feePricer.GetTotalFee(ctx, quote.Transaction.OriginChainId, quote.Transaction.DestChainId, destTokenID, false) if err != nil { return false, fmt.Errorf("error getting total fee: %w", err) } cost := new(big.Int).Add(quote.Transaction.DestAmount, fee) // adjust amounts for our internal offsets on origin / dest token values originAmountAdj, err := m.getAmountWithOffset(ctx, quote.Transaction.OriginChainId, quote.Transaction.OriginToken, quote.Transaction.OriginAmount) if err != nil { return false, fmt.Errorf("error getting origin amount with offset: %w", err) } // assume that fee is denominated in dest token terms costAdj, err := m.getAmountWithOffset(ctx, quote.Transaction.DestChainId, quote.Transaction.DestToken, cost) if err != nil { return false, fmt.Errorf("error getting cost with offset: %w", err) } span.SetAttributes( attribute.String("origin_amount_adj", originAmountAdj.String()), attribute.String("cost_adj", costAdj.String()), attribute.String("origin_amount", quote.Transaction.OriginAmount.String()), attribute.String("dest_amount", quote.Transaction.DestAmount.String()), attribute.String("fee", fee.String()), attribute.String("cost", cost.String()), ) return originAmountAdj.Cmp(costAdj) >= 0, nil } func (m *Manager) getAmountWithOffset(ctx context.Context, chainID uint32, tokenAddr common.Address, amount *big.Int) (*big.Int, error) { tokenName, err := m.config.GetTokenName(chainID, tokenAddr.Hex()) if err != nil { return nil, fmt.Errorf("error getting token name: %w", err) } // apply offset directly to amount without considering origin/dest quoteOffsetBps, err := m.config.GetQuoteOffsetBps(int(chainID), tokenName, true) if err != nil { return nil, fmt.Errorf("error getting quote offset bps: %w", err) } amountAdj := m.applyOffset(ctx, quoteOffsetBps, amount) return amountAdj, nil } // SubmitAllQuotes submits all quotes to the RFQ API. func (m *Manager) SubmitAllQuotes(ctx context.Context) (err error) { ctx, span := m.metricsHandler.Tracer().Start(ctx, "SubmitAllQuotes") defer func() { metrics.EndSpanWithErr(span, err) }() inv, err := m.inventoryManager.GetCommittableBalances(ctx, inventory.SkipDBCache()) if err != nil { return fmt.Errorf("error getting committable balances: %w", err) } return m.prepareAndSubmitQuotes(ctx, inv) } // SubscribeActiveRFQ subscribes to the RFQ websocket API. // This function is blocking and will run until the context is canceled. func (m *Manager) SubscribeActiveRFQ(ctx context.Context) (err error) { ctx, span := m.metricsHandler.Tracer().Start(ctx, "SubscribeActiveRFQ") defer func() { metrics.EndSpanWithErr(span, err) }() chainIDs := []int{} for chainID := range m.config.Chains { chainIDs = append(chainIDs, chainID) } req := model.SubscribeActiveRFQRequest{ ChainIDs: chainIDs, } span.SetAttributes(attribute.IntSlice("chain_ids", chainIDs)) reqChan := make(chan *model.ActiveRFQMessage) respChan, err := m.rfqClient.SubscribeActiveQuotes(ctx, &req, reqChan) if err != nil { return fmt.Errorf("error subscribing to active quotes: %w", err) } span.AddEvent("subscribed to active quotes") for { select { case <-ctx.Done(): return nil case msg, ok := <-respChan: if !ok { return errors.New("ws channel closed") } if msg == nil { continue } resp, err := m.generateActiveRFQ(ctx, msg) if err != nil { return fmt.Errorf("error generating active RFQ message: %w", err) } reqChan <- resp } } } // getActiveRFQ handles an active RFQ message. // //nolint:nilnil func (m *Manager) generateActiveRFQ(ctx context.Context, msg *model.ActiveRFQMessage) (resp *model.ActiveRFQMessage, err error) { ctx, span := m.metricsHandler.Tracer().Start(ctx, "generateActiveRFQ", trace.WithAttributes( attribute.String("op", msg.Op), attribute.String("content", string(msg.Content)), )) defer func() { metrics.EndSpanWithErr(span, err) }() if msg.Op != rest.RequestQuoteOp { span.AddEvent("not a request quote op") return nil, nil } inv, err := m.inventoryManager.GetCommittableBalances(ctx, inventory.SkipDBCache()) if err != nil { return nil, fmt.Errorf("error getting committable balances: %w", err) } var rfqRequest model.WsRFQRequest err = json.Unmarshal(msg.Content, &rfqRequest) if err != nil { return nil, fmt.Errorf("error unmarshalling quote data: %w", err) } span.SetAttributes(attribute.String("request_id", rfqRequest.RequestID)) originAmountExact, ok := new(big.Int).SetString(rfqRequest.Data.OriginAmountExact, base10) if !ok { return nil, fmt.Errorf("invalid rfq request deposit amount: %s", rfqRequest.Data.OriginAmountExact) } quoteInput := QuoteInput{ OriginChainID: rfqRequest.Data.OriginChainID, DestChainID: rfqRequest.Data.DestChainID, OriginTokenAddr: common.HexToAddress(rfqRequest.Data.OriginTokenAddr), DestTokenAddr: common.HexToAddress(rfqRequest.Data.DestTokenAddr), OriginBalance: inv[rfqRequest.Data.OriginChainID][common.HexToAddress(rfqRequest.Data.OriginTokenAddr)], DestBalance: inv[rfqRequest.Data.DestChainID][common.HexToAddress(rfqRequest.Data.DestTokenAddr)], OriginAmountExact: originAmountExact, } rawQuote, err := m.generateQuote(ctx, quoteInput) if err != nil { return nil, fmt.Errorf("error generating quote: %w", err) } span.SetAttributes(attribute.String("dest_amount", rawQuote.DestAmount)) rfqResp := model.WsRFQResponse{ RequestID: rfqRequest.RequestID, DestAmount: rawQuote.DestAmount, } span.SetAttributes(attribute.String("dest_amount", rawQuote.DestAmount)) respBytes, err := json.Marshal(rfqResp) if err != nil { return nil, fmt.Errorf("error serializing response: %w", err) } resp = &model.ActiveRFQMessage{ Op: rest.SendQuoteOp, Content: respBytes, } span.AddEvent("generated response") return resp, nil } // GetPrice gets the price of a token. func (m *Manager) GetPrice(parentCtx context.Context, tokenName string) (_ float64, err error) { ctx, span := m.metricsHandler.Tracer().Start(parentCtx, "GetPrice") defer func() { metrics.EndSpanWithErr(span, err) }() price, err := m.feePricer.GetTokenPrice(ctx, tokenName) if err != nil { return 0, fmt.Errorf("error getting price: %w", err) } return price, nil } // Prepares and submits quotes based on inventory. func (m *Manager) prepareAndSubmitQuotes(ctx context.Context, inv map[int]map[common.Address]*big.Int) (err error) { ctx, span := m.metricsHandler.Tracer().Start(ctx, "prepareAndSubmitQuotes") defer func() { span.SetAttributes(attribute.Bool("relay_paused", m.relayPaused.Load())) metrics.EndSpanWithErr(span, err) }() var allQuotes []model.PutRelayerQuoteRequest // First, generate all quotes g, gctx := errgroup.WithContext(ctx) mtx := sync.Mutex{} for cid, balances := range inv { chainid := cid // capture loop variable for a, b := range balances { address := a balance := b g.Go(func() error { quotes, err := m.generateQuotes(gctx, chainid, address, balance, inv) if err != nil { return fmt.Errorf("error generating quotes: %w", err) } mtx.Lock() allQuotes = append(allQuotes, quotes...) mtx.Unlock() return nil }) } } err = g.Wait() if err != nil { return fmt.Errorf("error generating quotes: %w", err) } span.SetAttributes(attribute.Int("num_quotes", len(allQuotes))) // Now, submit all the generated quotes if m.config.SubmitSingleQuotes { for _, quote := range allQuotes { if err := m.submitQuote(ctx, quote); err != nil { span.AddEvent("error submitting quote; setting relayPaused to true", trace.WithAttributes( attribute.String("error", err.Error()), attribute.Int(metrics.Origin, quote.OriginChainID), attribute.Int(metrics.Destination, quote.DestChainID), attribute.String("origin_token_addr", quote.OriginTokenAddr), attribute.String("dest_token_addr", quote.DestTokenAddr), attribute.String("max_origin_amount", quote.MaxOriginAmount), attribute.String("dest_amount", quote.DestAmount), )) m.relayPaused.Store(true) // Suppress error so that we can continue submitting quotes return nil } } } else { err = m.submitBulkQuotes(ctx, allQuotes) if err != nil { span.AddEvent("error submitting bulk quotes; setting relayPaused to true", trace.WithAttributes( attribute.String("error", err.Error()))) m.relayPaused.Store(true) return fmt.Errorf("error submitting bulk quotes: %w", err) } } // We successfully submitted all quotes, so we can set relayPaused to false m.relayPaused.Store(false) return nil } const meterName = "github.com/synapsecns/sanguine/services/rfq/relayer/quoter" // Essentially, if we know a destination chain token balance, then we just need to find which tokens are bridgeable to it. // We can do this by looking at the quotableTokens map, and finding the key that matches the destination chain token. // Generates quotes for a given chain ID, address, and balance. func (m *Manager) generateQuotes(parentCtx context.Context, chainID int, address common.Address, balance *big.Int, inv map[int]map[common.Address]*big.Int) (quotes []model.PutRelayerQuoteRequest, err error) { ctx, span := m.metricsHandler.Tracer().Start(parentCtx, "generateQuotes", trace.WithAttributes( attribute.Int(metrics.Origin, chainID), attribute.String("address", address.String()), attribute.String("balance", balance.String()), )) defer func() { metrics.EndSpanWithErr(span, err) }() destRFQAddr, err := m.config.GetRFQAddress(chainID) if err != nil { return nil, fmt.Errorf("error getting destination RFQ address: %w", err) } destTokenID := fmt.Sprintf("%d-%s", chainID, address.Hex()) // generate quotes in parallel g, gctx := errgroup.WithContext(ctx) quoteMtx := &sync.Mutex{} quotes = []model.PutRelayerQuoteRequest{} for k, itemTokenIDs := range m.quotableTokens { for _, tokenID := range itemTokenIDs { //nolint:nestif if tokenID == destTokenID { keyTokenID := k // Parse token info originStr := strings.Split(keyTokenID, "-")[0] origin, tokenErr := strconv.Atoi(originStr) if err != nil { span.AddEvent("error converting origin chainID", trace.WithAttributes( attribute.String("key_token_id", keyTokenID), attribute.String("error", tokenErr.Error()), )) continue } originTokenAddr := common.HexToAddress(strings.Split(keyTokenID, "-")[1]) var originBalance *big.Int originTokens, ok := inv[origin] if ok { originBalance = originTokens[originTokenAddr] } g.Go(func() error { input := QuoteInput{ OriginChainID: origin, DestChainID: chainID, OriginTokenAddr: originTokenAddr, DestTokenAddr: address, OriginBalance: originBalance, DestBalance: balance, DestRFQAddr: destRFQAddr.Hex(), OriginAmountExact: nil, // OriginAmountExact is only used for Active Quotes } quote, quoteErr := m.generateQuote(gctx, input) if quoteErr != nil { // continue generating quotes even if one fails span.AddEvent("error generating quote", trace.WithAttributes( attribute.String("key_token_id", keyTokenID), attribute.String("error", quoteErr.Error()), )) return nil } quoteMtx.Lock() defer quoteMtx.Unlock() quotes = append(quotes, *quote) return nil }) } } } err = g.Wait() if err != nil { return nil, fmt.Errorf("error generating quotes: %w", err) } m.currentQuotes = quotes return quotes, nil } // QuoteInput is a wrapper struct for input arguments to generateQuote. type QuoteInput struct { OriginChainID int DestChainID int OriginTokenAddr common.Address DestTokenAddr common.Address OriginBalance *big.Int DestBalance *big.Int OriginAmountExact *big.Int DestRFQAddr string } func (m *Manager) generateQuote(ctx context.Context, input QuoteInput) (quote *model.PutRelayerQuoteRequest, err error) { // Calculate the quote amount for this route originAmount, err := m.getOriginAmount(ctx, input) // don't quote if gas exceeds quote if errors.Is(err, errMinGasExceedsQuoteAmount) { originAmount = big.NewInt(0) } else if err != nil { logger.Error("Error getting quote amount", "error", err) return nil, err } // Calculate the fee for this route destToken, err := m.config.GetTokenName(uint32(input.DestChainID), input.DestTokenAddr.Hex()) if err != nil { logger.Error("Error getting dest token ID", "error", err) return nil, fmt.Errorf("error getting dest token ID: %w", err) } fee, err := m.feePricer.GetTotalFee(ctx, uint32(input.OriginChainID), uint32(input.DestChainID), destToken, true) if err != nil { logger.Error("Error getting total fee", "error", err) return nil, fmt.Errorf("error getting total fee: %w", err) } originRFQAddr, err := m.config.GetRFQAddress(input.OriginChainID) if err != nil { logger.Error("Error getting RFQ address", "error", err) return nil, fmt.Errorf("error getting RFQ address: %w", err) } // Build the quote destAmount, err := m.getDestAmount(ctx, originAmount, destToken, input) if err != nil { logger.Error("Error getting dest amount", "error", err) return nil, fmt.Errorf("error getting dest amount: %w", err) } quote = &model.PutRelayerQuoteRequest{ OriginChainID: input.OriginChainID, OriginTokenAddr: input.OriginTokenAddr.Hex(), DestChainID: input.DestChainID, DestTokenAddr: input.DestTokenAddr.Hex(), DestAmount: destAmount.String(), MaxOriginAmount: originAmount.String(), FixedFee: fee.String(), OriginFastBridgeAddress: originRFQAddr.Hex(), DestFastBridgeAddress: input.DestRFQAddr, } return quote, nil } // recordQuoteAmounts records the latest quotes from the relayer. func (m *Manager) recordQuoteAmounts(_ context.Context, observer metric.Observer) (err error) { if m.meter == nil || m.quoteAmountGauge == nil || m.currentQuotes == nil { return nil } for _, quote := range m.currentQuotes { originMetadata, err := m.inventoryManager.GetTokenMetadata(quote.OriginChainID, common.HexToAddress(quote.OriginTokenAddr)) if err != nil { return fmt.Errorf("error getting origin token metadata: %w", err) } destMetadata, err := m.inventoryManager.GetTokenMetadata(quote.DestChainID, common.HexToAddress(quote.DestTokenAddr)) if err != nil { return fmt.Errorf("error getting dest token metadata: %w", err) } destAmount, err := strconv.ParseFloat(quote.DestAmount, 64) if err != nil { return fmt.Errorf("error parsing dest amount: %w", err) } opts := metric.WithAttributes( attribute.Int(metrics.Origin, quote.OriginChainID), attribute.Int(metrics.Destination, quote.DestChainID), attribute.String("origin_token_name", originMetadata.Name), attribute.String("dest_token_name", destMetadata.Name), attribute.String("max_origin_amount", quote.MaxOriginAmount), attribute.String("fixed_fee", quote.FixedFee), attribute.String("relayer", m.relayerSigner.Address().Hex()), ) observer.ObserveFloat64(m.quoteAmountGauge, destAmount, opts) } return nil } // getOriginAmount calculates the origin quote amount for a given route. // //nolint:cyclop func (m *Manager) getOriginAmount(parentCtx context.Context, input QuoteInput) (quoteAmount *big.Int, err error) { ctx, span := m.metricsHandler.Tracer().Start(parentCtx, "getOriginAmount", trace.WithAttributes( attribute.Int(metrics.Origin, input.OriginChainID), attribute.Int(metrics.Destination, input.DestChainID), attribute.String("dest_address", input.DestTokenAddr.String()), attribute.String("origin_address", input.OriginTokenAddr.String()), attribute.String("origin_balance", input.OriginBalance.String()), attribute.String("dest_balance", input.DestBalance.String()), )) defer func() { span.SetAttributes(attribute.String("quote_amount", quoteAmount.String())) metrics.EndSpanWithErr(span, err) }() // First, check if we have enough gas to complete the a bridge for this route // If not, set the quote amount to zero to make sure a stale quote won't be used // TODO: handle in-flight gas; for now we can set a high min_gas_token sufficentGasOrigin, err := m.inventoryManager.HasSufficientGas(ctx, input.OriginChainID, nil) if err != nil { return nil, fmt.Errorf("error checking sufficient gas: %w", err) } sufficentGasDest, err := m.inventoryManager.HasSufficientGas(ctx, input.DestChainID, nil) if err != nil { return nil, fmt.Errorf("error checking sufficient gas: %w", err) } span.SetAttributes( attribute.Bool("sufficient_gas_origin", sufficentGasOrigin), attribute.Bool("sufficient_gas_dest", sufficentGasDest), ) if !sufficentGasOrigin || !sufficentGasDest { return big.NewInt(0), nil } // quotePct is the maximum percentage of our total balance on the destination chain that we are willing to quote. quotePct, err := m.config.GetQuotePct(input.DestChainID) if err != nil { return nil, fmt.Errorf("error getting quote pct: %w", err) } balanceFlt := new(big.Float).SetInt(input.DestBalance) quoteAmount, _ = new(big.Float).Mul(balanceFlt, new(big.Float).SetFloat64(quotePct/100)).Int(nil) // minQuoteAmount is more like a minimum quote *ceiling* // If the quoteAmount is less than the minQuoteAmount, override it & set to the minQuoteAmount. // IE: If set, we will offer quotes *at least* up-to-and-including this amount for the given DestChain+Token combo. minQuoteAmount := m.config.GetMinQuoteAmount(input.DestChainID, input.DestTokenAddr) if quoteAmount.Cmp(minQuoteAmount) < 0 { span.AddEvent("quote amount less than min quote amount", trace.WithAttributes( attribute.String("quote_amount", quoteAmount.String()), attribute.String("min_quote_amount", minQuoteAmount.String()), )) quoteAmount = minQuoteAmount } // At this point, quoteAmount will be the *higher* of the output values from these modifiers: quotePct vs minQuoteAmount // Clip the quoteAmount by the max origin balance. // This is the maximum balance that we are willing to accumulate on the origin chain. maxBalance := m.config.GetMaxBalance(input.OriginChainID, input.OriginTokenAddr) if maxBalance != nil && input.OriginBalance != nil { quotableBalance := new(big.Int).Sub(maxBalance, input.OriginBalance) if quotableBalance.Cmp(big.NewInt(0)) <= 0 { span.AddEvent("non-positive quotable balance", trace.WithAttributes( attribute.String("quotable_balance", quotableBalance.String()), attribute.String("max_balance", maxBalance.String()), attribute.String("origin_balance", input.OriginBalance.String()), )) quoteAmount = big.NewInt(0) } else if quoteAmount.Cmp(quotableBalance) > 0 { span.AddEvent("quote amount greater than quotable balance", trace.WithAttributes( attribute.String("quote_amount", quoteAmount.String()), attribute.String("quotable_balance", quotableBalance.String()), attribute.String("max_balance", maxBalance.String()), attribute.String("origin_balance", input.OriginBalance.String()), )) quoteAmount = quotableBalance } } // Clip the quoteAmount by the dest balance // IE: if the calculated ceiling at this point exceeds the actual balance on the account, set ceiling to the actual balance. if quoteAmount.Cmp(input.DestBalance) > 0 { span.AddEvent("quote amount greater than destination balance", trace.WithAttributes( attribute.String("quote_amount", quoteAmount.String()), attribute.String("balance", input.DestBalance.String()), )) quoteAmount = input.DestBalance } // Clip the quoteAmount by the maxQuoteAmount // IE: If the calculated ceiling at this point exceeds the arbitrary maximum ceiling, set to the maxQuoteAmount setting maxQuoteAmount := m.config.GetMaxRelayAmount(input.DestChainID, input.DestTokenAddr) if maxQuoteAmount != nil && quoteAmount.Cmp(maxQuoteAmount) > 0 { span.AddEvent("quote amount greater than max quote amount", trace.WithAttributes( attribute.String("quote_amount", quoteAmount.String()), attribute.String("max_quote_amount", maxQuoteAmount.String()), )) quoteAmount = maxQuoteAmount } // Deduct gas cost from the quote amount, if necessary // IE: Regardless of all prior ceiling considerations, we will still reserve enough for gas when appropriate. quoteAmount, err = m.deductGasCost(ctx, quoteAmount, input.DestTokenAddr, input.DestChainID) if err != nil { return nil, fmt.Errorf("error deducting gas cost: %w", err) } // If input included a OriginAmountExact, and our calculated ceiling at this point is sufficient to cover it, // then clip to the OriginAmountExact to indicate ability to cover that exact amount, as requested. // Otherwise return 0 to indicate inability to cover the requested amount. if input.OriginAmountExact != nil { if quoteAmount.Cmp(input.OriginAmountExact) >= 0 { quoteAmount = input.OriginAmountExact } else { span.AddEvent("quote amount insufficient to cover deposit amount", trace.WithAttributes( attribute.String("quote_amount", quoteAmount.String()), attribute.String("origin_amount_exact", input.OriginAmountExact.String()), )) quoteAmount = big.NewInt(0) } } return quoteAmount, nil } // deductGasCost deducts the gas cost from the quote amount, if necessary. func (m *Manager) deductGasCost(parentCtx context.Context, quoteAmount *big.Int, address common.Address, dest int) (quoteAmountAdj *big.Int, err error) { if !util.IsGasToken(address) { return quoteAmount, nil } _, span := m.metricsHandler.Tracer().Start(parentCtx, "deductGasCost", trace.WithAttributes( attribute.String("quote_amount", quoteAmount.String()), )) defer func() { span.SetAttributes(attribute.String("quote_amount", quoteAmount.String())) metrics.EndSpanWithErr(span, err) }() // Deduct the minimum gas token balance from the quote amount var minGasToken *big.Int minGasToken, err = m.config.GetMinGasToken(dest) if err != nil { return nil, fmt.Errorf("error getting min gas token: %w", err) } quoteAmountAdj = new(big.Int).Sub(quoteAmount, minGasToken) if quoteAmountAdj.Cmp(big.NewInt(0)) < 0 { err = errMinGasExceedsQuoteAmount span.AddEvent(err.Error(), trace.WithAttributes( attribute.String("quote_amount_adj", quoteAmountAdj.String()), attribute.String("min_gas_token", minGasToken.String()), )) return nil, err } return quoteAmountAdj, nil } var errMinGasExceedsQuoteAmount = errors.New("min gas token exceeds quote amount") func (m *Manager) getDestAmount(parentCtx context.Context, originAmount *big.Int, tokenName string, input QuoteInput) (*big.Int, error) { ctx, span := m.metricsHandler.Tracer().Start(parentCtx, "getDestAmount", trace.WithAttributes( attribute.String("quote_amount", originAmount.String()), )) defer func() { metrics.EndSpan(span) }() // Apply origin, destination, and quote width offsets originOffsetBps, err := m.config.GetQuoteOffsetBps(input.OriginChainID, tokenName, true) if err != nil { return nil, fmt.Errorf("error getting quote offset bps: %w", err) } destOffsetBps, err := m.config.GetQuoteOffsetBps(input.DestChainID, tokenName, false) if err != nil { return nil, fmt.Errorf("error getting quote offset bps: %w", err) } quoteWidthBps, err := m.config.GetQuoteWidthBps(input.DestChainID, tokenName) if err != nil { return nil, fmt.Errorf("error getting quote width bps: %w", err) } totalOffsetBps := originOffsetBps + destOffsetBps + quoteWidthBps destAmount := m.applyOffset(ctx, totalOffsetBps, originAmount) span.SetAttributes( attribute.Float64("origin_offset_bps", originOffsetBps), attribute.Float64("dest_offset_bps", destOffsetBps), attribute.Float64("quote_width_bps", quoteWidthBps), attribute.Float64("total_offset_bps", totalOffsetBps), attribute.String("dest_amount", destAmount.String()), ) return destAmount, nil } // applyOffset applies an offset (in bps) to a target. func (m *Manager) applyOffset(parentCtx context.Context, offsetBps float64, target *big.Int) (result *big.Int) { _, span := m.metricsHandler.Tracer().Start(parentCtx, "applyOffset", trace.WithAttributes( attribute.Float64("offset_bps", offsetBps), attribute.String("target", target.String()), )) defer func() { metrics.EndSpan(span) }() offsetFraction := new(big.Float).Quo(new(big.Float).SetInt64(int64(offsetBps)), new(big.Float).SetInt64(10000)) offsetFactor := new(big.Float).Sub(new(big.Float).SetInt64(1), offsetFraction) result, _ = new(big.Float).Mul(new(big.Float).SetInt(target), offsetFactor).Int(nil) return result } // Submits a single quote. func (m *Manager) submitQuote(ctx context.Context, quote model.PutRelayerQuoteRequest) error { quoteCtx, quoteCancel := context.WithTimeout(ctx, m.config.GetQuoteSubmissionTimeout()) defer quoteCancel() err := m.rfqClient.PutQuote(quoteCtx, "e) if err != nil { return fmt.Errorf("error submitting quote: %w", err) } return nil } // Submits multiple quotes. func (m *Manager) submitBulkQuotes(ctx context.Context, quotes []model.PutRelayerQuoteRequest) error { quoteCtx, quoteCancel := context.WithTimeout(ctx, m.config.GetQuoteSubmissionTimeout()) defer quoteCancel() req := model.PutBulkQuotesRequest{ Quotes: quotes, } err := m.rfqClient.PutBulkQuotes(quoteCtx, &req) if err != nil { return fmt.Errorf("error submitting bulk quotes: %w", err) } return nil }