diff --git a/x/gamm/pool-models/balancer/amm_joinpool_test.go b/x/gamm/pool-models/balancer/amm_joinpool_test.go index f94398c4253f36218851615e23fb5cc35919e55e..9394012daded4271909751e30855f07564932123 100644 --- a/x/gamm/pool-models/balancer/amm_joinpool_test.go +++ b/x/gamm/pool-models/balancer/amm_joinpool_test.go @@ -3,7 +3,9 @@ package balancer_test import ( "errors" "fmt" + "math/rand" "testing" + time "time" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" @@ -1025,3 +1027,110 @@ func assertExpectedLiquidity(t *testing.T, expectLiq, tokensJoined, liquidity sd require.Equal(t, tokensJoined, liquidity) } } + +// Tests selecting a random amount of coins to LP, and then that ExitPool(JoinPool(tokens)) +// preserves the pools number of LP shares, and returns fewer coins to the acter than they started with. +func (suite *KeeperTestSuite) TestRandomizedJoinPoolExitPoolInvariants() { + type testCase struct { + initialTokensDenomIn int64 + initialTokensDenomOut int64 + + percentRatio int64 + + numShares sdk.Int + } + + const ( + denomOut = "denomOut" + denomIn = "denomIn" + ) + + now := int64(time.Now().Unix()) + rng := rand.NewSource(now) + suite.T().Logf("Using random source of %d\n", now) + + // generate test case with randomized initial assets and join/exit ratio + newCase := func() (tc *testCase) { + tc = new(testCase) + tc.initialTokensDenomIn = rng.Int63() % (1 << 62) + tc.initialTokensDenomOut = rng.Int63() % (1 << 62) + + // 1%~100% of initial assets + tc.percentRatio = rng.Int63()%100 + 1 + + return tc + } + + swapFeeDec := sdk.ZeroDec() + exitFeeDec := sdk.ZeroDec() + + // create pool with randomized initial token amounts + // and randomized ratio of join/exit + createPool := func(tc *testCase) (pool *balancer.Pool) { + poolAssetOut := balancer.PoolAsset{ + Token: sdk.NewInt64Coin(denomOut, tc.initialTokensDenomOut), + Weight: sdk.NewInt(5), + } + + poolAssetIn := balancer.PoolAsset{ + Token: sdk.NewInt64Coin(denomIn, tc.initialTokensDenomIn), + Weight: sdk.NewInt(5), + } + + pool = createTestPool(suite.T(), swapFeeDec, exitFeeDec, poolAssetOut, poolAssetIn).(*balancer.Pool) + suite.Require().NotNil(pool) + + return pool + } + + // joins with predetermined ratio + joinPool := func(pool types.PoolI, tc *testCase) { + tokensIn := sdk.Coins{ + sdk.NewCoin(denomIn, sdk.NewInt(tc.initialTokensDenomIn).MulRaw(tc.percentRatio).QuoRaw(100)), + sdk.NewCoin(denomOut, sdk.NewInt(tc.initialTokensDenomOut).MulRaw(tc.percentRatio).QuoRaw(100)), + } + numShares, err := pool.JoinPool(suite.Ctx, tokensIn, swapFeeDec) + suite.Require().NoError(err) + tc.numShares = numShares + } + + // exits for same amount of shares minted + exitPool := func(pool types.PoolI, tc *testCase) { + _, err := pool.ExitPool(suite.Ctx, tc.numShares, exitFeeDec) + suite.Require().NoError(err) + } + + invariantJoinExitInversePreserve := func( + beforeCoins, afterCoins sdk.Coins, + beforeShares, afterShares sdk.Int, + ) { + // test token amount has been preserved + suite.Require().True( + !beforeCoins.IsAnyGT(afterCoins), + "Coins has not been preserved before and after join-exit\nbefore:\t%s\nafter:\t%s", + beforeCoins, afterCoins, + ) + // test share amount has been preserved + suite.Require().True( + beforeShares.Equal(afterShares), + "Shares has not been preserved before and after join-exit\nbefore:\t%s\nafter:\t%s", + beforeShares, afterShares, + ) + } + + testPoolInvariants := func() { + tc := newCase() + pool := createPool(tc) + originalCoins, originalShares := pool.GetTotalPoolLiquidity(sdk.Context{}), pool.GetTotalShares() + joinPool(pool, tc) + exitPool(pool, tc) + invariantJoinExitInversePreserve( + originalCoins, pool.GetTotalPoolLiquidity(sdk.Context{}), + originalShares, pool.GetTotalShares(), + ) + } + + for i := 0; i < 50000; i++ { + testPoolInvariants() + } +} diff --git a/x/gamm/pool-models/internal/cfmm_common/lp.go b/x/gamm/pool-models/internal/cfmm_common/lp.go index e0a969814f3dd503ccc98351988942f899b55d58..84413c47749aaddd5d2fc0ad1fb1e25dee6b4226 100644 --- a/x/gamm/pool-models/internal/cfmm_common/lp.go +++ b/x/gamm/pool-models/internal/cfmm_common/lp.go @@ -110,6 +110,9 @@ func MaximalExactRatioJoin(p types.PoolI, ctx sdk.Context, tokensIn sdk.Coins) ( totalShares := p.GetTotalShares() for i, coin := range tokensIn { + // Note: QuoInt implements floor division, unlike Quo + // This is because it calls the native golang routine big.Int.Quo + // https://pkg.go.dev/math/big#Int.Quo shareRatio := coin.Amount.ToDec().QuoInt(poolLiquidity.AmountOfNoDenomValidation(coin.Denom)) if shareRatio.LT(minShareRatio) { minShareRatio = shareRatio @@ -125,6 +128,8 @@ func MaximalExactRatioJoin(p types.PoolI, ctx sdk.Context, tokensIn sdk.Coins) ( } remCoins = sdk.Coins{} + // critically we round down here (TruncateInt), to ensure that the returned LP shares + // are always less than or equal to % liquidity added. numShares = minShareRatio.MulInt(totalShares).TruncateInt() // if we have multiple share values, calculate remainingCoins