diff --git a/domain/candidate_routes.go b/domain/candidate_routes.go index b59284cc6..345a74574 100644 --- a/domain/candidate_routes.go +++ b/domain/candidate_routes.go @@ -65,10 +65,15 @@ var ( // CandidateRouteSearcher is the interface for finding candidate routes. type CandidateRouteSearcher interface { - // FindCandidateRoutes finds candidate routes for a given tokenIn and tokenOutDenom + // FindCandidateRoutesOutGivenIn finds candidate routes for a given tokenIn and tokenOutDenom // using the given options. // Returns the candidate routes and an error if any. - FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDenom string, options CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) + FindCandidateRoutesOutGivenIn(tokenIn sdk.Coin, tokenOutDenom string, options CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) + + // FindCandidateRoutesOutGivenIn finds candidate routes for a given tokenOut and tokenInDenom + // using the given options. + // Returns the candidate routes and an error if any. + FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, tokenInDenom string, options CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) } // CandidateRouteDenomData represents the data for a candidate route for a given denom. diff --git a/domain/mocks/candidate_route_finder_mock.go b/domain/mocks/candidate_route_finder_mock.go index f926b15ce..a9123590a 100644 --- a/domain/mocks/candidate_route_finder_mock.go +++ b/domain/mocks/candidate_route_finder_mock.go @@ -13,7 +13,12 @@ type CandidateRouteFinderMock struct { var _ domain.CandidateRouteSearcher = CandidateRouteFinderMock{} -// FindCandidateRoutes implements domain.CandidateRouteSearcher. -func (c CandidateRouteFinderMock) FindCandidateRoutes(tokenIn types.Coin, tokenOutDenom string, options domain.CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) { +// FindCandidateRoutesOutGivenIn implements domain.CandidateRouteSearcher. +func (c CandidateRouteFinderMock) FindCandidateRoutesOutGivenIn(tokenIn types.Coin, tokenOutDenom string, options domain.CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) { + return c.Routes, c.Error +} + +// FindCandidateRoutesInGivenOut implements domain.CandidateRouteSearcher. +func (c CandidateRouteFinderMock) FindCandidateRoutesInGivenOut(tokenOut types.Coin, tokenInDenom string, options domain.CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) { return c.Routes, c.Error } diff --git a/domain/mocks/quote_mock.go b/domain/mocks/quote_mock.go index f60600198..fa5c34823 100644 --- a/domain/mocks/quote_mock.go +++ b/domain/mocks/quote_mock.go @@ -11,7 +11,7 @@ import ( type MockQuote struct { GetAmountInFunc func() types.Coin - GetAmountOutFunc func() math.Int + GetAmountOutFunc func() types.Coin GetRouteFunc func() []domain.SplitRoute } @@ -25,7 +25,7 @@ func (m *MockQuote) GetAmountIn() types.Coin { } // GetAmountOut implements domain.Quote. -func (m *MockQuote) GetAmountOut() math.Int { +func (m *MockQuote) GetAmountOut() types.Coin { if m.GetAmountOutFunc != nil { return m.GetAmountOutFunc() } diff --git a/domain/mocks/quote_simulator_mock.go b/domain/mocks/quote_simulator_mock.go index 5b6c2bed6..a84b209df 100644 --- a/domain/mocks/quote_simulator_mock.go +++ b/domain/mocks/quote_simulator_mock.go @@ -12,7 +12,7 @@ type QuoteSimulatorMock struct { } // SimulateQuote implements domain.QuoteSimulator. -func (q *QuoteSimulatorMock) SimulateQuote(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier math.LegacyDec, simulatorAddress string) domain.TxFeeInfo { +func (q *QuoteSimulatorMock) SimulateQuoteOutGivenIn(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier math.LegacyDec, simulatorAddress string) domain.TxFeeInfo { if q.SimulateQuoteFn != nil { return q.SimulateQuoteFn(ctx, quote, slippageToleranceMultiplier, simulatorAddress) } diff --git a/domain/mocks/route_mock.go b/domain/mocks/route_mock.go index e03727a34..c3be71243 100644 --- a/domain/mocks/route_mock.go +++ b/domain/mocks/route_mock.go @@ -10,13 +10,15 @@ import ( ) type RouteMock struct { - CalculateTokenOutByTokenInFunc func(ctx context.Context, tokenIn types.Coin) (types.Coin, error) - ContainsGeneralizedCosmWasmPoolFunc func() bool - GetPoolsFunc func() []domain.RoutablePool - GetTokenOutDenomFunc func() string - GetTokenInDenomFunc func() string - PrepareResultPoolsFunc func(ctx context.Context, tokenIn types.Coin, logger log.Logger) ([]domain.RoutablePool, math.LegacyDec, math.LegacyDec, error) - StringFunc func() string + CalculateTokenOutByTokenInFunc func(ctx context.Context, tokenIn types.Coin) (types.Coin, error) + CalculateTokenInByTokenOutFunc func(ctx context.Context, tokenOut types.Coin) (types.Coin, error) + ContainsGeneralizedCosmWasmPoolFunc func() bool + GetPoolsFunc func() []domain.RoutablePool + GetTokenOutDenomFunc func() string + GetTokenInDenomFunc func() string + PrepareResultPoolsExactAmountInFunc func(ctx context.Context, tokenIn types.Coin, logger log.Logger) ([]domain.RoutablePool, math.LegacyDec, math.LegacyDec, error) + PrepareResultPoolsExactAmountOutFunc func(ctx context.Context, tokenOut types.Coin, logger log.Logger) ([]domain.RoutablePool, math.LegacyDec, math.LegacyDec, error) + StringFunc func() string GetAmountInFunc func() math.Int GetAmountOutFunc func() math.Int @@ -31,6 +33,15 @@ func (r *RouteMock) CalculateTokenOutByTokenIn(ctx context.Context, tokenIn type panic("unimplemented") } +// CalculateTokenOutByTokenIn implements domain.Route. +func (r *RouteMock) CalculateTokenInByTokenOut(ctx context.Context, tokenOut types.Coin) (types.Coin, error) { + if r.CalculateTokenInByTokenOutFunc != nil { + return r.CalculateTokenInByTokenOutFunc(ctx, tokenOut) + } + + panic("unimplemented") +} + // ContainsGeneralizedCosmWasmPool implements domain.Route. func (r *RouteMock) ContainsGeneralizedCosmWasmPool() bool { if r.ContainsGeneralizedCosmWasmPoolFunc != nil { @@ -67,10 +78,17 @@ func (r *RouteMock) GetTokenInDenom() string { panic("unimplemented") } -// PrepareResultPools implements domain.Route. -func (r *RouteMock) PrepareResultPools(ctx context.Context, tokenIn types.Coin, logger log.Logger) ([]domain.RoutablePool, math.LegacyDec, math.LegacyDec, error) { - if r.PrepareResultPoolsFunc != nil { - return r.PrepareResultPoolsFunc(ctx, tokenIn, logger) +// PrepareResultPoolsExactAmountIn implements domain.Route. +func (r *RouteMock) PrepareResultPoolsExactAmountIn(ctx context.Context, tokenIn types.Coin, logger log.Logger) ([]domain.RoutablePool, math.LegacyDec, math.LegacyDec, error) { + if r.PrepareResultPoolsExactAmountInFunc != nil { + return r.PrepareResultPoolsExactAmountInFunc(ctx, tokenIn, logger) + } + + panic("unimplemented") +} +func (r *RouteMock) PrepareResultPoolsExactAmountOut(ctx context.Context, tokenIn types.Coin, logger log.Logger) ([]domain.RoutablePool, math.LegacyDec, math.LegacyDec, error) { + if r.PrepareResultPoolsExactAmountOutFunc != nil { + return r.PrepareResultPoolsExactAmountOutFunc(ctx, tokenIn, logger) } panic("unimplemented") diff --git a/domain/mocks/router_usecase_mock.go b/domain/mocks/router_usecase_mock.go index 3a07ee9c8..24f258c12 100644 --- a/domain/mocks/router_usecase_mock.go +++ b/domain/mocks/router_usecase_mock.go @@ -21,7 +21,8 @@ type RouterUsecaseMock struct { GetOptimalQuoteFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) GetOptimalQuoteInGivenOutFunc func(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, opts ...domain.RouterOption) (domain.Quote, error) GetBestSingleRouteQuoteFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string) (domain.Quote, error) - GetCustomDirectQuoteFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) + GetCustomDirectQuoteOutGivenInFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) + GetCustomDirectQuoteInGivenOutFunc func(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, poolID uint64) (domain.Quote, error) GetCustomDirectQuoteMultiPoolFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) GetCustomDirectQuoteMultiPoolInGivenOutFunc func(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) GetCandidateRoutesFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string) (ingesttypes.CandidateRoutes, error) @@ -66,7 +67,7 @@ func (m *RouterUsecaseMock) GetPoolSpotPrice(ctx context.Context, poolID uint64, return osmomath.BigDec{}, nil } -func (m *RouterUsecaseMock) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) { +func (m *RouterUsecaseMock) GetOptimalQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) { if m.GetOptimalQuoteFunc != nil { return m.GetOptimalQuoteFunc(ctx, tokenIn, tokenOutDenom, opts...) } @@ -87,14 +88,21 @@ func (m *RouterUsecaseMock) GetBestSingleRouteQuote(ctx context.Context, tokenIn panic("unimplemented") } -func (m *RouterUsecaseMock) GetCustomDirectQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) { - if m.GetCustomDirectQuoteFunc != nil { - return m.GetCustomDirectQuoteFunc(ctx, tokenIn, tokenOutDenom, poolID) +func (m *RouterUsecaseMock) GetCustomDirectQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) { + if m.GetCustomDirectQuoteOutGivenInFunc != nil { + return m.GetCustomDirectQuoteOutGivenInFunc(ctx, tokenIn, tokenOutDenom, poolID) } panic("unimplemented") } -func (m *RouterUsecaseMock) GetCustomDirectQuoteMultiPool(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) { +func (m *RouterUsecaseMock) GetCustomDirectQuoteInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, poolID uint64) (domain.Quote, error) { + if m.GetCustomDirectQuoteInGivenOutFunc != nil { + return m.GetCustomDirectQuoteInGivenOutFunc(ctx, tokenOut, tokenInDenom, poolID) + } + panic("unimplemented") +} + +func (m *RouterUsecaseMock) GetCustomDirectQuoteMultiPoolOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) { if m.GetCustomDirectQuoteMultiPoolFunc != nil { return m.GetCustomDirectQuoteMultiPoolFunc(ctx, tokenIn, tokenOutDenom, poolIDs) } diff --git a/domain/mvc/router.go b/domain/mvc/router.go index bee80b35c..176081ac3 100644 --- a/domain/mvc/router.go +++ b/domain/mvc/router.go @@ -59,19 +59,25 @@ type SimpleRouterUsecase interface { type RouterUsecase interface { SimpleRouterUsecase - // GetOptimalQuote returns the optimal quote for the given tokenIn and tokenOutDenom. - GetOptimalQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) + // GetOptimalQuoteOutGivenIn returns the optimal quote for the given tokenIn and tokenOutDenom. + GetOptimalQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) // GetOptimalQuoteInGivenOut returns the optimal quote for the given token swap method exact amount out. GetOptimalQuoteInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, opts ...domain.RouterOption) (domain.Quote, error) - // GetCustomDirectQuote returns the custom direct quote for the given tokenIn, tokenOutDenom and poolID. + // GetCustomDirectQuoteOutGivenIn returns the custom direct quote for the given tokenIn, tokenOutDenom and poolID. // It does not search for the route. It directly computes the quote for the given poolID. // This allows to bypass a min liquidity requirement in the router when attempting to swap over a specific pool. - GetCustomDirectQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) - // GetCustomDirectQuoteMultiPool calculates direct custom quote for given tokenIn and tokenOutDenom over given poolID route. + GetCustomDirectQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) + + // GetCustomDirectQuoteInGivenOut returns the custom direct quote for the given tokenOut, tokenInDenom and poolID. + // It does not search for the route. It directly computes the quote for the given poolID. + // This allows to bypass a min liquidity requirement in the router when attempting to swap over a specific pool. + GetCustomDirectQuoteInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, poolID uint64) (domain.Quote, error) + + // GetCustomDirectQuoteMultiPoolOutGivenIn calculates direct custom quote for given tokenIn and tokenOutDenom over given poolID route. // Underlying implementation uses GetCustomDirectQuote. - GetCustomDirectQuoteMultiPool(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) + GetCustomDirectQuoteMultiPoolOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) // GetCustomDirectQuoteMultiPool calculates direct custom quote for given tokenOut and tokenInDenom over given poolID route. // Underlying implementation uses GetCustomDirectQuote. GetCustomDirectQuoteMultiPoolInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) diff --git a/domain/quote_simulator.go b/domain/quote_simulator.go index a79f4fba6..d17c044b1 100644 --- a/domain/quote_simulator.go +++ b/domain/quote_simulator.go @@ -13,5 +13,5 @@ type QuoteSimulator interface { // - Only direct (non-split) quotes are supported. // Retursn error if: // - Simulator address does not have enough funds to pay for the quote. - SimulateQuote(ctx context.Context, quote Quote, slippageToleranceMultiplier osmomath.Dec, simulatorAddress string) TxFeeInfo + SimulateQuoteOutGivenIn(ctx context.Context, quote Quote, slippageToleranceMultiplier osmomath.Dec, simulatorAddress string) TxFeeInfo } diff --git a/domain/route_test.go b/domain/route_test.go index a00465033..c779a7cd8 100644 --- a/domain/route_test.go +++ b/domain/route_test.go @@ -132,7 +132,7 @@ func (s *RouterTestSuite) TestPrepareResultPools() { s.Run(name, func() { // Note: token in is chosen arbitrarily since it is irrelevant for this test - actualPools, _, _, err := tc.route.PrepareResultPools(context.TODO(), sdk.NewCoin(DenomTwo, DefaultAmt0), &log.NoOpLogger{}) + actualPools, _, _, err := tc.route.PrepareResultPoolsExactAmountIn(context.TODO(), sdk.NewCoin(DenomTwo, DefaultAmt0), &log.NoOpLogger{}) s.Require().NoError(err) s.ValidateRoutePools(tc.expectedPools, actualPools) diff --git a/domain/router.go b/domain/router.go index e6a4a804f..e1a788f99 100644 --- a/domain/router.go +++ b/domain/router.go @@ -30,6 +30,10 @@ type Route interface { // Returns error if the calculation fails. CalculateTokenOutByTokenIn(ctx context.Context, tokenIn sdk.Coin) (sdk.Coin, error) + // CalculateTokenInByTokenOut calculates the token out amount given the token in amount. + // Returns error if the calculation fails. + CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin) (sdk.Coin, error) + // Returns token out denom of the last pool in the route. // If route is empty, returns empty string. GetTokenOutDenom() string @@ -38,7 +42,7 @@ type Route interface { // If route is empty, returns empty string. GetTokenInDenom() string - // PrepareResultPools strips away unnecessary fields + // PrepareResultPoolsExactAmountIn strips away unnecessary fields // from each pool in the route, // leaving only the data needed by client // Runs the quote logic one final time to compute the effective spot price. @@ -46,7 +50,9 @@ type Route interface { // Computes the spot price of the route. // Returns the spot price before swap and effective spot price. // The token in is the base token and the token out is the quote token. - PrepareResultPools(ctx context.Context, tokenIn sdk.Coin, logger log.Logger) ([]RoutablePool, osmomath.Dec, osmomath.Dec, error) + PrepareResultPoolsExactAmountIn(ctx context.Context, tokenIn sdk.Coin, logger log.Logger) ([]RoutablePool, osmomath.Dec, osmomath.Dec, error) + + PrepareResultPoolsExactAmountOut(ctx context.Context, tokenOut sdk.Coin, logger log.Logger) ([]RoutablePool, osmomath.Dec, osmomath.Dec, error) String() string } @@ -59,7 +65,7 @@ type SplitRoute interface { type Quote interface { GetAmountIn() sdk.Coin - GetAmountOut() osmomath.Int + GetAmountOut() sdk.Coin GetRoute() []SplitRoute GetEffectiveFee() osmomath.Dec GetPriceImpact() osmomath.Dec diff --git a/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go b/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go index 7e25db8dc..0af46b10c 100644 --- a/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go +++ b/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go @@ -19,13 +19,13 @@ func (o *orderbookFillerIngestPlugin) estimateCyclicArb(ctx blockctx.BlockCtxI, goCtx := ctx.AsGoCtx() - baseInOrderbookQuote, err := o.routerUseCase.GetCustomDirectQuote(goCtx, coinIn, denomOut, canonicalOrderbookPoolId) + baseInOrderbookQuote, err := o.routerUseCase.GetCustomDirectQuoteOutGivenIn(goCtx, coinIn, denomOut, canonicalOrderbookPoolId) if err != nil { return osmomath.Int{}, nil, err } // Make it $10 in USDC terms for quoteDenom - quoteInCoin := sdk.NewCoin(denomOut, baseInOrderbookQuote.GetAmountOut()) + quoteInCoin := sdk.NewCoin(denomOut, baseInOrderbookQuote.GetAmountOut().Amount) cyclicArbQuote, err := o.routerUseCase.GetSimpleQuote(goCtx, quoteInCoin, coinIn.Denom, domain.WithDisableSplitRoutes()) if err != nil { return osmomath.Int{}, nil, err @@ -45,7 +45,7 @@ func (o *orderbookFillerIngestPlugin) estimateCyclicArb(ctx blockctx.BlockCtxI, fullCyclicArbRoute := append(routeThere[0].GetPools(), routeBack[0].GetPools()...) - return inverseAmountIn, fullCyclicArbRoute, nil + return inverseAmountIn.Amount, fullCyclicArbRoute, nil } // validateArb validates the arb opportunity by constructing a route from SQS router and then simulating it against chain. diff --git a/pools/usecase/pools_usecase_test.go b/pools/usecase/pools_usecase_test.go index 0c75d5634..ad007ce10 100644 --- a/pools/usecase/pools_usecase_test.go +++ b/pools/usecase/pools_usecase_test.go @@ -256,9 +256,9 @@ func (s *PoolsUsecaseTestSuite) TestGetRoutesFromCandidates() { // helper method for validation. // Note token in is chosen arbitrarily since it is irrelevant for this test tokenIn := sdk.NewCoin(tc.tokenInDenom, osmomath.NewInt(100)) - actualPools, _, _, err := actualRoute.PrepareResultPools(context.TODO(), tokenIn, logger) + actualPools, _, _, err := actualRoute.PrepareResultPoolsExactAmountIn(context.TODO(), tokenIn, logger) s.Require().NoError(err) - expectedPools, _, _, err := expectedRoute.PrepareResultPools(context.TODO(), tokenIn, logger) + expectedPools, _, _, err := expectedRoute.PrepareResultPoolsExactAmountIn(context.TODO(), tokenIn, logger) s.Require().NoError(err) // Validates: diff --git a/quotesimulator/quote_simulator.go b/quotesimulator/quote_simulator.go index efc8d3962..509aed2ad 100644 --- a/quotesimulator/quote_simulator.go +++ b/quotesimulator/quote_simulator.go @@ -31,7 +31,7 @@ func NewQuoteSimulator(msgSimulator tx.MsgSimulator, encodingConfig params.Encod } // SimulateQuote implements domain.QuoteSimulator -func (q *quoteSimulator) SimulateQuote(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier osmomath.Dec, simulatorAddress string) domain.TxFeeInfo { +func (q *quoteSimulator) SimulateQuoteOutGivenIn(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier osmomath.Dec, simulatorAddress string) domain.TxFeeInfo { route := quote.GetRoute() if len(route) != 1 { return domain.TxFeeInfo{Err: fmt.Sprintf("route length must be 1, got %d", len(route))} @@ -50,7 +50,7 @@ func (q *quoteSimulator) SimulateQuote(ctx context.Context, quote domain.Quote, // Slippage bound from the token in and provided slippage tolerance multiplier tokenOutAmt := quote.GetAmountOut() - slippageBound := tokenOutAmt.ToLegacyDec().Mul(slippageToleranceMultiplier).TruncateInt() + slippageBound := tokenOutAmt.Amount.ToLegacyDec().Mul(slippageToleranceMultiplier).TruncateInt() // Create the swap message swapMsg := &poolmanagertypes.MsgSwapExactAmountIn{ diff --git a/quotesimulator/quote_simulator_test.go b/quotesimulator/quote_simulator_test.go index ef6fb87f5..341baf17b 100644 --- a/quotesimulator/quote_simulator_test.go +++ b/quotesimulator/quote_simulator_test.go @@ -54,8 +54,8 @@ func TestSimulateQuote(t *testing.T) { return uosmoCoinIn }, - GetAmountOutFunc: func() math.Int { - return osmomath.NewInt(200000) + GetAmountOutFunc: func() sdk.Coin { + return sdk.Coin{Amount: osmomath.NewInt(200000)} }, GetRouteFunc: func() []domain.SplitRoute { @@ -112,7 +112,7 @@ func TestSimulateQuote(t *testing.T) { ) // System under test - priceInfo := simulator.SimulateQuote( + priceInfo := simulator.SimulateQuoteOutGivenIn( context.Background(), mockQuote, tt.slippageToleranceMultiplier, diff --git a/router/delivery/http/router_handler.go b/router/delivery/http/router_handler.go index b5cc1b448..9f61f1749 100644 --- a/router/delivery/http/router_handler.go +++ b/router/delivery/http/router_handler.go @@ -128,7 +128,7 @@ func (a *RouterHandler) GetOptimalQuote(c echo.Context) (err error) { var quote domain.Quote if req.SwapMethod() == domain.TokenSwapMethodExactIn { - quote, err = a.RUsecase.GetOptimalQuote(ctx, *tokenIn, tokenOutDenom, routerOpts...) + quote, err = a.RUsecase.GetOptimalQuoteOutGivenIn(ctx, *tokenIn, tokenOutDenom, routerOpts...) } else { quote, err = a.RUsecase.GetOptimalQuoteInGivenOut(ctx, *tokenIn, tokenOutDenom, routerOpts...) } @@ -156,7 +156,7 @@ func (a *RouterHandler) GetOptimalQuote(c echo.Context) (err error) { // Only "out given in" swap method is supported for simulation. Thus, we also check for tokenOutDenom being set. simulatorAddress := req.SimulatorAddress if req.SingleRoute && simulatorAddress != "" && req.SwapMethod() == domain.TokenSwapMethodExactIn { - priceInfo := a.QuoteSimulator.SimulateQuote(ctx, quote, req.SlippageToleranceMultiplier, simulatorAddress) + priceInfo := a.QuoteSimulator.SimulateQuoteOutGivenIn(ctx, quote, req.SlippageToleranceMultiplier, simulatorAddress) // TODO: // Set the quote price info. quote.SetQuotePriceInfo(&priceInfo) @@ -232,7 +232,7 @@ func (a *RouterHandler) GetDirectCustomQuote(c echo.Context) (err error) { // Get the quote based on the swap method. var quote domain.Quote if req.SwapMethod() == domain.TokenSwapMethodExactIn { - quote, err = a.RUsecase.GetCustomDirectQuoteMultiPool(ctx, *tokenIn, tokenOutDenom, req.PoolID) + quote, err = a.RUsecase.GetCustomDirectQuoteMultiPoolOutGivenIn(ctx, *tokenIn, tokenOutDenom, req.PoolID) } else { quote, err = a.RUsecase.GetCustomDirectQuoteMultiPoolInGivenOut(ctx, *tokenIn, tokenOutDenom, req.PoolID) } diff --git a/router/usecase/candidate_routes.go b/router/usecase/candidate_routes.go index fc861bcfa..87910d0f7 100644 --- a/router/usecase/candidate_routes.go +++ b/router/usecase/candidate_routes.go @@ -36,8 +36,8 @@ func NewCandidateRouteFinder(candidateRouteDataHolder mvc.CandidateRouteSearchDa } } -// FindCandidateRoutes implements domain.CandidateRouteFinder. -func (c candidateRouteFinder) FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDenom string, options domain.CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) { +// FindCandidateRoutesOutGivenIn implements domain.CandidateRouteFinder. +func (c candidateRouteFinder) FindCandidateRoutesOutGivenIn(tokenIn sdk.Coin, tokenOutDenom string, options domain.CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) { routes := make([]candidateRouteWrapper, 0, options.MaxRoutes) // Preallocate constant visited map size to avoid reallocations. @@ -77,6 +77,7 @@ func (c candidateRouteFinder) FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDeno { CandidatePool: ingesttypes.CandidatePool{ ID: canonicalOrderbook.GetId(), + TokenInDenom: tokenIn.Denom, TokenOutDenom: tokenOutDenom, }, PoolDenoms: canonicalOrderbook.GetSQSPoolModel().PoolDenoms, @@ -208,6 +209,7 @@ func (c candidateRouteFinder) FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDeno newPath = append(newPath, candidatePoolWrapper{ CandidatePool: ingesttypes.CandidatePool{ ID: poolID, + TokenInDenom: currenTokenInDenom, TokenOutDenom: denom, }, PoolDenoms: poolDenoms, @@ -233,7 +235,209 @@ func (c candidateRouteFinder) FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDeno } } - return validateAndFilterRoutes(routes, tokenIn.Denom, c.logger) + return validateAndFilterRoutesOutGivenIn(routes, tokenIn.Denom, c.logger) +} + +// FindCandidateRoutesOutGivenIn implements domain.CandidateRouteFinder. +func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, tokenInDenom string, options domain.CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) { + routes := make([]candidateRouteWrapper, 0, options.MaxRoutes) + + // Preallocate constant visited map size to avoid reallocations. + // TODO: choose the best size for the visited map. + visited := make(map[uint64]struct{}, 100) + // visited := make([]bool, len(pools)) + + // Preallocate constant queue size to avoid dynamic reallocations. + // TODO: choose the best size for the queue. + queue := make([][]candidatePoolWrapper, 0, 100) + queue = append(queue, make([]candidatePoolWrapper, 0, options.MaxPoolsPerRoute)) + + denomData, err := c.candidateRouteDataHolder.GetDenomData(tokenOut.Denom) + if err != nil { + return ingesttypes.CandidateRoutes{}, err + } + + if len(denomData.CanonicalOrderbooks) > 0 { + canonicalOrderbook, ok := denomData.CanonicalOrderbooks[tokenInDenom] + if ok { + shouldSkipCanonicalOrderbook := false + // Filter the canonical orderbook pool using the pool filters. + for _, filter := range options.PoolFiltersAnyOf { + // nolint: forcetypeassert + canonicalOrderbookPoolWrapper := (canonicalOrderbook).(*ingesttypes.PoolWrapper) + if filter(canonicalOrderbookPoolWrapper) { + shouldSkipCanonicalOrderbook = true + break + } + } + + if !shouldSkipCanonicalOrderbook { + // Add the canonical orderbook as a route. + routes = append(routes, candidateRouteWrapper{ + IsCanonicalOrderboolRoute: true, + Pools: []candidatePoolWrapper{ + { + CandidatePool: ingesttypes.CandidatePool{ + ID: canonicalOrderbook.GetId(), + TokenInDenom: tokenInDenom, + TokenOutDenom: tokenOut.Denom, + }, + PoolDenoms: canonicalOrderbook.GetSQSPoolModel().PoolDenoms, + }, + }, + }) + } + + visited[canonicalOrderbook.GetId()] = struct{}{} + } + } + + for len(queue) > 0 && len(routes) < options.MaxRoutes { + currentRoute := queue[0] + queue[0] = nil // Clear the slice to avoid holding onto references + queue = queue[1:] + + lastPoolID := uint64(0) + currentTokenOutDenom := tokenOut.Denom + if len(currentRoute) > 0 { + lastPool := currentRoute[len(currentRoute)-1] + lastPoolID = lastPool.ID + currentTokenOutDenom = lastPool.TokenInDenom + } + + denomData, err := c.candidateRouteDataHolder.GetDenomData(currentTokenOutDenom) + if err != nil { + return ingesttypes.CandidateRoutes{}, err + } + + rankedPools := denomData.SortedPools + + if len(rankedPools) == 0 { + c.logger.Debug("no pools found for denom out candidate route search", zap.String("denom", currentTokenOutDenom)) + } + + for i := 0; i < len(rankedPools) && len(routes) < options.MaxRoutes; i++ { + // Unsafe cast for performance reasons. + // nolint: forcetypeassert + pool := (rankedPools[i]).(*ingesttypes.PoolWrapper) + poolID := pool.ChainModel.GetId() + + if _, ok := visited[poolID]; ok { + continue + } + + // If the option is configured to skip a given pool + // We mark it as visited and continue. + if options.ShouldSkipPool(pool) { + visited[poolID] = struct{}{} + continue + } + + if pool.GetLiquidityCap().Uint64() < options.MinPoolLiquidityCap { + visited[poolID] = struct{}{} + // Skip pools that have less liquidity than the minimum required. + continue + } + + poolDenoms := pool.SQSModel.PoolDenoms + hasTokenIn := false + hasTokenOut := false + shouldSkipPool := false + for _, denom := range poolDenoms { + if denom == currentTokenOutDenom { + hasTokenOut = true + } + if denom == tokenInDenom { + hasTokenIn = true + } + + // Avoid going through pools that has the initial token out denom twice. + if len(currentRoute) > 0 && denom == tokenOut.Denom { + shouldSkipPool = true + break + } + } + + if shouldSkipPool { + continue + } + + if !hasTokenOut { + continue + } + + // Microptimization for the first pool in the route. + if len(currentRoute) == 0 { + currentTokenOutAmount := pool.SQSModel.Balances.AmountOf(currentTokenOutDenom) + + // HACK: alloyed LP share is not contained in balances. + // TODO: remove the hack and ingest the LP share balance on the Osmosis side. + // https://linear.app/osmosis/issue/DATA-236/bug-alloyed-lp-share-is-not-present-in-balances + cosmwasmModel := pool.SQSModel.CosmWasmPoolModel + isAlloyed := cosmwasmModel != nil && cosmwasmModel.IsAlloyTransmuter() + + if currentTokenOutAmount.LT(tokenOut.Amount) && !isAlloyed { + visited[poolID] = struct{}{} + // Not enough tokenOut to swap. + continue + } + } + + currentPoolID := poolID + for _, denom := range poolDenoms { + if denom == currentTokenOutDenom { + continue + } + if hasTokenIn && denom != tokenInDenom { + continue + } + + denomData, err := c.candidateRouteDataHolder.GetDenomData(currentTokenOutDenom) + if err != nil { + return ingesttypes.CandidateRoutes{}, err + } + + rankedPools := denomData.SortedPools + if len(rankedPools) == 0 { + c.logger.Debug("no pools found for denom out candidate route search", zap.String("denom", denom)) + continue + } + + if lastPoolID == uint64(0) || lastPoolID != currentPoolID { + newPath := make([]candidatePoolWrapper, len(currentRoute), len(currentRoute)+1) + + copy(newPath, currentRoute) + + newPath = append(newPath, candidatePoolWrapper{ + CandidatePool: ingesttypes.CandidatePool{ + ID: poolID, + TokenInDenom: denom, + TokenOutDenom: currentTokenOutDenom, + }, + PoolDenoms: poolDenoms, + }) + + if len(newPath) <= options.MaxPoolsPerRoute { + if hasTokenIn { + routes = append(routes, candidateRouteWrapper{ + Pools: newPath, + IsCanonicalOrderboolRoute: false, + }) + break + } else { + queue = append(queue, newPath) + } + } + } + } + } + + for _, pool := range currentRoute { + visited[pool.ID] = struct{}{} + } + } + + return validateAndFilterRoutesInGivenOut(routes, tokenOut.Denom, c.logger) } // Pool represents a pool in the decentralized exchange. diff --git a/router/usecase/candidate_routes_bench_test.go b/router/usecase/candidate_routes_bench_test.go index dc032ab9b..35e303878 100644 --- a/router/usecase/candidate_routes_bench_test.go +++ b/router/usecase/candidate_routes_bench_test.go @@ -38,7 +38,7 @@ func BenchmarkCandidateRouteSearcher(b *testing.B) { // Run the benchmark for i := 0; i < b.N; i++ { // System under test - _, err := usecase.CandidateRouteSearcher.FindCandidateRoutes(tokenIn, tokenOutDenom, candidateRouteOptions) + _, err := usecase.CandidateRouteSearcher.FindCandidateRoutesOutGivenIn(tokenIn, tokenOutDenom, candidateRouteOptions) s.Require().NoError(err) if err != nil { b.Errorf("FindCandidateRoutes returned an error: %v", err) diff --git a/router/usecase/candidate_routes_test.go b/router/usecase/candidate_routes_test.go index 73d90b6f5..4ee91b2ad 100644 --- a/router/usecase/candidate_routes_test.go +++ b/router/usecase/candidate_routes_test.go @@ -71,7 +71,7 @@ func (s *RouterTestSuite) TestCandidateRouteSearcher_HappyPath() { expectedMinPoolLiquidityCapInt := osmomath.NewInt(int64(routerConfig.MinPoolLiquidityCap)) // System under test - candidateRoutes, err := usecase.CandidateRouteSearcher.FindCandidateRoutes(tc.tokenIn, tc.tokenOutDenom, candidateRouteOptions) + candidateRoutes, err := usecase.CandidateRouteSearcher.FindCandidateRoutesOutGivenIn(tc.tokenIn, tc.tokenOutDenom, candidateRouteOptions) s.Require().NoError(err) // Validate that at least one route found @@ -146,7 +146,7 @@ func (s *RouterTestSuite) TestCandidateRouteSearcher_SkipPoolOption() { const expectedPoolID = uint64(1) // System under test #1 - candidateRoutes, err := usecase.CandidateRouteSearcher.FindCandidateRoutes(oneOSMOIn, ATOM, candidateRouteOptions) + candidateRoutes, err := usecase.CandidateRouteSearcher.FindCandidateRoutesOutGivenIn(oneOSMOIn, ATOM, candidateRouteOptions) s.Require().NoError(err) // Contains default pool ID @@ -166,7 +166,7 @@ func (s *RouterTestSuite) TestCandidateRouteSearcher_SkipPoolOption() { } // System under test #2 - candidateRoutes, err = usecase.CandidateRouteSearcher.FindCandidateRoutes(oneOSMOIn, ATOM, candidateRouteOptions) + candidateRoutes, err = usecase.CandidateRouteSearcher.FindCandidateRoutesOutGivenIn(oneOSMOIn, ATOM, candidateRouteOptions) s.Require().NoError(err) didFindExpectedPoolID = foundExpectedPoolID(expectedPoolID, candidateRoutes.Routes) diff --git a/router/usecase/dynamic_splits.go b/router/usecase/dynamic_splits.go index c8c8ca748..3e6e8b675 100644 --- a/router/usecase/dynamic_splits.go +++ b/router/usecase/dynamic_splits.go @@ -19,12 +19,12 @@ type split struct { const totalIncrements = uint8(10) -// getSplitQuote returns the best quote for the given routes and tokenIn. +// getSplitQuoteOutGivenIn returns the best quote for the given routes and tokenIn. // It uses dynamic programming to find the optimal split of the tokenIn among the routes. // The algorithm is based on the knapsack problem. // The time complexity is O(n * m), where n is the number of routes and m is the totalIncrements. // The space complexity is O(n * m). -func getSplitQuote(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Coin) (domain.Quote, error) { +func getSplitQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Coin) (domain.Quote, error) { // Routes must be non-empty if len(routes) == 0 { return nil, errors.New("no routes") @@ -179,6 +179,166 @@ func getSplitQuote(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Co return quote, nil } +// getSplitQuoteOutGivenIn returns the best quote for the given routes and tokenOut. +// It uses dynamic programming to find the optimal split of the tokenIn among the routes. +// The algorithm is based on the knapsack problem. +// The time complexity is O(n * m), where n is the number of routes and m is the totalIncrements. +// The space complexity is O(n * m). +func getSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, tokenOut sdk.Coin) (domain.Quote, error) { + // Routes must be non-empty + if len(routes) == 0 { + return nil, errors.New("no routes") + } + // If only one route, return the best single route quote + if len(routes) == 1 { + route := routes[0] + coinIn, err := route.CalculateTokenInByTokenOut(ctx, tokenOut) + if err != nil { + return nil, err + } + + quote := "eExactAmountOut{ + AmountIn: coinIn.Amount, + AmountOut: tokenOut, + Route: []domain.SplitRoute{&RouteWithOutAmount{ + RouteImpl: route, + OutAmount: tokenOut.Amount, + InAmount: coinIn.Amount, + }}, + } + + return quote, nil + } + + // proportions[x][j] stores the proportion of tokens used for the j-th + // route that leads to the optimal value at each state. The proportions slice, + // essentially, records the decision made at each step. + proportions := make([][]uint8, totalIncrements+1) + // dp stores the maximum output values. + dp := make([][]osmomath.Int, totalIncrements+1) + + // Step 1: initialize tables + for i := 0; i < int(totalIncrements+1); i++ { + dp[i] = make([]osmomath.Int, len(routes)+1) + + dp[i][0] = zero + + proportions[i] = make([]uint8, len(routes)+1) + } + + // Initialize the first column with 0 + for j := 0; j <= len(routes); j++ { + dp[0][j] = zero + } + + outAmountDec := tokenOut.Amount.ToLegacyDec() + + // callback with caching capabilities. + computeAndCacheOutAmountCb := getComputeAndCacheInAmountCb(ctx, outAmountDec, tokenOut.Denom, routes) + + // Step 2: fill the tables + for x := uint8(1); x <= totalIncrements; x++ { + for j := 1; j <= len(routes); j++ { + dp[x][j] = dp[x][j-1] // Not using the j-th route + proportions[x][j] = 0 // Default increment (0% of the token) + + for p := uint8(0); p <= x; p++ { + // Consider two scenarios: + // 1) Not using the j-th route at all, which would yield an output of dp[x][j-1]. + // 2) Using the j-th route with a certain proportion p of the input. + // + // The recurrence relation would be: + // dp[x][j] = max(dp[x][j−1], dp[x−p][j−1] + output from j - th route with proportion p) + noChoice := dp[x][j] + choice := dp[x-p][j-1].Add(computeAndCacheOutAmountCb(j-1, p)) + + if choice.GT(noChoice) { + dp[x][j] = choice + proportions[x][j] = p + } + } + } + } + + // Step 3: trace back to find the optimal proportions + x, j := totalIncrements, len(routes) + optimalProportions := make([]uint8, len(routes)+1) + for j > 0 { + optimalProportions[j] = proportions[x][j] + x -= proportions[x][j] + j -= 1 + } + + optimalProportions = optimalProportions[1:] + + bestSplit := split{ + routeIncrements: optimalProportions, + amountOut: dp[totalIncrements][len(routes)], + } + + tokenAmountDec := tokenOut.Amount.ToLegacyDec() + + if bestSplit.amountOut.IsZero() { + return nil, errors.New("amount out is zero, try increasing amount in") + } + + // Step 4: validate the found choice + totalIncrementsInSplits := uint8(0) + resultRoutes := make([]domain.SplitRoute, 0, len(routes)) + totalAmoutOutFromSplits := osmomath.ZeroInt() + for i, currentRouteIncrement := range bestSplit.routeIncrements { + currentRoute := routes[i] + + currentRouteAmtOut := computeAndCacheOutAmountCb(i, currentRouteIncrement) + + currentRouteSplit := osmomath.NewDec(int64(currentRouteIncrement)).QuoInt64Mut(int64(totalIncrements)) + + inAmount := currentRouteSplit.MulMut(tokenAmountDec).TruncateInt() + outAmount := currentRouteAmtOut + + isAmountInNilOrZero := inAmount.IsNil() || inAmount.IsZero() + isAmountOutNilOrZero := outAmount.IsNil() || outAmount.IsZero() + if isAmountInNilOrZero && isAmountOutNilOrZero { + continue + } + + if isAmountInNilOrZero { + return nil, fmt.Errorf("in amount is zero when out is not (%s), route index (%d)", outAmount, i) + } + + if isAmountOutNilOrZero { + return nil, fmt.Errorf("out amount is zero when in is not (%s), route index (%d)", inAmount, i) + } + + resultRoutes = append(resultRoutes, &RouteWithOutAmount{ + RouteImpl: currentRoute, + InAmount: inAmount, + OutAmount: currentRouteAmtOut, + }) + + totalIncrementsInSplits += currentRouteIncrement + totalAmoutOutFromSplits = totalAmoutOutFromSplits.Add(currentRouteAmtOut) + } + + if !totalAmoutOutFromSplits.Equal(bestSplit.amountOut) { + return nil, fmt.Errorf("total amount out from splits (%s) does not equal actual amount out (%s)", totalAmoutOutFromSplits, bestSplit.amountOut) + } + + // This may happen if one of the routes is consistently returning 0 amount out for all increments. + // TODO: we may want to remove this check so that we get the best quote. + if totalIncrementsInSplits != totalIncrements { + return nil, fmt.Errorf("total increments (%d) does not match expected total increments (%d)", totalIncrementsInSplits, totalIncrements) + } + + quote := "eExactAmountOut{ + AmountIn: bestSplit.amountOut, + AmountOut: tokenOut, + Route: resultRoutes, + } + + return quote, nil +} + // This function computes the inAmountIncrement for a given proportion p. // It caches the result on the stack to avoid recomputing it. func getComputeAndCacheInAmountIncrementCb(totalInAmountDec osmomath.Dec) func(p uint8) osmomath.Int { @@ -198,6 +358,27 @@ func getComputeAndCacheInAmountIncrementCb(totalInAmountDec osmomath.Dec) func(p } } + +// This function computes the inAmountIncrement for a given proportion p. +// It caches the result on the stack to avoid recomputing it. +// TODO: +func getComputeAndCacheOutAmountIncrementCb(totalOutAmountDec osmomath.Dec) func(p uint8) osmomath.Int { + outAmountIncrements := make(map[uint8]osmomath.Int, totalIncrements) + return func(p uint8) osmomath.Int { + // If the inAmountIncrement has already been computed, return the cached value. + // Otherwise, compute the value and cache it. + currentIncrement, ok := outAmountIncrements[p] + if ok { + return currentIncrement + } + + currentIncrement = osmomath.NewDec(int64(p)).QuoInt64Mut(int64(totalIncrements)).MulMut(totalOutAmountDec).TruncateInt() + outAmountIncrements[p] = currentIncrement + + return currentIncrement + } +} + // This function computes the outAmountIncrement for a given routeIndex and inAmountIncrement. // It caches the result on the stack to avoid recomputing it. func getComputeAndCacheOutAmountCb(ctx context.Context, totalInAmountDec osmomath.Dec, tokenInDenom string, routes []route.RouteImpl) func(int, uint8) osmomath.Int { @@ -229,3 +410,36 @@ func getComputeAndCacheOutAmountCb(ctx context.Context, totalInAmountDec osmomat return curRouteOutAmountIncrement.Amount } } + + +// This function computes the inAmountIncrement for a given routeIndex and outAmountIncrement. +// It caches the result on the stack to avoid recomputing it. +func getComputeAndCacheInAmountCb(ctx context.Context, totalOutAmountDec osmomath.Dec, tokenOutDenom string, routes []route.RouteImpl) func(int, uint8) osmomath.Int { + // Pre-compute routes cache map. + routeOutAmtCache := make(map[int]map[uint8]osmomath.Int, len(routes)) + for routeIndex := 0; routeIndex < len(routes); routeIndex++ { + routeOutAmtCache[routeIndex] = make(map[uint8]osmomath.Int, totalIncrements+1) + } + + // Get callback with in amount increment capabilities. + computeAndCacheInAmountIncrementCb := getComputeAndCacheOutAmountIncrementCb(totalOutAmountDec) + + return func(routeIndex int, increment uint8) osmomath.Int { + inAmountIncrement := computeAndCacheInAmountIncrementCb(increment) + + curRouteAmt, ok := routeOutAmtCache[routeIndex][increment] + if ok { + return curRouteAmt + } + // This is the expensive computation that we aim to avoid. + curRouteOutAmountIncrement, _ := routes[routeIndex].CalculateTokenInByTokenOut(ctx, sdk.NewCoin(tokenOutDenom, inAmountIncrement)) + + if curRouteOutAmountIncrement.IsNil() || curRouteOutAmountIncrement.IsZero() { + curRouteOutAmountIncrement.Amount = zero + } + + routeOutAmtCache[routeIndex][increment] = curRouteOutAmountIncrement.Amount + + return curRouteOutAmountIncrement.Amount + } +} diff --git a/router/usecase/dynamic_splits_bench_test.go b/router/usecase/dynamic_splits_bench_test.go index 11b8ebde5..a71996541 100644 --- a/router/usecase/dynamic_splits_bench_test.go +++ b/router/usecase/dynamic_splits_bench_test.go @@ -9,11 +9,11 @@ import ( "github.com/osmosis-labs/sqs/router/usecase" ) -// Microbenchmark for the GetSplitQuote function. -func BenchmarkGetSplitQuote(b *testing.B) { +// Microbenchmark for the GetSplitQuoteOutGivenIn function. +func BenchmarkGetSplitQuoteOutGivenIn(b *testing.B) { // This is a hack to be able to use test suite helpers with the benchmark. // We need to set testing.T for assertings within the helpers. Otherwise, it would block - s := RouterTestSuite{} + s := DynamicSplitsTestSuite{} s.SetT(&testing.T{}) const displayDenomIn = "pepe" @@ -22,14 +22,42 @@ func BenchmarkGetSplitQuote(b *testing.B) { tokenIn = sdk.NewCoin(displayDenomIn, amountIn) ) - tokenIn, rankedRoutes := s.setupSplitsMainnetTestCase(displayDenomIn, amountIn, USDC) + tokenIn, rankedRoutes := s.setupSplitsMainnetTestCaseOutGivenIn(displayDenomIn, amountIn, USDC) b.ResetTimer() // Run the benchmark for i := 0; i < b.N; i++ { // System under test. - _, err := usecase.GetSplitQuote(context.TODO(), rankedRoutes, tokenIn) + _, err := usecase.GetSplitQuoteOutGivenIn(context.TODO(), rankedRoutes, tokenIn) + s.Require().NoError(err) + if err != nil { + b.Errorf("GetPrices returned an error: %v", err) + } + } +} + +// Microbenchmark for the GetSplitQuoteOutGivenIn function. +func BenchmarkGetSplitQuoteInGivenOut(b *testing.B) { + // This is a hack to be able to use test suite helpers with the benchmark. + // We need to set testing.T for assertings within the helpers. Otherwise, it would block + s := DynamicSplitsTestSuite{} + s.SetT(&testing.T{}) + + const displayDenomOut = "pepe" + var ( + amountOut = osmomath.NewInt(9_000_000_000_000_000_000) + tokenOut = sdk.NewCoin(displayDenomOut, amountOut) + ) + + tokenOut, rankedRoutes := s.setupSplitsMainnetTestCaseInGivenOut(displayDenomOut, amountOut, USDC) + + b.ResetTimer() + + // Run the benchmark + for i := 0; i < b.N; i++ { + // System under test. + _, err := usecase.GetSplitQuoteInGivenOut(context.TODO(), rankedRoutes, tokenOut) s.Require().NoError(err) if err != nil { b.Errorf("GetPrices returned an error: %v", err) diff --git a/router/usecase/dynamic_splits_test.go b/router/usecase/dynamic_splits_test.go index 3d0d3f856..d289c0f4d 100644 --- a/router/usecase/dynamic_splits_test.go +++ b/router/usecase/dynamic_splits_test.go @@ -2,6 +2,7 @@ package usecase_test import ( "context" + "testing" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/osmosis-labs/osmosis/osmomath" @@ -9,30 +10,59 @@ import ( "github.com/osmosis-labs/sqs/router/usecase" "github.com/osmosis-labs/sqs/router/usecase/route" "github.com/osmosis-labs/sqs/router/usecase/routertesting" + + "github.com/stretchr/testify/suite" ) +type DynamicSplitsTestSuite struct { + routertesting.RouterTestHelper +} + +func TestDynamicSplitsTestSuite(t *testing.T) { + suite.Run(t, new(DynamicSplitsTestSuite)) +} + // Sanity check test case to validate get split quote function with a given denom and amount. -func (s *RouterTestSuite) TestGetSplitQuote() { +// This test case tests OutGivenIn swap method. +func (s *DynamicSplitsTestSuite) TestGetSplitQuoteOutGivenIn() { const displayDenomIn = "pepe" var ( amountIn = osmomath.NewInt(9_000_000_000_000_000_000) tokenIn = sdk.NewCoin(displayDenomIn, amountIn) ) - tokenIn, rankedRoutes := s.setupSplitsMainnetTestCase(displayDenomIn, amountIn, USDC) + tokenIn, rankedRoutes := s.setupSplitsMainnetTestCaseOutGivenIn(displayDenomIn, amountIn, USDC) - splitQuote, err := usecase.GetSplitQuote(context.TODO(), rankedRoutes, tokenIn) + splitQuote, err := usecase.GetSplitQuoteOutGivenIn(context.TODO(), rankedRoutes, tokenIn) s.Require().NotNil(splitQuote) s.Require().NoError(err) } -// setupSplitsMainnetTestCase sets up the test case for GetSplitQuote using mainnet state. + +// Sanity check test case to validate get split quote function with a given denom and amount. +// This test case tests InGivenOut swap method. +func (s *DynamicSplitsTestSuite) TestGetSplitQuoteInGivenOut() { + const displayDenomIn = "pepe" + var ( + amountIn = osmomath.NewInt(9_000_000_000_000_000_000) + tokenIn = sdk.NewCoin(displayDenomIn, amountIn) + ) + + tokenIn, rankedRoutes := s.setupSplitsMainnetTestCaseInGivenOut(displayDenomIn, amountIn, USDC) + + splitQuote, err := usecase.GetSplitQuoteInGivenOut(context.TODO(), rankedRoutes, tokenIn) + + s.Require().NotNil(splitQuote) + s.Require().NoError(err) +} + +// setupSplitsMainnetTestCaseOutGivenIn sets up the test case for GetSplitQuote using mainnet state. // Calls all the relevant functions as if we were estimating the quote up until starting the // splits computation. // // Utilizes the given display denom in, amount in and chain denom out. -func (s *RouterTestSuite) setupSplitsMainnetTestCase(displayDenomIn string, amountIn osmomath.Int, chainDenomOut string) (sdk.Coin, []route.RouteImpl) { +func (s *DynamicSplitsTestSuite) setupSplitsMainnetTestCaseOutGivenIn(displayDenomIn string, amountIn osmomath.Int, chainDenomOut string) (sdk.Coin, []route.RouteImpl) { // Setup mainnet state mainnetState := s.SetupMainnetState() @@ -55,7 +85,7 @@ func (s *RouterTestSuite) setupSplitsMainnetTestCase(displayDenomIn string, amou MinPoolLiquidityCap: config.MinPoolLiquidityCap, } // Get candidate routes - candidateRoutes, err := useCases.CandidateRouteSearcher.FindCandidateRoutes(tokenIn, chainDenomOut, options) + candidateRoutes, err := useCases.CandidateRouteSearcher.FindCandidateRoutesOutGivenIn(tokenIn, chainDenomOut, options) s.Require().NoError(err) // TODO: consider moving to interface. @@ -63,8 +93,50 @@ func (s *RouterTestSuite) setupSplitsMainnetTestCase(displayDenomIn string, amou s.Require().True(ok) // Estimate direct quote - _, rankedRoutes, err := routerUseCase.RankRoutesByDirectQuote(ctx, candidateRoutes, tokenIn, chainDenomOut, config.MaxRoutes) + _, rankedRoutes, err := routerUseCase.RankRoutesByDirectQuoteOutGivenIn(ctx, candidateRoutes, tokenIn, chainDenomOut, config.MaxRoutes) s.Require().NoError(err) return tokenIn, rankedRoutes } + +// setupSplitsMainnetTestCaseInGivenOut sets up the test case for GetSplitQuote using mainnet state. +// Calls all the relevant functions as if we were estimating the quote up until starting the +// splits computation. +// +// Utilizes the given display denom in, amount in and chain denom out. +func (s *DynamicSplitsTestSuite) setupSplitsMainnetTestCaseInGivenOut(displayDenomOut string, amountOut osmomath.Int, chainDenomIn string) (sdk.Coin, []route.RouteImpl) { + // Setup mainnet state + mainnetState := s.SetupMainnetState() + + // Setup router and pools use case. + useCases := s.SetupRouterAndPoolsUsecase(mainnetState, routertesting.WithLoggerDisabled()) + + // Translate display denom to chain denom + chainDenom, err := useCases.Tokens.GetChainDenom(displayDenomOut) + s.Require().NoError(err) + + tokenOut := sdk.NewCoin(chainDenom, amountOut) + + ctx := context.TODO() + + config := useCases.Router.GetConfig() + + options := domain.CandidateRouteSearchOptions{ + MaxRoutes: config.MaxRoutes, + MaxPoolsPerRoute: config.MaxPoolsPerRoute, + MinPoolLiquidityCap: config.MinPoolLiquidityCap, + } + // Get candidate routes + candidateRoutes, err := useCases.CandidateRouteSearcher.FindCandidateRoutesInGivenOut(tokenOut, chainDenomIn, options) + s.Require().NoError(err) + + // TODO: consider moving to interface. + routerUseCase, ok := useCases.Router.(*usecase.RouterUseCaseImpl) + s.Require().True(ok) + + // Estimate direct quote + _, rankedRoutes, err := routerUseCase.RankRoutesByDirectQuoteInGivenOut(ctx, candidateRoutes, tokenOut, chainDenomIn, config.MaxRoutes) + s.Require().NoError(err) + + return tokenOut, rankedRoutes +} diff --git a/router/usecase/export_test.go b/router/usecase/export_test.go index c19eccb43..ae5b1564b 100644 --- a/router/usecase/export_test.go +++ b/router/usecase/export_test.go @@ -26,15 +26,15 @@ const ( ) func ValidateAndFilterRoutes(candidateRoutes []candidateRouteWrapper, tokenInDenom string, logger log.Logger) (ingesttypes.CandidateRoutes, error) { - return validateAndFilterRoutes(candidateRoutes, tokenInDenom, logger) + return validateAndFilterRoutesOutGivenIn(candidateRoutes, tokenInDenom, logger) } func (r *routerUseCaseImpl) HandleRoutes(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, candidateRouteSearchOptions domain.CandidateRouteSearchOptions) (candidateRoutes ingesttypes.CandidateRoutes, err error) { - return r.handleCandidateRoutes(ctx, tokenIn, tokenOutDenom, candidateRouteSearchOptions) + return r.handleCandidateRoutesOutGivenIn(ctx, tokenIn, tokenOutDenom, candidateRouteSearchOptions) } func (r *routerUseCaseImpl) EstimateAndRankSingleRouteQuote(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Coin, logger log.Logger) (domain.Quote, []RouteWithOutAmount, error) { - return r.estimateAndRankSingleRouteQuote(ctx, routes, tokenIn, logger) + return r.estimateAndRankSingleRouteQuoteOutGivenIn(ctx, routes, tokenIn, logger) } func FilterDuplicatePoolIDRoutes(rankedRoutes []RouteWithOutAmount) []route.RouteImpl { @@ -61,12 +61,20 @@ func SortPools(pools []ingesttypes.PoolI, transmuterCodeIDs map[uint64]struct{}, return sortPools(pools, transmuterCodeIDs, totalTVL, preferredPoolIDsMap, logger) } -func GetSplitQuote(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Coin) (domain.Quote, error) { - return getSplitQuote(ctx, routes, tokenIn) +func GetSplitQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Coin) (domain.Quote, error) { + return getSplitQuoteOutGivenIn(ctx, routes, tokenIn) } -func (r *routerUseCaseImpl) RankRoutesByDirectQuote(ctx context.Context, candidateRoutes ingesttypes.CandidateRoutes, tokenIn sdk.Coin, tokenOutDenom string, maxRoutes int) (domain.Quote, []route.RouteImpl, error) { - return r.rankRoutesByDirectQuote(ctx, candidateRoutes, tokenIn, tokenOutDenom, maxRoutes) +func GetSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, tokenOut sdk.Coin) (domain.Quote, error) { + return getSplitQuoteInGivenOut(ctx, routes, tokenOut) +} + +func (r *routerUseCaseImpl) RankRoutesByDirectQuoteOutGivenIn(ctx context.Context, candidateRoutes ingesttypes.CandidateRoutes, tokenIn sdk.Coin, tokenOutDenom string, maxRoutes int) (domain.Quote, []route.RouteImpl, error) { + return r.rankRoutesByDirectQuoteOutGivenIn(ctx, candidateRoutes, tokenIn, tokenOutDenom, maxRoutes) +} + +func (r *routerUseCaseImpl) RankRoutesByDirectQuoteInGivenOut(ctx context.Context, candidateRoutes ingesttypes.CandidateRoutes, tokenOut sdk.Coin, tokenInDenom string, maxRoutes int) (domain.Quote, []route.RouteImpl, error) { + return r.rankRoutesByDirectQuoteInGivenOut(ctx, candidateRoutes, tokenOut, tokenInDenom, maxRoutes) } func CutRoutesForSplits(maxSplitRoutes int, routes []route.RouteImpl) []route.RouteImpl { diff --git a/router/usecase/optimized_routes.go b/router/usecase/optimized_routes.go index 9ff76a5d6..d2b56a8bb 100644 --- a/router/usecase/optimized_routes.go +++ b/router/usecase/optimized_routes.go @@ -20,7 +20,7 @@ import ( // Returns best quote as well as all routes sorted by amount out and error if any. // CONTRACT: router repository must be set on the router. // CONTRACT: pools reporitory must be set on the router -func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuote(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Coin, logger log.Logger) (quote domain.Quote, sortedRoutesByAmtOut []RouteWithOutAmount, err error) { +func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Coin, logger log.Logger) (quote domain.Quote, sortedRoutesByAmtOut []RouteWithOutAmount, err error) { if len(routes) == 0 { return nil, nil, fmt.Errorf("no routes were provided for token in (%s)", tokenIn.Denom) } @@ -82,7 +82,69 @@ func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuote(ctx context.Context, return finalQuote, routesWithAmountOut, nil } -// validateAndFilterRoutes validates all routes. Specifically: +func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, tokenOut sdk.Coin, logger log.Logger) (quote domain.Quote, sortedRoutesByAmtOut []RouteWithOutAmount, err error) { + if len(routes) == 0 { + return nil, nil, fmt.Errorf("no routes were provided for token in (%s)", tokenOut.Denom) + } + + routesWithAmountOut := make([]RouteWithOutAmount, 0, len(routes)) + + errors := []error{} + + for _, route := range routes { + directRouteTokenIn, err := route.CalculateTokenInByTokenOut(ctx, tokenOut) + if err != nil { + logger.Debug("skipping single route due to error in estimate", zap.Error(err)) + errors = append(errors, err) + continue + } + + if directRouteTokenIn.Amount.IsNil() { + directRouteTokenIn.Amount = osmomath.ZeroInt() + } + + routesWithAmountOut = append(routesWithAmountOut, RouteWithOutAmount{ + RouteImpl: route, + InAmount: directRouteTokenIn.Amount, + OutAmount: tokenOut.Amount, + }) + } + + // If we skipped all routes due to errors, return the first error + if len(routesWithAmountOut) == 0 && len(errors) > 0 { + // If we encounter this problem, we attempte to invalidate all caches to recompute the routes + // completely. + // This might be helpful in alloyed cases where the pool gets imbalanced and runs out of liquidity. + // If the original routes were computed only through the zero liquidity token, they will be recomputed + // through another token due to changed order. + + // Note: the zero length check occurred at the start of function. + tokenOutDenom := routes[0].GetTokenOutDenom() + + r.candidateRouteCache.Delete(formatCandidateRouteCacheKey(tokenOut.Denom, tokenOutDenom)) + tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenOut.Amount) + r.rankedRouteCache.Delete(formatRankedRouteCacheKey(tokenOut.Denom, tokenOutDenom, tokenInOrderOfMagnitude)) + + return nil, nil, errors[0] + } + + // Sort by amount out in ascending order + sort.Slice(routesWithAmountOut, func(i, j int) bool { + return routesWithAmountOut[i].InAmount.LT(routesWithAmountOut[j].InAmount) + }) + + bestRoute := routesWithAmountOut[0] + + finalQuote := "eExactAmountOut{ + AmountIn: bestRoute.InAmount, + AmountOut: tokenOut, + Route: []domain.SplitRoute{&bestRoute}, + } + + return finalQuote, routesWithAmountOut, nil +} + +// validateAndFilterRoutesOutGivenIn validates all routes. Specifically: // - all routes have at least one pool. // - all routes have the same final token out denom. // - the final token out denom is not the same as the token in denom. @@ -90,7 +152,7 @@ func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuote(ctx context.Context, // - the previous pool token out denom is in the current pool. // - the current pool token out denom is in the current pool. // Returns error if not. Nil otherwise. -func validateAndFilterRoutes(candidateRoutes []candidateRouteWrapper, tokenInDenom string, logger log.Logger) (ingesttypes.CandidateRoutes, error) { +func validateAndFilterRoutesOutGivenIn(candidateRoutes []candidateRouteWrapper, tokenInDenom string, logger log.Logger) (ingesttypes.CandidateRoutes, error) { var ( tokenOutDenom string filteredRoutes []ingesttypes.CandidateRoute @@ -194,6 +256,7 @@ ROUTE_LOOP: filteredRoute.Pools = append(filteredRoute.Pools, ingesttypes.CandidatePool{ ID: pool.ID, TokenOutDenom: pool.TokenOutDenom, + TokenInDenom: pool.TokenInDenom, }) } @@ -211,6 +274,125 @@ ROUTE_LOOP: }, nil } +// validateAndFilterRoutesOutGivenIn validates all routes. Specifically: +// - all routes have at least one pool. +// - all routes have the same final token out denom. +// - the final token out denom is not the same as the token in denom. +// - intermediary pools in the route do not contain the token in denom or token out denom. +// - the previous pool token out denom is in the current pool. +// - the current pool token out denom is in the current pool. +// Returns error if not. Nil otherwise. +func validateAndFilterRoutesInGivenOut(candidateRoutes []candidateRouteWrapper, tokenOutDenom string, logger log.Logger) (ingesttypes.CandidateRoutes, error) { + var ( + tokenInDenom string + filteredRoutes []ingesttypes.CandidateRoute + ) + + uniquePoolIDs := make(map[uint64]struct{}) + + containsCanonicalOrderbook := false + +ROUTE_LOOP: + for i, candidateRoute := range candidateRoutes { + candidateRoutePools := candidateRoute.Pools + + containsCanonicalOrderbook = containsCanonicalOrderbook || candidateRoute.IsCanonicalOrderboolRoute + + if len(candidateRoute.Pools) == 0 { + return ingesttypes.CandidateRoutes{}, NoPoolsInRouteError{RouteIndex: i} + } + + lastPool := candidateRoutePools[len(candidateRoutePools)-1] + currentRouteTokenInDenom := lastPool.TokenInDenom + + previousTokenIn := tokenOutDenom + + uniquePoolIDsIntraRoute := make(map[uint64]struct{}, len(candidateRoutePools)) + + for j, currentPool := range candidateRoutePools { + if _, ok := uniquePoolIDs[currentPool.ID]; !ok { + uniquePoolIDs[currentPool.ID] = struct{}{} + } + + if _, ok := uniquePoolIDsIntraRoute[currentPool.ID]; ok { + continue ROUTE_LOOP + } else { + uniquePoolIDsIntraRoute[currentPool.ID] = struct{}{} + } + + currentPoolDenoms := candidateRoutePools[j].PoolDenoms + currentPoolTokenInDenom := currentPool.TokenInDenom + + foundPreviousTokenIn := false + foundCurrentTokenIn := false + for _, denom := range currentPoolDenoms { + if denom == previousTokenIn { + foundPreviousTokenIn = true + } + + if denom == currentPoolTokenInDenom { + foundCurrentTokenIn = true + } + + if j > 0 && j < len(candidateRoutePools)-1 { + if denom == tokenOutDenom { + logger.Warn("route skipped - found token out in intermediary pool", zap.Error(RoutePoolWithTokenOutDenomError{RouteIndex: i, TokenOutDenom: tokenOutDenom})) + continue ROUTE_LOOP + } + + if denom == currentRouteTokenInDenom { + logger.Warn("route skipped - found token in intermediary pool", zap.Error(RoutePoolWithTokenInDenomError{RouteIndex: i, TokenInDenom: currentPoolTokenInDenom})) + continue ROUTE_LOOP + } + } + } + + if !foundPreviousTokenIn { + return ingesttypes.CandidateRoutes{}, PreviousTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, PreviousTokenOutDenom: previousTokenIn} + } + + if !foundCurrentTokenIn { + return ingesttypes.CandidateRoutes{}, CurrentTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, CurrentTokenOutDenom: currentPoolTokenInDenom} + } + + previousTokenIn = currentPoolTokenInDenom + } + + if i > 0 { + if currentRouteTokenInDenom != tokenInDenom { + return ingesttypes.CandidateRoutes{}, TokenOutMismatchBetweenRoutesError{TokenOutDenomRouteA: tokenInDenom, TokenOutDenomRouteB: currentRouteTokenInDenom} + } + } + + tokenInDenom = currentRouteTokenInDenom + + filteredRoute := ingesttypes.CandidateRoute{ + IsCanonicalOrderboolRoute: candidateRoute.IsCanonicalOrderboolRoute, + Pools: make([]ingesttypes.CandidatePool, 0, len(candidateRoutePools)), + } + + for _, pool := range candidateRoutePools { + filteredRoute.Pools = append(filteredRoute.Pools, ingesttypes.CandidatePool{ + ID: pool.ID, + TokenInDenom: pool.TokenInDenom, + TokenOutDenom: pool.TokenOutDenom, + }) + } + + filteredRoutes = append(filteredRoutes, filteredRoute) + } + + if tokenInDenom == tokenOutDenom { + return ingesttypes.CandidateRoutes{}, TokenOutDenomMatchesTokenInDenomError{Denom: tokenInDenom} + } + + return ingesttypes.CandidateRoutes{ + Routes: filteredRoutes, + UniquePoolIDs: uniquePoolIDs, + ContainsCanonicalOrderbook: containsCanonicalOrderbook, + }, nil +} + type RouteWithOutAmount struct { route.RouteImpl OutAmount osmomath.Int "json:\"out_amount\"" diff --git a/router/usecase/optimized_routes_test.go b/router/usecase/optimized_routes_test.go index 61c3754de..727c7be2f 100644 --- a/router/usecase/optimized_routes_test.go +++ b/router/usecase/optimized_routes_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "sort" + "testing" sdk "github.com/cosmos/cosmos-sdk/types" ingesttypes "github.com/osmosis-labs/sqs/ingest/types" + "github.com/stretchr/testify/suite" "github.com/osmosis-labs/osmosis/osmomath" "github.com/osmosis-labs/osmosis/osmoutils/coinutil" @@ -202,7 +204,7 @@ func (s *RouterTestSuite) TestGetBestSplitRoutesQuote() { for name, tc := range tests { s.Run(name, func() { - quote, err := routerusecase.GetSplitQuote(context.TODO(), tc.routes, tc.tokenIn) + quote, err := routerusecase.GetSplitQuoteOutGivenIn(context.TODO(), tc.routes, tc.tokenIn) if tc.expectError != nil { s.Require().Error(err) @@ -628,7 +630,7 @@ var optimalQuoteTestCases = map[string]struct { amountIn: osmomath.NewInt(5000000), expectedRoutesCountExactAmountIn: 2, - expectedRoutesCountExactAmountOut: 3, + expectedRoutesCountExactAmountOut: 2, }, "usdt for atom": { tokenInDenom: USDT, @@ -677,7 +679,7 @@ var optimalQuoteTestCases = map[string]struct { amountIn: oneHundredThousandUSDValue, expectedRoutesCountExactAmountIn: usdtOsmoExpectedRoutesHighLiq, - expectedRoutesCountExactAmountOut: 2, + expectedRoutesCountExactAmountOut: usdtOsmoExpectedRoutesHighLiq, }, "kava.USDT for uosmo - should have the same routes as allUSDT for uosmo": { @@ -687,10 +689,128 @@ var optimalQuoteTestCases = map[string]struct { amountIn: oneHundredThousandUSDValue, expectedRoutesCountExactAmountIn: usdtOsmoExpectedRoutesHighLiq, - expectedRoutesCountExactAmountOut: 2, + expectedRoutesCountExactAmountOut: usdtOsmoExpectedRoutesHighLiq, }, } +type RouterTestSuite1 struct { + routertesting.RouterTestHelper +} + +func TestRouterTestSuite1(t *testing.T) { + suite.Run(t, new(RouterTestSuite1)) +} + +func (s *RouterTestSuite1) TestGetOptimalQuoteExactAmounOut_Mainnet() { + for name, tc := range optimalQuoteTestCases { + if name != "uosmo for uion" { + continue + } + tc := tc + s.Run(name, func() { + // Setup mainnet router + mainnetState := s.SetupMainnetState() + + // Mock router use case. + mainnetUseCase := s.SetupRouterAndPoolsUsecase(mainnetState) + + // TODO: fix + // TokenInDenom is empty + quote, err := mainnetUseCase.Router.GetOptimalQuoteInGivenOut(context.Background(), sdk.NewCoin(tc.tokenOutDenom, tc.amountIn), tc.tokenInDenom) + s.Require().NoError(err) + + // TODO: update mainnet state and validate the quote for each test stricter. + routes, _, err := quote.PrepareResult(context.Background(), osmomath.NewDec(0), &log.NoOpLogger{}) // we are not checking the scaling factor + s.Require().NoError(err) + + s.Require().Len(routes, tc.expectedRoutesCountExactAmountOut) + + // Validate that the routes are valid + for _, r := range routes { + output := tc.tokenOutDenom + for _, p := range r.GetPools() { + pool, err := mainnetUseCase.Pools.GetPool(p.GetId()) + s.Require().NoError(err) + + denoms := pool.GetPoolDenoms() + + // Pool denoms must contain output denom + s.Require().Contains(denoms, output) + + // Pool denoms must contain route input denom + s.Require().Contains(denoms, p.GetTokenInDenom()) + + // Pool's token in denom becomes output of the next pool + output = p.GetTokenInDenom() + } + + // The last route's token out denom must be the output denom of the quote + s.Require().Equal(tc.tokenInDenom, r.GetTokenInDenom()) + } + + // Validate that the quote is not nil + s.Require().NotNil(quote.GetAmountOut()) + }) + } +} + +type RouterTestSuite2 struct { + routertesting.RouterTestHelper +} + +func TestRouterTestSuite2(t *testing.T) { + suite.Run(t, new(RouterTestSuite2)) +} + +func (s *RouterTestSuite2) TestGetOptimalQuoteExactAmounIn_Mainnet() { + for name, tc := range optimalQuoteTestCases { + if name != "uosmo for uion" { + continue + } + tc := tc + s.Run(name, func() { + // Setup mainnet router + mainnetState := s.SetupMainnetState() + + // Mock router use case. + mainnetUseCase := s.SetupRouterAndPoolsUsecase(mainnetState) + + quote, err := mainnetUseCase.Router.GetOptimalQuoteOutGivenIn(context.Background(), sdk.NewCoin(tc.tokenInDenom, tc.amountIn), tc.tokenOutDenom) + s.Require().NoError(err) + + // TODO: update mainnet state and validate the quote for each test stricter. + routes := quote.GetRoute() + s.Require().Len(routes, tc.expectedRoutesCountExactAmountIn) + + // Validate that the routes are valid + for _, r := range routes { + input := tc.tokenInDenom + for _, p := range r.GetPools() { + pool, err := mainnetUseCase.Pools.GetPool(p.GetId()) + s.Require().NoError(err) + + denoms := pool.GetPoolDenoms() + + // Pool denoms must contain input denom + s.Require().Contains(denoms, input) + + // Pool denoms must contain route output denom + s.Require().Contains(denoms, p.GetTokenOutDenom()) + + // Pool's token out denom becomes input to the next pool + input = p.GetTokenOutDenom() + } + + // The last route's token out denom must be the output denom of the quote + s.Require().Equal(tc.tokenOutDenom, r.GetTokenOutDenom()) + } + + // Validate that the quote is not nil + s.Require().NotNil(quote.GetAmountOut()) + }) + } +} + // Validates that quotes constructed from mainnet state can be computed with no error // for selected pairs. func (s *RouterTestSuite) TestGetOptimalQuoteExactAmounIn_Mainnet() { @@ -703,7 +823,7 @@ func (s *RouterTestSuite) TestGetOptimalQuoteExactAmounIn_Mainnet() { // Mock router use case. mainnetUseCase := s.SetupRouterAndPoolsUsecase(mainnetState) - quote, err := mainnetUseCase.Router.GetOptimalQuote(context.Background(), sdk.NewCoin(tc.tokenInDenom, tc.amountIn), tc.tokenOutDenom) + quote, err := mainnetUseCase.Router.GetOptimalQuoteOutGivenIn(context.Background(), sdk.NewCoin(tc.tokenInDenom, tc.amountIn), tc.tokenOutDenom) s.Require().NoError(err) // TODO: update mainnet state and validate the quote for each test stricter. @@ -821,7 +941,7 @@ func (s *RouterTestSuite) TestGetCustomQuote_GetCustomDirectQuote_Mainnet_UOSMOU const expectedPoolID = uint64(2) // System under test 2 - quote, err := routerUsecase.GetCustomDirectQuote(context.Background(), sdk.NewCoin(UOSMO, amountIn), UION, expectedPoolID) + quote, err := routerUsecase.GetCustomDirectQuoteOutGivenIn(context.Background(), sdk.NewCoin(UOSMO, amountIn), UION, expectedPoolID) s.Require().NoError(err) s.validateExpectedPoolIDOneRouteOneHopQuote(quote, expectedPoolID) } @@ -1012,7 +1132,7 @@ func (s *RouterTestSuite) TestEstimateAndRankSingleRouteQuote() { s.Require().NoError(sytErr) // Validate quote amount out - s.Require().Equal(tokenOutCoin.Amount, quote.GetAmountOut()) + s.Require().Equal(tokenOutCoin.Amount, quote.GetAmountOut().Amount) // Validate ranked route order s.Require().Equal(len(tc.expectedRouteAmounstOut), len(rankedRoutes)) diff --git a/router/usecase/quote_in_given_out.go b/router/usecase/quote_in_given_out.go index 4e1214534..bd5648512 100644 --- a/router/usecase/quote_in_given_out.go +++ b/router/usecase/quote_in_given_out.go @@ -2,10 +2,13 @@ package usecase import ( "context" + "fmt" + "strings" "github.com/osmosis-labs/sqs/domain" "github.com/osmosis-labs/sqs/log" "github.com/osmosis-labs/sqs/router/types" + "github.com/osmosis-labs/sqs/router/usecase/route" "github.com/osmosis-labs/osmosis/osmomath" @@ -20,12 +23,61 @@ var ( // Note that only the PrepareResult method is different from the quoteExactAmountIn. type quoteExactAmountOut struct { *quoteExactAmountIn "json:\"-\"" - AmountIn osmomath.Int "json:\"amount_in\"" - AmountOut sdk.Coin "json:\"amount_out\"" - Route []domain.SplitRoute "json:\"route\"" - EffectiveFee osmomath.Dec "json:\"effective_fee\"" - PriceImpact osmomath.Dec "json:\"price_impact\"" - InBaseOutQuoteSpotPrice osmomath.Dec "json:\"in_base_out_quote_spot_price\"" + AmountIn osmomath.Int `json:"amount_in"` + AmountOut sdk.Coin `json:"amount_out"` + Route []domain.SplitRoute `json:"route"` + EffectiveFee osmomath.Dec `json:"effective_fee"` + PriceImpact osmomath.Dec `json:"price_impact"` + InBaseOutQuoteSpotPrice osmomath.Dec `json:"in_base_out_quote_spot_price"` + PriceInfo *domain.TxFeeInfo `json:"price_info,omitempty"` +} + +// GetAmountIn implements Quote. +func (q *quoteExactAmountOut) GetAmountIn() sdk.Coin { + return sdk.Coin{Amount: q.AmountIn} +} + +// GetAmountOut implements Quote. +func (q *quoteExactAmountOut) GetAmountOut() sdk.Coin { + return q.AmountOut +} + +// GetRoute implements Quote. +func (q *quoteExactAmountOut) GetRoute() []domain.SplitRoute { + return q.Route +} + +// GetEffectiveFee implements Quote. +func (q *quoteExactAmountOut) GetEffectiveFee() osmomath.Dec { + return q.EffectiveFee +} + +// String implements domain.Quote. +func (q *quoteExactAmountOut) String() string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Quote: %s in for %s out \n", q.AmountIn, q.AmountOut)) + + for _, route := range q.Route { + builder.WriteString(route.String()) + } + + return builder.String() +} + +// GetPriceImpact implements domain.Quote. +func (q *quoteExactAmountOut) GetPriceImpact() osmomath.Dec { + return q.PriceImpact +} + +// GetInBaseOutQuoteSpotPrice implements domain.Quote. +func (q *quoteExactAmountOut) GetInBaseOutQuoteSpotPrice() osmomath.Dec { + return q.InBaseOutQuoteSpotPrice +} + +// SetQuotePriceInfo implements domain.Quote. +func (q *quoteExactAmountOut) SetQuotePriceInfo(info *domain.TxFeeInfo) { + q.PriceInfo = info } // PrepareResult implements domain.Quote. @@ -37,36 +89,93 @@ type quoteExactAmountOut struct { // // Returns the updated route and the effective spread factor. func (q *quoteExactAmountOut) PrepareResult(ctx context.Context, scalingFactor osmomath.Dec, logger log.Logger) ([]domain.SplitRoute, osmomath.Dec, error) { - // Prepare exact out in the quote for inputs inversion - if _, _, err := q.quoteExactAmountIn.PrepareResult(ctx, scalingFactor, logger); err != nil { - return nil, osmomath.Dec{}, err - } + if q.quoteExactAmountIn != nil { + // Prepare exact out in the quote for inputs inversion + if _, _, err := q.quoteExactAmountIn.PrepareResult(ctx, scalingFactor, logger); err != nil { + return nil, osmomath.Dec{}, err + } + + // Assign the inverted values to the quote + q.AmountOut = q.quoteExactAmountIn.AmountIn + q.AmountIn = q.quoteExactAmountIn.AmountOut + q.Route = q.quoteExactAmountIn.Route + q.EffectiveFee = q.quoteExactAmountIn.EffectiveFee + q.PriceImpact = q.quoteExactAmountIn.PriceImpact + q.InBaseOutQuoteSpotPrice = q.quoteExactAmountIn.InBaseOutQuoteSpotPrice + + for i, route := range q.Route { + route, ok := route.(*RouteWithOutAmount) + if !ok { + return nil, osmomath.Dec{}, types.ErrInvalidRouteType + } + + // invert the in and out amounts + route.InAmount, route.OutAmount = route.OutAmount, route.InAmount - // Assign the inverted values to the quote - q.AmountOut = q.quoteExactAmountIn.AmountIn - q.AmountIn = q.quoteExactAmountIn.AmountOut - q.Route = q.quoteExactAmountIn.Route - q.EffectiveFee = q.quoteExactAmountIn.EffectiveFee - q.PriceImpact = q.quoteExactAmountIn.PriceImpact - q.InBaseOutQuoteSpotPrice = q.quoteExactAmountIn.InBaseOutQuoteSpotPrice - - for i, route := range q.Route { - route, ok := route.(*RouteWithOutAmount) - if !ok { - return nil, osmomath.Dec{}, types.ErrInvalidRouteType + q.Route[i] = route + + // invert the in and out amounts for each pool + for _, p := range route.GetPools() { + p.SetTokenInDenom(p.GetTokenOutDenom()) + p.SetTokenOutDenom("") + } } - // invert the in and out amounts - route.InAmount, route.OutAmount = route.OutAmount, route.InAmount + return q.Route, q.EffectiveFee, nil + } + + totalAmountOut := q.AmountOut.Amount.ToLegacyDec() + totalFeeAcrossRoutes := osmomath.ZeroDec() + + totalSpotPriceOutBaseInQuote := osmomath.ZeroDec() + totalEffectiveSpotPriceOutBaseInQuote := osmomath.ZeroDec() + + resultRoutes := make([]domain.SplitRoute, 0, len(q.Route)) - q.Route[i] = route + for _, curRoute := range q.Route { + routeTotalFee := osmomath.ZeroDec() + routeAmountOutFraction := curRoute.GetAmountOut().ToLegacyDec().Quo(totalAmountOut) - // invert the in and out amounts for each pool - for _, p := range route.GetPools() { - p.SetTokenInDenom(p.GetTokenOutDenom()) - p.SetTokenOutDenom("") + // Calculate the spread factor across pools in the route + for _, pool := range curRoute.GetPools() { + poolTakerFee := pool.GetTakerFee() + + routeTotalFee.AddMut( + // (1 - routeTotalFee) * poolTakerFee + osmomath.OneDec().SubMut(routeTotalFee).MulTruncateMut(poolTakerFee), + ) } + + // Update the spread factor pro-rated by the amount in + totalFeeAcrossRoutes.AddMut(routeTotalFee.MulMut(routeAmountOutFraction)) + + amountOutFraction := q.AmountOut.Amount.ToLegacyDec().MulMut(routeAmountOutFraction).TruncateInt() + newPools, routeSpotPriceOutBaseInQuote, effectiveSpotPriceOutBaseInQuote, err := curRoute.PrepareResultPoolsExactAmountOut(ctx, sdk.NewCoin(q.AmountOut.Denom, amountOutFraction), logger) + if err != nil { + return nil, osmomath.Dec{}, err + } + + totalSpotPriceOutBaseInQuote = totalSpotPriceOutBaseInQuote.AddMut(routeSpotPriceOutBaseInQuote.MulMut(routeAmountOutFraction)) + totalEffectiveSpotPriceOutBaseInQuote = totalEffectiveSpotPriceOutBaseInQuote.AddMut(effectiveSpotPriceOutBaseInQuote.MulMut(routeAmountOutFraction)) + + resultRoutes = append(resultRoutes, &RouteWithOutAmount{ + RouteImpl: route.RouteImpl{ + Pools: newPools, + HasGeneralizedCosmWasmPool: curRoute.ContainsGeneralizedCosmWasmPool(), + }, + InAmount: curRoute.GetAmountIn(), + OutAmount: curRoute.GetAmountOut(), + }) + } + + // Calculate price impact + if !totalSpotPriceOutBaseInQuote.IsZero() { + q.PriceImpact = totalEffectiveSpotPriceOutBaseInQuote.Quo(totalSpotPriceOutBaseInQuote).SubMut(one) } + q.EffectiveFee = totalFeeAcrossRoutes + q.Route = resultRoutes + q.InBaseOutQuoteSpotPrice = totalSpotPriceOutBaseInQuote + return q.Route, q.EffectiveFee, nil } diff --git a/router/usecase/quote_out_given_in.go b/router/usecase/quote_out_given_in.go index b4a89c6bb..b8bb3fea4 100644 --- a/router/usecase/quote_out_given_in.go +++ b/router/usecase/quote_out_given_in.go @@ -36,12 +36,12 @@ func NewQuoteExactAmountOut(q *QuoteExactAmountIn) *quoteExactAmountOut { // quoteExactAmountIn is a quote implementation for token swap method exact in. type quoteExactAmountIn struct { - AmountIn sdk.Coin "json:\"amount_in\"" - AmountOut osmomath.Int "json:\"amount_out\"" - Route []domain.SplitRoute "json:\"route\"" - EffectiveFee osmomath.Dec "json:\"effective_fee\"" - PriceImpact osmomath.Dec "json:\"price_impact\"" - InBaseOutQuoteSpotPrice osmomath.Dec "json:\"in_base_out_quote_spot_price\"" + AmountIn sdk.Coin `json:"amount_in"` + AmountOut osmomath.Int `json:"amount_out"` + Route []domain.SplitRoute `json:"route"` + EffectiveFee osmomath.Dec `json:"effective_fee"` + PriceImpact osmomath.Dec `json:"price_impact"` + InBaseOutQuoteSpotPrice osmomath.Dec `json:"in_base_out_quote_spot_price"` PriceInfo *domain.TxFeeInfo `json:"price_info,omitempty"` } @@ -80,7 +80,7 @@ func (q *quoteExactAmountIn) PrepareResult(ctx context.Context, scalingFactor os totalFeeAcrossRoutes.AddMut(routeTotalFee.MulMut(routeAmountInFraction)) amountInFraction := q.AmountIn.Amount.ToLegacyDec().MulMut(routeAmountInFraction).TruncateInt() - newPools, routeSpotPriceInBaseOutQuote, effectiveSpotPriceInBaseOutQuote, err := curRoute.PrepareResultPools(ctx, sdk.NewCoin(q.AmountIn.Denom, amountInFraction), logger) + newPools, routeSpotPriceInBaseOutQuote, effectiveSpotPriceInBaseOutQuote, err := curRoute.PrepareResultPoolsExactAmountIn(ctx, sdk.NewCoin(q.AmountIn.Denom, amountInFraction), logger) if err != nil { return nil, osmomath.Dec{}, err } @@ -116,8 +116,8 @@ func (q *quoteExactAmountIn) GetAmountIn() sdk.Coin { } // GetAmountOut implements Quote. -func (q *quoteExactAmountIn) GetAmountOut() osmomath.Int { - return q.AmountOut +func (q *quoteExactAmountIn) GetAmountOut() sdk.Coin { + return sdk.Coin{Amount: q.AmountOut} } // GetRoute implements Quote. diff --git a/router/usecase/route/route.go b/router/usecase/route/route.go index 297ee90d3..980105c05 100644 --- a/router/usecase/route/route.go +++ b/router/usecase/route/route.go @@ -41,7 +41,7 @@ var ( ) ) -// PrepareResultPools implements domain.Route. +// PrepareResultPoolsExactAmountIn implements domain.Route. // Strips away unnecessary fields from each pool in the route, // leaving only the data needed by client // The following are the list of fields that are returned to the client in each pool: @@ -54,7 +54,7 @@ var ( // Note that it mutates the route. // Returns spot price before swap and the effective spot price // with token in as base and token out as quote. -func (r RouteImpl) PrepareResultPools(ctx context.Context, tokenIn sdk.Coin, logger log.Logger) ([]domain.RoutablePool, osmomath.Dec, osmomath.Dec, error) { +func (r RouteImpl) PrepareResultPoolsExactAmountIn(ctx context.Context, tokenIn sdk.Coin, logger log.Logger) ([]domain.RoutablePool, osmomath.Dec, osmomath.Dec, error) { var ( routeSpotPriceInBaseOutQuote = osmomath.OneDec() effectiveSpotPriceInBaseOutQuote = osmomath.OneDec() @@ -110,6 +110,62 @@ func (r RouteImpl) PrepareResultPools(ctx context.Context, tokenIn sdk.Coin, log return newPools, routeSpotPriceInBaseOutQuote, effectiveSpotPriceInBaseOutQuote, nil } +func (r RouteImpl) PrepareResultPoolsExactAmountOut(ctx context.Context, tokenOut sdk.Coin, logger log.Logger) ([]domain.RoutablePool, osmomath.Dec, osmomath.Dec, error) { + var ( + routeSpotPriceOutBaseInQuote = osmomath.OneDec() + effectiveSpotPriceOutBaseInQuote = osmomath.OneDec() + ) + + newPools := make([]domain.RoutablePool, 0, len(r.Pools)) + + for _, pool := range r.Pools { + // Compute spot price before swap. + spotPriceOutBaseInQuote, err := pool.CalcSpotPrice(ctx, tokenOut.Denom, pool.GetTokenInDenom()) + if err != nil { + logger.Error("failed to calculate spot price for pool", zap.Error(err)) + + // We don't want to fail the entire quote if one pool fails to calculate spot price. + // This might cause miestimaions downsream but we a + spotPriceOutBaseInQuote = osmomath.ZeroBigDec() + + // Increment the counter for the error + spotPriceErrorResultCounter.WithLabelValues( + tokenOut.Denom, + pool.GetTokenOutDenom(), + r.Pools[len(r.Pools)-1].GetTokenOutDenom(), + ).Inc() + } + + // Charge taker fee + tokenOut = pool.ChargeTakerFeeExactOut(tokenOut) + + tokenIn, err := pool.CalculateTokenInByTokenOut(ctx, tokenOut) + if err != nil { + return nil, osmomath.Dec{}, osmomath.Dec{}, err + } + + // Update effective spot price + effectiveSpotPriceOutBaseInQuote.MulMut(tokenIn.Amount.ToLegacyDec().QuoMut(tokenOut.Amount.ToLegacyDec())) + + // Note, in the future we may want to increase the precision of the spot price + routeSpotPriceOutBaseInQuote.MulMut(spotPriceOutBaseInQuote.Dec()) + + newPool := pools.NewExactAmountOutRoutableResultPool( + pool.GetId(), + pool.GetType(), + pool.GetSpreadFactor(), + pool.GetTokenInDenom(), + pool.GetTakerFee(), + pool.GetCodeID(), + ) + + newPools = append(newPools, newPool) + + tokenOut = tokenIn + } + return newPools, routeSpotPriceOutBaseInQuote, effectiveSpotPriceOutBaseInQuote, nil +} + // GetPools implements Route. func (r *RouteImpl) GetPools() []domain.RoutablePool { return r.Pools @@ -145,6 +201,37 @@ func (r *RouteImpl) CalculateTokenOutByTokenIn(ctx context.Context, tokenIn sdk. return tokenOut, nil } +// CalculateTokenInByTokenOut implements Route. +func (r *RouteImpl) CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin) (tokenIn sdk.Coin, err error) { + defer func() { + // TODO: cover this by test + if r := recover(); r != nil { + tokenIn = sdk.Coin{} + err = fmt.Errorf("error when calculating in by out in route: %v", r) + } + }() + + for _, pool := range r.Pools { + tokenIn, err = pool.CalculateTokenInByTokenOut(ctx, tokenOut) + if err != nil { + return sdk.Coin{}, err + } + + // Charge taker fee + tokenIn = pool.ChargeTakerFeeExactOut(tokenIn) + + tokenInAmt := tokenIn.Amount.ToLegacyDec() + + if tokenInAmt.IsNil() || tokenInAmt.IsZero() { + return sdk.Coin{}, nil + } + + tokenOut = tokenIn + } + + return tokenIn, nil +} + // String implements domain.Route. func (r *RouteImpl) String() string { var strBuilder strings.Builder diff --git a/router/usecase/route/route_test.go b/router/usecase/route/route_test.go index 3e10b7dd5..21fe6f81e 100644 --- a/router/usecase/route/route_test.go +++ b/router/usecase/route/route_test.go @@ -207,7 +207,7 @@ func (s *RouterTestSuite) TestPrepareResultPools() { s.Run(name, func() { // Note: token in is chosen arbitrarily since it is irrelevant for this test - actualPools, spotPriceBeforeInBaseOutQuote, _, err := tc.route.PrepareResultPools(context.TODO(), tc.tokenIn, &log.NoOpLogger{}) + actualPools, spotPriceBeforeInBaseOutQuote, _, err := tc.route.PrepareResultPoolsExactAmountIn(context.TODO(), tc.tokenIn, &log.NoOpLogger{}) s.Require().NoError(err) s.Require().Equal(tc.expectedSpotPriceInBaseOutQuote, spotPriceBeforeInBaseOutQuote) diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 5a8243894..617cd445c 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -76,7 +76,7 @@ func NewRouterUsecase(tokensRepository mvc.RouterRepository, poolsUsecase mvc.Po } } -// GetOptimalQuote returns the optimal quote by estimating the optimal route(s) through pools +// GetOptimalQuoteOutGivenIn returns the optimal quote by estimating the optimal route(s) through pools // on the osmosis network. // Uses default router config if no options parameter is provided. // With the options parameter, you can customize the router behavior. See domain.RouterOption for more details. @@ -88,7 +88,7 @@ func NewRouterUsecase(tokensRepository mvc.RouterRepository, poolsUsecase mvc.Po // Returns error if: // - fails to estimate direct quotes for ranked routes // - fails to retrieve candidate routes -func (r *routerUseCaseImpl) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) { +func (r *routerUseCaseImpl) GetOptimalQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) { options := domain.RouterOptions{ MaxPoolsPerRoute: r.defaultConfig.MaxPoolsPerRoute, MaxRoutes: r.defaultConfig.MaxRoutes, @@ -137,13 +137,13 @@ func (r *routerUseCaseImpl) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coi } // Find candidate routes and rank them by direct quotes. - topSingleRouteQuote, rankedRoutes, err = r.computeAndRankRoutesByDirectQuote(ctx, tokenIn, tokenOutDenom, options) + topSingleRouteQuote, rankedRoutes, err = r.computeAndRankRoutesByDirectQuoteOutGivenIn(ctx, tokenIn, tokenOutDenom, options) if err != nil { return nil, err } } else { // Otherwise, simply compute quotes over cached ranked routes - topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuote(ctx, candidateRankedRoutes, tokenIn, tokenOutDenom, options.MaxSplitRoutes) + topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuoteOutGivenIn(ctx, candidateRankedRoutes, tokenIn, tokenOutDenom, options.MaxSplitRoutes) if err != nil { return nil, err } @@ -162,7 +162,7 @@ func (r *routerUseCaseImpl) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coi } // Compute split route quote - topSplitQuote, err := getSplitQuote(ctx, rankedRoutes, tokenIn) + topSplitQuote, err := getSplitQuoteOutGivenIn(ctx, rankedRoutes, tokenIn) if err != nil { // If error occurs in splits, return the single route quote // rather than failing. @@ -172,7 +172,7 @@ func (r *routerUseCaseImpl) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coi finalQuote := topSingleRouteQuote // If the split route quote is better than the single route quote, return the split route quote - if topSplitQuote.GetAmountOut().GT(topSingleRouteQuote.GetAmountOut()) { + if topSplitQuote.GetAmountOut().Amount.GT(topSingleRouteQuote.GetAmountOut().Amount) { routes := topSplitQuote.GetRoute() r.logger.Debug("split route selected", zap.Int("route_count", len(routes))) @@ -191,30 +191,107 @@ func (r *routerUseCaseImpl) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coi // GetOptimalQuoteInGivenOut returns an optimal quote through the pools for the exact amount out token swap method. // Underlying implementation is the same as GetOptimalQuote, but the returned quote is wrapped in a quoteExactAmountOut. -func (r *routerUseCaseImpl) GetOptimalQuoteInGivenOut(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) { - // Disable cache and add orderbook pool filter - // So that order-book pools are not used in the candidate route search. - // The reason is that order-book contract does not implement the MsgSwapExactAmountOut API. - // The reason we disable cache is so that the exluded candidate routes do not interfere with the main - // "out given in" API. - opts = append(opts, - domain.WithDisableCache(), - domain.WithCandidateRoutesPoolFiltersAnyOf(domain.ShouldSkipOrderbookPool), +func (r *routerUseCaseImpl) GetOptimalQuoteInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, opts ...domain.RouterOption) (domain.Quote, error) { + options := domain.RouterOptions{ + MaxPoolsPerRoute: r.defaultConfig.MaxPoolsPerRoute, + MaxRoutes: r.defaultConfig.MaxRoutes, + MinPoolLiquidityCap: r.defaultConfig.MinPoolLiquidityCap, + CandidateRouteCacheExpirySeconds: r.defaultConfig.CandidateRouteCacheExpirySeconds, + RankedRouteCacheExpirySeconds: r.defaultConfig.RankedRouteCacheExpirySeconds, + MaxSplitRoutes: r.defaultConfig.MaxSplitRoutes, + DisableCache: true, // TODO + CandidateRoutesPoolFiltersAnyOf: []domain.CandidateRoutePoolFiltrerCb{}, + } + // Apply options + for _, opt := range opts { + opt(&options) + } + + var ( + candidateRankedRoutes ingesttypes.CandidateRoutes + err error ) - quote, err := r.GetOptimalQuote(ctx, tokenIn, tokenOutDenom, opts...) + // TODO + if !options.DisableCache { + // Get an order of magnitude for the token out amount + // This is used for caching ranked routes as these might differ depending on the amount swapped out. + tokenOutOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenOut.Amount) + + candidateRankedRoutes, err = r.GetCachedRankedRoutes(ctx, tokenOut.Denom, tokenInDenom, tokenOutOrderOfMagnitude) + if err != nil { + return nil, err + } + } + + var ( + topSingleRouteQuote domain.Quote + rankedRoutes []route.RouteImpl + ) + + // If no cached candidate routes are found, we attempt to + // compute them. + if len(candidateRankedRoutes.Routes) == 0 { + // Get the dynamic min pool liquidity cap for the given token in and token out denoms. + dynamicMinPoolLiquidityCap, err := r.tokenMetadataHolder.GetMinPoolLiquidityCap(tokenOut.Denom, tokenInDenom) + if err == nil { + // Set the dynamic min pool liquidity cap only if there is no error retrieving it. + // Otherwise, use the default. + options.MinPoolLiquidityCap = r.ConvertMinTokensPoolLiquidityCapToFilter(dynamicMinPoolLiquidityCap) + } + + // HERE + // Find candidate routes and rank them by direct quotes. + topSingleRouteQuote, rankedRoutes, err = r.computeAndRankRoutesByDirectQuoteInGivenOut(ctx, tokenOut, tokenInDenom, options) + if err != nil { + return nil, err + } + } else { + // Otherwise, simply compute quotes over cached ranked routes + topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuoteOutGivenIn(ctx, candidateRankedRoutes, tokenOut, tokenInDenom, options.MaxSplitRoutes) + if err != nil { + return nil, err + } + } + + if len(rankedRoutes) == 1 || options.MaxSplitRoutes == domain.DisableSplitRoutes { + return topSingleRouteQuote, nil + } + + // Filter out generalized cosmWasm pool routes + rankedRoutes = filterOutGeneralizedCosmWasmPoolRoutes(rankedRoutes) + + // If filtering leads to a single route left, return it. + if len(rankedRoutes) == 1 { + return topSingleRouteQuote, nil + } + + // Compute split route quote + topSplitQuote, err := getSplitQuoteInGivenOut(ctx, rankedRoutes, tokenOut) if err != nil { - return nil, err + // If error occurs in splits, return the single route quote + // rather than failing. + return topSingleRouteQuote, nil } - q, ok := quote.(*quoteExactAmountIn) - if !ok { - return nil, errors.New("quote is not a quoteExactAmountIn") + finalQuote := topSingleRouteQuote + + // If the split route quote is better than the single route quote, return the split route quote + if topSplitQuote.GetAmountOut().Amount.GT(topSingleRouteQuote.GetAmountOut().Amount) { + routes := topSplitQuote.GetRoute() + + r.logger.Debug("split route selected", zap.Int("route_count", len(routes))) + + finalQuote = topSplitQuote } - return "eExactAmountOut{ - quoteExactAmountIn: q, - }, nil + r.logger.Debug("single route selected", zap.Stringer("route", finalQuote.GetRoute()[0])) + + if finalQuote.GetAmountOut().IsZero() { + return nil, errors.New("best we can do is no tokens out") + } + + return finalQuote, nil } // GetSimpleQuote implements mvc.RouterUsecase. @@ -249,7 +326,7 @@ func (r *routerUseCaseImpl) GetSimpleQuote(ctx context.Context, tokenIn sdk.Coin MaxPoolsPerRoute: options.MaxPoolsPerRoute, MinPoolLiquidityCap: options.MinPoolLiquidityCap, } - candidateRoutes, err := r.candidateRouteSearcher.FindCandidateRoutes(tokenIn, tokenOutDenom, candidateRouteSearchOptions) + candidateRoutes, err := r.candidateRouteSearcher.FindCandidateRoutesOutGivenIn(tokenIn, tokenOutDenom, candidateRouteSearchOptions) if err != nil { r.logger.Error("error getting candidate routes for pricing", zap.Error(err)) return nil, err @@ -261,7 +338,7 @@ func (r *routerUseCaseImpl) GetSimpleQuote(ctx context.Context, tokenIn sdk.Coin return nil, err } - topQuote, _, err := r.estimateAndRankSingleRouteQuote(ctx, routes, tokenIn, r.logger) + topQuote, _, err := r.estimateAndRankSingleRouteQuoteOutGivenIn(ctx, routes, tokenIn, r.logger) if err != nil { return nil, fmt.Errorf("%s, tokenOutDenom (%s)", err, tokenOutDenom) } @@ -322,7 +399,7 @@ func filterAndConvertDuplicatePoolIDRankedRoutes(rankedRoutes []RouteWithOutAmou return filteredRankedRoutes } -// rankRoutesByDirectQuote ranks the given candidate routes by estimating direct quotes over each route. +// rankRoutesByDirectQuoteOutGivenIn ranks the given candidate routes by estimating direct quotes over each route. // Additionally, it fileters out routes with duplicate pool IDs and cuts them for splits // based on the value of maxSplitRoutes. // Returns the top quote as well as the ranked routes in decrease order of amount out. @@ -330,7 +407,7 @@ func filterAndConvertDuplicatePoolIDRankedRoutes(rankedRoutes []RouteWithOutAmou // - fails to read taker fees // - fails to convert candidate routes to routes // - fails to estimate direct quotes -func (r *routerUseCaseImpl) rankRoutesByDirectQuote(ctx context.Context, candidateRoutes ingesttypes.CandidateRoutes, tokenIn sdk.Coin, tokenOutDenom string, maxSplitRoutes int) (domain.Quote, []route.RouteImpl, error) { +func (r *routerUseCaseImpl) rankRoutesByDirectQuoteOutGivenIn(ctx context.Context, candidateRoutes ingesttypes.CandidateRoutes, tokenIn sdk.Coin, tokenOutDenom string, maxSplitRoutes int) (domain.Quote, []route.RouteImpl, error) { // Note that retrieving pools and taker fees is done in separate transactions. // This is fine because taker fees don't change often. routes, err := r.poolsUsecase.GetRoutesFromCandidates(candidateRoutes, tokenIn.Denom, tokenOutDenom) @@ -338,7 +415,7 @@ func (r *routerUseCaseImpl) rankRoutesByDirectQuote(ctx context.Context, candida return nil, nil, err } - topQuote, routesWithAmtOut, err := r.estimateAndRankSingleRouteQuote(ctx, routes, tokenIn, r.logger) + topQuote, routesWithAmtOut, err := r.estimateAndRankSingleRouteQuoteOutGivenIn(ctx, routes, tokenIn, r.logger) if err != nil { return nil, nil, fmt.Errorf("%s, tokenOutDenom (%s)", err, tokenOutDenom) } @@ -352,8 +429,38 @@ func (r *routerUseCaseImpl) rankRoutesByDirectQuote(ctx context.Context, candida return topQuote, routes, nil } -// computeAndRankRoutesByDirectQuote computes candidate routes and ranks them by token out after estimating direct quotes. -func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, routingOptions domain.RouterOptions) (domain.Quote, []route.RouteImpl, error) { +// rankRoutesByDirectQuoteInGivenOut ranks the given candidate routes by estimating direct quotes over each route. +// Additionally, it fileters out routes with duplicate pool IDs and cuts them for splits +// based on the value of maxSplitRoutes. +// Returns the top quote as well as the ranked routes in decrease order of amount out. +// Returns error if: +// - fails to read taker fees +// - fails to convert candidate routes to routes +// - fails to estimate direct quotes +func (r *routerUseCaseImpl) rankRoutesByDirectQuoteInGivenOut(ctx context.Context, candidateRoutes ingesttypes.CandidateRoutes, tokenOut sdk.Coin, tokenInDenom string, maxSplitRoutes int) (domain.Quote, []route.RouteImpl, error) { + // Note that retrieving pools and taker fees is done in separate transactions. + // This is fine because taker fees don't change often. + routes, err := r.poolsUsecase.GetRoutesFromCandidates(candidateRoutes, tokenOut.Denom, tokenInDenom) + if err != nil { + return nil, nil, err + } + + topQuote, routesWithAmtOut, err := r.estimateAndRankSingleRouteQuoteInGivenOut(ctx, routes, tokenOut, r.logger) + if err != nil { + return nil, nil, fmt.Errorf("%s, tokenOutDenom (%s)", err, tokenInDenom) + } + + // Update ranked routes with filtered ranked routes + routes = filterAndConvertDuplicatePoolIDRankedRoutes(routesWithAmtOut) + + // Cut routes for splits + routes = cutRoutesForSplits(maxSplitRoutes, routes) + + return topQuote, routes, nil +} + +// computeAndRankRoutesByDirectQuoteOutGivenIn computes candidate routes and ranks them by token out after estimating direct quotes. +func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, routingOptions domain.RouterOptions) (domain.Quote, []route.RouteImpl, error) { tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenIn.Amount) candidateRouteSearchOptions := domain.CandidateRouteSearchOptions{ @@ -365,7 +472,7 @@ func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuote(ctx context.Contex } // If top routes are not present in cache, retrieve unranked candidate routes - candidateRoutes, err := r.handleCandidateRoutes(ctx, tokenIn, tokenOutDenom, candidateRouteSearchOptions) + candidateRoutes, err := r.handleCandidateRoutesOutGivenIn(ctx, tokenIn, tokenOutDenom, candidateRouteSearchOptions) if err != nil { r.logger.Error("error handling routes", zap.Error(err)) return nil, nil, err @@ -393,7 +500,7 @@ func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuote(ctx context.Contex } // Rank candidate routes by estimating direct quotes - topSingleRouteQuote, rankedRoutes, err := r.rankRoutesByDirectQuote(ctx, candidateRoutes, tokenIn, tokenOutDenom, routingOptions.MaxSplitRoutes) + topSingleRouteQuote, rankedRoutes, err := r.rankRoutesByDirectQuoteOutGivenIn(ctx, candidateRoutes, tokenIn, tokenOutDenom, routingOptions.MaxSplitRoutes) if err != nil { r.logger.Error("error getting ranked routes", zap.Error(err)) return nil, nil, err @@ -428,13 +535,88 @@ func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuote(ctx context.Contex return topSingleRouteQuote, rankedRoutes, nil } +func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuoteInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, routingOptions domain.RouterOptions) (domain.Quote, []route.RouteImpl, error) { + tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenOut.Amount) + + candidateRouteSearchOptions := domain.CandidateRouteSearchOptions{ + MaxRoutes: routingOptions.MaxRoutes, + MaxPoolsPerRoute: routingOptions.MaxPoolsPerRoute, + MinPoolLiquidityCap: routingOptions.MinPoolLiquidityCap, + DisableCache: routingOptions.DisableCache, + PoolFiltersAnyOf: routingOptions.CandidateRoutesPoolFiltersAnyOf, + } + + // If top routes are not present in cache, retrieve unranked candidate + candidateRoutes, err := r.handleCandidateRoutesInGivenOut(ctx, tokenOut, tokenInDenom, candidateRouteSearchOptions) + if err != nil { + r.logger.Error("error handling routes", zap.Error(err)) + return nil, nil, err + } + + // Get request path for metrics + requestURLPath, err := domain.GetURLPathFromContext(ctx) + if err != nil { + return nil, nil, err + } + + if !routingOptions.DisableCache { + if len(candidateRoutes.Routes) > 0 { + domain.SQSRoutesCacheWritesCounter.WithLabelValues(requestURLPath, candidateRouteCacheLabel).Inc() + + r.candidateRouteCache.Set(formatCandidateRouteCacheKey(tokenOut.Denom, tokenInDenom), candidateRoutes, time.Duration(routingOptions.CandidateRouteCacheExpirySeconds)*time.Second) + } else { + // If no candidate routes found, cache them for quarter of the duration + r.candidateRouteCache.Set(formatCandidateRouteCacheKey(tokenOut.Denom, tokenInDenom), candidateRoutes, time.Duration(routingOptions.CandidateRouteCacheExpirySeconds/4)*time.Second) + + r.rankedRouteCache.Set(formatRankedRouteCacheKey(tokenOut.Denom, tokenInDenom, tokenInOrderOfMagnitude), candidateRoutes, time.Duration(routingOptions.RankedRouteCacheExpirySeconds/4)*time.Second) + + return nil, nil, fmt.Errorf("no candidate routes found") + } + } + + // Rank candidate routes by estimating direct quotes + topSingleRouteQuote, rankedRoutes, err := r.rankRoutesByDirectQuoteInGivenOut(ctx, candidateRoutes, tokenOut, tokenInDenom, routingOptions.MaxSplitRoutes) + if err != nil { + r.logger.Error("error getting ranked routes", zap.Error(err)) + return nil, nil, err + } + + if len(rankedRoutes) == 0 { + return nil, nil, fmt.Errorf("no ranked routes found") + } + + // Convert ranked routes back to candidate for caching + convertedCandidateRoutes := convertRankedToCandidateRoutes(rankedRoutes) + + if len(rankedRoutes) > 0 { + // We would like to always consider the canonical orderbook route so that if new limits appear + // we can detect them. Oterwise, our cache would have to expire to detect them. + if !convertedCandidateRoutes.ContainsCanonicalOrderbook && candidateRoutes.ContainsCanonicalOrderbook { + // Find the canonical orderbook route and add it to the converted candidate routes. + for _, candidateRoute := range candidateRoutes.Routes { + if candidateRoute.IsCanonicalOrderboolRoute { + convertedCandidateRoutes.Routes = append(convertedCandidateRoutes.Routes, candidateRoute) + break + } + } + } + + if !routingOptions.DisableCache { + domain.SQSRoutesCacheWritesCounter.WithLabelValues(requestURLPath, rankedRouteCacheLabel).Inc() + r.rankedRouteCache.Set(formatRankedRouteCacheKey(tokenOut.Denom, tokenInDenom, tokenInOrderOfMagnitude), convertedCandidateRoutes, time.Duration(routingOptions.RankedRouteCacheExpirySeconds)*time.Second) + } + } + + return topSingleRouteQuote, rankedRoutes, nil +} + var ( ErrTokenInDenomPoolNotFound = fmt.Errorf("token in denom not found in pool") ErrTokenOutDenomPoolNotFound = fmt.Errorf("token out denom not found in pool") ) -// GetCustomDirectQuote implements mvc.RouterUsecase. -func (r *routerUseCaseImpl) GetCustomDirectQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) { +// GetCustomDirectQuoteOutGivenIn implements mvc.RouterUsecase. +func (r *routerUseCaseImpl) GetCustomDirectQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) { pool, err := r.poolsUsecase.GetPool(poolID) if err != nil { return nil, err @@ -450,7 +632,7 @@ func (r *routerUseCaseImpl) GetCustomDirectQuote(ctx context.Context, tokenIn sd } // create candidate routes with given token out denom and pool ID. - candidateRoutes := r.createCandidateRouteByPoolID(tokenOutDenom, poolID) + candidateRoutes := r.createCandidateRouteByPoolID(tokenIn.Denom, tokenOutDenom, poolID) // Convert candidate route into a route with all the pool data routes, err := r.poolsUsecase.GetRoutesFromCandidates(candidateRoutes, tokenIn.Denom, tokenOutDenom) @@ -459,7 +641,7 @@ func (r *routerUseCaseImpl) GetCustomDirectQuote(ctx context.Context, tokenIn sd } // Compute direct quote - bestSingleRouteQuote, _, err := r.estimateAndRankSingleRouteQuote(ctx, routes, tokenIn, r.logger) + bestSingleRouteQuote, _, err := r.estimateAndRankSingleRouteQuoteOutGivenIn(ctx, routes, tokenIn, r.logger) if err != nil { return nil, err } @@ -467,8 +649,42 @@ func (r *routerUseCaseImpl) GetCustomDirectQuote(ctx context.Context, tokenIn sd return bestSingleRouteQuote, nil } -// GetCustomDirectQuoteMultiPool implements mvc.RouterUsecase. -func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPool(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) { +// GetCustomDirectQuoteOutGivenIn implements mvc.RouterUsecase. +func (r *routerUseCaseImpl) GetCustomDirectQuoteInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, poolID uint64) (domain.Quote, error) { + pool, err := r.poolsUsecase.GetPool(poolID) + if err != nil { + return nil, err + } + + poolDenoms := pool.GetPoolDenoms() + + if !osmoutils.Contains(poolDenoms, tokenOut.Denom) { + return nil, fmt.Errorf("denom %s in pool %d: %w", tokenOut.Denom, poolID, ErrTokenInDenomPoolNotFound) + } + if !osmoutils.Contains(poolDenoms, tokenInDenom) { + return nil, fmt.Errorf("denom %s in pool %d: %w", tokenInDenom, poolID, ErrTokenOutDenomPoolNotFound) + } + + // create candidate routes with given token out denom and pool ID. + candidateRoutes := r.createCandidateRouteByPoolID(tokenInDenom, tokenOut.Denom, poolID) + + // Convert candidate route into a route with all the pool data + routes, err := r.poolsUsecase.GetRoutesFromCandidates(candidateRoutes, tokenOut.Denom, tokenInDenom) + if err != nil { + return nil, err + } + + // Compute direct quote + bestSingleRouteQuote, _, err := r.estimateAndRankSingleRouteQuoteOutGivenIn(ctx, routes, tokenOut, r.logger) + if err != nil { + return nil, err + } + + return bestSingleRouteQuote, nil +} + +// GetCustomDirectQuoteMultiPoolOutGivenIn implements mvc.RouterUsecase. +func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPoolOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) { if len(poolIDs) == 0 { return nil, fmt.Errorf("%w: at least one pool ID should be specified", routertypes.ErrValidationFailed) } @@ -490,7 +706,7 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPool(ctx context.Context, t for i, v := range poolIDs { tokenOutDenom := tokenOutDenom[i] - quote, err := r.GetCustomDirectQuote(ctx, tokenIn, tokenOutDenom, v) + quote, err := r.GetCustomDirectQuoteOutGivenIn(ctx, tokenIn, tokenOutDenom, v) if err != nil { return nil, err } @@ -506,12 +722,12 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPool(ctx context.Context, t } // the amountOut value is the amount out of last the tokenOutDenom - result.AmountOut = quote.GetAmountOut() + result.AmountOut = quote.GetAmountOut().Amount // append each pool to the route pools = append(pools, poolsInRoute...) - tokenIn = sdk.NewCoin(tokenOutDenom, quote.GetAmountOut()) + tokenIn = sdk.NewCoin(tokenOutDenom, quote.GetAmountOut().Amount) } // Construct the final multi-hop custom direct quote route. @@ -530,19 +746,63 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPool(ctx context.Context, t // GetCustomDirectQuoteMultiPool implements mvc.RouterUsecase. func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPoolInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) { - quote, err := r.GetCustomDirectQuoteMultiPool(ctx, tokenOut, tokenInDenom, poolIDs) - if err != nil { - return nil, err + if len(poolIDs) == 0 { + return nil, fmt.Errorf("%w: at least one pool ID should be specified", routertypes.ErrValidationFailed) } - q, ok := quote.(*quoteExactAmountIn) - if !ok { - return nil, errors.New("quote is not a quoteExactAmountIn") + if len(tokenInDenom) == 0 { + return nil, fmt.Errorf("%w: at least one token in denom should be specified", routertypes.ErrValidationFailed) } - return "eExactAmountOut{ - quoteExactAmountIn: q, - }, nil + // for each given pool we expect to have provided token out denom + if len(poolIDs) != len(tokenInDenom) { + return nil, fmt.Errorf("%w: number of pool ID should match number of in denom", routertypes.ErrValidationFailed) + } + + // AmountIn is the first token of the asset pair. + result := quoteExactAmountOut{AmountOut: tokenOut} + + pools := make([]domain.RoutablePool, 0, len(poolIDs)) + + for i, v := range poolIDs { + tokenInDenom := tokenInDenom[i] + + quote, err := r.GetCustomDirectQuoteInGivenOut(ctx, tokenOut, tokenInDenom, v) + if err != nil { + return nil, err + } + + route := quote.GetRoute() + if len(route) != 1 { + return nil, fmt.Errorf("custom direct quote must have 1 route, had: %d", len(route)) + } + + poolsInRoute := route[0].GetPools() + if len(poolsInRoute) != 1 { + return nil, fmt.Errorf("custom direct quote route must have 1 pool, had: %d", len(poolsInRoute)) + } + + // the amountIn value is the amount out of last the tokenInDenom + result.AmountIn = quote.GetAmountIn().Amount + + // append each pool to the route + pools = append(pools, poolsInRoute...) + + tokenOut = sdk.NewCoin(tokenInDenom, quote.GetAmountOut().Amount) + } + + // Construct the final multi-hop custom direct quote route. + result.Route = []domain.SplitRoute{ + &RouteWithOutAmount{ + RouteImpl: route.RouteImpl{ + Pools: pools, + }, + OutAmount: result.AmountOut.Amount, + InAmount: result.AmountIn, + }, + } + + return &result, nil } // GetCandidateRoutes implements domain.RouterUsecase. @@ -561,7 +821,7 @@ func (r *routerUseCaseImpl) GetCandidateRoutes(ctx context.Context, tokenIn sdk. candidateRouteSearchOptions.MinPoolLiquidityCap = r.ConvertMinTokensPoolLiquidityCapToFilter(dynamicMinPoolLiquidityCap) } - candidateRoutes, err := r.handleCandidateRoutes(ctx, tokenIn, tokenOutDenom, candidateRouteSearchOptions) + candidateRoutes, err := r.handleCandidateRoutesOutGivenIn(ctx, tokenIn, tokenOutDenom, candidateRouteSearchOptions) if err != nil { return ingesttypes.CandidateRoutes{}, err } @@ -664,14 +924,14 @@ func (r *routerUseCaseImpl) GetCachedRankedRoutes(ctx context.Context, tokenInDe return rankedRoutes, nil } -// handleCandidateRoutes attempts to retrieve candidate routes from the cache. If no routes are cached, it will +// handleCandidateRoutesOutGivenIn attempts to retrieve candidate routes from the cache. If no routes are cached, it will // compute, persist in cache and return them. // Returns routes on success // Errors if: // - there is an error retrieving routes from cache // - there are no routes cached and there is an error computing them // - fails to persist the computed routes in cache -func (r *routerUseCaseImpl) handleCandidateRoutes(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, candidateRouteSearchOptions domain.CandidateRouteSearchOptions) (candidateRoutes ingesttypes.CandidateRoutes, err error) { +func (r *routerUseCaseImpl) handleCandidateRoutesOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, candidateRouteSearchOptions domain.CandidateRouteSearchOptions) (candidateRoutes ingesttypes.CandidateRoutes, err error) { r.logger.Debug("getting routes") // Check cache for routes if enabled @@ -689,7 +949,7 @@ func (r *routerUseCaseImpl) handleCandidateRoutes(ctx context.Context, tokenIn s if !isFoundCached { r.logger.Debug("calculating routes") - candidateRoutes, err = r.candidateRouteSearcher.FindCandidateRoutes(tokenIn, tokenOutDenom, candidateRouteSearchOptions) + candidateRoutes, err = r.candidateRouteSearcher.FindCandidateRoutesOutGivenIn(tokenIn, tokenOutDenom, candidateRouteSearchOptions) if err != nil { r.logger.Error("error getting candidate routes for pricing", zap.Error(err)) return ingesttypes.CandidateRoutes{}, err @@ -714,6 +974,56 @@ func (r *routerUseCaseImpl) handleCandidateRoutes(ctx context.Context, tokenIn s return candidateRoutes, nil } +// handleCandidateRoutesInGivenOut attempts to retrieve candidate routes from the cache. If no routes are cached, it will +// compute, persist in cache and return them. +// Returns routes on success +// Errors if: +// - there is an error retrieving routes from cache +// - there are no routes cached and there is an error computing them +// - fails to persist the computed routes in cache +func (r *routerUseCaseImpl) handleCandidateRoutesInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string, candidateRouteSearchOptions domain.CandidateRouteSearchOptions) (candidateRoutes ingesttypes.CandidateRoutes, err error) { + r.logger.Debug("getting routes") + + // Check cache for routes if enabled + var isFoundCached bool + if !candidateRouteSearchOptions.DisableCache { + candidateRoutes, isFoundCached, err = r.GetCachedCandidateRoutes(ctx, tokenOut.Denom, tokenInDenom) + if err != nil { + return ingesttypes.CandidateRoutes{}, err + } + } + + r.logger.Debug("cached routes", zap.Int("num_routes", len(candidateRoutes.Routes))) + + // If no routes are cached, find them + if !isFoundCached { + r.logger.Debug("calculating routes") + + candidateRoutes, err = r.candidateRouteSearcher.FindCandidateRoutesInGivenOut(tokenOut, tokenInDenom, candidateRouteSearchOptions) + if err != nil { + r.logger.Error("error getting candidate routes for pricing", zap.Error(err)) + return ingesttypes.CandidateRoutes{}, err + } + + r.logger.Info("calculated routes", zap.Int("num_routes", len(candidateRoutes.Routes))) + + // Persist routes + if !candidateRouteSearchOptions.DisableCache { + cacheDurationSeconds := r.defaultConfig.CandidateRouteCacheExpirySeconds + if len(candidateRoutes.Routes) == 0 { + // If there are no routes, we want to cache the result for a shorter duration + // Add 1 to ensure that it is never 0 as zero signifies never clearing. + cacheDurationSeconds = cacheDurationSeconds/4 + 1 + } + + r.logger.Debug("persisting routes", zap.Int("num_routes", len(candidateRoutes.Routes))) + r.candidateRouteCache.Set(formatCandidateRouteCacheKey(tokenOut.Denom, tokenInDenom), candidateRoutes, time.Duration(cacheDurationSeconds)*time.Second) + } + } + + return candidateRoutes, nil +} + // StoreRouterStateFiles implements domain.RouterUsecase. // TODO: clean up func (r *routerUseCaseImpl) StoreRouterStateFiles() error { @@ -936,7 +1246,7 @@ func filterOutGeneralizedCosmWasmPoolRoutes(rankedRoutes []route.RouteImpl) []ro } // createCandidateRouteByPoolID constructs a candidate route with the desired pool. -func (r *routerUseCaseImpl) createCandidateRouteByPoolID(tokenOutDenom string, poolID uint64) ingesttypes.CandidateRoutes { +func (r *routerUseCaseImpl) createCandidateRouteByPoolID(tokenInDenom string, tokenOutDenom string, poolID uint64) ingesttypes.CandidateRoutes { // Create a candidate route with the desired pool return ingesttypes.CandidateRoutes{ Routes: []ingesttypes.CandidateRoute{ @@ -944,6 +1254,7 @@ func (r *routerUseCaseImpl) createCandidateRouteByPoolID(tokenOutDenom string, p Pools: []ingesttypes.CandidatePool{ { ID: poolID, + TokenInDenom: tokenInDenom, TokenOutDenom: tokenOutDenom, }, }, diff --git a/router/usecase/router_usecase_test.go b/router/usecase/router_usecase_test.go index d11e6ebf7..df9781e59 100644 --- a/router/usecase/router_usecase_test.go +++ b/router/usecase/router_usecase_test.go @@ -806,7 +806,7 @@ func (s *RouterTestSuite) TestGetOptimalQuote_Cache_Overwrites() { } // System under test - quote, err := mainnetUseCase.Router.GetOptimalQuote(context.Background(), sdk.NewCoin(defaultTokenInDenom, tc.amountIn), defaultTokenOutDenom, options...) + quote, err := mainnetUseCase.Router.GetOptimalQuoteOutGivenIn(context.Background(), sdk.NewCoin(defaultTokenInDenom, tc.amountIn), defaultTokenOutDenom, options...) // We only validate that error does not occur without actually validating the quote. s.Require().NoError(err) @@ -892,7 +892,7 @@ func (s *RouterTestSuite) TestGetCandidateRoutes_Chain_FindUnsupportedRoutes() { MaxPoolsPerRoute: config.Router.MaxPoolsPerRoute, } - routes, err := mainnetUsecase.CandidateRouteSearcher.FindCandidateRoutes(sdk.NewCoin(chainDenom, one), USDC, options) + routes, err := mainnetUsecase.CandidateRouteSearcher.FindCandidateRoutesOutGivenIn(sdk.NewCoin(chainDenom, one), USDC, options) if err != nil { fmt.Printf("Error for %s -- %s -- %v\n", chainDenom, tokenMeta.HumanDenom, err) errorCounter++ @@ -932,7 +932,7 @@ func (s *RouterTestSuite) TestGetCandidateRoutes_Chain_FindUnsupportedRoutes() { continue } - routes, err := mainnetUsecase.CandidateRouteSearcher.FindCandidateRoutes(sdk.NewCoin(chainDenom, one), USDC, options) + routes, err := mainnetUsecase.CandidateRouteSearcher.FindCandidateRoutesOutGivenIn(sdk.NewCoin(chainDenom, one), USDC, options) if err != nil { fmt.Printf("Error for %s -- %s -- %v\n", chainDenom, tokenMeta.HumanDenom, err) errorCounter++ @@ -980,7 +980,7 @@ func (s *RouterTestSuite) TestPriceImpactRoute_Fractions() { s.Require().True(ok) // Get quote. - quote, err := mainnetUsecase.Router.GetOptimalQuote(context.Background(), sdk.NewCoin(chainWBTC, osmomath.NewInt(1_00_000_000)), USDC) + quote, err := mainnetUsecase.Router.GetOptimalQuoteOutGivenIn(context.Background(), sdk.NewCoin(chainWBTC, osmomath.NewInt(1_00_000_000)), USDC) s.Require().NoError(err) // Prepare quote result. @@ -1273,7 +1273,7 @@ func (s *RouterTestSuite) TestGetCustomQuote_GetCustomDirectQuotes_Mainnet_UOSMO for _, tc := range testCases { s.Run(tc.name, func() { - quotes, err := routerUsecase.GetCustomDirectQuoteMultiPool(context.Background(), tc.tokenIn, tc.tokenOutDenom, tc.poolID) + quotes, err := routerUsecase.GetCustomDirectQuoteMultiPoolOutGivenIn(context.Background(), tc.tokenIn, tc.tokenOutDenom, tc.poolID) s.Require().ErrorIs(err, tc.err) if err != nil { return // nothing else to do @@ -1509,7 +1509,7 @@ func (s *RouterTestSuite) TestGetCustomQuote_GetCustomDirectQuotes_Mainnet_Order for _, tc := range testCases { s.Run(tc.name, func() { - quote, err := routerUsecase.GetCustomDirectQuote(context.Background(), tc.tokenIn, tc.tokenOutDenom, tc.poolID) + quote, err := routerUsecase.GetCustomDirectQuoteOutGivenIn(context.Background(), tc.tokenIn, tc.tokenOutDenom, tc.poolID) if err != nil { s.Require().EqualError(tc.err, err.Error()) @@ -1706,7 +1706,7 @@ func (s *RouterTestSuite) TestGetMinPoolLiquidityCapFilter() { // This helper is useful in specific tests that rely on this configuration. func (s *RouterTestSuite) validatePoolIDInRoute(routerUseCase mvc.RouterUsecase, coinIn sdk.Coin, tokenOutDenom string, expectedPoolID uint64) { // Get quote - quote, err := routerUseCase.GetOptimalQuote(context.Background(), coinIn, tokenOutDenom) + quote, err := routerUseCase.GetOptimalQuoteOutGivenIn(context.Background(), coinIn, tokenOutDenom) s.Require().NoError(err) quoteRoutes := quote.GetRoute() diff --git a/tokens/usecase/pricing/chain/pricing_chain.go b/tokens/usecase/pricing/chain/pricing_chain.go index 2b0a75379..70113ece1 100644 --- a/tokens/usecase/pricing/chain/pricing_chain.go +++ b/tokens/usecase/pricing/chain/pricing_chain.go @@ -200,7 +200,7 @@ func (c *chainPricing) computePrice(ctx context.Context, baseDenom string, quote // if there is an error in the spot price computation above. if !isSpotPriceComputeMethod { // Compute on-chain price for 10 units of base denom and resulted quote denom out. - chainPrice = osmomath.NewBigDecFromBigInt(tenQuoteCoin.Amount.BigIntMut()).QuoMut(osmomath.NewBigDecFromBigInt(quote.GetAmountOut().BigIntMut())) + chainPrice = osmomath.NewBigDecFromBigInt(tenQuoteCoin.Amount.BigIntMut()).QuoMut(osmomath.NewBigDecFromBigInt(quote.GetAmountOut().Amount.BigIntMut())) } if chainPrice.IsZero() {