diff --git a/services/rfq/relayer/quoter/export_test.go b/services/rfq/relayer/quoter/export_test.go index af7338a0ea..81d719ae03 100644 --- a/services/rfq/relayer/quoter/export_test.go +++ b/services/rfq/relayer/quoter/export_test.go @@ -9,13 +9,13 @@ import ( "github.com/synapsecns/sanguine/services/rfq/relayer/relconfig" ) -func (m *Manager) GenerateQuotes(ctx context.Context, chainID int, address common.Address, balance *big.Int) ([]model.PutQuoteRequest, error) { +func (m *Manager) GenerateQuotes(ctx context.Context, chainID int, address common.Address, balance *big.Int, inv map[int]map[common.Address]*big.Int) ([]model.PutQuoteRequest, error) { // nolint: errcheck - return m.generateQuotes(ctx, chainID, address, balance) + return m.generateQuotes(ctx, chainID, address, balance, inv) } -func (m *Manager) GetOriginAmount(ctx context.Context, origin, dest int, address common.Address, balance *big.Int) (*big.Int, error) { - return m.getOriginAmount(ctx, origin, dest, address, balance) +func (m *Manager) GetOriginAmount(ctx context.Context, input QuoteInput) (*big.Int, error) { + return m.getOriginAmount(ctx, input) } func (m *Manager) GetDestAmount(ctx context.Context, quoteAmount *big.Int, chainID int, tokenName string) (*big.Int, error) { diff --git a/services/rfq/relayer/quoter/quoter.go b/services/rfq/relayer/quoter/quoter.go index 81c8659a70..e4475b45a6 100644 --- a/services/rfq/relayer/quoter/quoter.go +++ b/services/rfq/relayer/quoter/quoter.go @@ -266,7 +266,7 @@ func (m *Manager) prepareAndSubmitQuotes(ctx context.Context, inv map[int]map[co address := a balance := b g.Go(func() error { - quotes, err := m.generateQuotes(gctx, chainID, address, balance) + quotes, err := m.generateQuotes(gctx, chainID, address, balance, inv) if err != nil { return fmt.Errorf("error generating quotes: %w", err) } @@ -324,7 +324,7 @@ 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) (quotes []model.PutQuoteRequest, err error) { +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.PutQuoteRequest, err error) { ctx, span := m.metricsHandler.Tracer().Start(parentCtx, "generateQuotes", trace.WithAttributes( attribute.Int(metrics.Origin, chainID), attribute.String("address", address.String()), @@ -348,9 +348,36 @@ func (m *Manager) generateQuotes(parentCtx context.Context, chainID int, address for _, tokenID := range itemTokenIDs { //nolint:nestif if tokenID == destTokenID { - keyTokenID := k + 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 { - quote, quoteErr := m.generateQuote(gctx, keyTokenID, chainID, address, balance, destRFQAddr.Hex()) + input := QuoteInput{ + OriginChainID: origin, + DestChainID: chainID, + OriginTokenAddr: originTokenAddr, + DestTokenAddr: address, + OriginBalance: originBalance, + DestBalance: balance, + DestRFQAddr: destRFQAddr.Hex(), + } + + quote, quoteErr := m.generateQuote(gctx, input) if quoteErr != nil { // continue generating quotes even if one fails span.AddEvent("error generating quote", trace.WithAttributes( @@ -376,18 +403,20 @@ func (m *Manager) generateQuotes(parentCtx context.Context, chainID int, address return quotes, nil } -func (m *Manager) generateQuote(ctx context.Context, keyTokenID string, chainID int, address common.Address, balance *big.Int, destRFQAddr string) (quote *model.PutQuoteRequest, err error) { - // Parse token info - originStr := strings.Split(keyTokenID, "-")[0] - origin, err := strconv.Atoi(originStr) - if err != nil { - logger.Error("Error converting origin chainID", "error", err) - return nil, fmt.Errorf("error converting origin chainID: %w", err) - } - originTokenAddr := common.HexToAddress(strings.Split(keyTokenID, "-")[1]) +// 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 + DestRFQAddr string +} +func (m *Manager) generateQuote(ctx context.Context, input QuoteInput) (quote *model.PutQuoteRequest, err error) { // Calculate the quote amount for this route - originAmount, err := m.getOriginAmount(ctx, origin, chainID, address, balance) + originAmount, err := m.getOriginAmount(ctx, input) // don't quote if gas exceeds quote if errors.Is(err, errMinGasExceedsQuoteAmount) { originAmount = big.NewInt(0) @@ -397,38 +426,38 @@ func (m *Manager) generateQuote(ctx context.Context, keyTokenID string, chainID } // Calculate the fee for this route - destToken, err := m.config.GetTokenName(uint32(chainID), address.Hex()) + 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(origin), uint32(chainID), destToken, true) + 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(origin) + 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, chainID, destToken) + destAmount, err := m.getDestAmount(ctx, originAmount, input.DestChainID, destToken) if err != nil { logger.Error("Error getting dest amount", "error", err) return nil, fmt.Errorf("error getting dest amount: %w", err) } quote = &model.PutQuoteRequest{ - OriginChainID: origin, - OriginTokenAddr: originTokenAddr.Hex(), - DestChainID: chainID, - DestTokenAddr: address.Hex(), + 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: destRFQAddr, + DestFastBridgeAddress: input.DestRFQAddr, } return quote, nil } @@ -471,12 +500,14 @@ func (m *Manager) recordQuoteAmounts(_ context.Context, observer metric.Observer // getOriginAmount calculates the origin quote amount for a given route. // //nolint:cyclop -func (m *Manager) getOriginAmount(parentCtx context.Context, origin, dest int, address common.Address, balance *big.Int) (quoteAmount *big.Int, err error) { +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.String(metrics.Origin, strconv.Itoa(origin)), - attribute.String(metrics.Destination, strconv.Itoa(dest)), - attribute.String("address", address.String()), - attribute.String("balance", balance.String()), + 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() { @@ -487,11 +518,11 @@ func (m *Manager) getOriginAmount(parentCtx context.Context, origin, dest int, a // 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, origin, nil) + 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, dest, nil) + sufficentGasDest, err := m.inventoryManager.HasSufficientGas(ctx, input.DestChainID, nil) if err != nil { return nil, fmt.Errorf("error checking sufficient gas: %w", err) } @@ -504,26 +535,26 @@ func (m *Manager) getOriginAmount(parentCtx context.Context, origin, dest int, a } // Apply the quotePct - quotePct, err := m.config.GetQuotePct(dest) + 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(balance) + balanceFlt := new(big.Float).SetInt(input.DestBalance) quoteAmount, _ = new(big.Float).Mul(balanceFlt, new(big.Float).SetFloat64(quotePct/100)).Int(nil) // Apply the quoteOffset to origin token. - tokenName, err := m.config.GetTokenName(uint32(dest), address.Hex()) + tokenName, err := m.config.GetTokenName(uint32(input.DestChainID), input.DestTokenAddr.Hex()) if err != nil { return nil, fmt.Errorf("error getting token name: %w", err) } - quoteOffsetBps, err := m.config.GetQuoteOffsetBps(origin, tokenName, true) + quoteOffsetBps, err := m.config.GetQuoteOffsetBps(input.OriginChainID, tokenName, true) if err != nil { return nil, fmt.Errorf("error getting quote offset bps: %w", err) } quoteAmount = m.applyOffset(ctx, quoteOffsetBps, quoteAmount) // Clip the quoteAmount by the minQuoteAmount - minQuoteAmount := m.config.GetMinQuoteAmount(dest, address) + 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()), @@ -532,17 +563,39 @@ func (m *Manager) getOriginAmount(parentCtx context.Context, origin, dest int, a quoteAmount = minQuoteAmount } - // Finally, clip the quoteAmount by the balance - if quoteAmount.Cmp(balance) > 0 { - span.AddEvent("quote amount greater than balance", trace.WithAttributes( + // Clip the quoteAmount by the max origin balance + 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 + } + } + + // Finally, clip the quoteAmount by the dest 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", balance.String()), + attribute.String("balance", input.DestBalance.String()), )) - quoteAmount = balance + quoteAmount = input.DestBalance } // Deduct gas cost from the quote amount, if necessary - quoteAmount, err = m.deductGasCost(ctx, quoteAmount, address, dest) + quoteAmount, err = m.deductGasCost(ctx, quoteAmount, input.DestTokenAddr, input.DestChainID) if err != nil { return nil, fmt.Errorf("error deducting gas cost: %w", err) } diff --git a/services/rfq/relayer/quoter/quoter_test.go b/services/rfq/relayer/quoter/quoter_test.go index bf587380e5..328ba60eaa 100644 --- a/services/rfq/relayer/quoter/quoter_test.go +++ b/services/rfq/relayer/quoter/quoter_test.go @@ -22,7 +22,8 @@ import ( func (s *QuoterSuite) TestGenerateQuotes() { // Generate quotes for USDC on the destination chain. balance := big.NewInt(1000_000_000) // 1000 USDC - quotes, err := s.manager.GenerateQuotes(s.GetTestContext(), int(s.destination), common.HexToAddress("0x0b2c639c533813f4aa9d7837caf62653d097ff85"), balance) + inv := map[int]map[common.Address]*big.Int{} + quotes, err := s.manager.GenerateQuotes(s.GetTestContext(), int(s.destination), common.HexToAddress("0x0b2c639c533813f4aa9d7837caf62653d097ff85"), balance, inv) s.Require().NoError(err) // Verify the quotes are generated as expected. @@ -45,7 +46,8 @@ func (s *QuoterSuite) TestGenerateQuotes() { func (s *QuoterSuite) TestGenerateQuotesForNativeToken() { // Generate quotes for ETH on the destination chain. balance, _ := new(big.Int).SetString("1000000000000000000", 10) // 1 ETH - quotes, err := s.manager.GenerateQuotes(s.GetTestContext(), int(s.destinationEth), util.EthAddress, balance) + inv := map[int]map[common.Address]*big.Int{} + quotes, err := s.manager.GenerateQuotes(s.GetTestContext(), int(s.destinationEth), util.EthAddress, balance, inv) s.Require().NoError(err) minGasToken, err := s.config.GetMinGasToken(int(s.destination)) @@ -72,7 +74,7 @@ func (s *QuoterSuite) TestGenerateQuotesForNativeToken() { s.config.BaseChainConfig.MinGasToken = "100000000000000000" // 0.1 ETH s.manager.SetConfig(s.config) - quotes, err = s.manager.GenerateQuotes(s.GetTestContext(), int(s.destinationEth), util.EthAddress, balance) + quotes, err = s.manager.GenerateQuotes(s.GetTestContext(), int(s.destinationEth), util.EthAddress, balance, inv) s.Require().NoError(err) minGasToken, err = s.config.GetMinGasToken(int(s.destination)) @@ -99,7 +101,7 @@ func (s *QuoterSuite) TestGenerateQuotesForNativeToken() { s.config.BaseChainConfig.MinGasToken = "1000000000000000001" // 0.1 ETH s.manager.SetConfig(s.config) - quotes, err = s.manager.GenerateQuotes(s.GetTestContext(), int(s.destinationEth), util.EthAddress, balance) + quotes, err = s.manager.GenerateQuotes(s.GetTestContext(), int(s.destinationEth), util.EthAddress, balance, inv) s.NoError(err) s.Equal(quotes[0].DestAmount, "0") s.Equal(quotes[0].MaxOriginAmount, "0") @@ -168,63 +170,81 @@ func (s *QuoterSuite) TestGetOriginAmount() { origin := int(s.origin) dest := int(s.destination) address := common.HexToAddress("0x0b2c639c533813f4aa9d7837caf62653d097ff85") + originAddr := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") balance := big.NewInt(1000_000_000) // 1000 USDC - setQuoteParams := func(quotePct, quoteOffset float64, minQuoteAmount string) { + setQuoteParams := func(quotePct, quoteOffset float64, minQuoteAmount, maxBalance string) { s.config.BaseChainConfig.QuotePct = "ePct destTokenCfg := s.config.Chains[dest].Tokens["USDC"] destTokenCfg.MinQuoteAmount = minQuoteAmount originTokenCfg := s.config.Chains[origin].Tokens["USDC"] originTokenCfg.QuoteOffsetBps = quoteOffset + originTokenCfg.MaxBalance = &maxBalance s.config.Chains[dest].Tokens["USDC"] = destTokenCfg s.config.Chains[origin].Tokens["USDC"] = originTokenCfg s.manager.SetConfig(s.config) } + input := quoter.QuoteInput{ + OriginChainID: origin, + DestChainID: dest, + OriginTokenAddr: originAddr, + DestTokenAddr: address, + OriginBalance: balance, + DestBalance: balance, + } + // Set default quote params; should return the balance. - quoteAmount, err := s.manager.GetOriginAmount(s.GetTestContext(), origin, dest, address, balance) + quoteAmount, err := s.manager.GetOriginAmount(s.GetTestContext(), input) s.NoError(err) expectedAmount := balance s.Equal(expectedAmount, quoteAmount) // Set QuotePct to 50 with MinQuoteAmount of 0; should be 50% of balance. - setQuoteParams(50, 0, "0") - quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), origin, dest, address, balance) + setQuoteParams(50, 0, "0", "0") + quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), input) s.NoError(err) expectedAmount = big.NewInt(500_000_000) s.Equal(expectedAmount, quoteAmount) // Set QuotePct to 50 with QuoteOffset of -1%. Should be 1% less than 50% of balance. - setQuoteParams(50, -100, "0") - quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), origin, dest, address, balance) + setQuoteParams(50, -100, "0", "0") + quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), input) s.NoError(err) expectedAmount = big.NewInt(495_000_000) s.Equal(expectedAmount, quoteAmount) // Set QuotePct to 25 with MinQuoteAmount of 500; should be 50% of balance. - setQuoteParams(25, 0, "500") - quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), origin, dest, address, balance) + setQuoteParams(25, 0, "500", "0") + quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), input) s.NoError(err) expectedAmount = big.NewInt(500_000_000) s.Equal(expectedAmount, quoteAmount) // Set QuotePct to 25 with MinQuoteAmount of 500; should be 50% of balance. - setQuoteParams(25, 0, "500") - quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), origin, dest, address, balance) + setQuoteParams(25, 0, "500", "0") + quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), input) s.NoError(err) expectedAmount = big.NewInt(500_000_000) s.Equal(expectedAmount, quoteAmount) // Set QuotePct to 25 with MinQuoteAmount of 1500; should be total balance. - setQuoteParams(25, 0, "1500") - quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), origin, dest, address, balance) + setQuoteParams(25, 0, "1500", "0") + quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), input) s.NoError(err) expectedAmount = big.NewInt(1000_000_000) s.Equal(expectedAmount, quoteAmount) + // Set QuotePct to 25 with MinQuoteAmount of 1500 and MaxBalance of 1200; should be 200. + setQuoteParams(25, 0, "1500", "1200") + quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), input) + s.NoError(err) + expectedAmount = big.NewInt(200_000_000) + s.Equal(expectedAmount, quoteAmount) + // Toggle insufficient gas; should be 0. s.setGasSufficiency(false) - quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), origin, dest, address, balance) + quoteAmount, err = s.manager.GetOriginAmount(s.GetTestContext(), input) s.NoError(err) expectedAmount = big.NewInt(0) s.Equal(expectedAmount, quoteAmount) diff --git a/services/rfq/relayer/relconfig/config.go b/services/rfq/relayer/relconfig/config.go index 94a3aa7fcc..57d012d37f 100644 --- a/services/rfq/relayer/relconfig/config.go +++ b/services/rfq/relayer/relconfig/config.go @@ -133,6 +133,8 @@ type TokenConfig struct { // 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"` + // 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. diff --git a/services/rfq/relayer/relconfig/getters.go b/services/rfq/relayer/relconfig/getters.go index c7e4966515..f24518bde6 100644 --- a/services/rfq/relayer/relconfig/getters.go +++ b/services/rfq/relayer/relconfig/getters.go @@ -390,6 +390,41 @@ func (c Config) GetQuoteOffsetBps(chainID int, tokenName string, isOrigin bool) return offset, nil } +var defaultMaxBalance *big.Int // default to nil, signifies 'positive inf' + +// GetMaxBalance returns the MaxBalance for the given chain and address. +// Note that this getter returns the value in native token decimals. +func (c Config) GetMaxBalance(chainID int, addr common.Address) *big.Int { + chainCfg, ok := c.Chains[chainID] + if !ok { + return defaultMaxBalance + } + + var tokenCfg *TokenConfig + for _, cfg := range chainCfg.Tokens { + if common.HexToAddress(cfg.Address).Hex() == addr.Hex() { + cfgCopy := cfg + tokenCfg = &cfgCopy + break + } + } + if tokenCfg == nil || tokenCfg.MaxBalance == nil { + return defaultMaxBalance + } + quoteAmountFlt, ok := new(big.Float).SetString(*tokenCfg.MaxBalance) + if !ok { + return defaultMaxBalance + } + if quoteAmountFlt.Cmp(big.NewFloat(0)) <= 0 { + return defaultMaxBalance + } + + // Scale the minBalance by the token decimals. + denomDecimalsFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenCfg.Decimals)), nil) + quoteAmountScaled, _ := new(big.Float).Mul(quoteAmountFlt, new(big.Float).SetInt(denomDecimalsFactor)).Int(nil) + return quoteAmountScaled +} + // GetQuoteWidthBps returns the QuoteWidthBps for the given chainID. func (c Config) GetQuoteWidthBps(chainID int) (value float64, err error) { rawValue, err := c.getChainConfigValue(chainID, "QuoteWidthBps")