diff --git a/tests/dex/state_cancel_limit_order_test.go b/tests/dex/state_cancel_limit_order_test.go new file mode 100644 index 000000000..9d1821e1c --- /dev/null +++ b/tests/dex/state_cancel_limit_order_test.go @@ -0,0 +1,205 @@ +package dex_state_test + +import ( + "errors" + "strconv" + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + + math_utils "github.com/neutron-org/neutron/v5/utils/math" + dextypes "github.com/neutron-org/neutron/v5/x/dex/types" +) + +type cancelLimitOrderTestParams struct { + // State Conditions + SharedParams + ExistingTokenAHolders string + Filled int64 + WithdrawnCreator bool + WithdrawnOneOther bool + Expired bool + OrderType dextypes.LimitOrderType // JIT, GTT, GTC +} + +func (p cancelLimitOrderTestParams) printTestInfo(t *testing.T) { + t.Logf(` + Existing Shareholders: %s + Filled: %v + WithdrawnCreator: %v + WithdrawnOneOther: %t + Expired: %t + OrderType: %v`, + p.ExistingTokenAHolders, + p.Filled, + p.WithdrawnCreator, + p.WithdrawnOneOther, + p.Expired, + p.OrderType.String(), + ) +} + +func hydrateCancelLoTestCase(params map[string]string) cancelLimitOrderTestParams { + selltick, err := dextypes.CalcTickIndexFromPrice(math_utils.MustNewPrecDecFromStr(DefaultSellPrice)) + if err != nil { + panic(err) + } + c := cancelLimitOrderTestParams{ + ExistingTokenAHolders: params["ExistingTokenAHolders"], + Filled: int64(parseInt(params["Filled"])), + WithdrawnCreator: parseBool(params["WithdrawnCreator"]), + WithdrawnOneOther: parseBool(params["WithdrawnOneOther"]), + Expired: parseBool(params["Expired"]), + OrderType: dextypes.LimitOrderType(dextypes.LimitOrderType_value[params["OrderType"]]), + } + c.SharedParams.Tick = selltick + return c +} + +func (s *DexStateTestSuite) setupCancelTest(params cancelLimitOrderTestParams) (tranche *dextypes.LimitOrderTranche) { + coinA := sdk.NewCoin(params.PairID.Token0, BaseTokenAmountInt) + coinB := sdk.NewCoin(params.PairID.Token1, BaseTokenAmountInt.MulRaw(10)) + s.FundAcc(s.creator, sdk.NewCoins(coinA)) + var expTime *time.Time + if params.OrderType.IsGoodTil() { + t := time.Now() + expTime = &t + } + res := s.makePlaceLOSuccess(s.creator, coinA, coinB.Denom, DefaultSellPrice, params.OrderType, expTime) + + totalDeposited := BaseTokenAmountInt + if params.ExistingTokenAHolders == OneOtherAndCreatorLO { + totalDeposited = totalDeposited.MulRaw(2) + s.FundAcc(s.alice, sdk.NewCoins(coinA)) + s.makePlaceLOSuccess(s.alice, coinA, coinB.Denom, DefaultSellPrice, params.OrderType, expTime) + } + + if params.Filled > 0 { + s.FundAcc(s.bob, sdk.NewCoins(coinB)) + fillAmount := totalDeposited.MulRaw(params.Filled).QuoRaw(100) + _, err := s.makePlaceTakerLO(s.bob, coinB, coinA.Denom, DefaultBuyPriceTaker, dextypes.LimitOrderType_IMMEDIATE_OR_CANCEL, &fillAmount) + s.NoError(err) + } + + if params.WithdrawnCreator { + s.makeWithdrawFilledSuccess(s.creator, res.TrancheKey) + } + + if params.WithdrawnOneOther { + s.makeWithdrawFilledSuccess(s.alice, res.TrancheKey) + } + + if params.Expired { + s.App.DexKeeper.PurgeExpiredLimitOrders(s.Ctx, time.Now()) + } + + req := dextypes.QueryGetLimitOrderTrancheRequest{ + PairId: params.PairID.CanonicalString(), + TickIndex: params.Tick, + TokenIn: params.PairID.Token0, + TrancheKey: res.TrancheKey, + } + tranchResp, err := s.App.DexKeeper.LimitOrderTranche(s.Ctx, &req) + s.NoError(err) + + return tranchResp.LimitOrderTranche +} + +func hydrateAllCancelLoTestCases(paramsList []map[string]string) []cancelLimitOrderTestParams { + allTCs := make([]cancelLimitOrderTestParams, 0) + for i, paramsRaw := range paramsList { + tc := hydrateCancelLoTestCase(paramsRaw) + + pairID := generatePairID(i) + tc.PairID = pairID + + allTCs = append(allTCs, tc) + } + + return removeRedundantCancelLOTests(allTCs) +} + +func removeRedundantCancelLOTests(params []cancelLimitOrderTestParams) []cancelLimitOrderTestParams { + newParams := make([]cancelLimitOrderTestParams, 0) + for _, p := range params { + // it's impossible to withdraw 0 filled + // error checks is not in a scope of the testcase (see withdraw filled test) + if p.Filled == 0 && (p.WithdrawnOneOther || p.WithdrawnCreator) { + continue + } + if p.Expired && p.OrderType.IsGTC() { + continue + } + if p.WithdrawnOneOther && p.ExistingTokenAHolders == CreatorLO { + continue + } + if p.ExistingTokenAHolders == OneOtherAndCreatorLO && !p.OrderType.IsGTC() { + // user tranches combined into tranches only for LimitOrderType_GOOD_TIL_CANCELLED + // it does not make any sense to create two tranches + continue + } + newParams = append(newParams, p) + } + return newParams +} + +func (s *DexStateTestSuite) handleCancelErrors(params cancelLimitOrderTestParams, err error) { + if params.Filled == 100 && params.WithdrawnCreator { + if errors.Is(dextypes.ErrValidLimitOrderTrancheNotFound, err) { + s.T().Skip() + } + } + s.NoError(err) +} + +func (s *DexStateTestSuite) assertCalcelAmount(params cancelLimitOrderTestParams) { + depositSize := BaseTokenAmountInt + + // expected balance: InitialBalance - depositSize + pre-withdrawn (filled/2 or 0) + withdrawn (filled/2 or filled) + // pre-withdrawn (filled/2 or 0) + withdrawn (filled/2 or filled) === filled + // converted to TokenB + price := dextypes.MustCalcPrice(params.Tick) + expectedBalanceB := price.MulInt(depositSize.MulRaw(params.Filled).QuoRaw(100)).Ceil().TruncateInt() + expectedBalanceA := depositSize.Sub(depositSize.MulRaw(params.Filled).QuoRaw(100)) + // 1 - withdrawn amount + s.assertBalanceWithPrecision(s.creator, params.PairID.Token1, expectedBalanceB, 3) + + s.assertBalance(s.creator, params.PairID.Token0, expectedBalanceA) +} + +func TestCancel(t *testing.T) { + testParams := []testParams{ + {field: "ExistingTokenAHolders", states: []string{CreatorLO, OneOtherAndCreatorLO}}, + {field: "Filled", states: []string{ZeroPCT, FiftyPCT, HundredPct}}, + {field: "WithdrawnCreator", states: []string{True, False}}, + {field: "WithdrawnOneOther", states: []string{True, False}}, + {field: "OrderType", states: []string{ + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_GOOD_TIL_CANCELLED)], + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_GOOD_TIL_TIME)], + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_JUST_IN_TIME)], + }}, + {field: "Expired", states: []string{True, False}}, + } + testCasesRaw := generatePermutations(testParams) + testCases := hydrateAllCancelLoTestCases(testCasesRaw) + + s := new(DexStateTestSuite) + s.SetT(t) + s.SetupTest() + + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + s.SetT(t) + tc.printTestInfo(t) + + tranche := s.setupCancelTest(tc) + + _, err := s.makeCancel(s.creator, tranche.Key.TrancheKey) + s.handleCancelErrors(tc, err) + _, found := s.App.DexKeeper.GetLimitOrderTrancheUser(s.Ctx, s.creator.String(), tranche.Key.TrancheKey) + s.False(found) + s.assertCalcelAmount(tc) + }) + } +} diff --git a/tests/dex/state_deposit_test.go b/tests/dex/state_deposit_test.go new file mode 100644 index 000000000..2eed60742 --- /dev/null +++ b/tests/dex/state_deposit_test.go @@ -0,0 +1,354 @@ +package dex_state_test + +import ( + "strconv" + "testing" + + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + + math_utils "github.com/neutron-org/neutron/v5/utils/math" + dextypes "github.com/neutron-org/neutron/v5/x/dex/types" +) + +type DepositState struct { + SharedParams + // State Conditions + ExistingShareHolders string + ExistingLiquidityDistribution LiquidityDistribution + PoolValueIncrease LiquidityDistribution +} + +type depositTestParams struct { + DepositState + // Message Variants + DisableAutoswap bool + FailTxOnBEL bool + DepositAmounts LiquidityDistribution +} + +func (p depositTestParams) printTestInfo(t *testing.T) { + t.Logf(` + Existing Shareholders: %s + Existing Liquidity Distribution: %v + Pool Value Increase: %v + Disable Autoswap: %t + Fail Tx on BEL: %t + Deposit Amounts: %v`, + p.ExistingShareHolders, + p.ExistingLiquidityDistribution, + p.PoolValueIncrease, + p.DisableAutoswap, + p.FailTxOnBEL, + p.DepositAmounts, + ) +} + +func (s *DexStateTestSuite) setupDepositState(params DepositState) { + // NOTE: for setup we know the deposit will be completely used so we fund the accounts before the deposit + // so the expected account balance is unaffected. + liquidityDistr := params.ExistingLiquidityDistribution + + switch params.ExistingShareHolders { + case None: + break + case Creator: + coins := sdk.NewCoins(liquidityDistr.TokenA, liquidityDistr.TokenB) + s.FundAcc(s.creator, coins) + + s.makeDepositSuccess(s.creator, liquidityDistr, false) + case OneOther: + coins := sdk.NewCoins(liquidityDistr.TokenA, liquidityDistr.TokenB) + s.FundAcc(s.alice, coins) + + s.makeDepositSuccess(s.alice, liquidityDistr, false) + case OneOtherAndCreator: + splitLiqDistrArr := splitLiquidityDistribution(liquidityDistr, 2) + + coins := sdk.NewCoins(splitLiqDistrArr.TokenA, splitLiqDistrArr.TokenB) + s.FundAcc(s.creator, coins) + + coins = sdk.NewCoins(splitLiqDistrArr.TokenA, splitLiqDistrArr.TokenB) + s.FundAcc(s.alice, coins) + + s.makeDepositSuccess(s.creator, splitLiqDistrArr, false) + s.makeDepositSuccess(s.alice, splitLiqDistrArr, false) + } + + // handle pool value increase + + if !params.PoolValueIncrease.empty() { + // Increase the value of the pool. This is analogous to a pool being swapped through + pool, found := s.App.DexKeeper.GetPool(s.Ctx, params.PairID, params.Tick, params.Fee) + s.True(found, "Pool not found") + + pool.LowerTick0.ReservesMakerDenom = pool.LowerTick0.ReservesMakerDenom.Add(params.PoolValueIncrease.TokenA.Amount) + pool.UpperTick1.ReservesMakerDenom = pool.UpperTick1.ReservesMakerDenom.Add(params.PoolValueIncrease.TokenB.Amount) + s.App.DexKeeper.UpdatePool(s.Ctx, pool) + + // Add fund dex with the additional balance + err := s.App.BankKeeper.MintCoins(s.Ctx, dextypes.ModuleName, sdk.NewCoins(params.PoolValueIncrease.TokenA, params.PoolValueIncrease.TokenB)) + s.NoError(err) + } +} + +func CalcTotalPreDepositLiquidity(params depositTestParams) LiquidityDistribution { + return LiquidityDistribution{ + TokenA: params.ExistingLiquidityDistribution.TokenA.Add(params.PoolValueIncrease.TokenA), + TokenB: params.ExistingLiquidityDistribution.TokenB.Add(params.PoolValueIncrease.TokenB), + } +} + +func CalcDepositAmountNoAutoswap(params depositTestParams) (resultAmountA, resultAmountB math.Int) { + depositA := params.DepositAmounts.TokenA.Amount + depositB := params.DepositAmounts.TokenB.Amount + + existingLiquidity := CalcTotalPreDepositLiquidity(params) + existingA := existingLiquidity.TokenA.Amount + existingB := existingLiquidity.TokenB.Amount + + switch { + // Pool is empty can deposit full amounts + case existingA.IsZero() && existingB.IsZero(): + return depositA, depositB + // Pool only has TokenB, can deposit all of depositB + case existingA.IsZero(): + return math.ZeroInt(), depositB + // Pool only has TokenA, can deposit all of depositA + case existingB.IsZero(): + return depositA, math.ZeroInt() + // Pool has a ratio of A and B, deposit must match this ratio + case existingA.IsPositive() && existingB.IsPositive(): + maxAmountA := math.LegacyNewDecFromInt(depositB).MulInt(existingA).QuoInt(existingB).TruncateInt() + resultAmountA = math.MinInt(depositA, maxAmountA) + maxAmountB := math.LegacyNewDecFromInt(depositA).MulInt(existingB).QuoInt(existingA).TruncateInt() + resultAmountB = math.MinInt(depositB, maxAmountB) + + return resultAmountA, resultAmountB + default: + panic("unhandled deposit calc case") + } +} + +func calcCurrentShareValue(params depositTestParams, existingValue math_utils.PrecDec) math_utils.PrecDec { + initialValueA := params.ExistingLiquidityDistribution.TokenA.Amount + initialValueB := params.ExistingLiquidityDistribution.TokenB.Amount + + existingShares := calcDepositValueAsToken0(params.Tick, initialValueA, initialValueB).TruncateInt() + if existingShares.IsZero() { + return math_utils.OnePrecDec() + } + + currentShareValue := math_utils.NewPrecDecFromInt(existingShares).Quo(existingValue) + + return currentShareValue +} + +func calcAutoswapAmount(params depositTestParams) (swapAmountA, swapAmountB math.Int) { + existingLiquidity := CalcTotalPreDepositLiquidity(params) + existingA := existingLiquidity.TokenA.Amount + existingB := existingLiquidity.TokenB.Amount + depositAmountA := params.DepositAmounts.TokenA.Amount + depositAmountB := params.DepositAmounts.TokenB.Amount + price1To0 := dextypes.MustCalcPrice(-params.Tick) + if existingA.IsZero() && existingB.IsZero() { + return math.ZeroInt(), math.ZeroInt() + } + + existingADec := math_utils.NewPrecDecFromInt(existingA) + existingBDec := math_utils.NewPrecDecFromInt(existingB) + // swapAmount = (reserves0*depositAmount1 - reserves1*depositAmount0) / (price * reserves1 + reserves0) + swapAmount := existingADec.MulInt(depositAmountB).Sub(existingBDec.MulInt(depositAmountA)). + Quo(existingADec.Add(existingBDec.Quo(price1To0))) + + switch { + case swapAmount.IsZero(): // nothing to be swapped + return math.ZeroInt(), math.ZeroInt() + + case swapAmount.IsPositive(): // Token1 needs to be swapped + return math.ZeroInt(), swapAmount.Ceil().TruncateInt() + + default: // Token0 needs to be swapped + amountSwappedAs1 := swapAmount.Neg() + + amountSwapped0 := amountSwappedAs1.Quo(price1To0) + return amountSwapped0.Ceil().TruncateInt(), math.ZeroInt() + } +} + +func calcExpectedDepositAmounts(params depositTestParams) (tokenAAmount, tokenBAmount, sharesIssued math.Int) { + var depositValueAsToken0 math_utils.PrecDec + var inAmountA math.Int + var inAmountB math.Int + + existingLiquidity := CalcTotalPreDepositLiquidity(params) + existingA := existingLiquidity.TokenA.Amount + existingB := existingLiquidity.TokenB.Amount + existingValueAsToken0 := calcDepositValueAsToken0(params.Tick, existingA, existingB) + + if params.DisableAutoswap { + inAmountA, inAmountB = CalcDepositAmountNoAutoswap(params) + depositValueAsToken0 = calcDepositValueAsToken0(params.Tick, inAmountA, inAmountB) + + shareValue := calcCurrentShareValue(params, existingValueAsToken0) + sharesIssued = depositValueAsToken0.Mul(shareValue).TruncateInt() + + return inAmountA, inAmountB, sharesIssued + } // else + autoSwapAmountA, autoswapAmountB := calcAutoswapAmount(params) + autoswapValueAsToken0 := calcDepositValueAsToken0(params.Tick, autoSwapAmountA, autoswapAmountB) + + autoswapFeeAsPrice := dextypes.MustCalcPrice(-int64(params.Fee)) + autoswapFeePct := math_utils.OnePrecDec().Sub(autoswapFeeAsPrice) + autoswapFee := autoswapValueAsToken0.Mul(autoswapFeePct) + + inAmountA = params.DepositAmounts.TokenA.Amount + inAmountB = params.DepositAmounts.TokenB.Amount + + fullDepositValueAsToken0 := calcDepositValueAsToken0(params.Tick, inAmountA, inAmountB) + depositAmountMinusFee := fullDepositValueAsToken0.Sub(autoswapFee) + currentValueWithAutoswapFee := existingValueAsToken0.Add(autoswapFee) + shareValue := calcCurrentShareValue(params, currentValueWithAutoswapFee) + + sharesIssued = depositAmountMinusFee.Mul(shareValue).TruncateInt() + + return inAmountA, inAmountB, sharesIssued +} + +func (s *DexStateTestSuite) handleBaseFailureCases(params depositTestParams, err error) { + currentLiquidity := CalcTotalPreDepositLiquidity(params) + // cannot deposit single sided liquidity into a non-empty pool if you are missing one of the tokens in the pool + if !currentLiquidity.empty() && params.DisableAutoswap { + if (!params.DepositAmounts.hasTokenA() && currentLiquidity.hasTokenA()) || (!params.DepositAmounts.hasTokenB() && currentLiquidity.hasTokenB()) { + s.ErrorIs(err, dextypes.ErrZeroTrueDeposit) + s.T().Skip("Ending test due to expected error") + } + } + + s.NoError(err) +} + +func hydrateDepositTestCase(params map[string]string, pairID *dextypes.PairID) depositTestParams { + existingShareHolders := params["ExistingShareHolders"] + var liquidityDistribution LiquidityDistribution + + if existingShareHolders == None { + liquidityDistribution = parseLiquidityDistribution(TokenA0TokenB0, pairID) + } else { + liquidityDistribution = parseLiquidityDistribution(params["LiquidityDistribution"], pairID) + } + + var valueIncrease LiquidityDistribution + if liquidityDistribution.empty() { + // Cannot increase value on empty pool + valueIncrease = parseLiquidityDistribution(TokenA0TokenB0, pairID) + } else { + valueIncrease = parseLiquidityDistribution(params["PoolValueIncrease"], pairID) + } + + return depositTestParams{ + DepositState: DepositState{ + ExistingShareHolders: existingShareHolders, + ExistingLiquidityDistribution: liquidityDistribution, + PoolValueIncrease: valueIncrease, + SharedParams: DefaultSharedParams, + }, + DepositAmounts: parseLiquidityDistribution(params["DepositAmounts"], pairID), + DisableAutoswap: parseBool(params["DisableAutoswap"]), + } +} + +func hydrateAllDepositTestCases(paramsList []map[string]string) []depositTestParams { + allTCs := make([]depositTestParams, 0) + for i, paramsRaw := range paramsList { + pairID := generatePairID(i) + tc := hydrateDepositTestCase(paramsRaw, pairID) + tc.PairID = pairID + allTCs = append(allTCs, tc) + } + + // De-dupe test cases hydration creates some duplicates + return removeDuplicateTests(allTCs) +} + +func TestDeposit(t *testing.T) { + testParams := []testParams{ + {field: "ExistingShareHolders", states: []string{None, Creator, OneOther}}, + {field: "LiquidityDistribution", states: []string{ + TokenA0TokenB1, + TokenA0TokenB2, + TokenA1TokenB0, + TokenA1TokenB1, + TokenA1TokenB2, + TokenA2TokenB0, + TokenA2TokenB1, + TokenA2TokenB2, + }}, + {field: "DisableAutoswap", states: []string{True, False}}, + {field: "PoolValueIncrease", states: []string{TokenA0TokenB0, TokenA1TokenB0, TokenA0TokenB1}}, + {field: "DepositAmounts", states: []string{ + TokenA0TokenB1, + TokenA0TokenB2, + TokenA1TokenB1, + TokenA1TokenB2, + TokenA2TokenB2, + }}, + // TODO: test over a list of Fees/Ticks + } + testCasesRaw := generatePermutations(testParams) + testCases := hydrateAllDepositTestCases(testCasesRaw) + + s := new(DexStateTestSuite) + s.SetT(t) + s.SetupTest() + + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + s.SetT(t) + tc.printTestInfo(t) + + s.setupDepositState(tc.DepositState) + s.fundCreatorBalanceDefault(tc.PairID) + + poolID, found := s.App.DexKeeper.GetPoolIDByParams(s.Ctx, tc.PairID, tc.Tick, tc.Fee) + if tc.ExistingShareHolders == None { + // There is no pool yet. This is the ID that will be used when the pool is created + poolID = s.App.DexKeeper.GetPoolCount(s.Ctx) + } else { + require.True(t, found, "Pool not found after deposit") + } + poolDenom := dextypes.NewPoolDenom(poolID) + existingSharesOwned := s.App.BankKeeper.GetBalance(s.Ctx, s.creator, poolDenom) + + // Do the actual deposit + resp, err := s.makeDepositDefault(s.creator, tc.DepositAmounts, tc.DisableAutoswap) + + // Assert that if there is an error it is expected + s.handleBaseFailureCases(tc, err) + + expectedDepositA, expectedDepositB, expectedShares := calcExpectedDepositAmounts(tc) + + // Check that response is correct + s.intsApproxEqual("Response Deposit0", expectedDepositA, resp.Reserve0Deposited[0], 1) + s.intsApproxEqual("Response Deposit1", expectedDepositB, resp.Reserve1Deposited[0], 1) + + expectedTotalShares := existingSharesOwned.Amount.Add(expectedShares) + s.assertCreatorBalance(poolDenom, expectedTotalShares) + + // Assert Creator Balance is correct + expectedBalanceA := DefaultStartingBalanceInt.Sub(expectedDepositA) + expectedBalanceB := DefaultStartingBalanceInt.Sub(expectedDepositB) + s.assertCreatorBalance(tc.PairID.Token0, expectedBalanceA) + s.assertCreatorBalance(tc.PairID.Token1, expectedBalanceB) + + // Assert dex state is correct + dexBalanceBeforeDeposit := CalcTotalPreDepositLiquidity(tc) + expectedDexBalanceA := dexBalanceBeforeDeposit.TokenA.Amount.Add(expectedDepositA) + expectedDexBalanceB := dexBalanceBeforeDeposit.TokenB.Amount.Add(expectedDepositB) + s.assertPoolBalance(tc.PairID, tc.Tick, tc.Fee, expectedDexBalanceA, expectedDexBalanceB) + s.assertDexBalance(tc.PairID.Token0, expectedDexBalanceA) + s.assertDexBalance(tc.PairID.Token1, expectedDexBalanceB) + }) + } +} diff --git a/tests/dex/state_place_limit_order_maker_test.go b/tests/dex/state_place_limit_order_maker_test.go new file mode 100644 index 000000000..fa046e83a --- /dev/null +++ b/tests/dex/state_place_limit_order_maker_test.go @@ -0,0 +1,307 @@ +package dex_state_test + +import ( + "strconv" + "testing" + "time" + + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + + math_utils "github.com/neutron-org/neutron/v5/utils/math" + dextypes "github.com/neutron-org/neutron/v5/x/dex/types" +) + +// ExistingTokenAHolders +const ( + NoneLO = "NoneLO" + CreatorLO = "CreatorLO" + OneOtherLO = "OneOtherLO" + OneOtherAndCreatorLO = "OneOtherAndCreatorLO" +) + +// tests autoswap, BehindEnemyLineLessLimit and BehindEnemyLineGreaterLimit only makes sense when ExistingTokenAHolders==NoneLO +// BehindEnemyLine +const ( + BehindEnemyLineNo = "BehindEnemyLinesNo" // no opposite liquidity to trigger swap + BehindEnemyLineLessLimit = "BehindEnemyLinesLessLimit" // not enough opposite liquidity to swap maker lo fully + BehindEnemyLineGreaterLimit = "BehindEnemyLinesGreaterLimit" // enough liquidity to swap make fully +) + +const ( + DefaultSellPrice = "2" + DefaultBuyPriceTaker = "0.4" // 1/(DefaultSellPrice+0.5) immediately trade over DefaultSellPrice maker order +) + +const MakerAmountIn = 1_000_000 + +type placeLimitOrderMakerTestParams struct { + // State Conditions + SharedParams + ExistingLOLiquidityDistribution LiquidityDistribution + ExistingTokenAHolders string + BehindEnemyLine string + PreexistingTraded bool + // Message Variants + OrderType dextypes.LimitOrderType // JIT, GTT, GTC +} + +func (p placeLimitOrderMakerTestParams) printTestInfo(t *testing.T) { + t.Logf(` + Existing ExistingTokenAHolders: %s + BehindEnemyLine: %v + Pre-existing Traded: %v + Order Type: %v`, + p.ExistingTokenAHolders, + p.BehindEnemyLine, + p.PreexistingTraded, + p.OrderType.String(), + ) +} + +func parseLOLiquidityDistribution(existingShareHolders, behindEnemyLine string, pairID *dextypes.PairID) LiquidityDistribution { + tokenA := pairID.Token0 + tokenB := pairID.Token1 + switch { + case existingShareHolders == NoneLO && behindEnemyLine == BehindEnemyLineLessLimit: + // half "taker" deposit. We buy all of it by placing opposite maker order + return LiquidityDistribution{ + TokenA: sdk.NewCoin(tokenA, math.ZeroInt()), + TokenB: sdk.NewCoin(tokenB, BaseTokenAmountInt.QuoRaw(2)), + } + case existingShareHolders == NoneLO && behindEnemyLine == BehindEnemyLineGreaterLimit: + // double "taker" deposit. We spend whole limit to partially consume LO. + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.ZeroInt()), TokenB: sdk.NewCoin(tokenB, math.NewInt(4).Mul(BaseTokenAmountInt))} + default: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.NewInt(1).Mul(BaseTokenAmountInt)), TokenB: sdk.NewCoin(tokenB, math.NewInt(1).Mul(BaseTokenAmountInt))} + } +} + +func removeRedundantPlaceLOMakerTests(tcs []placeLimitOrderMakerTestParams) []placeLimitOrderMakerTestParams { + // here we remove impossible cases such two side LO at the "same" (1/-1, 2/-2) ticks + result := make([]placeLimitOrderMakerTestParams, 0) + for _, tc := range tcs { + if tc.ExistingTokenAHolders != NoneLO && tc.BehindEnemyLine != BehindEnemyLineNo { + continue + } + if tc.PreexistingTraded && tc.ExistingTokenAHolders != OneOtherLO { + // PreexistingTraded only make sense in case `tc.ExistingTokenAHolders == OneOtherLO` + continue + } + result = append(result, tc) + } + return result +} + +func hydratePlaceLOMakerTestCase(params map[string]string, pairID *dextypes.PairID) placeLimitOrderMakerTestParams { + liquidityDistribution := parseLOLiquidityDistribution(params["ExistingTokenAHolders"], params["BehindEnemyLines"], pairID) + return placeLimitOrderMakerTestParams{ + ExistingLOLiquidityDistribution: liquidityDistribution, + ExistingTokenAHolders: params["ExistingTokenAHolders"], + BehindEnemyLine: params["BehindEnemyLines"], + PreexistingTraded: parseBool(params["PreexistingTraded"]), + OrderType: dextypes.LimitOrderType(dextypes.LimitOrderType_value[params["OrderType"]]), + } +} + +func hydrateAllPlaceLOMakerTestCases(paramsList []map[string]string) []placeLimitOrderMakerTestParams { + allTCs := make([]placeLimitOrderMakerTestParams, 0) + for i, paramsRaw := range paramsList { + pairID := generatePairID(i) + tc := hydratePlaceLOMakerTestCase(paramsRaw, pairID) + tc.PairID = pairID + allTCs = append(allTCs, tc) + } + + return removeRedundantPlaceLOMakerTests(allTCs) +} + +func (s *DexStateTestSuite) setupLoState(params placeLimitOrderMakerTestParams) (trancheKey string) { + liquidityDistr := params.ExistingLOLiquidityDistribution + coins := sdk.NewCoins(liquidityDistr.TokenA, liquidityDistr.TokenB) + switch params.BehindEnemyLine { + case BehindEnemyLineNo: + switch params.ExistingTokenAHolders { + case OneOtherLO: + s.FundAcc(s.alice, coins) + res := s.makePlaceLOSuccess(s.alice, params.ExistingLOLiquidityDistribution.TokenA, params.ExistingLOLiquidityDistribution.TokenB.Denom, DefaultSellPrice, dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + trancheKey = res.TrancheKey + + case OneOtherAndCreatorLO: + s.FundAcc(s.alice, coins) + res := s.makePlaceLOSuccess(s.alice, params.ExistingLOLiquidityDistribution.TokenA, params.ExistingLOLiquidityDistribution.TokenB.Denom, DefaultSellPrice, dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + trancheKey = res.TrancheKey + + s.FundAcc(s.creator, coins) + s.makePlaceLOSuccess(s.creator, params.ExistingLOLiquidityDistribution.TokenA, params.ExistingLOLiquidityDistribution.TokenB.Denom, DefaultSellPrice, dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + + case CreatorLO: + s.FundAcc(s.creator, coins) + res := s.makePlaceLOSuccess(s.creator, params.ExistingLOLiquidityDistribution.TokenA, params.ExistingLOLiquidityDistribution.TokenB.Denom, DefaultSellPrice, dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + trancheKey = res.TrancheKey + } + + if params.PreexistingTraded { + s.FundAcc(s.bob, coins) + // bob trades over the tranche + InTokenBAmount := sdk.NewCoin(params.ExistingLOLiquidityDistribution.TokenB.Denom, BaseTokenAmountInt.QuoRaw(2)) + s.makePlaceLOSuccess(s.alice, InTokenBAmount, params.ExistingLOLiquidityDistribution.TokenA.Denom, DefaultBuyPriceTaker, dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + } + case BehindEnemyLineLessLimit: + s.FundAcc(s.alice, coins) + s.makePlaceLOSuccess(s.alice, params.ExistingLOLiquidityDistribution.TokenB, params.ExistingLOLiquidityDistribution.TokenA.Denom, DefaultBuyPriceTaker, dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + case BehindEnemyLineGreaterLimit: + s.FundAcc(s.alice, coins) + s.makePlaceLOSuccess(s.alice, params.ExistingLOLiquidityDistribution.TokenB, params.ExistingLOLiquidityDistribution.TokenA.Denom, DefaultBuyPriceTaker, dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + } + return trancheKey +} + +// assertLiquidity checks the amount of tokens at dex balance exactly equals the amount in all tranches (active + inactive) +// TODO: add AMM pools to check +func (s *DexStateTestSuite) assertLiquidity(id dextypes.PairID) { + TokenAInReserves := math.ZeroInt() + TokenBInReserves := math.ZeroInt() + + // Active tranches A -> B + tranches, err := s.App.DexKeeper.LimitOrderTrancheAll(s.Ctx, &dextypes.QueryAllLimitOrderTrancheRequest{ + PairId: id.CanonicalString(), + TokenIn: id.Token0, + Pagination: nil, + }) + s.Require().NoError(err) + for _, t := range tranches.LimitOrderTranche { + TokenAInReserves = TokenAInReserves.Add(t.ReservesMakerDenom) + TokenBInReserves = TokenBInReserves.Add(t.ReservesTakerDenom) + } + + // Active tranches B -> A + tranches, err = s.App.DexKeeper.LimitOrderTrancheAll(s.Ctx, &dextypes.QueryAllLimitOrderTrancheRequest{ + PairId: id.CanonicalString(), + TokenIn: id.Token1, + Pagination: nil, + }) + s.Require().NoError(err) + for _, t := range tranches.LimitOrderTranche { + TokenAInReserves = TokenAInReserves.Add(t.ReservesTakerDenom) + TokenBInReserves = TokenBInReserves.Add(t.ReservesMakerDenom) + } + + // Inactive tranches (expired or filled) + // TODO: since it's impossible to filter tranches against a specific pair in a request, pagination request may be needed in some cases. Add pagination + inactiveTranches, err := s.App.DexKeeper.InactiveLimitOrderTrancheAll(s.Ctx, &dextypes.QueryAllInactiveLimitOrderTrancheRequest{ + Pagination: nil, + }) + s.Require().NoError(err) + for _, t := range inactiveTranches.InactiveLimitOrderTranche { + // A -> B + if t.Key.TradePairId.MakerDenom == id.Token0 || t.Key.TradePairId.TakerDenom == id.Token1 { + TokenAInReserves = TokenAInReserves.Add(t.ReservesMakerDenom) + TokenBInReserves = TokenBInReserves.Add(t.ReservesTakerDenom) + } + // B -> A + if t.Key.TradePairId.MakerDenom == id.Token1 || t.Key.TradePairId.TakerDenom == id.Token0 { + TokenAInReserves = TokenAInReserves.Add(t.ReservesTakerDenom) + TokenBInReserves = TokenBInReserves.Add(t.ReservesMakerDenom) + } + + } + + s.assertDexBalance(id.Token0, TokenAInReserves) + s.assertDexBalance(id.Token1, TokenBInReserves) +} + +// We assume, if there is a TokenB tranche in dex module, it's always BEL. +func (s *DexStateTestSuite) expectedInOutTokensAmount(tokenA sdk.Coin, denomOut string) (amountOut math.Int) { + pair := dextypes.MustNewPairID(tokenA.Denom, denomOut) + amountOut = math.ZeroInt() + // Active tranches B -> A + tranches, err := s.App.DexKeeper.LimitOrderTrancheAll(s.Ctx, &dextypes.QueryAllLimitOrderTrancheRequest{ + PairId: pair.CanonicalString(), + TokenIn: pair.Token1, + Pagination: nil, + }) + s.Require().NoError(err) + reserveA := tokenA.Amount + + for _, t := range tranches.LimitOrderTranche { + // users tokenA denom = tranche TakerDenom + // t.ReservesMakerDenom - reserve TokenB we are going to get + // t.Price() - price taker -> maker => 1/t.Price() - maker -> taker + // maxSwap - max amount of tokenA (ReservesTakerDenom) tranche can consume us by changing ReservesMakerDenom -> ReservesTakerDenom + maxSwap := math_utils.NewPrecDecFromInt(t.ReservesMakerDenom).Mul(t.Price()).TruncateInt() + // we can swap full our tranche + if maxSwap.GTE(reserveA) { + // expected to get tokenB = tokenA* + amountOut = amountOut.Add(math_utils.NewPrecDecFromInt(reserveA).Quo(t.Price()).TruncateInt()) + break + } + reserveA = reserveA.Sub(maxSwap) + amountOut = amountOut.Add(t.ReservesMakerDenom) + } + return amountOut +} + +func (s *DexStateTestSuite) assertExpectedTrancheKey(initialKey, msgKey string, params placeLimitOrderMakerTestParams) { + // we expect initialKey != msgKey + if params.ExistingTokenAHolders == NoneLO || params.PreexistingTraded || params.OrderType.IsGoodTil() || params.OrderType.IsJIT() { + s.NotEqual(initialKey, msgKey) + return + } + + // otherwise they are equal + s.Equal(initialKey, msgKey) +} + +func TestPlaceLimitOrderMaker(t *testing.T) { + testParams := []testParams{ + {field: "ExistingTokenAHolders", states: []string{NoneLO, CreatorLO, OneOtherLO, OneOtherAndCreatorLO}}, + {field: "BehindEnemyLines", states: []string{BehindEnemyLineNo, BehindEnemyLineLessLimit, BehindEnemyLineGreaterLimit}}, + {field: "PreexistingTraded", states: []string{True, False}}, + {field: "OrderType", states: []string{ + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_GOOD_TIL_CANCELLED)], + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_GOOD_TIL_TIME)], + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_JUST_IN_TIME)], + }}, + } + testCasesRaw := generatePermutations(testParams) + testCases := hydrateAllPlaceLOMakerTestCases(testCasesRaw) + + s := new(DexStateTestSuite) + s.SetT(t) + s.SetupTest() + totalExpectedToSwap := math.ZeroInt() + + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + s.SetT(t) + tc.printTestInfo(t) + + initialTrancheKey := s.setupLoState(tc) + s.fundCreatorBalanceDefault(tc.PairID) + // + + amountIn := sdk.NewCoin(tc.PairID.Token0, math.NewInt(MakerAmountIn)) + var expTime *time.Time + if tc.OrderType.IsGoodTil() { + // any time is valid for tests + t := time.Now() + expTime = &t + } + expectedSwapTakerDenom := s.expectedInOutTokensAmount(amountIn, tc.PairID.Token1) + totalExpectedToSwap = totalExpectedToSwap.Add(expectedSwapTakerDenom) + resp, err := s.makePlaceLO(s.creator, amountIn, tc.PairID.Token1, DefaultSellPrice, tc.OrderType, expTime) + s.Require().NoError(err) + + // 1. generic liquidity check assertion + s.assertLiquidity(*tc.PairID) + // 2. BEL assertion + s.intsApproxEqual("", expectedSwapTakerDenom, resp.TakerCoinOut.Amount, 1) + // 3. TrancheKey assertion + s.assertExpectedTrancheKey(initialTrancheKey, resp.TrancheKey, tc) + }) + } + s.SetT(t) + // sanity check: at least one `expectedSwapTakerDenom` > 0 + s.True(totalExpectedToSwap.GT(math.ZeroInt())) +} diff --git a/tests/dex/state_place_limit_order_taker_test.go b/tests/dex/state_place_limit_order_taker_test.go new file mode 100644 index 000000000..372a42ff8 --- /dev/null +++ b/tests/dex/state_place_limit_order_taker_test.go @@ -0,0 +1,293 @@ +package dex_state_test + +import ( + "errors" + "strconv" + "strings" + "testing" + + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + + math_utils "github.com/neutron-org/neutron/v5/utils/math" + dextypes "github.com/neutron-org/neutron/v5/x/dex/types" + "github.com/neutron-org/neutron/v5/x/dex/utils" +) + +// LiquidityType +const ( + LO = "LO" + LP = "LP" + LOLP = "LOLP" +) + +// LimitPrice +const ( + LOWSELLPRICE = "LOWSELLPRICE" + AVGSELLPRICE = "AVGSELLPRICE" + HIGHSELLPRICE = "HIGHSELLPRICE" +) + +var ( + DefaultPriceDelta = math_utils.NewPrecDecWithPrec(1, 1) // 0.1 + DefaultStartPrice = math_utils.NewPrecDecWithPrec(2, 0) // 2.0 +) + +type placeLimitOrderTakerTestParams struct { + PairID *dextypes.PairID + + // State Conditions + LiquidityType string + TicksDistribution []int64 + + // Message Variants + OrderType dextypes.LimitOrderType // FillOrKill or ImmediateOrCancel + AmountIn sdk.Coin + LimitPrice math_utils.PrecDec + MaxAmountOut *math.Int +} + +func (p placeLimitOrderTakerTestParams) printTestInfo(t *testing.T) { + t.Logf(` + LiquidityType: %s + TicksDistribution: %v + OrderType: %v + AmountIn: %v + LimitPrice: %v, + MaxAmountOut: %v`, + p.LiquidityType, + p.TicksDistribution, + p.OrderType.String(), + p.AmountIn, + p.LimitPrice, + p.MaxAmountOut, + ) +} + +func hydratePlaceLOTakerTestCase(params map[string]string, pairID *dextypes.PairID) placeLimitOrderTakerTestParams { + ticks, err := strconv.Atoi(params["TicksDistribution"]) + if err != nil { + panic(err) + } + amountInShare, err := strconv.Atoi(params["AmountIn"]) + if err != nil { + panic(err) + } + // average sell price is defined by loop over the ticks in `setupLoTakerState` + // and ~ ((2+0.1*(ticksAmount-1))+2)/2 = 2+0.05*(ticksAmount-1) + // to buy 100% we want to put ~ BaseTokenAmountInt*(2+0.05*(ticksAmount-1)) as amountIn + avgPrice := DefaultStartPrice.Add( + DefaultPriceDelta.QuoInt64(2).MulInt64(int64(ticks - 1)), + ) + amountIn := avgPrice.MulInt(BaseTokenAmountInt).MulInt64(int64(amountInShare)).QuoInt64(100).TruncateInt() + + maxOutShare, err := strconv.Atoi(params["MaxAmountOut"]) + if err != nil { + panic(err) + } + + var maxAmountOut *math.Int + if maxOutShare > 0 { + maxAmountOut = &math.Int{} + *maxAmountOut = BaseTokenAmountInt.MulRaw(int64(maxOutShare)).QuoRaw(100) + } + + LimitPrice := DefaultStartPrice // LOWSELLPRICE + switch params["LimitPrice"] { + case AVGSELLPRICE: + LimitPrice = avgPrice + case HIGHSELLPRICE: + // 2 * max price + LimitPrice = DefaultStartPrice.Add(DefaultPriceDelta.MulInt64(int64(ticks)).MulInt64Mut(2)) + } + return placeLimitOrderTakerTestParams{ + LiquidityType: params["LiquidityType"], + TicksDistribution: generateTicks(ticks), + OrderType: dextypes.LimitOrderType(dextypes.LimitOrderType_value[params["OrderType"]]), + AmountIn: sdk.NewCoin(pairID.Token1, amountIn), + MaxAmountOut: maxAmountOut, + LimitPrice: math_utils.OnePrecDec().Quo(LimitPrice.Add(DefaultPriceDelta)), + } +} + +func hydrateAllPlaceLOTakerTestCases(paramsList []map[string]string) []placeLimitOrderTakerTestParams { + allTCs := make([]placeLimitOrderTakerTestParams, 0) + for i, paramsRaw := range paramsList { + pairID := generatePairID(i) + tc := hydratePlaceLOTakerTestCase(paramsRaw, pairID) + tc.PairID = pairID + allTCs = append(allTCs, tc) + } + + return allTCs +} + +func generateTicks(ticksAmount int) []int64 { + ticks := make([]int64, 0, ticksAmount) + for i := 0; i < ticksAmount; i++ { + tick, err := dextypes.CalcTickIndexFromPrice(DefaultStartPrice.Add(DefaultPriceDelta.MulInt64(int64(i)))) + if err != nil { + panic(err) + } + ticks = append(ticks, tick) + } + return ticks +} + +func (s *DexStateTestSuite) setupLoTakerState(params placeLimitOrderTakerTestParams) { + if params.LiquidityType == None { + return + } + coins := sdk.NewCoins(sdk.NewCoin(params.PairID.Token0, BaseTokenAmountInt), sdk.NewCoin(params.PairID.Token1, BaseTokenAmountInt)) + s.FundAcc(s.alice, coins) + // BaseTokenAmountInt is full liquidity + tickLiquidity := BaseTokenAmountInt.QuoRaw(int64(len(params.TicksDistribution))) + if params.LiquidityType == LOLP { + tickLiquidity = tickLiquidity.QuoRaw(2) + } + for _, tick := range params.TicksDistribution { + // hit both if LOLP + if strings.Contains(params.LiquidityType, LO) { + price := dextypes.MustCalcPrice(tick) + amountIn := sdk.NewCoin(params.PairID.Token0, tickLiquidity) + s.makePlaceLOSuccess(s.alice, amountIn, params.PairID.Token1, price.String(), dextypes.LimitOrderType_GOOD_TIL_CANCELLED, nil) + } + if strings.Contains(params.LiquidityType, LP) { + liduidity := LiquidityDistribution{ + TokenA: sdk.NewCoin(params.PairID.Token0, tickLiquidity), + TokenB: sdk.NewCoin(params.PairID.Token1, math.ZeroInt()), + } + // tick+DefaultFee to put liquidity the same tick as LO + _, err := s.makeDeposit(s.alice, liduidity, DefaultFee, -tick+DefaultFee, true) + s.NoError(err) + } + } +} + +func ExpectedInOut(params placeLimitOrderTakerTestParams) (math.Int, math.Int) { + if params.LiquidityType == None { + return math.ZeroInt(), math.ZeroInt() + } + limitSellTick, err := dextypes.CalcTickIndexFromPrice(params.LimitPrice) + if err != nil { + panic(err) + } + + limitBuyTick := limitSellTick * -1 + + tickLiquidity := BaseTokenAmountInt.QuoRaw(int64(len(params.TicksDistribution))) + TotalIn := math.ZeroInt() + TotalOut := math.ZeroInt() + for _, tick := range params.TicksDistribution { + + if limitBuyTick < tick { + break + } + price := dextypes.MustCalcPrice(tick) + remainingIn := params.AmountIn.Amount.Sub(TotalIn) + + if !remainingIn.IsPositive() { + break + } + + availableLiquidity := tickLiquidity + outGivenIn := math_utils.NewPrecDecFromInt(remainingIn).Quo(price).TruncateInt() + var amountOut math.Int + var amountIn math.Int + if params.MaxAmountOut != nil { + maxAmountOut := params.MaxAmountOut.Sub(TotalOut) + if !maxAmountOut.IsPositive() { + break + } + amountOut = utils.MinIntArr([]math.Int{availableLiquidity, outGivenIn, maxAmountOut}) + } else { + amountOut = utils.MinIntArr([]math.Int{availableLiquidity, outGivenIn}) + } + + amountInRaw := price.MulInt(amountOut) + + if params.LiquidityType == LOLP { + // Simulate rounding on two different tickLiquidities + amountIn = amountInRaw.QuoInt(math.NewInt(2)).Ceil().TruncateInt().MulRaw(2) + } else { + amountIn = amountInRaw.Ceil().TruncateInt() + } + + TotalIn = TotalIn.Add(amountIn) + TotalOut = TotalOut.Add(amountOut) + } + return TotalIn, TotalOut +} + +func (s *DexStateTestSuite) handleTakerErrors(params placeLimitOrderTakerTestParams, err error) { + if params.OrderType.IsFoK() { + maxIn, _ := ExpectedInOut(params) + if maxIn.LT(params.AmountIn.Amount) { + if errors.Is(err, dextypes.ErrFoKLimitOrderNotFilled) { + s.T().Skip() + } + } + } + s.NoError(err) +} + +func TestPlaceLimitOrderTaker(t *testing.T) { + testParams := []testParams{ + // state + {field: "LiquidityType", states: []string{LO, LP, LOLP}}, + {field: "TicksDistribution", states: []string{"1", "2", "10"}}, // these are not the ticks but the amount of ticks we want to distribute liquidity over + {field: "OrderType", states: []string{ + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_FILL_OR_KILL)], + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_IMMEDIATE_OR_CANCEL)], + }}, + // msg + {field: "AmountIn", states: []string{FiftyPCT, TwoHundredPct}}, + {field: "MaxAmountOut", states: []string{ZeroPCT, FiftyPCT, HundredPct, TwoHundredPct}}, + {field: "LimitPrice", states: []string{LOWSELLPRICE, AVGSELLPRICE, HIGHSELLPRICE}}, + } + testCasesRaw := generatePermutations(testParams) + testCases := hydrateAllPlaceLOTakerTestCases(testCasesRaw) + + s := new(DexStateTestSuite) + s.SetT(t) + s.SetupTest() + + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + s.SetT(t) + tc.printTestInfo(t) + + s.setupLoTakerState(tc) + s.fundCreatorBalanceDefault(tc.PairID) + // + + resp, err := s.makePlaceTakerLO(s.creator, tc.AmountIn, tc.PairID.Token0, tc.LimitPrice.String(), tc.OrderType, tc.MaxAmountOut) + + s.handleTakerErrors(tc, err) + + expIn, expOut := ExpectedInOut(tc) + // TODO: fix rounding issues + s.intsApproxEqual("swapAmountIn", expIn, resp.CoinIn.Amount, 1) + s.intsApproxEqual("swapAmountOut", expOut, resp.TakerCoinOut.Amount, 0) + + s.True( + tc.LimitPrice.MulInt(resp.CoinIn.Amount).TruncateInt().LTE(resp.TakerCoinOut.Amount), + ) + + if tc.MaxAmountOut != nil { + s.True(resp.TakerCoinOut.Amount.LTE(*tc.MaxAmountOut)) + } + + if tc.OrderType.IsFoK() { + // we should fill either AmountIn or MaxAmountOut + s.Condition(func() bool { + if tc.MaxAmountOut != nil { + return resp.TakerCoinOut.Amount.Sub(*tc.MaxAmountOut).Abs().LTE(math.NewInt(1)) || resp.CoinIn.Amount.Sub(tc.AmountIn.Amount).Abs().LTE(math.NewInt(1)) + } + return resp.CoinIn.Amount.Sub(tc.AmountIn.Amount).Abs().LTE(math.NewInt(1)) + }) + } + }) + } +} diff --git a/tests/dex/state_setup_test.go b/tests/dex/state_setup_test.go new file mode 100644 index 000000000..ca878601b --- /dev/null +++ b/tests/dex/state_setup_test.go @@ -0,0 +1,464 @@ +package dex_state_test + +import ( + "fmt" + "strconv" + "time" + + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/query" + "github.com/cosmos/cosmos-sdk/x/bank/types" + + "github.com/neutron-org/neutron/v5/testutil/apptesting" + "github.com/neutron-org/neutron/v5/testutil/common/sample" + math_utils "github.com/neutron-org/neutron/v5/utils/math" + dexkeeper "github.com/neutron-org/neutron/v5/x/dex/keeper" + dextypes "github.com/neutron-org/neutron/v5/x/dex/types" +) + +// Constants ////////////////////////////////////////////////////////////////// + +// Bools +const ( + True = "True" + False = "False" +) + +// Percents +const ( + ZeroPCT = "0" + FiftyPCT = "50" + HundredPct = "100" + TwoHundredPct = "200" +) + +// ExistingShareHolders +const ( + None = "None" + Creator = "Creator" + OneOther = "OneOther" + OneOtherAndCreator = "OneOtherAndCreator" +) + +// LiquidityDistribution +// +//nolint:gosec +const ( + TokenA0TokenB0 = "TokenA0TokenB0" + TokenA0TokenB1 = "TokenA0TokenB1" + TokenA0TokenB2 = "TokenA0TokenB2" + TokenA1TokenB0 = "TokenA1TokenB0" + TokenA1TokenB1 = "TokenA1TokenB1" + TokenA1TokenB2 = "TokenA1TokenB2" + TokenA2TokenB0 = "TokenA2TokenB0" + TokenA2TokenB1 = "TokenA2TokenB1" + TokenA2TokenB2 = "TokenA2TokenB2" +) + +// Default Values +const ( + BaseTokenAmount = 1_000_000 + DefaultTick = 6932 // 1.0001^6932 ~ 2.00003 + DefaultFee = 200 + DefaultStartingBalance = 10_000_000 +) + +var ( + BaseTokenAmountInt = math.NewInt(BaseTokenAmount) + DefaultStartingBalanceInt = math.NewInt(DefaultStartingBalance) +) + +type Balances struct { + Dex sdk.Coins + Creator sdk.Coins + Alice sdk.Coins + Total sdk.Coins +} + +type BalanceDelta struct { + Dex math.Int + Creator math.Int + Alice math.Int + Total math.Int +} + +func BalancesDelta(b1, b2 Balances, denom string) BalanceDelta { + return BalanceDelta{ + Dex: b1.Dex.AmountOf(denom).Sub(b2.Dex.AmountOf(denom)), + Creator: b1.Creator.AmountOf(denom).Sub(b2.Creator.AmountOf(denom)), + Alice: b1.Alice.AmountOf(denom).Sub(b2.Alice.AmountOf(denom)), + Total: b1.Total.AmountOf(denom).Sub(b2.Total.AmountOf(denom)), + } +} + +type SharedParams struct { + Tick int64 + Fee uint64 + PairID *dextypes.PairID + TestName string +} + +var DefaultSharedParams = SharedParams{ + Tick: DefaultTick, + Fee: DefaultFee, +} + +// Types ////////////////////////////////////////////////////////////////////// + +type LiquidityDistribution struct { + TokenA sdk.Coin + TokenB sdk.Coin +} + +//nolint:unused +func (l LiquidityDistribution) doubleSided() bool { + return l.TokenA.Amount.IsPositive() && l.TokenB.Amount.IsPositive() +} + +func (l LiquidityDistribution) empty() bool { + return l.TokenA.Amount.IsZero() && l.TokenB.Amount.IsZero() +} + +//nolint:unused +func (l LiquidityDistribution) singleSided() bool { + return !l.doubleSided() && !l.empty() +} + +func (l LiquidityDistribution) hasTokenA() bool { + return l.TokenA.Amount.IsPositive() +} + +func (l LiquidityDistribution) hasTokenB() bool { + return l.TokenB.Amount.IsPositive() +} + +func splitLiquidityDistribution(liquidityDistribution LiquidityDistribution, n int64) LiquidityDistribution { + nInt := math.NewInt(n) + amount0 := liquidityDistribution.TokenA.Amount.Quo(nInt) + amount1 := liquidityDistribution.TokenB.Amount.Quo(nInt) + + return LiquidityDistribution{ + TokenA: sdk.NewCoin(liquidityDistribution.TokenA.Denom, amount0), + TokenB: sdk.NewCoin(liquidityDistribution.TokenB.Denom, amount1), + } +} + +// State Parsers ////////////////////////////////////////////////////////////// + +func parseInt(v string) int { + i, err := strconv.Atoi(v) + if err != nil { + panic(err) + } + return i +} + +func parseBool(b string) bool { + switch b { + case True: + return true + case False: + return false + default: + panic("invalid bool") + + } +} + +func parseLiquidityDistribution(liquidityDistribution string, pairID *dextypes.PairID) LiquidityDistribution { + tokenA := pairID.Token0 + tokenB := pairID.Token1 + switch liquidityDistribution { + case TokenA0TokenB0: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.ZeroInt()), TokenB: sdk.NewCoin(tokenB, math.ZeroInt())} + case TokenA0TokenB1: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.ZeroInt()), TokenB: sdk.NewCoin(tokenB, math.NewInt(1).Mul(BaseTokenAmountInt))} + case TokenA0TokenB2: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.ZeroInt()), TokenB: sdk.NewCoin(tokenB, math.NewInt(2).Mul(BaseTokenAmountInt))} + case TokenA1TokenB0: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.NewInt(1).Mul(BaseTokenAmountInt)), TokenB: sdk.NewCoin(tokenB, math.ZeroInt())} + case TokenA1TokenB1: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.NewInt(1).Mul(BaseTokenAmountInt)), TokenB: sdk.NewCoin(tokenB, math.NewInt(1).Mul(BaseTokenAmountInt))} + case TokenA1TokenB2: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.NewInt(1).Mul(BaseTokenAmountInt)), TokenB: sdk.NewCoin(tokenB, math.NewInt(2).Mul(BaseTokenAmountInt))} + case TokenA2TokenB0: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.NewInt(2).Mul(BaseTokenAmountInt)), TokenB: sdk.NewCoin(tokenB, math.ZeroInt())} + case TokenA2TokenB1: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.NewInt(2).Mul(BaseTokenAmountInt)), TokenB: sdk.NewCoin(tokenB, math.NewInt(1).Mul(BaseTokenAmountInt))} + case TokenA2TokenB2: + return LiquidityDistribution{TokenA: sdk.NewCoin(tokenA, math.NewInt(2).Mul(BaseTokenAmountInt)), TokenB: sdk.NewCoin(tokenB, math.NewInt(2).Mul(BaseTokenAmountInt))} + default: + panic("invalid liquidity distribution") + } +} + +// Misc. Helpers ////////////////////////////////////////////////////////////// +func (s *DexStateTestSuite) GetBalances() Balances { + var snap Balances + snap.Creator = s.App.BankKeeper.GetAllBalances(s.Ctx, s.creator) + snap.Alice = s.App.BankKeeper.GetAllBalances(s.Ctx, s.alice) + snap.Dex = s.App.BankKeeper.GetAllBalances(s.Ctx, s.App.AccountKeeper.GetModuleAddress("dex")) + resp, err := s.App.BankKeeper.TotalSupply(s.Ctx, &types.QueryTotalSupplyRequest{}) + if err != nil { + panic(err) + } + snap.Total = resp.Supply + var key []byte + if resp.Pagination != nil { + key = resp.Pagination.NextKey + } + for key != nil { + resp, err = s.App.BankKeeper.TotalSupply(s.Ctx, &types.QueryTotalSupplyRequest{ + Pagination: &query.PageRequest{ + Key: key, + Offset: 0, + Limit: 0, + CountTotal: false, + Reverse: false, + }, + }) + if err != nil { + panic(err) + } + snap.Total = snap.Total.Add(resp.Supply...) + if resp.Pagination != nil { + key = resp.Pagination.NextKey + } + } + + return snap +} + +func (s *DexStateTestSuite) makeDepositDefault(addr sdk.AccAddress, depositAmts LiquidityDistribution, disableAutoSwap bool) (*dextypes.MsgDepositResponse, error) { + return s.makeDeposit(addr, depositAmts, DefaultFee, DefaultTick, disableAutoSwap) +} + +func (s *DexStateTestSuite) makeDeposit(addr sdk.AccAddress, depositAmts LiquidityDistribution, fee uint64, tick int64, disableAutoSwap bool) (*dextypes.MsgDepositResponse, error) { + return s.msgServer.Deposit(s.Ctx, &dextypes.MsgDeposit{ + Creator: addr.String(), + Receiver: addr.String(), + TokenA: depositAmts.TokenA.Denom, + TokenB: depositAmts.TokenB.Denom, + AmountsA: []math.Int{depositAmts.TokenA.Amount}, + AmountsB: []math.Int{depositAmts.TokenB.Amount}, + TickIndexesAToB: []int64{tick}, + Fees: []uint64{fee}, + Options: []*dextypes.DepositOptions{{DisableAutoswap: disableAutoSwap}}, + }) +} + +//nolint:unparam +func (s *DexStateTestSuite) makeDepositSuccess(addr sdk.AccAddress, depositAmts LiquidityDistribution, disableAutoSwap bool) *dextypes.MsgDepositResponse { + resp, err := s.makeDepositDefault(addr, depositAmts, disableAutoSwap) + s.NoError(err) + + return resp +} + +func (s *DexStateTestSuite) makeWithdraw(addr sdk.AccAddress, tokenA, tokenB string, sharesToRemove math.Int) (*dextypes.MsgWithdrawalResponse, error) { + return s.msgServer.Withdrawal(s.Ctx, &dextypes.MsgWithdrawal{ + Creator: addr.String(), + Receiver: addr.String(), + TokenA: tokenA, + TokenB: tokenB, + SharesToRemove: []math.Int{sharesToRemove}, + TickIndexesAToB: []int64{DefaultTick}, + Fees: []uint64{DefaultFee}, + }) +} + +func (s *DexStateTestSuite) makePlaceTakerLO(addr sdk.AccAddress, amountIn sdk.Coin, tokenOut, sellPrice string, orderType dextypes.LimitOrderType, maxAmountOut *math.Int) (*dextypes.MsgPlaceLimitOrderResponse, error) { + p, err := math_utils.NewPrecDecFromStr(sellPrice) + if err != nil { + panic(err) + } + return s.msgServer.PlaceLimitOrder(s.Ctx, &dextypes.MsgPlaceLimitOrder{ + Creator: addr.String(), + Receiver: addr.String(), + TokenIn: amountIn.Denom, + TokenOut: tokenOut, + TickIndexInToOut: 0, + AmountIn: amountIn.Amount, + OrderType: orderType, + ExpirationTime: nil, + MaxAmountOut: maxAmountOut, + LimitSellPrice: &p, + }) +} + +func (s *DexStateTestSuite) makePlaceLO(addr sdk.AccAddress, amountIn sdk.Coin, tokenOut, sellPrice string, orderType dextypes.LimitOrderType, expTime *time.Time) (*dextypes.MsgPlaceLimitOrderResponse, error) { + p, err := math_utils.NewPrecDecFromStr(sellPrice) + if err != nil { + panic(err) + } + return s.msgServer.PlaceLimitOrder(s.Ctx, &dextypes.MsgPlaceLimitOrder{ + Creator: addr.String(), + Receiver: addr.String(), + TokenIn: amountIn.Denom, + TokenOut: tokenOut, + TickIndexInToOut: 0, + AmountIn: amountIn.Amount, + OrderType: orderType, + ExpirationTime: expTime, + MaxAmountOut: nil, + LimitSellPrice: &p, + }) +} + +func (s *DexStateTestSuite) makePlaceLOSuccess(addr sdk.AccAddress, amountIn sdk.Coin, tokenOut, sellPrice string, orderType dextypes.LimitOrderType, expTime *time.Time) *dextypes.MsgPlaceLimitOrderResponse { + resp, err := s.makePlaceLO(addr, amountIn, tokenOut, sellPrice, orderType, expTime) + s.NoError(err) + return resp +} + +func (s *DexStateTestSuite) makeCancel(addr sdk.AccAddress, trancheKey string) (*dextypes.MsgCancelLimitOrderResponse, error) { + return s.msgServer.CancelLimitOrder(s.Ctx, &dextypes.MsgCancelLimitOrder{ + Creator: addr.String(), + TrancheKey: trancheKey, + }) +} + +func (s *DexStateTestSuite) makeWithdrawFilled(addr sdk.AccAddress, trancheKey string) (*dextypes.MsgWithdrawFilledLimitOrderResponse, error) { + return s.msgServer.WithdrawFilledLimitOrder(s.Ctx, &dextypes.MsgWithdrawFilledLimitOrder{ + Creator: addr.String(), + TrancheKey: trancheKey, + }) +} + +func (s *DexStateTestSuite) makeWithdrawFilledSuccess(addr sdk.AccAddress, trancheKey string) *dextypes.MsgWithdrawFilledLimitOrderResponse { + resp, err := s.makeWithdrawFilled(addr, trancheKey) + s.NoError(err) + return resp +} + +func calcDepositValueAsToken0(tick int64, amount0, amount1 math.Int) math_utils.PrecDec { + price1To0CenterTick := dextypes.MustCalcPrice(tick) + amount1ValueAsToken0 := price1To0CenterTick.MulInt(amount1) + depositValue := amount1ValueAsToken0.Add(math_utils.NewPrecDecFromInt(amount0)) + + return depositValue +} + +func generatePairID(i int) *dextypes.PairID { + token0 := fmt.Sprintf("TokenA%d", i) + token1 := fmt.Sprintf("TokenB%d", i+1) + return dextypes.MustNewPairID(token0, token1) +} + +func (s *DexStateTestSuite) fundCreatorBalanceDefault(pairID *dextypes.PairID) { + coins := sdk.NewCoins( + sdk.NewCoin(pairID.Token0, DefaultStartingBalanceInt), + sdk.NewCoin(pairID.Token1, DefaultStartingBalanceInt), + ) + s.FundAcc(s.creator, coins) +} + +// Assertions ///////////////////////////////////////////////////////////////// + +func (s *DexStateTestSuite) intsApproxEqual(field string, expected, actual math.Int, absPrecision int64) { + s.True(actual.Sub(expected).Abs().LTE(math.NewInt(absPrecision)), "For %v: Expected %v (+-%d) Got %v)", field, expected, absPrecision, actual) +} + +func (s *DexStateTestSuite) assertBalance(addr sdk.AccAddress, denom string, expected math.Int) { + trueBalance := s.App.BankKeeper.GetBalance(s.Ctx, addr, denom) + s.intsApproxEqual(fmt.Sprintf("Balance %s", denom), expected, trueBalance.Amount, 1) +} + +func (s *DexStateTestSuite) assertBalanceWithPrecision(addr sdk.AccAddress, denom string, expected math.Int, prec int64) { + trueBalance := s.App.BankKeeper.GetBalance(s.Ctx, addr, denom) + s.intsApproxEqual(fmt.Sprintf("Balance %s", denom), expected, trueBalance.Amount, prec) +} + +func (s *DexStateTestSuite) assertCreatorBalance(denom string, expected math.Int) { + s.assertBalance(s.creator, denom, expected) +} + +func (s *DexStateTestSuite) assertDexBalance(denom string, expected math.Int) { + s.assertBalance(s.App.AccountKeeper.GetModuleAddress("dex"), denom, expected) +} + +func (s *DexStateTestSuite) assertPoolBalance(pairID *dextypes.PairID, tick int64, fee uint64, expectedA, expectedB math.Int) { + pool, found := s.App.DexKeeper.GetPool(s.Ctx, pairID, tick, fee) + s.True(found, "Pool not found") + + reservesA := pool.LowerTick0.ReservesMakerDenom + reservesB := pool.UpperTick1.ReservesMakerDenom + + s.intsApproxEqual("Pool ReservesA", expectedA, reservesA, 1) + s.intsApproxEqual("Pool ReservesB", expectedB, reservesB, 1) +} + +// Core Test Setup //////////////////////////////////////////////////////////// + +type DexStateTestSuite struct { + apptesting.KeeperTestHelper + msgServer dextypes.MsgServer + creator sdk.AccAddress + alice sdk.AccAddress + bob sdk.AccAddress +} + +func (s *DexStateTestSuite) SetupTest() { + s.Setup() + s.creator = sdk.MustAccAddressFromBech32(sample.AccAddress()) + s.alice = sdk.MustAccAddressFromBech32(sample.AccAddress()) + s.bob = sdk.MustAccAddressFromBech32(sample.AccAddress()) + + s.msgServer = dexkeeper.NewMsgServerImpl(s.App.DexKeeper) +} + +func cloneMap(original map[string]string) map[string]string { + // Create a new map + cloned := make(map[string]string) + // Copy each key-value pair from the original map to the new map + for key, value := range original { + cloned[key] = value + } + + return cloned +} + +type testParams struct { + field string + states []string +} + +func generatePermutations(testStates []testParams) []map[string]string { + result := make([]map[string]string, 0) + + var generate func(int, map[string]string) + generate = func(index int, current map[string]string) { + // Base Case + if index == len(testStates) { + result = append(result, current) + return + } + + // Iterate through all possible values and create new states + for _, value := range testStates[index].states { + fieldName := testStates[index].field + temp := cloneMap(current) + temp[fieldName] = value + generate(index+1, temp) + } + } + emptyMap := make(map[string]string) + generate(0, emptyMap) + + return result +} + +func removeDuplicateTests[T depositTestParams](testCases []T) []T { + result := make([]T, 0) + seenTCs := make(map[string]bool) + for _, tc := range testCases { + tcStr := fmt.Sprintf("%v", tc) + if _, ok := seenTCs[tcStr]; !ok { + result = append(result, tc) + } + seenTCs[tcStr] = true + } + return result +} diff --git a/tests/dex/state_withdraw_limit_order_test.go b/tests/dex/state_withdraw_limit_order_test.go new file mode 100644 index 000000000..ab797546d --- /dev/null +++ b/tests/dex/state_withdraw_limit_order_test.go @@ -0,0 +1,238 @@ +package dex_state_test + +import ( + "errors" + "fmt" + "strconv" + "testing" + "time" + + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + + math_utils "github.com/neutron-org/neutron/v5/utils/math" + dextypes "github.com/neutron-org/neutron/v5/x/dex/types" +) + +type withdrawLimitOrderTestParams struct { + // State Conditions + SharedParams + ExistingTokenAHolders string + Filled int + WithdrawnCreator bool + WithdrawnOneOther bool + Expired bool + OrderType int32 // JIT, GTT, GTC +} + +func (p withdrawLimitOrderTestParams) printTestInfo(t *testing.T) { + t.Logf(` + Existing Shareholders: %s + Filled: %v + WithdrawnCreator: %v + WithdrawnOneOther: %t + Expired: %t + OrderType: %v`, + p.ExistingTokenAHolders, + p.Filled, + // Two fields define a state with a pre-withdrawn tranche + p.WithdrawnCreator, + p.WithdrawnOneOther, + + p.Expired, + p.OrderType, + ) +} + +func hydrateWithdrawLoTestCase(params map[string]string) withdrawLimitOrderTestParams { + selltick, err := dextypes.CalcTickIndexFromPrice(math_utils.MustNewPrecDecFromStr(DefaultSellPrice)) + if err != nil { + panic(err) + } + w := withdrawLimitOrderTestParams{ + ExistingTokenAHolders: params["ExistingTokenAHolders"], + Filled: parseInt(params["Filled"]), + WithdrawnCreator: parseBool(params["WithdrawnCreator"]), + WithdrawnOneOther: parseBool(params["WithdrawnOneOther"]), + Expired: parseBool(params["Expired"]), + OrderType: dextypes.LimitOrderType_value[params["OrderType"]], + } + w.SharedParams.Tick = selltick + return w +} + +func (s *DexStateTestSuite) setupWithdrawLimitOrderTest(params withdrawLimitOrderTestParams) *dextypes.LimitOrderTranche { + coinA := sdk.NewCoin(params.PairID.Token0, BaseTokenAmountInt) + coinB := sdk.NewCoin(params.PairID.Token1, BaseTokenAmountInt.MulRaw(10)) + s.FundAcc(s.creator, sdk.NewCoins(coinA)) + var expTime *time.Time + if params.OrderType == int32(dextypes.LimitOrderType_GOOD_TIL_TIME) { + t := time.Now() + expTime = &t + } + res := s.makePlaceLOSuccess(s.creator, coinA, coinB.Denom, DefaultSellPrice, dextypes.LimitOrderType(params.OrderType), expTime) + + totalDeposited := BaseTokenAmountInt + if params.ExistingTokenAHolders == OneOtherAndCreatorLO { + totalDeposited = totalDeposited.MulRaw(2) + s.FundAcc(s.alice, sdk.NewCoins(coinA)) + s.makePlaceLOSuccess(s.alice, coinA, coinB.Denom, DefaultSellPrice, dextypes.LimitOrderType(params.OrderType), expTime) + } + + // withdraw in two steps: before and after pre-withdraw (if there are any) + halfAmount := totalDeposited.MulRaw(int64(params.Filled)).QuoRaw(2 * 100) + s.FundAcc(s.bob, sdk.NewCoins(coinB).MulInt(math.NewInt(10))) + if params.Filled > 0 { + _, err := s.makePlaceTakerLO(s.bob, coinB, coinA.Denom, DefaultBuyPriceTaker, dextypes.LimitOrderType_IMMEDIATE_OR_CANCEL, &halfAmount) + s.NoError(err) + } + + if params.WithdrawnCreator { + s.makeWithdrawFilledSuccess(s.creator, res.TrancheKey) + } + + if params.WithdrawnOneOther { + s.makeWithdrawFilledSuccess(s.alice, res.TrancheKey) + } + + if params.Filled > 0 { + _, err := s.makePlaceTakerLO(s.bob, coinB, coinA.Denom, DefaultBuyPriceTaker, dextypes.LimitOrderType_IMMEDIATE_OR_CANCEL, &halfAmount) + s.NoError(err) + } + + if params.Expired { + s.App.DexKeeper.PurgeExpiredLimitOrders(s.Ctx, time.Now()) + } + tick, err := dextypes.CalcTickIndexFromPrice(DefaultStartPrice) + s.NoError(err) + + req := dextypes.QueryGetLimitOrderTrancheRequest{ + PairId: params.PairID.CanonicalString(), + TickIndex: tick, + TokenIn: params.PairID.Token0, + TrancheKey: res.TrancheKey, + } + tranchResp, err := s.App.DexKeeper.LimitOrderTranche(s.Ctx, &req) + s.NoError(err) + + return tranchResp.LimitOrderTranche +} + +func hydrateAllWithdrawLoTestCases(paramsList []map[string]string) []withdrawLimitOrderTestParams { + allTCs := make([]withdrawLimitOrderTestParams, 0) + for i, paramsRaw := range paramsList { + pairID := generatePairID(i) + tc := hydrateWithdrawLoTestCase(paramsRaw) + tc.PairID = pairID + allTCs = append(allTCs, tc) + } + + // return allTCs + return removeRedundantWithdrawLOTests(allTCs) +} + +func removeRedundantWithdrawLOTests(params []withdrawLimitOrderTestParams) []withdrawLimitOrderTestParams { + newParams := make([]withdrawLimitOrderTestParams, 0) + for _, p := range params { + // it's impossible to withdraw 0 filled + if p.Filled == 0 && (p.WithdrawnOneOther || p.WithdrawnCreator) { + continue + } + if p.Expired && p.OrderType == int32(dextypes.LimitOrderType_GOOD_TIL_CANCELLED) { + continue + } + if p.WithdrawnOneOther && p.ExistingTokenAHolders == CreatorLO { + continue + } + if p.ExistingTokenAHolders == OneOtherAndCreatorLO && p.OrderType != int32(dextypes.LimitOrderType_GOOD_TIL_CANCELLED) { + // user tranches combined into tranches only for LimitOrderType_GOOD_TIL_CANCELLED + // it does not make any sense to create two tranches + continue + } + newParams = append(newParams, p) + } + return newParams +} + +func (s *DexStateTestSuite) handleWithdrawLimitOrderErrors(params withdrawLimitOrderTestParams, err error) { + if params.Filled == 0 { + if errors.Is(dextypes.ErrWithdrawEmptyLimitOrder, err) { + s.T().Skip() + } + } + s.NoError(err) +} + +func (s *DexStateTestSuite) assertWithdrawFilledAmount(params withdrawLimitOrderTestParams, trancheKey string) { + depositSize := BaseTokenAmountInt + + // expected balance: InitialBalance - depositSize + pre-withdrawn (filled/2 or 0) + withdrawn (filled/2 or filled) + // pre-withdrawn (filled/2 or 0) + withdrawn (filled/2 or filled) === filled + // converted to TokenB + price := dextypes.MustCalcPrice(params.Tick) + expectedBalanceB := price.MulInt(depositSize.MulRaw(int64(params.Filled)).QuoRaw(100)).Ceil().TruncateInt() + expectedBalanceA := depositSize.Sub(depositSize.MulRaw(int64(params.Filled)).QuoRaw(100)) + // 1 - withdrawn amount + s.assertBalanceWithPrecision(s.creator, params.PairID.Token1, expectedBalanceB, 3) + + ut, found := s.App.DexKeeper.GetLimitOrderTrancheUser(s.Ctx, s.creator.String(), trancheKey) + if params.Expired { + // "canceled" amount + s.assertBalance(s.creator, params.PairID.Token0, expectedBalanceA) + s.False(found) + } else { + s.assertBalance(s.creator, params.PairID.Token0, math.ZeroInt()) + if params.Filled == 100 { + s.False(found) + } else { + s.True(found) + s.intsApproxEqual("", expectedBalanceA, ut.SharesOwned.Sub(ut.SharesWithdrawn), 1) + } + } +} + +func TestWithdrawLimitOrder(t *testing.T) { + testParams := []testParams{ + {field: "ExistingTokenAHolders", states: []string{CreatorLO, OneOtherAndCreatorLO}}, + {field: "Filled", states: []string{ZeroPCT, FiftyPCT, HundredPct}}, + {field: "WithdrawnCreator", states: []string{True, False}}, + {field: "WithdrawnOneOther", states: []string{True, False}}, + {field: "OrderType", states: []string{ + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_GOOD_TIL_CANCELLED)], + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_GOOD_TIL_TIME)], + dextypes.LimitOrderType_name[int32(dextypes.LimitOrderType_JUST_IN_TIME)], + }}, + {field: "Expired", states: []string{True, False}}, + } + testCasesRaw := generatePermutations(testParams) + testCases := hydrateAllWithdrawLoTestCases(testCasesRaw) + + s := new(DexStateTestSuite) + s.SetT(t) + s.SetupTest() + // totalExpectedToSwap := math.ZeroInt() + + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + s.SetT(t) + tc.printTestInfo(t) + + initialTrancheKey := s.setupWithdrawLimitOrderTest(tc) + fmt.Println(initialTrancheKey) + + resp, err := s.makeWithdrawFilled(s.creator, initialTrancheKey.Key.TrancheKey) + s.handleWithdrawLimitOrderErrors(tc, err) + fmt.Println("resp", resp) + fmt.Println("err", err) + s.assertWithdrawFilledAmount(tc, initialTrancheKey.Key.TrancheKey) + /* + 3. Assertions + 1. (Value returned + remaining LO value)/ValueIn ~= LimitPrice + 2. TakerDenom withdrawn == userOwnershipRatio * fillPercentage * takerReserves + 3. If expired + 1. MakerDenom withdraw == userOwnershipRatio * fillPercentage * makerReserves + */ + }) + } +} diff --git a/tests/dex/state_withdraw_test.go b/tests/dex/state_withdraw_test.go new file mode 100644 index 000000000..a52cab15d --- /dev/null +++ b/tests/dex/state_withdraw_test.go @@ -0,0 +1,175 @@ +package dex_state_test + +import ( + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + dextypes "github.com/neutron-org/neutron/v5/x/dex/types" +) + +type withdrawTestParams struct { + // State Conditions + DepositState + // Message Variants + SharesToRemoveAmm int64 +} + +func (p withdrawTestParams) printTestInfo(t *testing.T) { + t.Logf(` + Existing Shareholders: %s + Existing Liquidity Distribution: %v + Shares to remove: %v`, + p.ExistingShareHolders, + p.ExistingLiquidityDistribution, + p.SharesToRemoveAmm, + ) +} + +func (s *DexStateTestSuite) handleWithdrawFailureCases(params withdrawTestParams, err error) { + if params.SharesToRemoveAmm == 0 { + s.ErrorIs(err, dextypes.ErrZeroWithdraw) + } else { + s.NoError(err) + } +} + +func hydrateWithdrawTestCase(params map[string]string, pairID *dextypes.PairID) withdrawTestParams { + existingShareHolders := params["ExistingShareHolders"] + var liquidityDistribution LiquidityDistribution + + if existingShareHolders == None { + liquidityDistribution = parseLiquidityDistribution(TokenA0TokenB0, pairID) + } else { + liquidityDistribution = parseLiquidityDistribution(params["LiquidityDistribution"], pairID) + } + + sharesToRemove, err := strconv.ParseInt(params["SharesToRemoveAmm"], 10, 64) + if err != nil { + panic(fmt.Sprintln("invalid SharesToRemoveAmm", err)) + } + + var valueIncrease LiquidityDistribution + if liquidityDistribution.empty() { + valueIncrease = parseLiquidityDistribution(TokenA0TokenB0, pairID) + } else { + valueIncrease = parseLiquidityDistribution(params["PoolValueIncrease"], pairID) + } + + return withdrawTestParams{ + DepositState: DepositState{ + ExistingShareHolders: existingShareHolders, + ExistingLiquidityDistribution: liquidityDistribution, + SharedParams: DefaultSharedParams, + PoolValueIncrease: valueIncrease, + }, + SharesToRemoveAmm: sharesToRemove, + } +} + +func hydrateAllWithdrawTestCases(paramsList []map[string]string) []withdrawTestParams { + allTCs := make([]withdrawTestParams, 0) + for i, paramsRaw := range paramsList { + pairID := generatePairID(i) + tc := hydrateWithdrawTestCase(paramsRaw, pairID) + tc.PairID = pairID + allTCs = append(allTCs, tc) + } + + return allTCs +} + +func TestWithdraw(t *testing.T) { + testParams := []testParams{ + {field: "ExistingShareHolders", states: []string{Creator, OneOtherAndCreator}}, + {field: "LiquidityDistribution", states: []string{ + TokenA0TokenB1, + TokenA0TokenB2, + TokenA1TokenB0, + TokenA1TokenB1, + TokenA1TokenB2, + TokenA2TokenB0, + TokenA2TokenB1, + TokenA2TokenB2, + }}, + {field: "PoolValueIncrease", states: []string{TokenA0TokenB0}}, + {field: "SharesToRemoveAmm", states: []string{ZeroPCT, FiftyPCT, HundredPct}}, + } + testCasesRaw := generatePermutations(testParams) + testCases := hydrateAllWithdrawTestCases(testCasesRaw) + + s := new(DexStateTestSuite) + s.SetT(t) + s.SetupTest() + + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + s.SetT(t) + tc.printTestInfo(t) + + s.setupDepositState(tc.DepositState) + s.fundCreatorBalanceDefault(tc.PairID) + // + poolID, found := s.App.DexKeeper.GetPoolIDByParams(s.Ctx, tc.PairID, tc.Tick, tc.Fee) + if tc.ExistingShareHolders == None { + // This is the ID that will be used when the pool is created + poolID = s.App.DexKeeper.GetPoolCount(s.Ctx) + } else { + require.True(t, found, "Pool not found after deposit") + } + poolDenom := dextypes.NewPoolDenom(poolID) + balancesBefore := s.GetBalances() + existingSharesOwned := balancesBefore.Creator.AmountOf(poolDenom) + toWithdraw := existingSharesOwned.MulRaw(tc.SharesToRemoveAmm).QuoRaw(100) + //// Do the actual Withdraw + _, err := s.makeWithdraw( + s.creator, + tc.ExistingLiquidityDistribution.TokenA.Denom, + tc.ExistingLiquidityDistribution.TokenB.Denom, + toWithdraw, + ) + + // Assert new state is correct + s.handleWithdrawFailureCases(tc, err) + + TokenABalanceBefore := balancesBefore.Creator.AmountOf(tc.ExistingLiquidityDistribution.TokenA.Denom) + TokenBBalanceBefore := balancesBefore.Creator.AmountOf(tc.ExistingLiquidityDistribution.TokenB.Denom) + + balancesAfter := s.GetBalances() + TokenABalanceAfter := balancesAfter.Creator.AmountOf(tc.ExistingLiquidityDistribution.TokenA.Denom) + TokenBBalanceAfter := balancesAfter.Creator.AmountOf(tc.ExistingLiquidityDistribution.TokenB.Denom) + // Assertion 1 + // toWithdraw = withdrawnTokenA + withdrawnTokenB*priceTakerToMaker + price1To0 := dextypes.MustCalcPrice(tc.Tick) + s.Require().Equal( + toWithdraw, + TokenABalanceAfter.Sub(TokenABalanceBefore).Add( + price1To0.MulInt(TokenBBalanceAfter.Sub(TokenBBalanceBefore)).TruncateInt(), + ), + ) + newExistingSharesOwned := balancesAfter.Creator.AmountOf(poolDenom) + // Assertion 2 + // exact amount of shares burned from a `creator` account + s.intsApproxEqual("New shares owned", newExistingSharesOwned, existingSharesOwned.Sub(toWithdraw), 1) + + // Assertion 3 + // exact amount of shares burned not just moved + newExistingSharesTotal := balancesAfter.Total.AmountOf(poolDenom) + existingSharesTotal := balancesBefore.Total.AmountOf(poolDenom) + s.intsApproxEqual("New total shares supply", newExistingSharesTotal, existingSharesTotal.Sub(toWithdraw), 1) + + // Assertion 4 + // Withdrawn ratio equals pool liquidity ratio (dex balance of the tokens) + // Ac/Bc = Ap/Bp => Ac*Bp = Ap*Bc, modified the equation to avoid div operation + balDeltaTokenA := BalancesDelta(balancesAfter, balancesBefore, tc.ExistingLiquidityDistribution.TokenA.Denom) + balDeltaTokenB := BalancesDelta(balancesAfter, balancesBefore, tc.ExistingLiquidityDistribution.TokenB.Denom) + s.intsApproxEqual("", + balDeltaTokenA.Creator.Mul(balancesBefore.Dex.AmountOf(tc.ExistingLiquidityDistribution.TokenB.Denom)), + balDeltaTokenB.Creator.Mul(balancesBefore.Dex.AmountOf(tc.ExistingLiquidityDistribution.TokenA.Denom)), + 1, + ) + }) + } +}