From 996cec0de0be47b26b40d91314bf652d4fcf8c6e Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Fri, 24 Jan 2025 17:14:55 +0200 Subject: [PATCH 01/18] BE-677 | InGivenOut APIs for Alloyed pool --- domain/mocks/pool_mock.go | 4 +- domain/routable_pool.go | 2 +- .../pools/routable_concentrated_pool.go | 2 +- .../routable_cw_alloy_transmuter_pool.go | 66 +++++++++++++++++-- .../routable_cw_alloy_transmuter_pool_test.go | 66 +++++++++++++++++++ .../pools/routable_cw_orderbook_pool.go | 2 +- router/usecase/pools/routable_result_pool.go | 2 +- 7 files changed, 132 insertions(+), 12 deletions(-) diff --git a/domain/mocks/pool_mock.go b/domain/mocks/pool_mock.go index f824b3c5..a86e83a4 100644 --- a/domain/mocks/pool_mock.go +++ b/domain/mocks/pool_mock.go @@ -201,8 +201,8 @@ func (mp *MockRoutablePool) ChargeTakerFeeExactIn(tokenIn sdk.Coin) (tokenInAfte } // ChargeTakerFeeExactOut implements domain.RoutablePool. -func (mp *MockRoutablePool) ChargeTakerFeeExactOut(tokenOut sdk.Coin) (tokenInAfterFee sdk.Coin) { - return tokenOut.Add(sdk.NewCoin(tokenOut.Denom, mp.TakerFee.Mul(tokenOut.Amount.ToLegacyDec()).TruncateInt())) +func (mp *MockRoutablePool) ChargeTakerFeeExactOut(tokenIn sdk.Coin) (tokenInAfterFee sdk.Coin) { + return tokenIn.Add(sdk.NewCoin(tokenIn.Denom, mp.TakerFee.Mul(tokenIn.Amount.ToLegacyDec()).TruncateInt())) } // GetTakerFee implements ingesttypes.PoolI. diff --git a/domain/routable_pool.go b/domain/routable_pool.go index 5041fa35..a7997c80 100644 --- a/domain/routable_pool.go +++ b/domain/routable_pool.go @@ -62,7 +62,7 @@ type RoutablePool interface { CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin) (sdk.Coin, error) ChargeTakerFeeExactIn(tokenIn sdk.Coin) (tokenInAfterFee sdk.Coin) - ChargeTakerFeeExactOut(tokenOut sdk.Coin) (tokenOutAfterFee sdk.Coin) + ChargeTakerFeeExactOut(tokenIn sdk.Coin) (tokenInAfterFee sdk.Coin) GetTakerFee() osmomath.Dec diff --git a/router/usecase/pools/routable_concentrated_pool.go b/router/usecase/pools/routable_concentrated_pool.go index 5775d31d..9935c86e 100644 --- a/router/usecase/pools/routable_concentrated_pool.go +++ b/router/usecase/pools/routable_concentrated_pool.go @@ -224,7 +224,7 @@ func (r *routableConcentratedPoolImpl) ChargeTakerFeeExactIn(tokenIn sdk.Coin) ( // ChargeTakerFee implements domain.RoutablePool. // Charges the taker fee for the given token out and returns the token out after the fee has been charged. -func (r *routableConcentratedPoolImpl) ChargeTakerFeeExactOut(tokenOut sdk.Coin) (tokenOutAfterFee sdk.Coin) { +func (r *routableConcentratedPoolImpl) ChargeTakerFeeExactOut(tokenIn sdk.Coin) (tokenInAfterFee sdk.Coin) { return sdk.Coin{} } diff --git a/router/usecase/pools/routable_cw_alloy_transmuter_pool.go b/router/usecase/pools/routable_cw_alloy_transmuter_pool.go index 65fa89fd..7e196aca 100644 --- a/router/usecase/pools/routable_cw_alloy_transmuter_pool.go +++ b/router/usecase/pools/routable_cw_alloy_transmuter_pool.go @@ -2,7 +2,6 @@ package pools import ( "context" - "errors" "fmt" "cosmossdk.io/math" @@ -81,8 +80,30 @@ func (r *routableAlloyTransmuterPoolImpl) CalculateTokenOutByTokenIn(ctx context } // CalculateTokenInByTokenOut implements domain.RoutablePool. -func (r *routableAlloyTransmuterPoolImpl) CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin) (sdk.Coin, error) { - return sdk.Coin{}, errors.New("not implemented") +// It calculates the amount of token in given the amount of token out for a transmuter pool. +// Transmuter pool allows no slippage swaps. For v3, the ratio of token out to token in is dependent on the normalization factor. +// Returns error if: +// - the underlying chain pool set on the routable pool is not of transmuter type +// - the token out amount is greater than the balance of the token out +// - the token out amount is greater than the balance of the token in +// +// Note that balance validation does not apply to alloyed asset since it can be minted or burned by the pool. +func (r *routableAlloyTransmuterPoolImpl) CalculateTokenInByTokenOut(ctx context.Context, tokenIn sdk.Coin) (sdk.Coin, error) { + tokenInAtm, err := r.CalcTokenInAmt(tokenIn, r.TokenOutDenom) + if err != nil { + return sdk.Coin{}, err + } + + tokenInAmtInt := tokenInAtm.Dec().TruncateInt() + + // Validate token out balance if not alloyed + if r.TokenInDenom != r.AlloyTransmuterData.AlloyedDenom { + if err := validateTransmuterBalance(tokenInAmtInt, r.Balances, r.TokenInDenom); err != nil { + return sdk.Coin{}, err + } + } + + return sdk.Coin{Denom: r.TokenInDenom, Amount: tokenInAmtInt}, nil } // GetTokenOutDenom implements RoutablePool. @@ -108,9 +129,10 @@ func (r *routableAlloyTransmuterPoolImpl) ChargeTakerFeeExactIn(tokenIn sdk.Coin } // ChargeTakerFeeExactOut implements domain.RoutablePool. -// Returns tokenOutAmount and does not charge any fee for transmuter pools. -func (r *routableAlloyTransmuterPoolImpl) ChargeTakerFeeExactOut(tokenOut sdk.Coin) (outAmountAfterFee sdk.Coin) { - return sdk.Coin{} +// Returns tokenInAmount and does not charge any fee for transmuter pools. +func (r *routableAlloyTransmuterPoolImpl) ChargeTakerFeeExactOut(tokenIn sdk.Coin) (inAmountAfterFee sdk.Coin) { + tokenInAfterTakerFee, _ := poolmanager.CalcTakerFeeExactOut(tokenIn, r.GetTakerFee()) + return tokenInAfterTakerFee } // GetTakerFee implements domain.RoutablePool. @@ -208,6 +230,38 @@ func (r *routableAlloyTransmuterPoolImpl) CalcTokenOutAmt(tokenIn sdk.Coin, toke return tokenOutAmount, nil } +// Calculate the token in amount based on the normalization factors: +// +// token_in_amt = token_out_amt * token_in_norm_factor / token_out_norm_factor +func (r *routableAlloyTransmuterPoolImpl) CalcTokenInAmt(tokenOut sdk.Coin, tokenInDenom string) (osmomath.BigDec, error) { + tokenInNormFactor, tokenOutNormFactor, err := r.FindNormalizationFactors(tokenInDenom, tokenOut.Denom) + if err != nil { + return osmomath.BigDec{}, err + } + + if tokenInNormFactor.IsZero() { + return osmomath.BigDec{}, domain.ZeroNormalizationFactorError{Denom: tokenOut.Denom, PoolId: r.GetId()} + } + + if tokenOutNormFactor.IsZero() { + return osmomath.BigDec{}, domain.ZeroNormalizationFactorError{Denom: tokenInDenom, PoolId: r.GetId()} + } + + // Check static upper rate limiter + if err := r.checkStaticRateLimiter(tokenOut); err != nil { + return osmomath.BigDec{}, err + } + + tokenOutAmount := osmomath.BigDecFromSDKInt(tokenOut.Amount) + + tokenOutNormFactorBig := osmomath.NewBigIntFromBigInt(tokenOutNormFactor.BigInt()) + tokenInNormFactorBig := osmomath.NewBigIntFromBigInt(tokenInNormFactor.BigInt()) + + tokenInAmount := tokenOutAmount.MulInt(tokenInNormFactorBig).QuoInt(tokenOutNormFactorBig) + + return tokenInAmount, nil +} + // checkStaticRateLimiter checks the static rate limiter. // If token in denom is not alloyed, we only need to validate the token in balance. // Since the token in balance is the only one that is increased by the current quote. diff --git a/router/usecase/pools/routable_cw_alloy_transmuter_pool_test.go b/router/usecase/pools/routable_cw_alloy_transmuter_pool_test.go index 044c8895..f412d119 100644 --- a/router/usecase/pools/routable_cw_alloy_transmuter_pool_test.go +++ b/router/usecase/pools/routable_cw_alloy_transmuter_pool_test.go @@ -276,6 +276,72 @@ func (s *RoutablePoolTestSuite) TestCalcTokenOutAmt_AlloyTransmuter() { } } +func (s *RoutablePoolTestSuite) TestCalcTokenInAmt_AlloyTransmuter() { + tests := map[string]struct { + tokenOut sdk.Coin + tokenInDenom string + expectedTokenOut osmomath.BigDec + expectedError error + }{ + "valid calculation using normalization factors": { + tokenOut: sdk.NewCoin(USDC, osmomath.NewInt(100)), + tokenInDenom: USDT, + expectedTokenOut: osmomath.NewBigDec(1), // (100 * 1) / 100 = 1 + expectedError: nil, + }, + "valid calculation with decimal points": { + tokenOut: sdk.NewCoin(USDC, osmomath.NewInt(10)), + tokenInDenom: USDT, + expectedTokenOut: osmomath.MustNewBigDecFromStr("0.1"), // (10 * 1) / 100 = 0.1 + expectedError: nil, + }, + "valid calculation, truncated to zero": { + tokenOut: sdk.NewCoin(OVERLY_PRECISE_USD, osmomath.NewInt(10)), + tokenInDenom: USDC, + expectedTokenOut: osmomath.MustNewBigDecFromStr("0"), + expectedError: nil, + }, + "missing normalization factor for token in": { + tokenOut: sdk.NewCoin(INVALID_DENOM, osmomath.NewInt(100)), + tokenInDenom: USDT, + expectedTokenOut: osmomath.BigDec{}, + expectedError: domain.MissingNormalizationFactorError{Denom: INVALID_DENOM, PoolId: defaultPoolID}, + }, + "missing normalization factor for token out": { + tokenOut: sdk.NewCoin(USDC, osmomath.NewInt(100)), + tokenInDenom: INVALID_DENOM, + expectedTokenOut: osmomath.BigDec{}, + expectedError: domain.MissingNormalizationFactorError{Denom: INVALID_DENOM, PoolId: defaultPoolID}, + }, + "missing normalization factors for both token in and token out": { + tokenOut: sdk.NewCoin(INVALID_DENOM, osmomath.NewInt(100)), + tokenInDenom: INVALID_DENOM, + expectedTokenOut: osmomath.BigDec{}, + expectedError: domain.MissingNormalizationFactorError{Denom: INVALID_DENOM, PoolId: defaultPoolID}, + }, + } + + for name, tc := range tests { + s.Run(name, func() { + s.Setup() + + routablePool := s.SetupRoutableAlloyTransmuterPool(tc.tokenInDenom, tc.tokenOut.Denom, sdk.Coins{}, osmomath.ZeroDec()) + + r := routablePool.(*pools.RoutableAlloyTransmuterPoolImpl) + + tokenIn, err := r.CalcTokenInAmt(tc.tokenOut, tc.tokenInDenom) + + if tc.expectedError != nil { + s.Require().Error(err) + s.Require().ErrorIs(err, tc.expectedError) + } else { + s.Require().NoError(err) + s.Require().Equal(tc.expectedTokenOut, tokenIn) + } + }) + } +} + func (s *RoutablePoolTestSuite) TestChargeTakerFeeExactIn_AlloyTransmuter() { tests := map[string]struct { tokenIn sdk.Coin diff --git a/router/usecase/pools/routable_cw_orderbook_pool.go b/router/usecase/pools/routable_cw_orderbook_pool.go index b3f3bc2d..0e4d92a2 100644 --- a/router/usecase/pools/routable_cw_orderbook_pool.go +++ b/router/usecase/pools/routable_cw_orderbook_pool.go @@ -183,7 +183,7 @@ func (r *routableOrderbookPoolImpl) ChargeTakerFeeExactIn(tokenIn sdk.Coin) (tok // ChargeTakerFee implements sqsdomain.RoutablePool. // Charges the taker fee for the given token out and returns the token out after the fee has been charged. -func (r *routableOrderbookPoolImpl) ChargeTakerFeeExactOut(tokenOut sdk.Coin) (tokenOutAfterFee sdk.Coin) { +func (r *routableOrderbookPoolImpl) ChargeTakerFeeExactOut(tokenIn sdk.Coin) (tokenInAfterFee sdk.Coin) { return sdk.Coin{} } diff --git a/router/usecase/pools/routable_result_pool.go b/router/usecase/pools/routable_result_pool.go index 8b50841d..c15f53a4 100644 --- a/router/usecase/pools/routable_result_pool.go +++ b/router/usecase/pools/routable_result_pool.go @@ -157,7 +157,7 @@ func (r *routableResultPoolImpl) ChargeTakerFeeExactIn(tokenIn sdk.Coin) (tokenI // ChargeTakerFee implements domain.RoutablePool. // Charges the taker fee for the given token out and returns the token out after the fee has been charged. -func (r *routableResultPoolImpl) ChargeTakerFeeExactOut(tokenOut sdk.Coin) (tokenOutAfterFee sdk.Coin) { +func (r *routableResultPoolImpl) ChargeTakerFeeExactOut(tokenIn sdk.Coin) (tokenInAfterFee sdk.Coin) { return sdk.Coin{} } From 023d5400a0b89f9d399893c85b41de035536952b Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Mon, 27 Jan 2025 12:03:13 +0200 Subject: [PATCH 02/18] BE-678 | InGivenOut APIs for Cosmwasm pool --- router/usecase/pools/routable_cw_pool.go | 37 ++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/router/usecase/pools/routable_cw_pool.go b/router/usecase/pools/routable_cw_pool.go index 19a0eac3..c594ca90 100644 --- a/router/usecase/pools/routable_cw_pool.go +++ b/router/usecase/pools/routable_cw_pool.go @@ -2,7 +2,6 @@ package pools import ( "context" - "errors" "fmt" "cosmossdk.io/math" @@ -86,8 +85,35 @@ func (r *routableCosmWasmPoolImpl) GetSpreadFactor() math.LegacyDec { } // CalculateTokenInByTokenOut implements domain.RoutablePool. +// It calculates the amount of token in given the amount of token out for a transmuter pool. +// Transmuter pool allows no slippage swaps. It just returns the same amount of token in as token out +// Returns error if: +// - the underlying chain pool set on the routable pool is not of transmuter type +// - the token out amount is greater than the balance of the token out +// - the token out amount is greater than the balance of the token in func (r *routableCosmWasmPoolImpl) CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin) (sdk.Coin, error) { - return sdk.Coin{}, errors.New("not implemented") + return r.calculateTokenOutByTokenIn(ctx, tokenOut, r.TokenInDenom) +} + +func (r *routableCosmWasmPoolImpl) calculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string) (sdk.Coin, error) { + poolType := r.GetType() + + // Ensure that the pool is cosmwasm + if poolType != poolmanagertypes.CosmWasm { + return sdk.Coin{}, domain.InvalidPoolTypeError{PoolType: int32(poolType)} + } + + // Configure the calc query message + calcMessage := msg.NewCalcInAmtGivenOutRequest(tokenInDenom, tokenOut, r.SpreadFactor) + + calcInAmtGivenOutResponse := msg.CalcInAmtGivenOutResponse{} + if err := cosmwasmdomain.QueryCosmwasmContract(ctx, r.wasmClient, r.ChainPool.ContractAddress, &calcMessage, &calcInAmtGivenOutResponse); err != nil { + return sdk.Coin{}, err + } + + // No slippage swaps - just return the same amount of token out as token in + // as long as there is enough liquidity in the pool. + return calcInAmtGivenOutResponse.TokenIn, nil } // CalculateTokenOutByTokenIn implements domain.RoutablePool. @@ -155,9 +181,10 @@ func (r *routableCosmWasmPoolImpl) ChargeTakerFeeExactIn(tokenIn sdk.Coin) (inAm } // ChargeTakerFeeExactOut implements domain.RoutablePool. -// Returns tokenOutAmount and does not charge any fee for transmuter pools. -func (r *routableCosmWasmPoolImpl) ChargeTakerFeeExactOut(tokenOut sdk.Coin) (outAmountAfterFee sdk.Coin) { - return sdk.Coin{} +// Returns tokenInAmount and does not charge any fee for transmuter pools. +func (r *routableCosmWasmPoolImpl) ChargeTakerFeeExactOut(tokenIn sdk.Coin) (inAmountAfterFee sdk.Coin) { + tokenInAfterTakerFee, _ := poolmanager.CalcTakerFeeExactOut(tokenIn, r.GetTakerFee()) + return tokenInAfterTakerFee } // GetTakerFee implements domain.RoutablePool. From b3174bc374815f23f11e83697441670e9c1bf003 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Mon, 27 Jan 2025 13:55:42 +0200 Subject: [PATCH 03/18] BE-678 | Add unit tests --- router/usecase/pools/routable_cw_pool.go | 2 +- router/usecase/pools/routable_cw_pool_test.go | 269 ++++++++++++++++++ 2 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 router/usecase/pools/routable_cw_pool_test.go diff --git a/router/usecase/pools/routable_cw_pool.go b/router/usecase/pools/routable_cw_pool.go index c594ca90..7b99a417 100644 --- a/router/usecase/pools/routable_cw_pool.go +++ b/router/usecase/pools/routable_cw_pool.go @@ -92,7 +92,7 @@ func (r *routableCosmWasmPoolImpl) GetSpreadFactor() math.LegacyDec { // - the token out amount is greater than the balance of the token out // - the token out amount is greater than the balance of the token in func (r *routableCosmWasmPoolImpl) CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin) (sdk.Coin, error) { - return r.calculateTokenOutByTokenIn(ctx, tokenOut, r.TokenInDenom) + return r.calculateTokenInByTokenOut(ctx, tokenOut, r.TokenInDenom) } func (r *routableCosmWasmPoolImpl) calculateTokenInByTokenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom string) (sdk.Coin, error) { diff --git a/router/usecase/pools/routable_cw_pool_test.go b/router/usecase/pools/routable_cw_pool_test.go new file mode 100644 index 00000000..3d732760 --- /dev/null +++ b/router/usecase/pools/routable_cw_pool_test.go @@ -0,0 +1,269 @@ +package pools_test + +import ( + "context" + "fmt" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/osmosis-labs/sqs/domain" + + cosmwasmdomain "github.com/osmosis-labs/sqs/domain/cosmwasm" + "github.com/osmosis-labs/sqs/domain/mocks" + "github.com/osmosis-labs/sqs/router/usecase/pools" + + "github.com/osmosis-labs/osmosis/osmomath" + poolmanagertypes "github.com/osmosis-labs/osmosis/v28/x/poolmanager/types" + + "github.com/osmosis-labs/osmosis/v28/app/apptesting" + + "github.com/stretchr/testify/suite" +) + +type CosmWasmPoolSuite struct { + apptesting.KeeperTestHelper +} + +func TestCosmWasmPoolSuite(t *testing.T) { + suite.Run(t, new(CosmWasmPoolSuite)) +} + +func (s *CosmWasmPoolSuite) SetupTest() { + s.Setup() +} + +func (s *CosmWasmPoolSuite) newPool(method domain.TokenSwapMethod, coin sdk.Coin, denom string, isInvalidPoolType bool, takerFee osmomath.Dec, err error) domain.RoutablePool { + cosmwasmPool := s.PrepareCustomTransmuterPoolCustomProject(s.TestAccs[0], []string{coin.Denom, denom}, "sqs", "scripts") + + mock := &mocks.MockRoutablePool{ChainPoolModel: cosmwasmPool.AsSerializablePool(), PoolType: poolmanagertypes.CosmWasm} + wasmclient := &mocks.WasmClient{} + + token := "token_out" + if method == domain.TokenSwapMethodExactOut { + token = "token_in" + } + wasmclient.WithSmartContractState( + []byte(fmt.Sprintf(`{ "%s": { "denom" : "%s", "amount" : "%s" } }`, token, ETH, coin.Amount.String())), + err, + ) + + cosmWasmPoolsParams := cosmwasmdomain.CosmWasmPoolsParams{ + Config: domain.CosmWasmPoolRouterConfig{ + GeneralCosmWasmCodeIDs: map[uint64]struct{}{ + cosmwasmPool.GetCodeId(): {}, + }, + }, + WasmClient: wasmclient, + ScalingFactorGetterCb: domain.UnsetScalingFactorGetterCb, + } + + routablePool, err := pools.NewRoutablePool(mock, coin.Denom, denom, takerFee, cosmWasmPoolsParams) + s.Require().NoError(err) + + // Overwrite pool type for edge case testing + if isInvalidPoolType { + mock.PoolType = poolmanagertypes.Concentrated + } + + return routablePool +} + +func (s *CosmWasmPoolSuite) TestCalculateTokenOutByTokenIn() { + defaultAmount := DefaultAmt0 + defaultBalances := sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount), sdk.NewCoin(ETH, defaultAmount)) + + tests := map[string]struct { + tokenIn sdk.Coin + tokenOutDenom string + balances sdk.Coins + isInvalidPoolType bool + expectError error + }{ + "valid CosmWasm quote": { + tokenIn: sdk.NewCoin(USDC, defaultAmount), + tokenOutDenom: ETH, + balances: defaultBalances, + }, + "no error: token in is larger than balance of token in": { + tokenIn: sdk.NewCoin(USDC, defaultAmount), + tokenOutDenom: ETH, + // Make token in amount 1 smaller than the default amount + balances: sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount.Sub(osmomath.OneInt())), sdk.NewCoin(ETH, defaultAmount)), + }, + "error: token in is larger than balance of token out": { + tokenIn: sdk.NewCoin(USDC, defaultAmount), + tokenOutDenom: ETH, + + // Make token out amount 1 smaller than the default amount + balances: sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount), sdk.NewCoin(ETH, defaultAmount.Sub(osmomath.OneInt()))), + + expectError: domain.TransmuterInsufficientBalanceError{ + Denom: ETH, + BalanceAmount: defaultAmount.Sub(osmomath.OneInt()).String(), + Amount: defaultAmount.String(), + }, + }, + } + + for name, tc := range tests { + s.Run(name, func() { + s.Setup() + + routablePool := s.newPool(domain.TokenSwapMethodExactIn, tc.tokenIn, tc.tokenOutDenom, tc.isInvalidPoolType, noTakerFee, tc.expectError) + + tokenOut, err := routablePool.CalculateTokenOutByTokenIn(context.TODO(), tc.tokenIn) + + if tc.expectError != nil { + s.Require().Error(err) + s.Require().ErrorIs(err, tc.expectError) + return + } + s.Require().NoError(err) + + // No slippage swaps on success + s.Require().Equal(tc.tokenIn.Amount, tokenOut.Amount) + }) + } +} + +func (s *CosmWasmPoolSuite) TestChargeTakerFeeExactIn() { + defaultAmount := DefaultAmt0 + defaultBalances := sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount), sdk.NewCoin(ETH, defaultAmount)) + + tests := map[string]struct { + poolType poolmanagertypes.PoolType + tokenIn sdk.Coin + takerFee osmomath.Dec + balances sdk.Coins + expectedToken sdk.Coin + }{ + "no taker fee": { + tokenIn: sdk.NewCoin(USDC, osmomath.NewInt(100)), + balances: defaultBalances, + takerFee: osmomath.NewDec(0), + expectedToken: sdk.NewCoin(USDC, osmomath.NewInt(100)), + }, + "small taker fee": { + tokenIn: sdk.NewCoin(USDT, osmomath.NewInt(100)), + takerFee: osmomath.NewDecWithPrec(1, 2), // 1% + expectedToken: sdk.NewCoin(USDT, osmomath.NewInt(99)), // 100 - 1 = 99 + }, + "large taker fee": { + tokenIn: sdk.NewCoin(USDC, osmomath.NewInt(100)), + takerFee: osmomath.NewDecWithPrec(5, 1), // 50% + expectedToken: sdk.NewCoin(USDC, osmomath.NewInt(50)), // 100 - 50 = 50 + }, + } + + for name, tc := range tests { + s.Run(name, func() { + s.Setup() + + routablePool := s.newPool(domain.TokenSwapMethodExactIn, tc.tokenIn, "", false, tc.takerFee, nil) + + tokenAfterFee := routablePool.ChargeTakerFeeExactIn(tc.tokenIn) + + s.Require().Equal(tc.expectedToken, tokenAfterFee) + }) + } +} + +func (s *CosmWasmPoolSuite) TestCalculateTokenInByTokenOut() { + defaultAmount := DefaultAmt0 + defaultBalances := sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount), sdk.NewCoin(ETH, defaultAmount)) + + tests := map[string]struct { + tokenOut sdk.Coin + tokenInDenom string + balances sdk.Coins + isInvalidPoolType bool + expectError error + }{ + "valid CosmWasm quote": { + tokenOut: sdk.NewCoin(USDC, defaultAmount), + tokenInDenom: ETH, + balances: defaultBalances, + }, + "no error: token in is larger than balance of token in": { + tokenOut: sdk.NewCoin(USDC, defaultAmount), + tokenInDenom: ETH, + // Make token in amount 1 smaller than the default amount + balances: sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount.Sub(osmomath.OneInt())), sdk.NewCoin(ETH, defaultAmount)), + }, + "error: token in is larger than balance of token out": { + tokenOut: sdk.NewCoin(USDC, defaultAmount), + tokenInDenom: ETH, + + // Make token out amount 1 smaller than the default amount + balances: sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount), sdk.NewCoin(ETH, defaultAmount.Sub(osmomath.OneInt()))), + + expectError: domain.TransmuterInsufficientBalanceError{ + Denom: ETH, + BalanceAmount: defaultAmount.Sub(osmomath.OneInt()).String(), + Amount: defaultAmount.String(), + }, + }, + } + + for name, tc := range tests { + s.Run(name, func() { + s.Setup() + + routablePool := s.newPool(domain.TokenSwapMethodExactOut, tc.tokenOut, tc.tokenInDenom, tc.isInvalidPoolType, noTakerFee, tc.expectError) + + tokenIn, err := routablePool.CalculateTokenInByTokenOut(context.TODO(), tc.tokenOut) + + if tc.expectError != nil { + s.Require().Error(err) + s.Require().ErrorIs(err, tc.expectError) + return + } + s.Require().NoError(err) + + // No slippage swaps on success + s.Require().Equal(tc.tokenOut.Amount, tokenIn.Amount) + }) + } +} + +func (s *CosmWasmPoolSuite) TestChargeTakerFeeExactOut() { + defaultAmount := DefaultAmt0 + defaultBalances := sdk.NewCoins(sdk.NewCoin(USDC, defaultAmount), sdk.NewCoin(ETH, defaultAmount)) + + tests := map[string]struct { + poolType poolmanagertypes.PoolType + tokenIn sdk.Coin + takerFee osmomath.Dec + balances sdk.Coins + expectedToken sdk.Coin + }{ + "no taker fee": { + tokenIn: sdk.NewCoin(USDC, osmomath.NewInt(100)), + balances: defaultBalances, + takerFee: osmomath.NewDec(0), + expectedToken: sdk.NewCoin(USDC, osmomath.NewInt(100)), + }, + "small taker fee": { + tokenIn: sdk.NewCoin(USDT, osmomath.NewInt(100)), + takerFee: osmomath.NewDecWithPrec(1, 2), // 1% + expectedToken: sdk.NewCoin(USDT, osmomath.NewInt(102)), // 100 + 1 = 101.01 = 102 (round up) + }, + "large taker fee": { + tokenIn: sdk.NewCoin(USDC, osmomath.NewInt(100)), + takerFee: osmomath.NewDecWithPrec(5, 1), // 50% + expectedToken: sdk.NewCoin(USDC, osmomath.NewInt(200)), // 100 + 100 = 200 + }, + } + + for name, tc := range tests { + s.Run(name, func() { + s.Setup() + + routablePool := s.newPool(domain.TokenSwapMethodExactOut, tc.tokenIn, "", false, tc.takerFee, nil) + + tokenAfterFee := routablePool.ChargeTakerFeeExactOut(tc.tokenIn) + + s.Require().Equal(tc.expectedToken, tokenAfterFee) + }) + } +} From b5fca4949c3242b7cdea62b70e7fdbdeda342802 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Mon, 27 Jan 2025 13:59:00 +0200 Subject: [PATCH 04/18] BE-678 | Add wasm client mock --- domain/mocks/wasm_client.go | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 domain/mocks/wasm_client.go diff --git a/domain/mocks/wasm_client.go b/domain/mocks/wasm_client.go new file mode 100644 index 00000000..e14eaefb --- /dev/null +++ b/domain/mocks/wasm_client.go @@ -0,0 +1,116 @@ +package mocks + +import ( + "context" + + wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" + + "google.golang.org/grpc" +) + +type WasmClient struct { + ContractInfoFunc func(ctx context.Context, in *wasmtypes.QueryContractInfoRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractInfoResponse, error) + ContractHistoryFunc func(ctx context.Context, in *wasmtypes.QueryContractHistoryRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractHistoryResponse, error) + ContractsByCodeFunc func(ctx context.Context, in *wasmtypes.QueryContractsByCodeRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractsByCodeResponse, error) + AllContractStateFunc func(ctx context.Context, in *wasmtypes.QueryAllContractStateRequest, opts ...grpc.CallOption) (*wasmtypes.QueryAllContractStateResponse, error) + RawContractStateFunc func(ctx context.Context, in *wasmtypes.QueryRawContractStateRequest, opts ...grpc.CallOption) (*wasmtypes.QueryRawContractStateResponse, error) + SmartContractStateFunc func(ctx context.Context, in *wasmtypes.QuerySmartContractStateRequest, opts ...grpc.CallOption) (*wasmtypes.QuerySmartContractStateResponse, error) + CodeFunc func(ctx context.Context, in *wasmtypes.QueryCodeRequest, opts ...grpc.CallOption) (*wasmtypes.QueryCodeResponse, error) + CodesFunc func(ctx context.Context, in *wasmtypes.QueryCodesRequest, opts ...grpc.CallOption) (*wasmtypes.QueryCodesResponse, error) + PinnedCodesFunc func(ctx context.Context, in *wasmtypes.QueryPinnedCodesRequest, opts ...grpc.CallOption) (*wasmtypes.QueryPinnedCodesResponse, error) + ParamsFunc func(ctx context.Context, in *wasmtypes.QueryParamsRequest, opts ...grpc.CallOption) (*wasmtypes.QueryParamsResponse, error) + ContractsByCreatorFunc func(ctx context.Context, in *wasmtypes.QueryContractsByCreatorRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractsByCreatorResponse, error) + BuildAddressFunc func(ctx context.Context, in *wasmtypes.QueryBuildAddressRequest, opts ...grpc.CallOption) (*wasmtypes.QueryBuildAddressResponse, error) +} + +func (m *WasmClient) ContractInfo(ctx context.Context, in *wasmtypes.QueryContractInfoRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractInfoResponse, error) { + if m.ContractInfoFunc != nil { + return m.ContractInfoFunc(ctx, in, opts...) + } + panic("MockQueryClient.ContractInfo unimplemented") +} + +func (m *WasmClient) ContractHistory(ctx context.Context, in *wasmtypes.QueryContractHistoryRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractHistoryResponse, error) { + if m.ContractHistoryFunc != nil { + return m.ContractHistoryFunc(ctx, in, opts...) + } + panic("MockQueryClient.ContractHistory unimplemented") +} + +func (m *WasmClient) ContractsByCode(ctx context.Context, in *wasmtypes.QueryContractsByCodeRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractsByCodeResponse, error) { + if m.ContractsByCodeFunc != nil { + return m.ContractsByCodeFunc(ctx, in, opts...) + } + panic("MockQueryClient.ContractsByCode unimplemented") +} + +func (m *WasmClient) AllContractState(ctx context.Context, in *wasmtypes.QueryAllContractStateRequest, opts ...grpc.CallOption) (*wasmtypes.QueryAllContractStateResponse, error) { + if m.AllContractStateFunc != nil { + return m.AllContractStateFunc(ctx, in, opts...) + } + panic("MockQueryClient.AllContractState unimplemented") +} + +func (m *WasmClient) RawContractState(ctx context.Context, in *wasmtypes.QueryRawContractStateRequest, opts ...grpc.CallOption) (*wasmtypes.QueryRawContractStateResponse, error) { + if m.RawContractStateFunc != nil { + return m.RawContractStateFunc(ctx, in, opts...) + } + panic("MockQueryClient.RawContractState unimplemented") +} + +func (m *WasmClient) SmartContractState(ctx context.Context, in *wasmtypes.QuerySmartContractStateRequest, opts ...grpc.CallOption) (*wasmtypes.QuerySmartContractStateResponse, error) { + if m.SmartContractStateFunc != nil { + return m.SmartContractStateFunc(ctx, in, opts...) + } + panic("MockQueryClient.SmartContractState unimplemented") +} + +func (m *WasmClient) WithSmartContractState(data wasmtypes.RawContractMessage, err error) { + m.SmartContractStateFunc = func(ctx context.Context, in *wasmtypes.QuerySmartContractStateRequest, opts ...grpc.CallOption) (*wasmtypes.QuerySmartContractStateResponse, error) { + return &wasmtypes.QuerySmartContractStateResponse{ + Data: data, + }, err + } +} + +func (m *WasmClient) Code(ctx context.Context, in *wasmtypes.QueryCodeRequest, opts ...grpc.CallOption) (*wasmtypes.QueryCodeResponse, error) { + if m.CodeFunc != nil { + return m.CodeFunc(ctx, in, opts...) + } + panic("MockQueryClient.Code unimplemented") +} + +func (m *WasmClient) Codes(ctx context.Context, in *wasmtypes.QueryCodesRequest, opts ...grpc.CallOption) (*wasmtypes.QueryCodesResponse, error) { + if m.CodesFunc != nil { + return m.CodesFunc(ctx, in, opts...) + } + panic("MockQueryClient.Codes unimplemented") +} + +func (m *WasmClient) PinnedCodes(ctx context.Context, in *wasmtypes.QueryPinnedCodesRequest, opts ...grpc.CallOption) (*wasmtypes.QueryPinnedCodesResponse, error) { + if m.PinnedCodesFunc != nil { + return m.PinnedCodesFunc(ctx, in, opts...) + } + panic("MockQueryClient.PinnedCodes unimplemented") +} + +func (m *WasmClient) Params(ctx context.Context, in *wasmtypes.QueryParamsRequest, opts ...grpc.CallOption) (*wasmtypes.QueryParamsResponse, error) { + if m.ParamsFunc != nil { + return m.ParamsFunc(ctx, in, opts...) + } + panic("MockQueryClient.Params unimplemented") +} + +func (m *WasmClient) ContractsByCreator(ctx context.Context, in *wasmtypes.QueryContractsByCreatorRequest, opts ...grpc.CallOption) (*wasmtypes.QueryContractsByCreatorResponse, error) { + if m.ContractsByCreatorFunc != nil { + return m.ContractsByCreatorFunc(ctx, in, opts...) + } + panic("MockQueryClient.ContractsByCreator unimplemented") +} + +func (m *WasmClient) BuildAddress(ctx context.Context, in *wasmtypes.QueryBuildAddressRequest, opts ...grpc.CallOption) (*wasmtypes.QueryBuildAddressResponse, error) { + if m.BuildAddressFunc != nil { + return m.BuildAddressFunc(ctx, in, opts...) + } + panic("MockQueryClient.BuildAddress unimplemented") +} From 35f50a27a78a5e00c6b6ddcc5eaf058d6406b91d Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Tue, 28 Jan 2025 10:39:05 +0200 Subject: [PATCH 05/18] BE-680 | Wire InGivenOut APIs to /custom-direct-quote --- domain/mocks/router_usecase_mock.go | 6 ++--- domain/mvc/router.go | 12 +++++----- .../plugins/orderbook/fillbot/cyclic_arb.go | 2 +- router/delivery/http/router_handler.go | 4 ++-- router/usecase/optimized_routes_test.go | 4 ++-- router/usecase/router_usecase.go | 23 ++++++++++--------- router/usecase/router_usecase_test.go | 10 ++++---- 7 files changed, 31 insertions(+), 30 deletions(-) diff --git a/domain/mocks/router_usecase_mock.go b/domain/mocks/router_usecase_mock.go index 3a07ee9c..0bab5ed8 100644 --- a/domain/mocks/router_usecase_mock.go +++ b/domain/mocks/router_usecase_mock.go @@ -66,7 +66,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 +87,14 @@ 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) { +func (m *RouterUsecaseMock) GetCustomDirectQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) { if m.GetCustomDirectQuoteFunc != nil { return m.GetCustomDirectQuoteFunc(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) 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 bee80b35..e0636476 100644 --- a/domain/mvc/router.go +++ b/domain/mvc/router.go @@ -59,19 +59,19 @@ 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) + // 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/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go b/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go index 7e25db8d..a34b3732 100644 --- a/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go +++ b/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go @@ -19,7 +19,7 @@ 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 } diff --git a/router/delivery/http/router_handler.go b/router/delivery/http/router_handler.go index b5cc1b44..76cc5005 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...) } @@ -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/optimized_routes_test.go b/router/usecase/optimized_routes_test.go index 61c3754d..f868eae0 100644 --- a/router/usecase/optimized_routes_test.go +++ b/router/usecase/optimized_routes_test.go @@ -703,7 +703,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 +821,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) } diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 5a824389..fcec959d 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, @@ -202,7 +202,7 @@ func (r *routerUseCaseImpl) GetOptimalQuoteInGivenOut(ctx context.Context, token domain.WithCandidateRoutesPoolFiltersAnyOf(domain.ShouldSkipOrderbookPool), ) - quote, err := r.GetOptimalQuote(ctx, tokenIn, tokenOutDenom, opts...) + quote, err := r.GetOptimalQuoteOutGivenIn(ctx, tokenIn, tokenOutDenom, opts...) if err != nil { return nil, err } @@ -433,8 +433,8 @@ var ( 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 +450,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) @@ -467,8 +467,8 @@ 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) { +// 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 +490,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 } @@ -530,7 +530,7 @@ 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) + quote, err := r.GetCustomDirectQuoteMultiPoolOutGivenIn(ctx, tokenOut, tokenInDenom, poolIDs) if err != nil { return nil, err } @@ -936,7 +936,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 +944,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 d11e6ebf..9441275f 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) @@ -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() From 684e7dd865c0c05280bb5d9145a55cd6d8dcd953 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Tue, 28 Jan 2025 12:01:19 +0200 Subject: [PATCH 06/18] BE-680 | Add new APIs for OutGivenIn --- domain/mocks/quote_mock.go | 4 +- domain/mocks/quote_simulator_mock.go | 2 +- domain/mocks/router_usecase_mock.go | 14 ++- domain/mvc/router.go | 6 ++ domain/quote_simulator.go | 2 +- domain/router.go | 2 +- .../plugins/orderbook/fillbot/cyclic_arb.go | 4 +- quotesimulator/quote_simulator.go | 4 +- quotesimulator/quote_simulator_test.go | 6 +- router/delivery/http/router_handler.go | 2 +- router/usecase/optimized_routes_test.go | 2 +- router/usecase/quote_in_given_out.go | 63 +++++++++-- router/usecase/quote_out_given_in.go | 16 +-- router/usecase/router_usecase.go | 102 +++++++++++++++--- tokens/usecase/pricing/chain/pricing_chain.go | 2 +- 15 files changed, 187 insertions(+), 44 deletions(-) diff --git a/domain/mocks/quote_mock.go b/domain/mocks/quote_mock.go index f6060019..fa5c3482 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 5b6c2bed..a84b209d 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/router_usecase_mock.go b/domain/mocks/router_usecase_mock.go index 0bab5ed8..24f258c1 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) @@ -88,8 +89,15 @@ func (m *RouterUsecaseMock) GetBestSingleRouteQuote(ctx context.Context, tokenIn } func (m *RouterUsecaseMock) GetCustomDirectQuoteOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) { - if m.GetCustomDirectQuoteFunc != nil { - return m.GetCustomDirectQuoteFunc(ctx, tokenIn, tokenOutDenom, poolID) + if m.GetCustomDirectQuoteOutGivenInFunc != nil { + return m.GetCustomDirectQuoteOutGivenInFunc(ctx, tokenIn, tokenOutDenom, poolID) + } + panic("unimplemented") +} + +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") } diff --git a/domain/mvc/router.go b/domain/mvc/router.go index e0636476..176081ac 100644 --- a/domain/mvc/router.go +++ b/domain/mvc/router.go @@ -69,6 +69,12 @@ type RouterUsecase interface { // 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. 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. GetCustomDirectQuoteMultiPoolOutGivenIn(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) diff --git a/domain/quote_simulator.go b/domain/quote_simulator.go index a79f4fba..d17c044b 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/router.go b/domain/router.go index e6a4a804..35091526 100644 --- a/domain/router.go +++ b/domain/router.go @@ -59,7 +59,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 a34b3732..0af46b10 100644 --- a/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go +++ b/ingest/usecase/plugins/orderbook/fillbot/cyclic_arb.go @@ -25,7 +25,7 @@ func (o *orderbookFillerIngestPlugin) estimateCyclicArb(ctx blockctx.BlockCtxI, } // 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/quotesimulator/quote_simulator.go b/quotesimulator/quote_simulator.go index efc8d396..509aed2a 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 ef6fb87f..341baf17 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 76cc5005..9f61f174 100644 --- a/router/delivery/http/router_handler.go +++ b/router/delivery/http/router_handler.go @@ -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) diff --git a/router/usecase/optimized_routes_test.go b/router/usecase/optimized_routes_test.go index f868eae0..24f309d5 100644 --- a/router/usecase/optimized_routes_test.go +++ b/router/usecase/optimized_routes_test.go @@ -1012,7 +1012,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 4e121453..8b3faf0a 100644 --- a/router/usecase/quote_in_given_out.go +++ b/router/usecase/quote_in_given_out.go @@ -2,6 +2,8 @@ package usecase import ( "context" + "fmt" + "strings" "github.com/osmosis-labs/sqs/domain" "github.com/osmosis-labs/sqs/log" @@ -20,12 +22,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. diff --git a/router/usecase/quote_out_given_in.go b/router/usecase/quote_out_given_in.go index b4a89c6b..951b4447 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"` } @@ -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/router_usecase.go b/router/usecase/router_usecase.go index fcec959d..699e77ed 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -172,7 +172,7 @@ func (r *routerUseCaseImpl) GetOptimalQuoteOutGivenIn(ctx context.Context, token 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))) @@ -467,6 +467,40 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteOutGivenIn(ctx context.Context, return bestSingleRouteQuote, nil } +// 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.estimateAndRankSingleRouteQuote(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 { @@ -506,12 +540,12 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPoolOutGivenIn(ctx context. } // 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 +564,63 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPoolOutGivenIn(ctx context. // GetCustomDirectQuoteMultiPool implements mvc.RouterUsecase. func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPoolInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) { - quote, err := r.GetCustomDirectQuoteMultiPoolOutGivenIn(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. diff --git a/tokens/usecase/pricing/chain/pricing_chain.go b/tokens/usecase/pricing/chain/pricing_chain.go index 2b0a7537..70113ece 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() { From a336c313aa8ed49848ea42d4c51c102fd9a076b9 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Tue, 28 Jan 2025 12:48:31 +0200 Subject: [PATCH 07/18] BE-680 | InGivenOut APIs --- domain/candidate_routes.go | 9 +- domain/mocks/candidate_route_finder_mock.go | 9 +- router/usecase/candidate_routes.go | 205 +++++++++++++- router/usecase/candidate_routes_bench_test.go | 2 +- router/usecase/candidate_routes_test.go | 6 +- router/usecase/dynamic_splits_test.go | 2 +- router/usecase/export_test.go | 2 +- router/usecase/router_usecase.go | 254 ++++++++++++++++-- router/usecase/router_usecase_test.go | 4 +- 9 files changed, 453 insertions(+), 40 deletions(-) diff --git a/domain/candidate_routes.go b/domain/candidate_routes.go index b59284cc..345a7457 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 f926b15c..a9123590 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/router/usecase/candidate_routes.go b/router/usecase/candidate_routes.go index fc861bcf..e27f280d 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, @@ -236,6 +237,206 @@ func (c candidateRouteFinder) FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDeno return validateAndFilterRoutes(routes, tokenIn.Denom, c.logger) } +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) + currenTokenInDenom := tokenOut.Denom + if len(currentRoute) > 0 { + lastPool := currentRoute[len(currentRoute)-1] + lastPoolID = lastPool.ID + currenTokenInDenom = lastPool.TokenOutDenom + } + + denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenInDenom) + if err != nil { + return ingesttypes.CandidateRoutes{}, err + } + + rankedPools := denomData.SortedPools + + if len(rankedPools) == 0 { + c.logger.Debug("no pools found for denom in candidate route search", zap.String("denom", currenTokenInDenom)) + } + + 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 == currenTokenInDenom { + hasTokenIn = true + } + if denom == tokenInDenom { + hasTokenOut = true + } + + // Avoid going through pools that has the initial token in denom twice. + if len(currentRoute) > 0 && denom == tokenOut.Denom { + shouldSkipPool = true + break + } + } + + if shouldSkipPool { + continue + } + + if !hasTokenIn { + continue + } + + // Microptimization for the first pool in the route. + if len(currentRoute) == 0 { + currentTokenInAmount := pool.SQSModel.Balances.AmountOf(currenTokenInDenom) + + // 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 currentTokenInAmount.LT(tokenOut.Amount) && !isAlloyed { + visited[poolID] = struct{}{} + // Not enough tokenIn to swap. + continue + } + } + + currentPoolID := poolID + for _, denom := range poolDenoms { + if denom == currenTokenInDenom { + continue + } + if hasTokenOut && denom != tokenInDenom { + continue + } + + denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenInDenom) + if err != nil { + return ingesttypes.CandidateRoutes{}, err + } + + rankedPools := denomData.SortedPools + if len(rankedPools) == 0 { + c.logger.Debug("no pools found for denom in 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, + TokenOutDenom: denom, + }, + PoolDenoms: poolDenoms, + }) + + if len(newPath) <= options.MaxPoolsPerRoute { + if hasTokenOut { + routes = append(routes, candidateRouteWrapper{ + Pools: newPath, + IsCanonicalOrderboolRoute: false, + }) + break + } else { + queue = append(queue, newPath) + } + } + } + } + } + + for _, pool := range currentRoute { + visited[pool.ID] = struct{}{} + } + } + + return validateAndFilterRoutes(routes, tokenOut.Denom, c.logger) +} + // Pool represents a pool in the decentralized exchange. type Pool struct { ID int diff --git a/router/usecase/candidate_routes_bench_test.go b/router/usecase/candidate_routes_bench_test.go index dc032ab9..35e30387 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 73d90b6f..4ee91b2a 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_test.go b/router/usecase/dynamic_splits_test.go index 3d0d3f85..7b63c621 100644 --- a/router/usecase/dynamic_splits_test.go +++ b/router/usecase/dynamic_splits_test.go @@ -55,7 +55,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. diff --git a/router/usecase/export_test.go b/router/usecase/export_test.go index c19eccb4..e810b464 100644 --- a/router/usecase/export_test.go +++ b/router/usecase/export_test.go @@ -30,7 +30,7 @@ func ValidateAndFilterRoutes(candidateRoutes []candidateRouteWrapper, tokenInDen } 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) { diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 699e77ed..8d467e33 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -137,7 +137,7 @@ func (r *routerUseCaseImpl) GetOptimalQuoteOutGivenIn(ctx context.Context, token } // 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 } @@ -191,30 +191,107 @@ func (r *routerUseCaseImpl) GetOptimalQuoteOutGivenIn(ctx context.Context, token // 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 + ) + + // 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 ) - quote, err := r.GetOptimalQuoteOutGivenIn(ctx, tokenIn, tokenOutDenom, opts...) + // 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.rankRoutesByDirectQuote(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 := getSplitQuote(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 @@ -352,8 +429,8 @@ 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) { +// 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 +442,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 @@ -428,6 +505,81 @@ 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 routes + 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.rankRoutesByDirectQuote(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") @@ -639,7 +791,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 } @@ -742,14 +894,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 @@ -767,7 +919,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 @@ -792,6 +944,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 { diff --git a/router/usecase/router_usecase_test.go b/router/usecase/router_usecase_test.go index 9441275f..df9781e5 100644 --- a/router/usecase/router_usecase_test.go +++ b/router/usecase/router_usecase_test.go @@ -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++ From 780d110389a7422be11f50c9af35c0a239767f84 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Tue, 28 Jan 2025 13:13:25 +0200 Subject: [PATCH 08/18] BE-680 | In Given Out APIs --- domain/mocks/route_mock.go | 14 +- domain/route_test.go | 2 +- domain/router.go | 8 +- pools/usecase/pools_usecase_test.go | 4 +- router/usecase/candidate_routes.go | 5 +- router/usecase/export_test.go | 4 +- router/usecase/optimized_routes.go | 199 ++++++++++++++++++++++++++- router/usecase/quote_out_given_in.go | 2 +- router/usecase/route/route.go | 34 ++++- router/usecase/route/route_test.go | 2 +- router/usecase/router_usecase.go | 8 +- 11 files changed, 260 insertions(+), 22 deletions(-) diff --git a/domain/mocks/route_mock.go b/domain/mocks/route_mock.go index e03727a3..62d96a62 100644 --- a/domain/mocks/route_mock.go +++ b/domain/mocks/route_mock.go @@ -11,6 +11,7 @@ import ( type RouteMock struct { 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 @@ -31,6 +32,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,8 +77,8 @@ 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) { +// 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.PrepareResultPoolsFunc != nil { return r.PrepareResultPoolsFunc(ctx, tokenIn, logger) } diff --git a/domain/route_test.go b/domain/route_test.go index a0046503..c779a7cd 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 35091526..bfbec858 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,7 @@ 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) String() string } diff --git a/pools/usecase/pools_usecase_test.go b/pools/usecase/pools_usecase_test.go index 0c75d563..ad007ce1 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/router/usecase/candidate_routes.go b/router/usecase/candidate_routes.go index e27f280d..64bd55f1 100644 --- a/router/usecase/candidate_routes.go +++ b/router/usecase/candidate_routes.go @@ -234,7 +234,7 @@ func (c candidateRouteFinder) FindCandidateRoutesOutGivenIn(tokenIn sdk.Coin, to } } - return validateAndFilterRoutes(routes, tokenIn.Denom, c.logger) + return validateAndFilterRoutesOutGivenIn(routes, tokenIn.Denom, c.logger) } func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, tokenInDenom string, options domain.CandidateRouteSearchOptions) (ingesttypes.CandidateRoutes, error) { @@ -410,6 +410,7 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t CandidatePool: ingesttypes.CandidatePool{ ID: poolID, TokenOutDenom: denom, + TokenInDenom: tokenInDenom, }, PoolDenoms: poolDenoms, }) @@ -434,7 +435,7 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t } } - return validateAndFilterRoutes(routes, tokenOut.Denom, c.logger) + return validateAndFilterRoutesOutGivenIn(routes, tokenOut.Denom, c.logger) } // Pool represents a pool in the decentralized exchange. diff --git a/router/usecase/export_test.go b/router/usecase/export_test.go index e810b464..a0fbf16e 100644 --- a/router/usecase/export_test.go +++ b/router/usecase/export_test.go @@ -26,7 +26,7 @@ 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) { @@ -34,7 +34,7 @@ func (r *routerUseCaseImpl) HandleRoutes(ctx context.Context, tokenIn sdk.Coin, } 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 { diff --git a/router/usecase/optimized_routes.go b/router/usecase/optimized_routes.go index 9ff76a5d..b09bd424 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 { + directRouteTokenOut, err := route.CalculateTokenOutByTokenIn(ctx, tokenOut) + if err != nil { + logger.Debug("skipping single route due to error in estimate", zap.Error(err)) + errors = append(errors, err) + continue + } + + if directRouteTokenOut.Amount.IsNil() { + directRouteTokenOut.Amount = osmomath.ZeroInt() + } + + routesWithAmountOut = append(routesWithAmountOut, RouteWithOutAmount{ + RouteImpl: route, + InAmount: tokenOut.Amount, + OutAmount: directRouteTokenOut.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 descending order + sort.Slice(routesWithAmountOut, func(i, j int) bool { + return routesWithAmountOut[i].OutAmount.GT(routesWithAmountOut[j].OutAmount) + }) + + bestRoute := routesWithAmountOut[0] + + finalQuote := "eExactAmountIn{ + AmountIn: tokenOut, + AmountOut: bestRoute.OutAmount, + 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,136 @@ 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] + currentRouteTokenOutDenom := lastPool.TokenOutDenom + + // Validate that route pools do not have the token in denom or token out denom + previousTokenOut := tokenOutDenom + + uniquePoolIDsIntraRoute := make(map[uint64]struct{}, len(candidateRoutePools)) + + for j, currentPool := range candidateRoutePools { + if _, ok := uniquePoolIDs[currentPool.ID]; !ok { + uniquePoolIDs[currentPool.ID] = struct{}{} + } + + // Skip routes for which we have already seen the pool ID within that route. + if _, ok := uniquePoolIDsIntraRoute[currentPool.ID]; ok { + continue ROUTE_LOOP + } else { + uniquePoolIDsIntraRoute[currentPool.ID] = struct{}{} + } + + currentPoolDenoms := candidateRoutePools[j].PoolDenoms + currentPoolTokenOutDenom := currentPool.TokenOutDenom + + // Check that token in denom and token out denom are in the pool + // Also check that previous token out is in the pool + foundPreviousTokenOut := false + foundCurrentTokenOut := false + for _, denom := range currentPoolDenoms { + if denom == previousTokenOut { + foundPreviousTokenOut = true + } + + if denom == currentPoolTokenOutDenom { + foundCurrentTokenOut = true + } + + // Validate that intermediary pools do not contain the token in denom or token out denom + if j > 0 && j < len(candidateRoutePools)-1 { + if denom == tokenOutDenom { + logger.Warn("route skipped - found token in intermediary pool", zap.Error(RoutePoolWithTokenInDenomError{RouteIndex: i, TokenInDenom: tokenOutDenom})) + continue ROUTE_LOOP + } + + if denom == currentRouteTokenOutDenom { + logger.Warn("route skipped- found token out in intermediary pool", zap.Error(RoutePoolWithTokenOutDenomError{RouteIndex: i, TokenOutDenom: currentPoolTokenOutDenom})) + continue ROUTE_LOOP + } + } + } + + // Ensure that the previous pool token out denom is in the current pool. + if !foundPreviousTokenOut { + return ingesttypes.CandidateRoutes{}, PreviousTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, PreviousTokenOutDenom: previousTokenOut} + } + + // Ensure that the current pool token out denom is in the current pool. + if !foundCurrentTokenOut { + return ingesttypes.CandidateRoutes{}, CurrentTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, CurrentTokenOutDenom: currentPoolTokenOutDenom} + } + + // Update previous token out denom + previousTokenOut = currentPoolTokenOutDenom + } + + if i > 0 { + // Ensure that all routes have the same final token out denom + if currentRouteTokenOutDenom != tokenInDenom { + return ingesttypes.CandidateRoutes{}, TokenOutMismatchBetweenRoutesError{TokenOutDenomRouteA: tokenInDenom, TokenOutDenomRouteB: currentRouteTokenOutDenom} + } + } + + tokenInDenom = currentRouteTokenOutDenom + + // Update filtered routes if this route passed all checks + filteredRoute := ingesttypes.CandidateRoute{ + IsCanonicalOrderboolRoute: candidateRoute.IsCanonicalOrderboolRoute, + Pools: make([]ingesttypes.CandidatePool, 0, len(candidateRoutePools)), + } + + // Convert route to the final output format + for _, pool := range candidateRoutePools { + filteredRoute.Pools = append(filteredRoute.Pools, ingesttypes.CandidatePool{ + ID: pool.ID, + TokenOutDenom: pool.TokenOutDenom, + TokenInDenom: pool.TokenInDenom, + }) + } + + 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/quote_out_given_in.go b/router/usecase/quote_out_given_in.go index 951b4447..b8bb3fea 100644 --- a/router/usecase/quote_out_given_in.go +++ b/router/usecase/quote_out_given_in.go @@ -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 } diff --git a/router/usecase/route/route.go b/router/usecase/route/route.go index 297ee90d..9203d39f 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() @@ -145,6 +145,36 @@ 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 { + // Charge taker fee + tokenOut = pool.ChargeTakerFeeExactOut(tokenIn) + tokenInAmt := tokenOut.Amount.ToLegacyDec() + + if tokenInAmt.IsNil() || tokenInAmt.IsZero() { + return sdk.Coin{}, nil + } + + tokenIn, err = pool.CalculateTokenInByTokenOut(ctx, tokenOut) + if err != nil { + return sdk.Coin{}, err + } + + 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 3e10b7dd..21fe6f81 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 8d467e33..312706a5 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -338,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) } @@ -415,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) } @@ -611,7 +611,7 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteOutGivenIn(ctx context.Context, } // 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 } @@ -645,7 +645,7 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteInGivenOut(ctx context.Context, } // Compute direct quote - bestSingleRouteQuote, _, err := r.estimateAndRankSingleRouteQuote(ctx, routes, tokenOut, r.logger) + bestSingleRouteQuote, _, err := r.estimateAndRankSingleRouteQuoteOutGivenIn(ctx, routes, tokenOut, r.logger) if err != nil { return nil, err } From 667e386acaa96043f5c84f1440a8036a2cb80d88 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Tue, 28 Jan 2025 19:33:16 +0200 Subject: [PATCH 09/18] BE-680 | InGivenOut --- domain/mocks/route_mock.go | 28 ++++--- domain/router.go | 2 + router/usecase/export_test.go | 2 +- router/usecase/optimized_routes.go | 20 ++--- router/usecase/optimized_routes_test.go | 70 ++++++++++++++++ router/usecase/quote_in_given_out.go | 104 ++++++++++++++++++------ router/usecase/route/route.go | 71 ++++++++++++++-- router/usecase/router_usecase.go | 46 +++++++++-- 8 files changed, 286 insertions(+), 57 deletions(-) diff --git a/domain/mocks/route_mock.go b/domain/mocks/route_mock.go index 62d96a62..c3be7124 100644 --- a/domain/mocks/route_mock.go +++ b/domain/mocks/route_mock.go @@ -10,14 +10,15 @@ import ( ) type RouteMock struct { - 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 - 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 @@ -79,8 +80,15 @@ func (r *RouteMock) GetTokenInDenom() string { // 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.PrepareResultPoolsFunc != nil { - return r.PrepareResultPoolsFunc(ctx, tokenIn, logger) + 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/router.go b/domain/router.go index bfbec858..e1a788f9 100644 --- a/domain/router.go +++ b/domain/router.go @@ -52,6 +52,8 @@ type Route interface { // The token in is the base token and the token out is the quote token. 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 } diff --git a/router/usecase/export_test.go b/router/usecase/export_test.go index a0fbf16e..c7d2eafe 100644 --- a/router/usecase/export_test.go +++ b/router/usecase/export_test.go @@ -66,7 +66,7 @@ func GetSplitQuote(ctx context.Context, routes []route.RouteImpl, tokenIn sdk.Co } 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) + return r.rankRoutesByDirectQuoteOutGivenIn(ctx, candidateRoutes, tokenIn, tokenOutDenom, 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 b09bd424..58d361cd 100644 --- a/router/usecase/optimized_routes.go +++ b/router/usecase/optimized_routes.go @@ -92,21 +92,21 @@ func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuoteInGivenOut(ctx contex errors := []error{} for _, route := range routes { - directRouteTokenOut, err := route.CalculateTokenOutByTokenIn(ctx, tokenOut) + 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 directRouteTokenOut.Amount.IsNil() { - directRouteTokenOut.Amount = osmomath.ZeroInt() + if directRouteTokenIn.Amount.IsNil() { + directRouteTokenIn.Amount = osmomath.ZeroInt() } routesWithAmountOut = append(routesWithAmountOut, RouteWithOutAmount{ RouteImpl: route, - InAmount: tokenOut.Amount, - OutAmount: directRouteTokenOut.Amount, + InAmount: directRouteTokenIn.Amount, + OutAmount: tokenOut.Amount, }) } @@ -128,16 +128,16 @@ func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuoteInGivenOut(ctx contex return nil, nil, errors[0] } - // Sort by amount out in descending order + // Sort by amount in in descending order sort.Slice(routesWithAmountOut, func(i, j int) bool { - return routesWithAmountOut[i].OutAmount.GT(routesWithAmountOut[j].OutAmount) + return routesWithAmountOut[i].InAmount.GT(routesWithAmountOut[j].InAmount) }) bestRoute := routesWithAmountOut[0] - finalQuote := "eExactAmountIn{ - AmountIn: tokenOut, - AmountOut: bestRoute.OutAmount, + finalQuote := "eExactAmountOut{ + AmountIn: bestRoute.OutAmount, + AmountOut: tokenOut, Route: []domain.SplitRoute{&bestRoute}, } diff --git a/router/usecase/optimized_routes_test.go b/router/usecase/optimized_routes_test.go index 24f309d5..1b417d7f 100644 --- a/router/usecase/optimized_routes_test.go +++ b/router/usecase/optimized_routes_test.go @@ -3,10 +3,13 @@ package usecase_test import ( "context" "errors" + "fmt" "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" @@ -691,6 +694,68 @@ var optimalQuoteTestCases = map[string]struct { }, } +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 { + tc := tc + s.Run(name, func() { + // Setup mainnet router + mainnetState := s.SetupMainnetState() + + // Mock router use case. + mainnetUseCase := s.SetupRouterAndPoolsUsecase(mainnetState) + + if name != "atom for akt" { + return + } + + // 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()) + }) + } +} + // Validates that quotes constructed from mainnet state can be computed with no error // for selected pairs. func (s *RouterTestSuite) TestGetOptimalQuoteExactAmounIn_Mainnet() { @@ -749,6 +814,11 @@ func (s *RouterTestSuite) TestGetOptimalQuoteExactAmounOut_Mainnet() { // Mock router use case. mainnetUseCase := s.SetupRouterAndPoolsUsecase(mainnetState) + if name == "atom for akt" { + fmt.Println("atom for akt") + } + // TODO: fix + // TokenInDenom is empty quote, err := mainnetUseCase.Router.GetOptimalQuoteInGivenOut(context.Background(), sdk.NewCoin(tc.tokenOutDenom, tc.amountIn), tc.tokenInDenom) s.Require().NoError(err) diff --git a/router/usecase/quote_in_given_out.go b/router/usecase/quote_in_given_out.go index 8b3faf0a..bd564851 100644 --- a/router/usecase/quote_in_given_out.go +++ b/router/usecase/quote_in_given_out.go @@ -8,6 +8,7 @@ import ( "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" @@ -88,36 +89,93 @@ func (q *quoteExactAmountOut) SetQuotePriceInfo(info *domain.TxFeeInfo) { // // 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 + // 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 + + 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)) + + for _, curRoute := range q.Route { + routeTotalFee := osmomath.ZeroDec() + routeAmountOutFraction := curRoute.GetAmountOut().ToLegacyDec().Quo(totalAmountOut) - q.Route[i] = route + // Calculate the spread factor across pools in the route + for _, pool := range curRoute.GetPools() { + poolTakerFee := pool.GetTakerFee() - // invert the in and out amounts for each pool - for _, p := range route.GetPools() { - p.SetTokenInDenom(p.GetTokenOutDenom()) - p.SetTokenOutDenom("") + 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/route/route.go b/router/usecase/route/route.go index 9203d39f..9b27adde 100644 --- a/router/usecase/route/route.go +++ b/router/usecase/route/route.go @@ -110,6 +110,62 @@ func (r RouteImpl) PrepareResultPoolsExactAmountIn(ctx context.Context, tokenIn 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 @@ -156,19 +212,20 @@ func (r *RouteImpl) CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk }() for _, pool := range r.Pools { + tokenIn, err = pool.CalculateTokenInByTokenOut(ctx, tokenOut) + if err != nil { + return sdk.Coin{}, err + } + // Charge taker fee - tokenOut = pool.ChargeTakerFeeExactOut(tokenIn) - tokenInAmt := tokenOut.Amount.ToLegacyDec() + tokenIn = pool.ChargeTakerFeeExactOut(tokenOut) + + tokenInAmt := tokenIn.Amount.ToLegacyDec() if tokenInAmt.IsNil() || tokenInAmt.IsZero() { return sdk.Coin{}, nil } - tokenIn, err = pool.CalculateTokenInByTokenOut(ctx, tokenOut) - if err != nil { - return sdk.Coin{}, err - } - tokenOut = tokenIn } diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 312706a5..254a2179 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -143,7 +143,7 @@ func (r *routerUseCaseImpl) GetOptimalQuoteOutGivenIn(ctx context.Context, token } } 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 } @@ -248,12 +248,16 @@ func (r *routerUseCaseImpl) GetOptimalQuoteInGivenOut(ctx context.Context, token } } else { // Otherwise, simply compute quotes over cached ranked routes - topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuote(ctx, candidateRankedRoutes, tokenOut, tokenInDenom, options.MaxSplitRoutes) + topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuoteOutGivenIn(ctx, candidateRankedRoutes, tokenOut, tokenInDenom, options.MaxSplitRoutes) if err != nil { return nil, err } } + // ---- + // TODO + // --- + if len(rankedRoutes) == 1 || options.MaxSplitRoutes == domain.DisableSplitRoutes { return topSingleRouteQuote, nil } @@ -399,7 +403,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. @@ -407,7 +411,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) @@ -429,6 +433,36 @@ func (r *routerUseCaseImpl) rankRoutesByDirectQuote(ctx context.Context, candida return topQuote, routes, nil } +// 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) @@ -470,7 +504,7 @@ func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuoteOutGivenIn(ctx cont } // 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 @@ -545,7 +579,7 @@ func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuoteInGivenOut(ctx cont } // Rank candidate routes by estimating direct quotes - topSingleRouteQuote, rankedRoutes, err := r.rankRoutesByDirectQuote(ctx, candidateRoutes, tokenOut, tokenInDenom, routingOptions.MaxSplitRoutes) + 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 From ccefbc3be816539803c86eb69f06c5a746f041cd Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Wed, 29 Jan 2025 15:18:53 +0200 Subject: [PATCH 10/18] BE-680 | WIP --- router/usecase/candidate_routes.go | 33 +++++++------ router/usecase/optimized_routes.go | 53 ++++++++------------ router/usecase/optimized_routes_test.go | 64 +++++++++++++++++++++++-- router/usecase/router_usecase.go | 2 +- 4 files changed, 100 insertions(+), 52 deletions(-) diff --git a/router/usecase/candidate_routes.go b/router/usecase/candidate_routes.go index 64bd55f1..151b2d13 100644 --- a/router/usecase/candidate_routes.go +++ b/router/usecase/candidate_routes.go @@ -296,14 +296,14 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t queue = queue[1:] lastPoolID := uint64(0) - currenTokenInDenom := tokenOut.Denom + currenTokenOutDenom := tokenOut.Denom if len(currentRoute) > 0 { lastPool := currentRoute[len(currentRoute)-1] lastPoolID = lastPool.ID - currenTokenInDenom = lastPool.TokenOutDenom + currenTokenOutDenom = lastPool.TokenInDenom } - denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenInDenom) + denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenOutDenom) // Get the ranked candidate route search pool data for a given denom if err != nil { return ingesttypes.CandidateRoutes{}, err } @@ -311,7 +311,7 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t rankedPools := denomData.SortedPools if len(rankedPools) == 0 { - c.logger.Debug("no pools found for denom in candidate route search", zap.String("denom", currenTokenInDenom)) + c.logger.Debug("no pools found for denom in candidate route search", zap.String("denom", currenTokenOutDenom)) } for i := 0; i < len(rankedPools) && len(routes) < options.MaxRoutes; i++ { @@ -342,11 +342,11 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t hasTokenOut := false shouldSkipPool := false for _, denom := range poolDenoms { - if denom == currenTokenInDenom { - hasTokenIn = true + if denom == currenTokenOutDenom { + hasTokenOut = true } if denom == tokenInDenom { - hasTokenOut = true + hasTokenIn = true } // Avoid going through pools that has the initial token in denom twice. @@ -360,13 +360,13 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t continue } - if !hasTokenIn { + if !hasTokenOut { continue } // Microptimization for the first pool in the route. if len(currentRoute) == 0 { - currentTokenInAmount := pool.SQSModel.Balances.AmountOf(currenTokenInDenom) + currentTokenInAmount := pool.SQSModel.Balances.AmountOf(currenTokenOutDenom) // HACK: alloyed LP share is not contained in balances. // TODO: remove the hack and ingest the LP share balance on the Osmosis side. @@ -382,15 +382,19 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t } currentPoolID := poolID + + if poolID == 4 { + currentPoolID = poolID + } for _, denom := range poolDenoms { - if denom == currenTokenInDenom { + if denom == currenTokenOutDenom { // not token out denom continue } - if hasTokenOut && denom != tokenInDenom { + if hasTokenIn && denom != tokenInDenom { // since above is not token out, it's token in continue } - denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenInDenom) + denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenOutDenom) // ranked if err != nil { return ingesttypes.CandidateRoutes{}, err } @@ -409,8 +413,7 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t newPath = append(newPath, candidatePoolWrapper{ CandidatePool: ingesttypes.CandidatePool{ ID: poolID, - TokenOutDenom: denom, - TokenInDenom: tokenInDenom, + TokenOutDenom: tokenOut.Denom, }, PoolDenoms: poolDenoms, }) @@ -435,7 +438,7 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t } } - return validateAndFilterRoutesOutGivenIn(routes, tokenOut.Denom, c.logger) + return validateAndFilterRoutesInGivenOut(routes, tokenOut.Denom, c.logger) } // Pool represents a pool in the decentralized exchange. diff --git a/router/usecase/optimized_routes.go b/router/usecase/optimized_routes.go index 58d361cd..813e9896 100644 --- a/router/usecase/optimized_routes.go +++ b/router/usecase/optimized_routes.go @@ -303,10 +303,9 @@ ROUTE_LOOP: } lastPool := candidateRoutePools[len(candidateRoutePools)-1] - currentRouteTokenOutDenom := lastPool.TokenOutDenom + currentRouteTokenInDenom := lastPool.TokenInDenom - // Validate that route pools do not have the token in denom or token out denom - previousTokenOut := tokenOutDenom + previousTokenIn := tokenOutDenom uniquePoolIDsIntraRoute := make(map[uint64]struct{}, len(candidateRoutePools)) @@ -315,7 +314,6 @@ ROUTE_LOOP: uniquePoolIDs[currentPool.ID] = struct{}{} } - // Skip routes for which we have already seen the pool ID within that route. if _, ok := uniquePoolIDsIntraRoute[currentPool.ID]; ok { continue ROUTE_LOOP } else { @@ -323,70 +321,61 @@ ROUTE_LOOP: } currentPoolDenoms := candidateRoutePools[j].PoolDenoms - currentPoolTokenOutDenom := currentPool.TokenOutDenom + currentPoolTokenInDenom := currentPool.TokenInDenom - // Check that token in denom and token out denom are in the pool - // Also check that previous token out is in the pool - foundPreviousTokenOut := false - foundCurrentTokenOut := false + foundPreviousTokenIn := false + foundCurrentTokenIn := false for _, denom := range currentPoolDenoms { - if denom == previousTokenOut { - foundPreviousTokenOut = true + if denom == previousTokenIn { + foundPreviousTokenIn = true } - if denom == currentPoolTokenOutDenom { - foundCurrentTokenOut = true + if denom == currentPoolTokenInDenom { + foundCurrentTokenIn = true } - // Validate that intermediary pools do not contain the token in denom or token out denom if j > 0 && j < len(candidateRoutePools)-1 { if denom == tokenOutDenom { - logger.Warn("route skipped - found token in intermediary pool", zap.Error(RoutePoolWithTokenInDenomError{RouteIndex: i, TokenInDenom: tokenOutDenom})) + logger.Warn("route skipped - found token out in intermediary pool", zap.Error(RoutePoolWithTokenOutDenomError{RouteIndex: i, TokenOutDenom: tokenOutDenom})) continue ROUTE_LOOP } - if denom == currentRouteTokenOutDenom { - logger.Warn("route skipped- found token out in intermediary pool", zap.Error(RoutePoolWithTokenOutDenomError{RouteIndex: i, TokenOutDenom: currentPoolTokenOutDenom})) + if denom == currentRouteTokenInDenom { + logger.Warn("route skipped - found token in intermediary pool", zap.Error(RoutePoolWithTokenInDenomError{RouteIndex: i, TokenInDenom: currentPoolTokenInDenom})) continue ROUTE_LOOP } } } - // Ensure that the previous pool token out denom is in the current pool. - if !foundPreviousTokenOut { - return ingesttypes.CandidateRoutes{}, PreviousTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, PreviousTokenOutDenom: previousTokenOut} + if !foundPreviousTokenIn { + return ingesttypes.CandidateRoutes{}, PreviousTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, PreviousTokenOutDenom: previousTokenIn} } - // Ensure that the current pool token out denom is in the current pool. - if !foundCurrentTokenOut { - return ingesttypes.CandidateRoutes{}, CurrentTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, CurrentTokenOutDenom: currentPoolTokenOutDenom} + if !foundCurrentTokenIn { + return ingesttypes.CandidateRoutes{}, CurrentTokenOutDenomNotInPoolError{RouteIndex: i, PoolId: currentPool.ID, CurrentTokenOutDenom: currentPoolTokenInDenom} } - // Update previous token out denom - previousTokenOut = currentPoolTokenOutDenom + previousTokenIn = currentPoolTokenInDenom } if i > 0 { - // Ensure that all routes have the same final token out denom - if currentRouteTokenOutDenom != tokenInDenom { - return ingesttypes.CandidateRoutes{}, TokenOutMismatchBetweenRoutesError{TokenOutDenomRouteA: tokenInDenom, TokenOutDenomRouteB: currentRouteTokenOutDenom} + if currentRouteTokenInDenom != tokenInDenom { + return ingesttypes.CandidateRoutes{}, TokenOutMismatchBetweenRoutesError{TokenOutDenomRouteA: tokenInDenom, TokenOutDenomRouteB: currentRouteTokenInDenom} } } - tokenInDenom = currentRouteTokenOutDenom + tokenInDenom = currentRouteTokenInDenom - // Update filtered routes if this route passed all checks filteredRoute := ingesttypes.CandidateRoute{ IsCanonicalOrderboolRoute: candidateRoute.IsCanonicalOrderboolRoute, Pools: make([]ingesttypes.CandidatePool, 0, len(candidateRoutePools)), } - // Convert route to the final output format for _, pool := range candidateRoutePools { filteredRoute.Pools = append(filteredRoute.Pools, ingesttypes.CandidatePool{ ID: pool.ID, - TokenOutDenom: pool.TokenOutDenom, TokenInDenom: pool.TokenInDenom, + TokenOutDenom: pool.TokenOutDenom, }) } diff --git a/router/usecase/optimized_routes_test.go b/router/usecase/optimized_routes_test.go index 1b417d7f..a2334337 100644 --- a/router/usecase/optimized_routes_test.go +++ b/router/usecase/optimized_routes_test.go @@ -704,6 +704,9 @@ func TestRouterTestSuite1(t *testing.T) { func (s *RouterTestSuite1) TestGetOptimalQuoteExactAmounOut_Mainnet() { for name, tc := range optimalQuoteTestCases { + if name != "atom for akt" { + continue + } tc := tc s.Run(name, func() { // Setup mainnet router @@ -712,10 +715,6 @@ func (s *RouterTestSuite1) TestGetOptimalQuoteExactAmounOut_Mainnet() { // Mock router use case. mainnetUseCase := s.SetupRouterAndPoolsUsecase(mainnetState) - if name != "atom for akt" { - return - } - // TODO: fix // TokenInDenom is empty quote, err := mainnetUseCase.Router.GetOptimalQuoteInGivenOut(context.Background(), sdk.NewCoin(tc.tokenOutDenom, tc.amountIn), tc.tokenInDenom) @@ -756,6 +755,63 @@ func (s *RouterTestSuite1) TestGetOptimalQuoteExactAmounOut_Mainnet() { } } +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 != "atom for akt" { + 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() { diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 254a2179..c0149bc4 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -550,7 +550,7 @@ func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuoteInGivenOut(ctx cont PoolFiltersAnyOf: routingOptions.CandidateRoutesPoolFiltersAnyOf, } - // If top routes are not present in cache, retrieve unranked candidate routes + // 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)) From 1f0c6d1fff239e59d8c9b4d7cab4ba44a54ed546 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Thu, 30 Jan 2025 12:46:46 +0200 Subject: [PATCH 11/18] BE-680 | Port FindCandidateRoutesInGivenOut --- router/usecase/candidate_routes.go | 37 +++++++++++++++--------------- router/usecase/router_usecase.go | 2 +- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/router/usecase/candidate_routes.go b/router/usecase/candidate_routes.go index 151b2d13..87910d0f 100644 --- a/router/usecase/candidate_routes.go +++ b/router/usecase/candidate_routes.go @@ -209,6 +209,7 @@ func (c candidateRouteFinder) FindCandidateRoutesOutGivenIn(tokenIn sdk.Coin, to newPath = append(newPath, candidatePoolWrapper{ CandidatePool: ingesttypes.CandidatePool{ ID: poolID, + TokenInDenom: currenTokenInDenom, TokenOutDenom: denom, }, PoolDenoms: poolDenoms, @@ -237,6 +238,7 @@ func (c candidateRouteFinder) FindCandidateRoutesOutGivenIn(tokenIn sdk.Coin, to 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) @@ -296,14 +298,14 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t queue = queue[1:] lastPoolID := uint64(0) - currenTokenOutDenom := tokenOut.Denom + currentTokenOutDenom := tokenOut.Denom if len(currentRoute) > 0 { lastPool := currentRoute[len(currentRoute)-1] lastPoolID = lastPool.ID - currenTokenOutDenom = lastPool.TokenInDenom + currentTokenOutDenom = lastPool.TokenInDenom } - denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenOutDenom) // Get the ranked candidate route search pool data for a given denom + denomData, err := c.candidateRouteDataHolder.GetDenomData(currentTokenOutDenom) if err != nil { return ingesttypes.CandidateRoutes{}, err } @@ -311,7 +313,7 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t rankedPools := denomData.SortedPools if len(rankedPools) == 0 { - c.logger.Debug("no pools found for denom in candidate route search", zap.String("denom", currenTokenOutDenom)) + 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++ { @@ -342,14 +344,14 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t hasTokenOut := false shouldSkipPool := false for _, denom := range poolDenoms { - if denom == currenTokenOutDenom { + if denom == currentTokenOutDenom { hasTokenOut = true } if denom == tokenInDenom { hasTokenIn = true } - // Avoid going through pools that has the initial token in denom twice. + // Avoid going through pools that has the initial token out denom twice. if len(currentRoute) > 0 && denom == tokenOut.Denom { shouldSkipPool = true break @@ -366,7 +368,7 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t // Microptimization for the first pool in the route. if len(currentRoute) == 0 { - currentTokenInAmount := pool.SQSModel.Balances.AmountOf(currenTokenOutDenom) + 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. @@ -374,34 +376,30 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t cosmwasmModel := pool.SQSModel.CosmWasmPoolModel isAlloyed := cosmwasmModel != nil && cosmwasmModel.IsAlloyTransmuter() - if currentTokenInAmount.LT(tokenOut.Amount) && !isAlloyed { + if currentTokenOutAmount.LT(tokenOut.Amount) && !isAlloyed { visited[poolID] = struct{}{} - // Not enough tokenIn to swap. + // Not enough tokenOut to swap. continue } } currentPoolID := poolID - - if poolID == 4 { - currentPoolID = poolID - } for _, denom := range poolDenoms { - if denom == currenTokenOutDenom { // not token out denom + if denom == currentTokenOutDenom { continue } - if hasTokenIn && denom != tokenInDenom { // since above is not token out, it's token in + if hasTokenIn && denom != tokenInDenom { continue } - denomData, err := c.candidateRouteDataHolder.GetDenomData(currenTokenOutDenom) // ranked + 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 in candidate route search", zap.String("denom", denom)) + c.logger.Debug("no pools found for denom out candidate route search", zap.String("denom", denom)) continue } @@ -413,13 +411,14 @@ func (c candidateRouteFinder) FindCandidateRoutesInGivenOut(tokenOut sdk.Coin, t newPath = append(newPath, candidatePoolWrapper{ CandidatePool: ingesttypes.CandidatePool{ ID: poolID, - TokenOutDenom: tokenOut.Denom, + TokenInDenom: denom, + TokenOutDenom: currentTokenOutDenom, }, PoolDenoms: poolDenoms, }) if len(newPath) <= options.MaxPoolsPerRoute { - if hasTokenOut { + if hasTokenIn { routes = append(routes, candidateRouteWrapper{ Pools: newPath, IsCanonicalOrderboolRoute: false, diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index c0149bc4..aceece63 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -550,7 +550,7 @@ func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuoteInGivenOut(ctx cont PoolFiltersAnyOf: routingOptions.CandidateRoutesPoolFiltersAnyOf, } - // If top routes are not present in cache, retrieve unranked candidate + // 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)) From f27f542dd7b68955565182b97570b17135ca7b4d Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Thu, 30 Jan 2025 15:57:19 +0200 Subject: [PATCH 12/18] BE-680 | WIP wiring InGivenOut and porting APIs --- router/usecase/optimized_routes.go | 6 +++--- router/usecase/route/route.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/router/usecase/optimized_routes.go b/router/usecase/optimized_routes.go index 813e9896..d2b56a8b 100644 --- a/router/usecase/optimized_routes.go +++ b/router/usecase/optimized_routes.go @@ -128,15 +128,15 @@ func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuoteInGivenOut(ctx contex return nil, nil, errors[0] } - // Sort by amount in in descending order + // Sort by amount out in ascending order sort.Slice(routesWithAmountOut, func(i, j int) bool { - return routesWithAmountOut[i].InAmount.GT(routesWithAmountOut[j].InAmount) + return routesWithAmountOut[i].InAmount.LT(routesWithAmountOut[j].InAmount) }) bestRoute := routesWithAmountOut[0] finalQuote := "eExactAmountOut{ - AmountIn: bestRoute.OutAmount, + AmountIn: bestRoute.InAmount, AmountOut: tokenOut, Route: []domain.SplitRoute{&bestRoute}, } diff --git a/router/usecase/route/route.go b/router/usecase/route/route.go index 9b27adde..980105c0 100644 --- a/router/usecase/route/route.go +++ b/router/usecase/route/route.go @@ -218,7 +218,7 @@ func (r *RouteImpl) CalculateTokenInByTokenOut(ctx context.Context, tokenOut sdk } // Charge taker fee - tokenIn = pool.ChargeTakerFeeExactOut(tokenOut) + tokenIn = pool.ChargeTakerFeeExactOut(tokenIn) tokenInAmt := tokenIn.Amount.ToLegacyDec() From cd8e80db2e8891c8c68b26256effed286062700e Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Fri, 31 Jan 2025 14:52:14 +0200 Subject: [PATCH 13/18] BE-680 | Fix some failing tests --- router/usecase/optimized_routes_test.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/router/usecase/optimized_routes_test.go b/router/usecase/optimized_routes_test.go index a2334337..727c7be2 100644 --- a/router/usecase/optimized_routes_test.go +++ b/router/usecase/optimized_routes_test.go @@ -3,7 +3,6 @@ package usecase_test import ( "context" "errors" - "fmt" "sort" "testing" @@ -205,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) @@ -631,7 +630,7 @@ var optimalQuoteTestCases = map[string]struct { amountIn: osmomath.NewInt(5000000), expectedRoutesCountExactAmountIn: 2, - expectedRoutesCountExactAmountOut: 3, + expectedRoutesCountExactAmountOut: 2, }, "usdt for atom": { tokenInDenom: USDT, @@ -680,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": { @@ -690,7 +689,7 @@ var optimalQuoteTestCases = map[string]struct { amountIn: oneHundredThousandUSDValue, expectedRoutesCountExactAmountIn: usdtOsmoExpectedRoutesHighLiq, - expectedRoutesCountExactAmountOut: 2, + expectedRoutesCountExactAmountOut: usdtOsmoExpectedRoutesHighLiq, }, } @@ -704,7 +703,7 @@ func TestRouterTestSuite1(t *testing.T) { func (s *RouterTestSuite1) TestGetOptimalQuoteExactAmounOut_Mainnet() { for name, tc := range optimalQuoteTestCases { - if name != "atom for akt" { + if name != "uosmo for uion" { continue } tc := tc @@ -765,7 +764,7 @@ func TestRouterTestSuite2(t *testing.T) { func (s *RouterTestSuite2) TestGetOptimalQuoteExactAmounIn_Mainnet() { for name, tc := range optimalQuoteTestCases { - if name != "atom for akt" { + if name != "uosmo for uion" { continue } tc := tc @@ -870,11 +869,6 @@ func (s *RouterTestSuite) TestGetOptimalQuoteExactAmounOut_Mainnet() { // Mock router use case. mainnetUseCase := s.SetupRouterAndPoolsUsecase(mainnetState) - if name == "atom for akt" { - fmt.Println("atom for akt") - } - // TODO: fix - // TokenInDenom is empty quote, err := mainnetUseCase.Router.GetOptimalQuoteInGivenOut(context.Background(), sdk.NewCoin(tc.tokenOutDenom, tc.amountIn), tc.tokenInDenom) s.Require().NoError(err) From 2767ac474d2fdac84c71bed9e9c538bcd2a99632 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Mon, 3 Feb 2025 12:59:22 +0200 Subject: [PATCH 14/18] BE-680 | Separate APIs for InGivenOut and OutGivenIn swap methods for split routes --- router/usecase/dynamic_splits.go | 218 +++++++++++++++++++- router/usecase/dynamic_splits_bench_test.go | 38 +++- router/usecase/dynamic_splits_test.go | 84 +++++++- router/usecase/export_test.go | 14 +- router/usecase/router_usecase.go | 8 +- 5 files changed, 340 insertions(+), 22 deletions(-) diff --git a/router/usecase/dynamic_splits.go b/router/usecase/dynamic_splits.go index c8c8ca74..3e6e8b67 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 11b8ebde..a7199654 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 7b63c621..d289c0f4 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() @@ -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 c7d2eafe..ae5b1564 100644 --- a/router/usecase/export_test.go +++ b/router/usecase/export_test.go @@ -61,14 +61,22 @@ 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) { +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 { return cutRoutesForSplits(maxSplitRoutes, routes) } diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index aceece63..617cd445 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -162,7 +162,7 @@ func (r *routerUseCaseImpl) GetOptimalQuoteOutGivenIn(ctx context.Context, token } // 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. @@ -254,10 +254,6 @@ func (r *routerUseCaseImpl) GetOptimalQuoteInGivenOut(ctx context.Context, token } } - // ---- - // TODO - // --- - if len(rankedRoutes) == 1 || options.MaxSplitRoutes == domain.DisableSplitRoutes { return topSingleRouteQuote, nil } @@ -271,7 +267,7 @@ func (r *routerUseCaseImpl) GetOptimalQuoteInGivenOut(ctx context.Context, token } // Compute split route quote - topSplitQuote, err := getSplitQuote(ctx, rankedRoutes, tokenOut) + topSplitQuote, err := getSplitQuoteInGivenOut(ctx, rankedRoutes, tokenOut) if err != nil { // If error occurs in splits, return the single route quote // rather than failing. From a1ce2aefef0ec9fb001e5321858871ead41ae3dc Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Fri, 7 Feb 2025 09:09:20 +0200 Subject: [PATCH 15/18] BE-680 | Split routes: invert the choice --- router/usecase/dynamic_splits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/usecase/dynamic_splits.go b/router/usecase/dynamic_splits.go index 3e6e8b67..2b739a4c 100644 --- a/router/usecase/dynamic_splits.go +++ b/router/usecase/dynamic_splits.go @@ -252,7 +252,7 @@ func getSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, toke noChoice := dp[x][j] choice := dp[x-p][j-1].Add(computeAndCacheOutAmountCb(j-1, p)) - if choice.GT(noChoice) { + if choice.LT(noChoice) { dp[x][j] = choice proportions[x][j] = p } From 8ae2dca1dfa955d29f0f55c0629ffab93907614b Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Fri, 7 Feb 2025 21:05:30 +0200 Subject: [PATCH 16/18] BE-680 | WIP --- router/usecase/dynamic_splits.go | 5 ++--- router/usecase/router_usecase.go | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/router/usecase/dynamic_splits.go b/router/usecase/dynamic_splits.go index 2b739a4c..385f7885 100644 --- a/router/usecase/dynamic_splits.go +++ b/router/usecase/dynamic_splits.go @@ -90,7 +90,7 @@ func getSplitQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, toke // 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)) + choice := dp[x-p][j-1].Add(computeAndCacheOutAmountCb(j-1, p)) // x=1,p=1,j=1 dp[0][0], if choice.GT(noChoice) { dp[x][j] = choice @@ -221,7 +221,7 @@ func getSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, toke for i := 0; i < int(totalIncrements+1); i++ { dp[i] = make([]osmomath.Int, len(routes)+1) - dp[i][0] = zero + dp[i][0] = inf proportions[i] = make([]uint8, len(routes)+1) } @@ -411,7 +411,6 @@ func getComputeAndCacheOutAmountCb(ctx context.Context, totalInAmountDec osmomat } } - // 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 { diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 617cd445..ff438234 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "sync" "time" @@ -55,6 +56,7 @@ const ( var ( zero = osmomath.ZeroInt() + inf = osmomath.NewInt(math.MaxInt64) ) // NewRouterUsecase will create a new pools use case object From d5a3df1dd4c4d2d9a4449d9b9a5cd5d845732406 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Sat, 8 Feb 2025 18:00:16 +0200 Subject: [PATCH 17/18] BE-680 | Isolate splits algorithm --- router/usecase/dynamic_splits.go | 207 +++++++++++++++----------- router/usecase/dynamic_splits_test.go | 145 +++++++++++++++++- 2 files changed, 263 insertions(+), 89 deletions(-) diff --git a/router/usecase/dynamic_splits.go b/router/usecase/dynamic_splits.go index 385f7885..5b989136 100644 --- a/router/usecase/dynamic_splits.go +++ b/router/usecase/dynamic_splits.go @@ -19,37 +19,69 @@ type split struct { const totalIncrements = uint8(10) -// 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 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") +type ProfitFunc func(int, uint8) osmomath.Int + +func Max(totalRoutes int, calcProfit ProfitFunc) ([][]osmomath.Int, [][]uint8) { + // 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, totalRoutes+1) + + dp[i][0] = zero + + proportions[i] = make([]uint8, totalRoutes+1) } - // If only one route, return the best single route quote - if len(routes) == 1 { - route := routes[0] - coinOut, err := route.CalculateTokenOutByTokenIn(ctx, tokenIn) - if err != nil { - return nil, err - } - quote := "eExactAmountIn{ - AmountIn: tokenIn, - AmountOut: coinOut.Amount, - Route: []domain.SplitRoute{&RouteWithOutAmount{ - RouteImpl: route, - OutAmount: coinOut.Amount, - InAmount: tokenIn.Amount, - }}, + // Initialize the first column with 0 + for j := 0; j <= totalRoutes; j++ { + dp[0][j] = zero + } + for x := uint8(1); x <= totalIncrements; x++ { + for j := 1; j <= totalRoutes; 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(calcProfit(j-1, p)) // x=1,p=1,j=1 dp[0][0], + + if choice.GT(noChoice) { + dp[x][j] = choice + proportions[x][j] = p + } + } } + } - return quote, nil + return dp, proportions +} + +func MaxBacktrack(totalRoutes int, proportions [][]uint8, calcProfit ProfitFunc) ([]uint8) { + // Step 3: trace back to find the optimal proportions + x, j := totalIncrements, totalRoutes + optimalProportions := make([]uint8, totalRoutes+1) + for j > 0 { + optimalProportions[j] = proportions[x][j] + x -= proportions[x][j] + j -= 1 } + return optimalProportions +} + +func Min(totalRoutes int, calcProfit ProfitFunc) ([][]osmomath.Int, [][]uint8) { // 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. @@ -59,27 +91,22 @@ func getSplitQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, toke // Step 1: initialize tables for i := 0; i < int(totalIncrements+1); i++ { - dp[i] = make([]osmomath.Int, len(routes)+1) + dp[i] = make([]osmomath.Int, totalRoutes+1) - dp[i][0] = zero + dp[i][0] = inf - proportions[i] = make([]uint8, len(routes)+1) + proportions[i] = make([]uint8, totalRoutes+1) } // Initialize the first column with 0 - for j := 0; j <= len(routes); j++ { + for j := 0; j <= totalRoutes; j++ { dp[0][j] = zero } - inAmountDec := tokenIn.Amount.ToLegacyDec() - - // callback with caching capabilities. - computeAndCacheOutAmountCb := getComputeAndCacheOutAmountCb(ctx, inAmountDec, tokenIn.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 + for j := 1; j <= totalRoutes; 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++ { @@ -89,10 +116,13 @@ func getSplitQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, toke // // 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)) // x=1,p=1,j=1 dp[0][0], + noChoice := dp[x][j-1] + choice := dp[x-p][j-1] + if choice.LT(inf) { + choice = choice.Add(calcProfit(j-1, p)) // adding to inf will result in inf + } - if choice.GT(noChoice) { + if choice.LT(noChoice) { dp[x][j] = choice proportions[x][j] = p } @@ -100,6 +130,48 @@ func getSplitQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, toke } } + return dp, proportions +} + +// 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 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") + } + // If only one route, return the best single route quote + if len(routes) == 1 { + route := routes[0] + coinOut, err := route.CalculateTokenOutByTokenIn(ctx, tokenIn) + if err != nil { + return nil, err + } + + quote := "eExactAmountIn{ + AmountIn: tokenIn, + AmountOut: coinOut.Amount, + Route: []domain.SplitRoute{&RouteWithOutAmount{ + RouteImpl: route, + OutAmount: coinOut.Amount, + InAmount: tokenIn.Amount, + }}, + } + + return quote, nil + } + + inAmountDec := tokenIn.Amount.ToLegacyDec() + + // callback with caching capabilities. + computeAndCacheOutAmountCb := getComputeAndCacheOutAmountCb(ctx, inAmountDec, tokenIn.Denom, routes) + + // Step 2: fill the tables + dp, proportions := Max(len(routes), computeAndCacheOutAmountCb) + // Step 3: trace back to find the optimal proportions x, j := totalIncrements, len(routes) optimalProportions := make([]uint8, len(routes)+1) @@ -131,7 +203,7 @@ func getSplitQuoteOutGivenIn(ctx context.Context, routes []route.RouteImpl, toke currentRouteAmtOut := computeAndCacheOutAmountCb(i, currentRouteIncrement) - currentRouteSplit := osmomath.NewDec(int64(currentRouteIncrement)).QuoInt64Mut(int64(totalIncrements)) + currentRouteSplit := osmomath.NewDec(int64(currentRouteIncrement)).QuoInt64Mut(int64(totalIncrements)) // 1/10 inAmount := currentRouteSplit.MulMut(tokenAmountDec).TruncateInt() outAmount := currentRouteAmtOut @@ -210,55 +282,13 @@ func getSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, toke 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] = inf - - 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.LT(noChoice) { - dp[x][j] = choice - proportions[x][j] = p - } - } - } - } + dp, proportions := Min(len(routes), computeAndCacheOutAmountCb) // Step 3: trace back to find the optimal proportions x, j := totalIncrements, len(routes) @@ -289,12 +319,12 @@ func getSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, toke for i, currentRouteIncrement := range bestSplit.routeIncrements { currentRoute := routes[i] - currentRouteAmtOut := computeAndCacheOutAmountCb(i, currentRouteIncrement) + currentRouteAmtIn := computeAndCacheOutAmountCb(i, currentRouteIncrement) currentRouteSplit := osmomath.NewDec(int64(currentRouteIncrement)).QuoInt64Mut(int64(totalIncrements)) inAmount := currentRouteSplit.MulMut(tokenAmountDec).TruncateInt() - outAmount := currentRouteAmtOut + outAmount := currentRouteAmtIn isAmountInNilOrZero := inAmount.IsNil() || inAmount.IsZero() isAmountOutNilOrZero := outAmount.IsNil() || outAmount.IsZero() @@ -310,14 +340,16 @@ func getSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, toke return nil, fmt.Errorf("out amount is zero when in is not (%s), route index (%d)", inAmount, i) } + // out uion 5000000 + // in ? resultRoutes = append(resultRoutes, &RouteWithOutAmount{ RouteImpl: currentRoute, InAmount: inAmount, - OutAmount: currentRouteAmtOut, + OutAmount: currentRouteAmtIn, }) totalIncrementsInSplits += currentRouteIncrement - totalAmoutOutFromSplits = totalAmoutOutFromSplits.Add(currentRouteAmtOut) + totalAmoutOutFromSplits = totalAmoutOutFromSplits.Add(currentRouteAmtIn) } if !totalAmoutOutFromSplits.Equal(bestSplit.amountOut) { @@ -358,7 +390,6 @@ 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: diff --git a/router/usecase/dynamic_splits_test.go b/router/usecase/dynamic_splits_test.go index d289c0f4..0ec263f7 100644 --- a/router/usecase/dynamic_splits_test.go +++ b/router/usecase/dynamic_splits_test.go @@ -2,6 +2,7 @@ package usecase_test import ( "context" + "math" "testing" sdk "github.com/cosmos/cosmos-sdk/types" @@ -11,6 +12,7 @@ import ( "github.com/osmosis-labs/sqs/router/usecase/route" "github.com/osmosis-labs/sqs/router/usecase/routertesting" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -22,6 +24,148 @@ func TestDynamicSplitsTestSuite(t *testing.T) { suite.Run(t, new(DynamicSplitsTestSuite)) } +var zero = osmomath.NewInt(0) +var inf = osmomath.NewInt(math.MaxInt64) + +func TestMin(t *testing.T) { + testCases := []struct { + name string + totalRoutes int + profitFunc usecase.ProfitFunc + expectedDP [][]osmomath.Int + expectedProportions [][]uint8 + }{ + { + name: "Single route", + totalRoutes: 1, + profitFunc: func(route int, proportion uint8) osmomath.Int { + return osmomath.NewInt(int64(proportion)) + }, + expectedDP: [][]osmomath.Int{ + {zero, zero}, + {inf, osmomath.NewInt(1)}, + {inf, osmomath.NewInt(2)}, + {inf, osmomath.NewInt(3)}, + {inf, osmomath.NewInt(4)}, + {inf, osmomath.NewInt(5)}, + {inf, osmomath.NewInt(6)}, + {inf, osmomath.NewInt(7)}, + {inf, osmomath.NewInt(8)}, + {inf, osmomath.NewInt(9)}, + {inf, osmomath.NewInt(10)}, + }, + expectedProportions: [][]uint8{ + {0, 0}, + {0, 1}, + {0, 2}, + {0, 3}, + {0, 4}, + {0, 5}, + {0, 6}, + {0, 7}, + {0, 8}, + {0, 9}, + {0, 10}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dp, proportions := usecase.Min(tc.totalRoutes, tc.profitFunc) + + require.Equal(t, tc.expectedDP, dp, "DP table doesn't match expected") + require.Equal(t, tc.expectedProportions, proportions, "Proportions table doesn't match expected") + }) + } +} +func TestMax(t *testing.T) { + testCases := []struct { + name string + totalRoutes int + profitFunc usecase.ProfitFunc + expectedDP [][]osmomath.Int + expectedProportions [][]uint8 + }{ + { + name: "Single route", + totalRoutes: 1, + profitFunc: func(route int, proportion uint8) osmomath.Int { + return osmomath.NewInt(int64(proportion)) + }, + expectedDP: [][]osmomath.Int{ + {zero, zero}, + {zero, osmomath.NewInt(1)}, + {zero, osmomath.NewInt(2)}, + {zero, osmomath.NewInt(3)}, + {zero, osmomath.NewInt(4)}, + {zero, osmomath.NewInt(5)}, + {zero, osmomath.NewInt(6)}, + {zero, osmomath.NewInt(7)}, + {zero, osmomath.NewInt(8)}, + {zero, osmomath.NewInt(9)}, + {zero, osmomath.NewInt(10)}, + }, + expectedProportions: [][]uint8{ + {0, 0}, + {0, 1}, + {0, 2}, + {0, 3}, + {0, 4}, + {0, 5}, + {0, 6}, + {0, 7}, + {0, 8}, + {0, 9}, + {0, 10}, + }, + }, + { + name: "Two routes with linear profit", + totalRoutes: 2, + profitFunc: func(route int, proportion uint8) osmomath.Int { + return osmomath.NewInt(int64(proportion * uint8(route+1))) + }, + expectedDP: [][]osmomath.Int{ + {zero, zero, zero}, + {zero, osmomath.NewInt(1), osmomath.NewInt(2)}, + {zero, osmomath.NewInt(2), osmomath.NewInt(4)}, + {zero, osmomath.NewInt(3), osmomath.NewInt(6)}, + {zero, osmomath.NewInt(4), osmomath.NewInt(8)}, + {zero, osmomath.NewInt(5), osmomath.NewInt(10)}, + {zero, osmomath.NewInt(6), osmomath.NewInt(12)}, + {zero, osmomath.NewInt(7), osmomath.NewInt(14)}, + {zero, osmomath.NewInt(8), osmomath.NewInt(16)}, + {zero, osmomath.NewInt(9), osmomath.NewInt(18)}, + {zero, osmomath.NewInt(10), osmomath.NewInt(20)}, + }, + expectedProportions: [][]uint8{ + {0, 0, 0}, + {0, 1, 1}, + {0, 2, 2}, + {0, 3, 3}, + {0, 4, 4}, + {0, 5, 5}, + {0, 6, 6}, + {0, 7, 7}, + {0, 8, 8}, + {0, 9, 9}, + {0, 10, 10}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dp, proportions := usecase.Max(tc.totalRoutes, tc.profitFunc) + + optimal := usecase.MaxBacktrack(tc.totalRoutes, proportions, tc.profitFunc) + require.Equal(t, tc.expectedDP, dp, "DP table doesn't match expected", optimal) + require.Equal(t, tc.expectedProportions, proportions, "Proportions table doesn't match expected") + }) + } +} + // Sanity check test case to validate get split quote function with a given denom and amount. // This test case tests OutGivenIn swap method. func (s *DynamicSplitsTestSuite) TestGetSplitQuoteOutGivenIn() { @@ -39,7 +183,6 @@ func (s *DynamicSplitsTestSuite) TestGetSplitQuoteOutGivenIn() { s.Require().NoError(err) } - // 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() { From ff6483535adfd23829bcf6be48c013309fe3446e Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Sat, 8 Feb 2025 18:17:35 +0200 Subject: [PATCH 18/18] BE-680 | Clean up --- router/usecase/dynamic_splits.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/router/usecase/dynamic_splits.go b/router/usecase/dynamic_splits.go index 5b989136..9cf0e643 100644 --- a/router/usecase/dynamic_splits.go +++ b/router/usecase/dynamic_splits.go @@ -68,7 +68,7 @@ func Max(totalRoutes int, calcProfit ProfitFunc) ([][]osmomath.Int, [][]uint8) { return dp, proportions } -func MaxBacktrack(totalRoutes int, proportions [][]uint8, calcProfit ProfitFunc) ([]uint8) { +func MaxBacktrack(totalRoutes int, proportions [][]uint8, calcProfit ProfitFunc) []uint8 { // Step 3: trace back to find the optimal proportions x, j := totalIncrements, totalRoutes optimalProportions := make([]uint8, totalRoutes+1) @@ -106,7 +106,7 @@ func Min(totalRoutes int, calcProfit ProfitFunc) ([][]osmomath.Int, [][]uint8) { // Step 2: fill the tables for x := uint8(1); x <= totalIncrements; x++ { for j := 1; j <= totalRoutes; j++ { - // dp[x][j] = dp[x][j-1] // Not using the j-th route + 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++ { @@ -116,11 +116,8 @@ func Min(totalRoutes int, calcProfit ProfitFunc) ([][]osmomath.Int, [][]uint8) { // // 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-1] - choice := dp[x-p][j-1] - if choice.LT(inf) { - choice = choice.Add(calcProfit(j-1, p)) // adding to inf will result in inf - } + noChoice := dp[x][j] + choice := dp[x-p][j-1].Add(calcProfit(j-1, p)) // adding to inf will result in inf if choice.LT(noChoice) { dp[x][j] = choice @@ -282,7 +279,6 @@ func getSplitQuoteInGivenOut(ctx context.Context, routes []route.RouteImpl, toke return quote, nil } - outAmountDec := tokenOut.Amount.ToLegacyDec() // callback with caching capabilities.