From e7685dec017815d1891bacbff62bdb01c4b779b6 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 15 May 2023 17:53:38 -0400 Subject: [PATCH] refactor: remove cache context from swap out given in (#5176) * refactor: remove cache from swap out given in * updates * dead code * update test name * updates --- x/concentrated-liquidity/export_test.go | 23 +++++- x/concentrated-liquidity/swaps.go | 52 ++++++------ x/concentrated-liquidity/swaps_test.go | 101 +++++++++++++++++++----- 3 files changed, 123 insertions(+), 53 deletions(-) diff --git a/x/concentrated-liquidity/export_test.go b/x/concentrated-liquidity/export_test.go index 35ede63a26e..af71c67fe51 100644 --- a/x/concentrated-liquidity/export_test.go +++ b/x/concentrated-liquidity/export_test.go @@ -55,12 +55,27 @@ func (k Keeper) CalcInAmtGivenOutInternal(ctx sdk.Context, desiredTokenOut sdk.C return k.calcInAmtGivenOut(ctx, desiredTokenOut, tokenInDenom, swapFee, priceLimit, poolId) } -func (k Keeper) CalcOutAmtGivenInInternal(ctx sdk.Context, tokenInMin sdk.Coin, tokenOutDenom string, swapFee sdk.Dec, priceLimit sdk.Dec, poolId uint64) (writeCtx func(), tokenIn, tokenOut sdk.Coin, updatedTick sdk.Int, updatedLiquidity, updatedSqrtPrice sdk.Dec, err error) { - return k.calcOutAmtGivenIn(ctx, tokenInMin, tokenOutDenom, swapFee, priceLimit, poolId) +func (k Keeper) SwapOutAmtGivenIn( + ctx sdk.Context, + sender sdk.AccAddress, + pool types.ConcentratedPoolExtension, + tokenIn sdk.Coin, + tokenOutDenom string, + swapFee sdk.Dec, + priceLimit sdk.Dec) (calcTokenIn, calcTokenOut sdk.Coin, currentTick sdk.Int, liquidity, sqrtPrice sdk.Dec, err error) { + return k.swapOutAmtGivenIn(ctx, sender, pool, tokenIn, tokenOutDenom, swapFee, priceLimit) } -func (k Keeper) SwapOutAmtGivenIn(ctx sdk.Context, sender sdk.AccAddress, pool types.ConcentratedPoolExtension, tokenIn sdk.Coin, tokenOutDenom string, swapFee sdk.Dec, priceLimit sdk.Dec) (calcTokenIn, calcTokenOut sdk.Coin, currentTick sdk.Int, liquidity, sqrtPrice sdk.Dec, err error) { - return k.swapOutAmtGivenIn(ctx, sender, pool, tokenIn, tokenOutDenom, swapFee, priceLimit) +func (k Keeper) ComputeOutAmtGivenIn( + ctx sdk.Context, + poolId uint64, + tokenInMin sdk.Coin, + tokenOutDenom string, + swapFee sdk.Dec, + priceLimit sdk.Dec, + +) (calcTokenIn, calcTokenOut sdk.Coin, currentTick sdk.Int, liquidity, sqrtPrice sdk.Dec, err error) { + return k.computeOutAmtGivenIn(ctx, poolId, tokenInMin, tokenOutDenom, swapFee, priceLimit) } func (k *Keeper) SwapInAmtGivenOut(ctx sdk.Context, sender sdk.AccAddress, pool types.ConcentratedPoolExtension, desiredTokenOut sdk.Coin, tokenInDenom string, swapFee sdk.Dec, priceLimit sdk.Dec) (calcTokenIn, calcTokenOut sdk.Coin, currentTick sdk.Int, liquidity, sqrtPrice sdk.Dec, err error) { diff --git a/x/concentrated-liquidity/swaps.go b/x/concentrated-liquidity/swaps.go index f0e5dcf1890..1cba311d2a3 100644 --- a/x/concentrated-liquidity/swaps.go +++ b/x/concentrated-liquidity/swaps.go @@ -160,7 +160,7 @@ func (k Keeper) swapOutAmtGivenIn( swapFee sdk.Dec, priceLimit sdk.Dec, ) (calcTokenIn, calcTokenOut sdk.Coin, currentTick sdk.Int, liquidity, sqrtPrice sdk.Dec, err error) { - writeCtx, tokenIn, tokenOut, newCurrentTick, newLiquidity, newSqrtPrice, err := k.calcOutAmtGivenIn(ctx, tokenIn, tokenOutDenom, swapFee, priceLimit, pool.GetId()) + tokenIn, tokenOut, newCurrentTick, newLiquidity, newSqrtPrice, err := k.computeOutAmtGivenIn(ctx, pool.GetId(), tokenIn, tokenOutDenom, swapFee, priceLimit) if err != nil { return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err } @@ -169,11 +169,6 @@ func (k Keeper) swapOutAmtGivenIn( return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, types.InvalidAmountCalculatedError{Amount: tokenOut.Amount} } - // N.B. making the call below ensures that any mutations done inside calcOutAmtGivenIn - // are written to store. If this call were skipped, calcOutAmtGivenIn would be non-mutative. - // An example of a store write done in calcOutAmtGivenIn is updating ticks as we cross them. - writeCtx() - // Settles balances between the tx sender and the pool to match the swap that was executed earlier. // Also emits swap event and updates related liquidity metrics if err := k.updatePoolForSwap(ctx, pool, sender, tokenIn, tokenOut, newCurrentTick, newLiquidity, newSqrtPrice); err != nil { @@ -223,7 +218,8 @@ func (k Keeper) CalcOutAmtGivenIn( tokenOutDenom string, swapFee sdk.Dec, ) (tokenOut sdk.Coin, err error) { - _, _, tokenOut, _, _, _, err = k.calcOutAmtGivenIn(ctx, tokenIn, tokenOutDenom, swapFee, sdk.ZeroDec(), poolI.GetId()) + cacheCtx, _ := ctx.CacheContext() + _, tokenOut, _, _, _, err = k.computeOutAmtGivenIn(cacheCtx, poolI.GetId(), tokenIn, tokenOutDenom, swapFee, sdk.ZeroDec()) if err != nil { return sdk.Coin{}, err } @@ -237,31 +233,31 @@ func (k Keeper) CalcInAmtGivenOut( tokenInDenom string, swapFee sdk.Dec, ) (tokenIn sdk.Coin, err error) { - _, tokenIn, _, _, _, _, err = k.calcInAmtGivenOut(ctx, tokenOut, tokenInDenom, swapFee, sdk.ZeroDec(), poolI.GetId()) + cacheCtx, _ := ctx.CacheContext() + _, tokenIn, _, _, _, _, err = k.calcInAmtGivenOut(cacheCtx, tokenOut, tokenInDenom, swapFee, sdk.ZeroDec(), poolI.GetId()) if err != nil { return sdk.Coin{}, err } return tokenIn, nil } -// calcOutAmtGivenIn calculates tokens to be swapped out given the provided amount and fee deducted. It also returns +// computeOutAmtGivenIn calculates tokens to be swapped out given the provided amount and fee deducted. It also returns // what the updated tick, liquidity, and currentSqrtPrice for the pool would be after this swap. -// Note this method is non-mutative, so the values returned by CalcOutAmtGivenIn do not get stored -// Instead, we return writeCtx function so that the caller of this method can decide to write the cached ctx to store or not. +// Note this method is mutative, some of the tick and accumulator updates get written to store. +// However, there are no token transfers or pool updates done in this method. These mutations are performed in swapInAmtGivenOut. // Note that passing in 0 for `priceLimit` will result in the price limit being set to the max/min value based on swap direction -func (k Keeper) calcOutAmtGivenIn(ctx sdk.Context, +func (k Keeper) computeOutAmtGivenIn( + ctx sdk.Context, + poolId uint64, tokenInMin sdk.Coin, tokenOutDenom string, swapFee sdk.Dec, priceLimit sdk.Dec, - poolId uint64, -) (writeCtx func(), tokenIn, tokenOut sdk.Coin, updatedTick sdk.Int, updatedLiquidity, updatedSqrtPrice sdk.Dec, err error) { - ctx, writeCtx = ctx.CacheContext() - +) (tokenIn, tokenOut sdk.Coin, updatedTick sdk.Int, updatedLiquidity, updatedSqrtPrice sdk.Dec, err error) { // Get pool and asset info p, err := k.getPoolById(ctx, poolId) if err != nil { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err } asset0 := p.GetToken0() asset1 := p.GetToken1() @@ -280,7 +276,7 @@ func (k Keeper) calcOutAmtGivenIn(ctx sdk.Context, // Take provided price limit and turn this into a sqrt price limit since formulas use sqrtPrice sqrtPriceLimit, err := priceLimit.ApproxSqrt() if err != nil { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, fmt.Errorf("issue calculating square root of price limit") + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, fmt.Errorf("issue calculating square root of price limit") } // Set the swap strategy @@ -290,20 +286,20 @@ func (k Keeper) calcOutAmtGivenIn(ctx sdk.Context, // on the correct side of the price limit given swap direction. curSqrtPrice := p.GetCurrentSqrtPrice() if err := swapStrategy.ValidateSqrtPrice(sqrtPriceLimit, curSqrtPrice); err != nil { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err } // Check that the specified tokenIn matches one of the assets in the specified pool if tokenInMin.Denom != asset0 && tokenInMin.Denom != asset1 { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, types.TokenInDenomNotInPoolError{TokenInDenom: tokenInMin.Denom} + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, types.TokenInDenomNotInPoolError{TokenInDenom: tokenInMin.Denom} } // Check that the specified tokenOut matches one of the assets in the specified pool if tokenOutDenom != asset0 && tokenOutDenom != asset1 { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, types.TokenOutDenomNotInPoolError{TokenOutDenom: tokenOutDenom} + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, types.TokenOutDenomNotInPoolError{TokenOutDenom: tokenOutDenom} } // Check that token in and token out are different denominations if tokenInMin.Denom == tokenOutDenom { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, types.DenomDuplicatedError{TokenInDenom: tokenInMin.Denom, TokenOutDenom: tokenOutDenom} + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, types.DenomDuplicatedError{TokenInDenom: tokenInMin.Denom, TokenOutDenom: tokenOutDenom} } // initialize swap state with the following parameters: @@ -332,13 +328,13 @@ func (k Keeper) calcOutAmtGivenIn(ctx sdk.Context, // if no ticks are initialized (no users have created liquidity positions) then we return an error nextTick, ok := swapStrategy.NextInitializedTick(ctx, poolId, swapState.tick.Int64()) if !ok { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, fmt.Errorf("there are no more ticks initialized to fill the swap") + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, fmt.Errorf("there are no more ticks initialized to fill the swap") } // Utilizing the next initialized tick, we find the corresponding nextPrice (the target price) _, nextTickSqrtPrice, err := math.TickToSqrtPrice(nextTick) if err != nil { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, fmt.Errorf("could not convert next tick (%v) to nextSqrtPrice", nextTick) + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, fmt.Errorf("could not convert next tick (%v) to nextSqrtPrice", nextTick) } // If nextSqrtPrice exceeds the price limit, we set the nextSqrtPrice to the price limit. @@ -377,7 +373,7 @@ func (k Keeper) calcOutAmtGivenIn(ctx sdk.Context, // Retrieve the liquidity held in the next closest initialized tick liquidityNet, err := k.crossTick(ctx, p.GetId(), nextTick.Int64(), sdk.NewDecCoinFromDec(tokenInMin.Denom, swapState.feeGrowthGlobal)) if err != nil { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err } liquidityNet = swapStrategy.SetLiquidityDeltaSign(liquidityNet) // Update the swapState's liquidity with the new tick's liquidity @@ -392,13 +388,13 @@ func (k Keeper) calcOutAmtGivenIn(ctx sdk.Context, price := sqrtPrice.Mul(sqrtPrice) swapState.tick, err = math.PriceToTickRoundDown(price, p.GetTickSpacing()) if err != nil { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err } } } if err := k.chargeFee(ctx, poolId, sdk.NewDecCoinFromDec(tokenInMin.Denom, swapState.feeGrowthGlobal)); err != nil { - return writeCtx, sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err + return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, err } // Coin amounts require int values @@ -413,7 +409,7 @@ func (k Keeper) calcOutAmtGivenIn(ctx sdk.Context, tokenIn = sdk.NewCoin(tokenInMin.Denom, amt0) tokenOut = sdk.NewCoin(tokenOutDenom, amt1) - return writeCtx, tokenIn, tokenOut, swapState.tick, swapState.liquidity, swapState.sqrtPrice, nil + return tokenIn, tokenOut, swapState.tick, swapState.liquidity, swapState.sqrtPrice, nil } // calcInAmtGivenOut calculates tokens to be swapped in given the desired token out and fee deducted. It also returns diff --git a/x/concentrated-liquidity/swaps_test.go b/x/concentrated-liquidity/swaps_test.go index cc4bd89238f..f59e3049730 100644 --- a/x/concentrated-liquidity/swaps_test.go +++ b/x/concentrated-liquidity/swaps_test.go @@ -1442,7 +1442,7 @@ var ( } ) -func (s *KeeperTestSuite) TestCalcAndSwapOutAmtGivenIn() { +func (s *KeeperTestSuite) TestComputeAndSwapOutAmtGivenIn() { tests := make(map[string]SwapTest, len(swapOutGivenInCases)+len(swapOutGivenInFeeCases)+len(swapOutGivenInErrorCases)) for name, test := range swapOutGivenInCases { tests[name] = test @@ -1484,11 +1484,17 @@ func (s *KeeperTestSuite) TestCalcAndSwapOutAmtGivenIn() { poolBeforeCalc, err := s.App.ConcentratedLiquidityKeeper.GetPoolById(s.Ctx, pool.GetId()) s.Require().NoError(err) - // perform calc - _, tokenIn, tokenOut, updatedTick, updatedLiquidity, sqrtPrice, err := s.App.ConcentratedLiquidityKeeper.CalcOutAmtGivenInInternal( - s.Ctx, + // Refetch the pool + pool, err = s.App.ConcentratedLiquidityKeeper.GetPoolById(s.Ctx, pool.GetId()) + s.Require().NoError(err) + + // perform compute + cacheCtx, _ := s.Ctx.CacheContext() + tokenIn, tokenOut, updatedTick, updatedLiquidity, sqrtPrice, err := s.App.ConcentratedLiquidityKeeper.ComputeOutAmtGivenIn( + cacheCtx, + pool.GetId(), test.tokenIn, test.tokenOutDenom, - test.swapFee, test.priceLimit, pool.GetId()) + test.swapFee, test.priceLimit) if test.expectErr { s.Require().Error(err) @@ -2278,9 +2284,9 @@ func (s *KeeperTestSuite) TestSwapExactAmountOut() { } } -// TestCalcOutAmtGivenInWriteCtx tests that writeCtx successfully performs state changes as expected. -// We expect writeCtx to only change fee accum state, since pool state change is not handled via writeCtx function. -func (s *KeeperTestSuite) TestCalcOutAmtGivenInWriteCtx() { +// TestComputeOutAmtGivenIn tests that ComputeOutAmtGivenIn successfully performs state changes as expected. +// We expect to only change fee accum state, since pool state change is not handled by ComputeOutAmtGivenIn. +func (s *KeeperTestSuite) TestComputeOutAmtGivenIn() { // we only use fee cases here since write Ctx only takes effect in the fee accumulator tests := make(map[string]SwapTest, len(swapOutGivenInFeeCases)) @@ -2316,11 +2322,11 @@ func (s *KeeperTestSuite) TestCalcOutAmtGivenInWriteCtx() { s.Require().NoError(err) // perform calc - writeCtx, _, _, _, _, _, err := s.App.ConcentratedLiquidityKeeper.CalcOutAmtGivenInInternal( + _, _, _, _, _, err = s.App.ConcentratedLiquidityKeeper.ComputeOutAmtGivenIn( s.Ctx, + pool.GetId(), test.tokenIn, test.tokenOutDenom, - test.swapFee, test.priceLimit, pool.GetId()) - s.Require().NoError(err) + test.swapFee, test.priceLimit) // check that the pool has not been modified after performing calc poolAfterCalc, err := s.App.ConcentratedLiquidityKeeper.GetPoolById(s.Ctx, pool.GetId()) @@ -2331,28 +2337,81 @@ func (s *KeeperTestSuite) TestCalcOutAmtGivenInWriteCtx() { s.Require().Equal(poolBeforeCalc.GetLiquidity(), poolAfterCalc.GetLiquidity()) s.Require().Equal(poolBeforeCalc.GetTickSpacing(), poolAfterCalc.GetTickSpacing()) + // check that fee accum has been correctly updated. feeAccum, err := s.App.ConcentratedLiquidityKeeper.GetFeeAccumulator(s.Ctx, 1) s.Require().NoError(err) feeAccumValue := feeAccum.GetValue() - s.Require().Equal(0, feeAccumValue.Len()) - s.Require().Equal(1, + s.Require().Equal(1, feeAccumValue.Len()) + s.Require().Equal(0, additiveFeeGrowthGlobalErrTolerance.CompareBigDec( osmomath.BigDecFromSDKDec(test.expectedFeeGrowthAccumulatorValue), osmomath.BigDecFromSDKDec(feeAccum.GetValue().AmountOf(test.tokenIn.Denom)), ), ) + }) + } +} - // System under test - writeCtx() +// TestCalcOutAmtGivenIn_NonMutative tests that CalcOutAmtGivenIn is non-mutative. +func (s *KeeperTestSuite) TestCalcOutAmtGivenIn_NonMutative() { + // we only use fee cases here since write Ctx only takes effect in the fee accumulator + tests := make(map[string]SwapTest, len(swapOutGivenInFeeCases)) - // now we check that fee accum has been correctly updated upon writeCtx - feeAccum, err = s.App.ConcentratedLiquidityKeeper.GetFeeAccumulator(s.Ctx, 1) + for name, test := range swapOutGivenInFeeCases { + tests[name] = test + } + + for name, test := range tests { + test := test + s.Run(name, func() { + s.SetupTest() + s.FundAcc(s.TestAccs[0], sdk.NewCoins(sdk.NewCoin("eth", sdk.NewInt(10000000000000)), sdk.NewCoin("usdc", sdk.NewInt(1000000000000)))) + s.FundAcc(s.TestAccs[1], sdk.NewCoins(sdk.NewCoin("eth", sdk.NewInt(10000000000000)), sdk.NewCoin("usdc", sdk.NewInt(1000000000000)))) + + // Create default CL pool + pool := s.PrepareConcentratedPool() + + // add default position + s.SetupDefaultPosition(pool.GetId()) + + // add second position depending on the test + if !test.secondPositionLowerPrice.IsNil() { + newLowerTick, err := math.PriceToTickRoundDown(test.secondPositionLowerPrice, pool.GetTickSpacing()) + s.Require().NoError(err) + newUpperTick, err := math.PriceToTickRoundDown(test.secondPositionUpperPrice, pool.GetTickSpacing()) + s.Require().NoError(err) + + _, _, _, _, _, _, _, err = s.App.ConcentratedLiquidityKeeper.CreatePosition(s.Ctx, pool.GetId(), s.TestAccs[1], DefaultCoins, sdk.ZeroInt(), sdk.ZeroInt(), newLowerTick.Int64(), newUpperTick.Int64()) + s.Require().NoError(err) + } + + poolBeforeCalc, err := s.App.ConcentratedLiquidityKeeper.GetPoolById(s.Ctx, pool.GetId()) s.Require().NoError(err) - feeAccumValue = feeAccum.GetValue() - s.Require().Equal(1, feeAccumValue.Len()) - s.Require().Equal(0, + // perform calc + _, err = s.App.ConcentratedLiquidityKeeper.CalcOutAmtGivenIn( + s.Ctx, + poolBeforeCalc, + test.tokenIn, test.tokenOutDenom, + test.swapFee) + s.Require().NoError(err) + + // check that the pool has not been modified after performing calc + poolAfterCalc, err := s.App.ConcentratedLiquidityKeeper.GetPoolById(s.Ctx, pool.GetId()) + s.Require().NoError(err) + + s.Require().Equal(poolBeforeCalc.GetCurrentSqrtPrice(), poolAfterCalc.GetCurrentSqrtPrice()) + s.Require().Equal(poolBeforeCalc.GetCurrentTick(), poolAfterCalc.GetCurrentTick()) + s.Require().Equal(poolBeforeCalc.GetLiquidity(), poolAfterCalc.GetLiquidity()) + s.Require().Equal(poolBeforeCalc.GetTickSpacing(), poolAfterCalc.GetTickSpacing()) + + feeAccum, err := s.App.ConcentratedLiquidityKeeper.GetFeeAccumulator(s.Ctx, 1) + s.Require().NoError(err) + + feeAccumValue := feeAccum.GetValue() + s.Require().Equal(0, feeAccumValue.Len()) + s.Require().Equal(1, additiveFeeGrowthGlobalErrTolerance.CompareBigDec( osmomath.BigDecFromSDKDec(test.expectedFeeGrowthAccumulatorValue), osmomath.BigDecFromSDKDec(feeAccum.GetValue().AmountOf(test.tokenIn.Denom)), @@ -2876,7 +2935,7 @@ func (s *KeeperTestSuite) TestFunctionalSwaps() { expectedTokenOut := sdk.NewInt(983645) // Compare the expected and actual values with a multiplicative tolerance of 0.0001% - s.Require().Equal(0, multiplicativeTolerance.CompareBigDec(expectedSqrtPrice, actualSqrtPrice)) + s.Require().Equal(0, multiplicativeTolerance.CompareBigDec(expectedSqrtPrice, actualSqrtPrice), "expected sqrt price: %s, actual sqrt price: %s", expectedSqrtPrice, actualSqrtPrice) s.Require().Equal(0, multiplicativeTolerance.Compare(expectedTokenIn, totalTokenIn.Amount)) s.Require().Equal(0, multiplicativeTolerance.Compare(expectedTokenOut, totalTokenOut.Amount))