package keeper_test import ( "fmt" "testing" "time" "github.com/stretchr/testify/suite" "go.uber.org/mock/gomock" "cosmossdk.io/core/header" "cosmossdk.io/math" storetypes "cosmossdk.io/store/types" "git.cw.tr/mukan-network/mukan-sdk/baseapp" "git.cw.tr/mukan-network/mukan-sdk/codec/address" "git.cw.tr/mukan-network/mukan-sdk/runtime" "git.cw.tr/mukan-network/mukan-sdk/testutil" sdk "git.cw.tr/mukan-network/mukan-sdk/types" sdkerrors "git.cw.tr/mukan-network/mukan-sdk/types/errors" moduletestutil "git.cw.tr/mukan-network/mukan-sdk/types/module/testutil" authtypes "git.cw.tr/mukan-network/mukan-sdk/x/auth/types" govtypes "git.cw.tr/mukan-network/mukan-sdk/x/gov/types" poolkeeper "git.cw.tr/mukan-network/mukan-sdk/x/protocolpool/keeper" pooltestutil "git.cw.tr/mukan-network/mukan-sdk/x/protocolpool/testutil" "git.cw.tr/mukan-network/mukan-sdk/x/protocolpool/types" ) var ( poolAcc = authtypes.NewEmptyModuleAccount(types.ModuleName) poolDistrAcc = authtypes.NewEmptyModuleAccount(types.ProtocolPoolEscrowAccount) recipientAddr = sdk.AccAddress("to1__________________") recipientAddr2 = sdk.AccAddress("to2__________________") fooCoin = sdk.NewInt64Coin("foo", 100) ) type KeeperTestSuite struct { suite.Suite ctx sdk.Context poolKeeper poolkeeper.Keeper authKeeper *pooltestutil.MockAccountKeeper bankKeeper *pooltestutil.MockBankKeeper msgServer types.MsgServer queryServer types.QueryServer } func (suite *KeeperTestSuite) SetupTest() { key := storetypes.NewKVStoreKey(types.StoreKey) storeService := runtime.NewKVStoreService(key) testCtx := testutil.DefaultContextWithDB(suite.T(), key, storetypes.NewTransientStoreKey("transient_test")) ctx := testCtx.Ctx.WithHeaderInfo(header.Info{Time: time.Now()}) encCfg := moduletestutil.MakeTestEncodingConfig() // gomock initializations ctrl := gomock.NewController(suite.T()) accountKeeper := pooltestutil.NewMockAccountKeeper(ctrl) accountKeeper.EXPECT().GetModuleAddress(types.ModuleName).Return(poolAcc.GetAddress()).AnyTimes() accountKeeper.EXPECT().GetModuleAddress(types.ProtocolPoolEscrowAccount).Return(poolDistrAcc.GetAddress()).AnyTimes() accountKeeper.EXPECT().AddressCodec().Return(address.NewBech32Codec("cosmos")).AnyTimes() suite.authKeeper = accountKeeper bankKeeper := pooltestutil.NewMockBankKeeper(ctrl) suite.bankKeeper = bankKeeper authority, err := accountKeeper.AddressCodec().BytesToString(authtypes.NewModuleAddress(govtypes.ModuleName)) suite.Require().NoError(err) poolKeeper := poolkeeper.NewKeeper( encCfg.Codec, storeService, accountKeeper, bankKeeper, authority, ) suite.ctx = ctx suite.poolKeeper = poolKeeper err = suite.poolKeeper.Params.Set(ctx, types.Params{ EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}, }) suite.Require().NoError(err) types.RegisterInterfaces(encCfg.InterfaceRegistry) queryHelper := baseapp.NewQueryServerTestHelper(ctx, encCfg.InterfaceRegistry) types.RegisterQueryServer(queryHelper, poolkeeper.Querier{Keeper: poolKeeper}) suite.msgServer = poolkeeper.NewMsgServerImpl(poolKeeper) suite.queryServer = poolkeeper.NewQuerier(poolKeeper) } func TestKeeperTestSuite(t *testing.T) { suite.Run(t, new(KeeperTestSuite)) } func (suite *KeeperTestSuite) TestGetCommunityPool() { expectedBalance := sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(1000000))) suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ModuleName).Return(poolAcc).Times(1) suite.bankKeeper.EXPECT().GetAllBalances(suite.ctx, poolAcc.GetAddress()).Return(expectedBalance).Times(1) balance, err := suite.poolKeeper.GetCommunityPool(suite.ctx) suite.Require().NoError(err) suite.Require().Equal(expectedBalance, balance) // Test error case when module account doesn't exist suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ModuleName).Return(nil).Times(1) _, err = suite.poolKeeper.GetCommunityPool(suite.ctx) suite.Require().Error(err) suite.Require().Contains(err.Error(), "module account protocolpool does not exist") } func (suite *KeeperTestSuite) TestGetAllContinuousFunds() { suite.Run("empty store", func() { // Reset the context to start with a clean store. suite.SetupTest() funds, err := suite.poolKeeper.GetAllContinuousFunds(suite.ctx) suite.Require().NoError(err) suite.Require().Empty(funds, "expected no continuous funds in store") }) suite.Run("one fund in store", func() { suite.SetupTest() fund := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.5"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund) suite.Require().NoError(err) funds, err := suite.poolKeeper.GetAllContinuousFunds(suite.ctx) suite.Require().NoError(err) suite.Require().Len(funds, 1, "expected one continuous fund in store") suite.Require().Equal(fund.Recipient, funds[0].Recipient) suite.Require().Equal(fund.Percentage, funds[0].Percentage) suite.Require().Equal(fund.Expiry, funds[0].Expiry) }) suite.Run("many funds in store", func() { suite.SetupTest() totalFunds := 10 // Insert a number of funds. for i := range totalFunds { accAddr := sdk.AccAddress(fmt.Sprintf("ao%d__________________", i)) fund := types.ContinuousFund{ Recipient: accAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.1"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, accAddr, fund) suite.Require().NoError(err) } funds, err := suite.poolKeeper.GetAllContinuousFunds(suite.ctx) suite.Require().NoError(err) suite.Require().Len(funds, totalFunds, "expected many continuous funds in store") // verify each inserted fund's percentage. for _, f := range funds { suite.Require().Equal(math.LegacyMustNewDecFromStr("0.1"), f.Percentage) } }) } func (suite *KeeperTestSuite) TestDistributeFunds() { initialBalance := sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(1000)) initalBalanceCoins := sdk.NewCoins(initialBalance) tests := []struct { name string params types.Params initialPoolBalance sdk.Coin setup func() expectedErr string verify func() }{ { name: "module account missing", params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount).Return(nil) }, expectedErr: "module account", }, { name: "zero funds in module account", initialPoolBalance: sdk.NewCoin(sdk.DefaultBondDenom, math.ZeroInt()), params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() }, expectedErr: "", }, { name: "one valid continuous fund", params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, initialPoolBalance: initialBalance, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() fund := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.3"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund) suite.Require().NoError(err) amountToStream := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.3"), initalBalanceCoins) suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr, amountToStream). Return(nil).Times(1) remainingCoins := initalBalanceCoins.Sub(amountToStream...) suite.bankKeeper.EXPECT().SendCoinsFromModuleToModule(suite.ctx, types.ProtocolPoolEscrowAccount, types.ModuleName, remainingCoins). Return(nil).Times(1) }, expectedErr: "", }, { name: "one valid continuous fund with a blocked account", params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, initialPoolBalance: initialBalance, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() fund := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.3"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund) suite.Require().NoError(err) amountToStream := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.3"), initalBalanceCoins) suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr, amountToStream). Return(sdkerrors.ErrUnauthorized).Times(1) suite.bankKeeper.EXPECT().SendCoinsFromModuleToModule(suite.ctx, types.ProtocolPoolEscrowAccount, types.ModuleName, initalBalanceCoins). Return(nil).Times(1) }, expectedErr: "", verify: func() { // check that the broken continuous fund is removed _, err := suite.poolKeeper.ContinuousFunds.Get(suite.ctx, recipientAddr) suite.Require().Error(err) }, }, { name: "one expired continuous fund", params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, initialPoolBalance: initialBalance, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() expiredTime := suite.ctx.BlockTime().Add(-time.Hour) fund := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.3"), Expiry: &expiredTime, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund) suite.Require().NoError(err) // And full amount to be sent to the community pool. suite.bankKeeper.EXPECT().SendCoinsFromModuleToModule(suite.ctx, types.ProtocolPoolEscrowAccount, types.ModuleName, initalBalanceCoins). Return(nil).Times(1) }, expectedErr: "", verify: func() { // Verify that the expired fund was removed. funds, err := suite.poolKeeper.GetAllContinuousFunds(suite.ctx) suite.Require().NoError(err) suite.Require().Empty(funds, "expected expired continuous fund to be removed") }, }, { name: "multiple valid continuous funds - one recipient is blocked", initialPoolBalance: initialBalance, params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() fund1 := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.3"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund1) suite.Require().NoError(err) fund2 := types.ContinuousFund{ Recipient: recipientAddr2.String(), Percentage: math.LegacyMustNewDecFromStr("0.2"), Expiry: nil, } accAddr, err := sdk.AccAddressFromBech32(fund2.Recipient) suite.Require().NoError(err) err = suite.poolKeeper.ContinuousFunds.Set(suite.ctx, accAddr, fund2) suite.Require().NoError(err) amountToStream1 := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.3"), initalBalanceCoins) amountToStream2 := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.2"), initalBalanceCoins) suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr, amountToStream1). Return(sdkerrors.ErrUnauthorized).Times(1) suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr2, amountToStream2). Return(nil).Times(1) remainingCoins := initalBalanceCoins.Sub(amountToStream2...) suite.bankKeeper.EXPECT().SendCoinsFromModuleToModule(suite.ctx, types.ProtocolPoolEscrowAccount, types.ModuleName, remainingCoins). Return(nil).Times(1) }, expectedErr: "", verify: func() { // check that the broken continuous fund is removed _, err := suite.poolKeeper.ContinuousFunds.Get(suite.ctx, recipientAddr) suite.Require().Error(err) // check that the valid continuous fund is in the store _, err = suite.poolKeeper.ContinuousFunds.Get(suite.ctx, recipientAddr2) suite.Require().NoError(err) }, }, { name: "fund percentages sum over 1 (resulting in negative remainder)", initialPoolBalance: initialBalance, params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() // Two funds whose percentages sum to 1.3 (80% and 50%). fund1 := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.8"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund1) suite.Require().NoError(err) fund2 := types.ContinuousFund{ Recipient: recipientAddr2.String(), Percentage: math.LegacyMustNewDecFromStr("0.5"), Expiry: nil, } err = suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr2, fund2) suite.Require().NoError(err) amountToStream1 := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.8"), initalBalanceCoins) // 800 stake suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr, amountToStream1). Return(nil).Times(1) }, // we will fail on the second iteration of the loop expectedErr: "negative funds for distribution from ContinuousFunds: -300stake", }, { name: "bank send to account error", params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() totalCoins := sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(1000))) suite.bankKeeper.EXPECT().GetBalance(suite.ctx, poolDistrAcc.GetAddress(), sdk.DefaultBondDenom). Return(sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(1000))).Times(1) fund := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.3"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund) suite.Require().NoError(err) amountToStream := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.3"), totalCoins) suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr, amountToStream). Return(fmt.Errorf("send account error")).Times(1) }, expectedErr: "failed to distribute fund", }, { name: "bank send to module error", initialPoolBalance: initialBalance, params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() fund := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.3"), Expiry: nil, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund) suite.Require().NoError(err) amountToStream := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.3"), initalBalanceCoins) suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr, amountToStream). Return(nil).Times(1) remainingCoins := initalBalanceCoins.Sub(amountToStream...) suite.bankKeeper.EXPECT().SendCoinsFromModuleToModule(suite.ctx, types.ProtocolPoolEscrowAccount, types.ModuleName, remainingCoins). Return(fmt.Errorf("send module error")).Times(1) }, expectedErr: "failed to send coins to community pool", }, { name: "fund expiry equals block time (not expired)", params: types.Params{EnabledDistributionDenoms: []string{sdk.DefaultBondDenom}}, initialPoolBalance: initialBalance, setup: func() { suite.authKeeper.EXPECT().GetModuleAccount(suite.ctx, types.ProtocolPoolEscrowAccount). Return(poolDistrAcc).AnyTimes() expiryTime := suite.ctx.BlockTime() // exactly equal to block time. fund := types.ContinuousFund{ Recipient: recipientAddr.String(), Percentage: math.LegacyMustNewDecFromStr("0.3"), Expiry: &expiryTime, } err := suite.poolKeeper.ContinuousFunds.Set(suite.ctx, recipientAddr, fund) suite.Require().NoError(err) amountToStream := poolkeeper.PercentageCoinMul(math.LegacyMustNewDecFromStr("0.3"), initalBalanceCoins) suite.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(suite.ctx, types.ProtocolPoolEscrowAccount, recipientAddr, amountToStream). Return(nil).Times(1) remainingCoins := initalBalanceCoins.Sub(amountToStream...) suite.bankKeeper.EXPECT().SendCoinsFromModuleToModule(suite.ctx, types.ProtocolPoolEscrowAccount, types.ModuleName, remainingCoins). Return(nil).Times(1) }, expectedErr: "", }, } for _, tc := range tests { suite.Run(tc.name, func() { suite.SetupTest() suite.Require().NoError(suite.poolKeeper.Params.Set(suite.ctx, tc.params)) if !tc.initialPoolBalance.IsNil() { suite.bankKeeper.EXPECT().GetBalance(suite.ctx, poolDistrAcc.GetAddress(), initialBalance.Denom). Return(tc.initialPoolBalance).Times(1) } tc.setup() err := suite.poolKeeper.DistributeFunds(suite.ctx) switch { case tc.expectedErr != "": suite.Require().Error(err) suite.Require().Contains(err.Error(), tc.expectedErr) default: suite.Require().NoError(err) } }) } } // TestPercentageCoinMul tests the PercentageCoinMul function. func TestPercentageCoinMul(t *testing.T) { tests := []struct { name string percentage math.LegacyDec coins sdk.Coins expected sdk.Coins }{ { name: "zero percentage", percentage: math.LegacyMustNewDecFromStr("0.0"), coins: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(100))), expected: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(0))), }, { name: "100 percent", percentage: math.LegacyMustNewDecFromStr("1.0"), coins: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(100))), expected: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(100))), }, { name: "50 percent", percentage: math.LegacyMustNewDecFromStr("0.5"), coins: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(100))), expected: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(50))), }, { name: "fraction with truncation", percentage: math.LegacyMustNewDecFromStr("0.333333333333333333"), // Approx. 1/3. coins: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(100))), // 100 * 1/3 = 33.333... which truncates to 33. expected: sdk.NewCoins(sdk.NewCoin("atom", math.NewInt(33))), }, { name: "multiple denominations", percentage: math.LegacyMustNewDecFromStr("0.5"), coins: sdk.NewCoins( sdk.NewCoin("atom", math.NewInt(100)), sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(200)), ), expected: sdk.NewCoins( sdk.NewCoin("atom", math.NewInt(50)), sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(100)), ), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Call the function under test. result := poolkeeper.PercentageCoinMul(tc.percentage, tc.coins) // Compare the resulting coins with the expected coins. if !result.Equal(tc.expected) { t.Errorf("unexpected result for %s:\nexpected: %s\ngot: %s", tc.name, tc.expected.String(), result.String()) } }) } }