diff --git a/CHANGELOG.md b/CHANGELOG.md index 09809324..f5a7d67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ # CHANGELOG -## Unreleased - ### Fixed +- Oracle slashing no longer burns coins but instead sends them to community pool +- Slashing overall no longer burn coins, but instead sends them to community pool - Add denom string length validation (max 128 bytes) to oracle precompile and query server to prevent memory exhaustion via oversized inputs - Add result limits to oracle list queries (ExchangeRates, Actives, VoteTargets capped at 1000; PriceSnapshotHistory capped at 500) to prevent unbounded iteration - Fix NewClaim constructor assigning power to Weight field instead of the weight parameter (x/oracle/types/ballot.go) diff --git a/app/keepers/keepers.go b/app/keepers/keepers.go index fca912c5..beabb6fe 100644 --- a/app/keepers/keepers.go +++ b/app/keepers/keepers.go @@ -268,6 +268,7 @@ func NewAppKeeper( stakingtypes.NewMultiStakingHooks( appKeepers.DistrKeeper.Hooks(), appKeepers.SlashingKeeper.Hooks(), + NewSlashHooks(appKeepers.StakingKeeper, appKeepers.BankKeeper, appKeepers.DistrKeeper), ), ) @@ -446,6 +447,7 @@ func NewAppKeeper( appKeepers.AccountKeeper, appKeepers.BankKeeper, appKeepers.StakingKeeper, + appKeepers.DistrKeeper, authtypes.NewModuleAddress(govtypes.ModuleName).String(), ) diff --git a/app/keepers/slash_hooks.go b/app/keepers/slash_hooks.go new file mode 100644 index 00000000..44b61cac --- /dev/null +++ b/app/keepers/slash_hooks.go @@ -0,0 +1,117 @@ +package keepers + +import ( + "context" + + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + + tokenfactorytypes "github.com/kiichain/kiichain/v7/x/tokenfactory/types" +) + +// slashStakingKeeper is the staking-keeper subset needed by SlashHooks +type slashStakingKeeper interface { + GetValidator(ctx context.Context, addr sdk.ValAddress) (stakingtypes.Validator, error) + BondDenom(ctx context.Context) (string, error) +} + +// slashBankKeeper is the bank-keeper subset needed by SlashHooks +type slashBankKeeper interface { + MintCoins(ctx context.Context, moduleName string, amounts sdk.Coins) error +} + +// slashDistrKeeper is the distribution-keeper subset needed by SlashHooks +type slashDistrKeeper interface { + FundCommunityPool(ctx context.Context, amount sdk.Coins, sender sdk.AccAddress) error +} + +// SlashHooks implements stakingtypes.StakingHooks +type SlashHooks struct { + stakingKeeper slashStakingKeeper + bankKeeper slashBankKeeper + distrKeeper slashDistrKeeper +} + +// NewSlashHooks returns a SlashHooks +func NewSlashHooks(sk slashStakingKeeper, bk slashBankKeeper, dk slashDistrKeeper) SlashHooks { + return SlashHooks{stakingKeeper: sk, bankKeeper: bk, distrKeeper: dk} +} + +var _ stakingtypes.StakingHooks = SlashHooks{} + +// BeforeValidatorSlashed mints the equivalent of the slash amount into the +// community pool before the tokens are burned +func (h SlashHooks) BeforeValidatorSlashed(ctx context.Context, valAddr sdk.ValAddress, fraction math.LegacyDec) error { + validator, err := h.stakingKeeper.GetValidator(ctx, valAddr) + if err != nil || !validator.Tokens.IsPositive() { + return err + } + + // The effectiveFraction passed by slashToPool is tokensToBurn / validator.Tokens, + // so multiplying back recovers tokensToBurn + mintAmount := math.LegacyNewDecFromInt(validator.Tokens).Mul(fraction).TruncateInt() + if mintAmount.IsZero() { + return nil + } + + bondDenom, err := h.stakingKeeper.BondDenom(ctx) + if err != nil { + return err + } + + coins := sdk.NewCoins(sdk.NewCoin(bondDenom, mintAmount)) + + // Mint into the tokenfactory module account (needs mint permission) + // and immediately forward to the distribution community pool + if err := h.bankKeeper.MintCoins(ctx, tokenfactorytypes.ModuleName, coins); err != nil { + return err + } + + tokenfactoryAddr := authtypes.NewModuleAddress(tokenfactorytypes.ModuleName) + return h.distrKeeper.FundCommunityPool(ctx, coins, tokenfactoryAddr) +} + +// The remaining hooks are not in use; only BeforeValidatorSlashed is used + +func (h SlashHooks) AfterValidatorCreated(_ context.Context, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) BeforeValidatorModified(_ context.Context, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) AfterValidatorRemoved(_ context.Context, _ sdk.ConsAddress, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) AfterValidatorBonded(_ context.Context, _ sdk.ConsAddress, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) AfterValidatorBeginUnbonding(_ context.Context, _ sdk.ConsAddress, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) BeforeDelegationCreated(_ context.Context, _ sdk.AccAddress, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) BeforeDelegationSharesModified(_ context.Context, _ sdk.AccAddress, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) BeforeDelegationRemoved(_ context.Context, _ sdk.AccAddress, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) AfterDelegationModified(_ context.Context, _ sdk.AccAddress, _ sdk.ValAddress) error { + return nil +} + +func (h SlashHooks) AfterUnbondingInitiated(_ context.Context, _ uint64) error { + return nil +} diff --git a/app/keepers/slash_hooks_test.go b/app/keepers/slash_hooks_test.go new file mode 100644 index 00000000..67bddf6d --- /dev/null +++ b/app/keepers/slash_hooks_test.go @@ -0,0 +1,139 @@ +package keepers_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + tmtypes "github.com/cometbft/cometbft/proto/tendermint/types" + + "cosmossdk.io/math" + + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + + kiichain "github.com/kiichain/kiichain/v7/app" + helpers "github.com/kiichain/kiichain/v7/app/helpers" +) + +// createBondedValidator funds a new validator address, creates the validator, +// runs EndBlocker to move it to the bonded set, and registers signing info. +// Returns the val address and consensus address +func createBondedValidator(t *testing.T, app *kiichain.KiichainApp, ctx sdk.Context, stakedAmount math.Int) (sdk.ValAddress, sdk.ConsAddress) { + t.Helper() + + privKey := secp256k1.GenPrivKey() + pubKey := privKey.PubKey() + valAddr := sdk.ValAddress(pubKey.Address()) + consAddr := sdk.ConsAddress(pubKey.Address()) + + bondDenom, err := app.StakingKeeper.BondDenom(ctx) + require.NoError(t, err) + + selfBond := sdk.NewCoins(sdk.NewCoin(bondDenom, stakedAmount)) + require.NoError(t, app.BankKeeper.MintCoins(ctx, "tokenfactory", selfBond)) + require.NoError(t, app.BankKeeper.SendCoinsFromModuleToAccount(ctx, "tokenfactory", sdk.AccAddress(valAddr), selfBond)) + + msgServer := stakingkeeper.NewMsgServerImpl(app.StakingKeeper) + createMsg, err := stakingtypes.NewMsgCreateValidator( + valAddr.String(), + pubKey, + sdk.NewCoin(bondDenom, stakedAmount), + stakingtypes.Description{Moniker: "test-validator"}, + stakingtypes.NewCommissionRates( + math.LegacyNewDecWithPrec(5, 2), + math.LegacyNewDecWithPrec(20, 2), + math.LegacyNewDecWithPrec(1, 2), + ), + math.OneInt(), + ) + require.NoError(t, err) + _, err = msgServer.CreateValidator(ctx, createMsg) + require.NoError(t, err) + + _, err = app.StakingKeeper.EndBlocker(ctx) + require.NoError(t, err) + + signingInfo := slashingtypes.NewValidatorSigningInfo( + consAddr, ctx.BlockHeight(), 0, time.Unix(0, 0), false, 0, + ) + require.NoError(t, app.SlashingKeeper.SetValidatorSigningInfo(ctx, consAddr, signingInfo)) + + return valAddr, consAddr +} + +// TestSlashHooks_SupplyUnchangedAfterSlash verifies that when +// the staking keeper slashes a validator, the total token supply is +// unchanged +func TestSlashHooks_SupplyUnchangedAfterSlash(t *testing.T) { + app := helpers.Setup(t) + ctx := app.NewUncachedContext(true, tmtypes.Header{ + Height: 1, + ChainID: "testing", + Time: time.Now().UTC(), + }) + + bondDenom, err := app.StakingKeeper.BondDenom(ctx) + require.NoError(t, err) + + stakedAmount := sdk.TokensFromConsensusPower(100, sdk.DefaultPowerReduction) + valAddr, consAddr := createBondedValidator(t, app, ctx, stakedAmount) + + supplyBefore := app.BankKeeper.GetSupply(ctx, bondDenom).Amount + + validator, err := app.StakingKeeper.GetValidator(ctx, valAddr) + require.NoError(t, err) + powerReduction := app.StakingKeeper.PowerReduction(ctx) + consensusPower := validator.GetConsensusPower(powerReduction) + + slashFactor := math.LegacyNewDecWithPrec(5, 2) // 5% + slashed, err := app.StakingKeeper.Slash(ctx, consAddr, 0, consensusPower, slashFactor) + require.NoError(t, err) + require.True(t, slashed.IsPositive(), "expected a non-zero slash amount") + + // Supply must be unchanged: the burn is offset by the hook's mint + require.Equal(t, supplyBefore, app.BankKeeper.GetSupply(ctx, bondDenom).Amount, + "total supply must be unchanged after slash") + + // Community pool must have received exactly the slashed amount + feePool, err := app.DistrKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPool := feePool.CommunityPool.AmountOf(bondDenom) + require.Equal(t, math.LegacyNewDecFromInt(slashed), communityPool, + "community pool must receive exactly the slash amount") +} + +// TestSlashHooks_ZeroSlashFactor verifies no tokens move and supply is +// unchanged when the slash factor is zero +func TestSlashHooks_ZeroSlashFactor(t *testing.T) { + app := helpers.Setup(t) + ctx := app.NewUncachedContext(true, tmtypes.Header{ + Height: 1, + ChainID: "testing", + Time: time.Now().UTC(), + }) + + bondDenom, err := app.StakingKeeper.BondDenom(ctx) + require.NoError(t, err) + + stakedAmount := sdk.TokensFromConsensusPower(100, sdk.DefaultPowerReduction) + _, consAddr := createBondedValidator(t, app, ctx, stakedAmount) + + supplyBefore := app.BankKeeper.GetSupply(ctx, bondDenom).Amount + + validator, err := app.StakingKeeper.GetValidatorByConsAddr(ctx, consAddr) + require.NoError(t, err) + powerReduction := app.StakingKeeper.PowerReduction(ctx) + consensusPower := validator.GetConsensusPower(powerReduction) + + slashed, err := app.StakingKeeper.Slash(ctx, consAddr, 0, consensusPower, math.LegacyZeroDec()) + require.NoError(t, err) + require.True(t, slashed.IsZero(), "expected zero slash for zero factor") + + require.Equal(t, supplyBefore, app.BankKeeper.GetSupply(ctx, bondDenom).Amount, + "supply must be unchanged when slash factor is zero") +} diff --git a/x/oracle/keeper/keeper.go b/x/oracle/keeper/keeper.go index cd6f1f33..753e0155 100644 --- a/x/oracle/keeper/keeper.go +++ b/x/oracle/keeper/keeper.go @@ -25,6 +25,7 @@ type Keeper struct { accountKeeper types.AccountKeeper bankKeeper types.BankKeeper StakingKeeper types.StakingKeeper + distrKeeper types.DistributionKeeper // Schema of the module Schema collections.Schema @@ -44,7 +45,7 @@ type Keeper struct { // NewKeeper creates an oracle Keeper instance func NewKeeper(cdc codec.BinaryCodec, storeService corestoretypes.KVStoreService, accountKeeper types.AccountKeeper, bankKeeper types.BankKeeper, stakingKeeper types.StakingKeeper, - authority string, + distrKeeper types.DistributionKeeper, authority string, ) Keeper { // Ensure oracle module account is set addr := accountKeeper.GetModuleAddress(types.ModuleName) @@ -66,6 +67,7 @@ func NewKeeper(cdc codec.BinaryCodec, storeService corestoretypes.KVStoreService accountKeeper: accountKeeper, bankKeeper: bankKeeper, StakingKeeper: stakingKeeper, + distrKeeper: distrKeeper, Params: collections.NewItem(sb, types.ParamsKey, "params", codec.CollValue[types.Params](cdc)), ExchangeRate: collections.NewMap(sb, types.ExchangeRateKey, "exchange_rate", collections.StringKey, codec.CollValue[types.OracleExchangeRate](cdc)), FeederDelegation: collections.NewMap(sb, types.FeederDelegationKey, "feeder_delegation", sdk.ValAddressKey, collections.StringValue), diff --git a/x/oracle/keeper/slash.go b/x/oracle/keeper/slash.go index 1399d5c6..ef4348e8 100644 --- a/x/oracle/keeper/slash.go +++ b/x/oracle/keeper/slash.go @@ -7,6 +7,7 @@ import ( "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/kiichain/kiichain/v7/x/oracle/types" @@ -15,9 +16,6 @@ import ( // SlashAndResetCounters calculate if the validator must be slashed if success votes / total votes // is lower than MinValidPerWindow param. Then reset the vote penalty info func (k Keeper) SlashAndResetCounters(ctx sdk.Context) error { - height := ctx.BlockHeight() - distributionHeight := height - sdk.ValidatorUpdateDelay - 1 - // Get the module params params, err := k.Params.Get(ctx) if err != nil { @@ -69,8 +67,8 @@ func (k Keeper) SlashAndResetCounters(ctx sdk.Context) error { // Calculate consensus power consensusPower := validator.GetConsensusPower(powerReduction) - // Slash the validator - _, err = k.StakingKeeper.Slash(ctx, consAddr, distributionHeight, consensusPower, slashFraction) + // Redirect the slash amount to the community pool instead of burning + _, err = k.slashToPool(ctx, consAddr, consensusPower, slashFraction) if err != nil { k.Logger(ctx).Error("failed to slash validator", "operator", operator.String(), "error", err) return true, err @@ -103,3 +101,54 @@ func (k Keeper) SlashAndResetCounters(ctx sdk.Context) error { }) return err } + +// slashToPool removes tokens from a validator and sends them to the community pool +// instead of burning, preserving total supply. +func (k Keeper) slashToPool(ctx sdk.Context, consAddr sdk.ConsAddress, consensusPower int64, slashFactor math.LegacyDec) (math.Int, error) { + if slashFactor.IsZero() { + return math.ZeroInt(), nil + } + + // Calculate slash amount: same math as the staking module + amount := k.StakingKeeper.TokensFromConsensusPower(ctx, consensusPower) + slashAmount := math.LegacyNewDecFromInt(amount).Mul(slashFactor).TruncateInt() + + // Get the concrete validator (needed for RemoveValidatorTokens) + validator, err := k.StakingKeeper.GetValidatorByConsAddr(ctx, consAddr) + if err != nil { + return math.ZeroInt(), err + } + + // Clamp slash amount to the validator's available tokens + tokensToBurn := math.MinInt(slashAmount, validator.Tokens) + if tokensToBurn.IsZero() { + return math.ZeroInt(), nil + } + + // Remove tokens from the validator's bookkeeping without burning + _, err = k.StakingKeeper.RemoveValidatorTokens(ctx, validator, tokensToBurn) + if err != nil { + return math.ZeroInt(), err + } + + // Create the coin (value but with akii) + bondDenom, err := k.StakingKeeper.BondDenom(ctx) + if err != nil { + return math.ZeroInt(), err + } + coins := sdk.NewCoins(sdk.NewCoin(bondDenom, tokensToBurn)) + // Send the slashed tokens from the bonded pool to the community pool + bondedPoolAddr := authtypes.NewModuleAddress(stakingtypes.BondedPoolName) + if err := k.distrKeeper.FundCommunityPool(ctx, coins, bondedPoolAddr); err != nil { + return math.ZeroInt(), err + } + + k.Logger(ctx).Info( + "oracle slash redirected to community pool", + "validator", consAddr.String(), + "slash_factor", slashFactor.String(), + "amount", tokensToBurn.String(), + ) + + return tokensToBurn, nil +} diff --git a/x/oracle/keeper/slash_test.go b/x/oracle/keeper/slash_test.go index 6d0fe196..8a8cabde 100644 --- a/x/oracle/keeper/slash_test.go +++ b/x/oracle/keeper/slash_test.go @@ -8,10 +8,12 @@ import ( "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/kiichain/kiichain/v7/x/oracle/types" + "github.com/kiichain/kiichain/v7/x/oracle/utils" ) func TestSlashAndResetMissCounters(t *testing.T) { @@ -236,3 +238,299 @@ func TestSlashAndResetCounters_MultipleValidatorsNotFound(t *testing.T) { require.Equal(t, expectedTokens, validator.Tokens) } } + +// TestSlashToPool_SupplyPreserved is the core regression test for the supply-burn bug. +// +// Previous behavior: oracle slash called StakingKeeper.Slash which burned tokens from the +// bonded pool, permanently reducing total supply. +// +// Current behavior: slashToPool redirects those tokens to the community pool instead. +// Total supply is unchanged; only ownership shifts (bonded pool → community pool). +func TestSlashToPool_SupplyPreserved(t *testing.T) { + input := CreateTestInput(t) + ctx := input.Ctx + bankKeeper := input.BankKeeper + stakingKeeper := input.StakingKeeper + oracleKeeper := input.OracleKeeper + distKeeper := input.DistKeeper + + // Create a validator with 100 units of staked tokens + stakedAmount := sdk.TokensFromConsensusPower(100, sdk.DefaultPowerReduction) + msgServer := stakingkeeper.NewMsgServerImpl(&stakingKeeper) + _, err := msgServer.CreateValidator(ctx, NewTestMsgCreateValidator(ValAddrs[0], ValPubKeys[0], stakedAmount)) + require.NoError(t, err) + _, err = stakingKeeper.EndBlocker(ctx) + require.NoError(t, err) + + // Fetch oracle params and set a non-zero slash fraction so the slash fires + params, err := oracleKeeper.Params.Get(ctx) + require.NoError(t, err) + params.SlashFraction = math.LegacyNewDecWithPrec(1, 2) // 1% + require.NoError(t, oracleKeeper.Params.Set(ctx, params)) + + bondDenom := utils.KiiDenom + expectedSlash := params.SlashFraction.MulInt(stakedAmount).TruncateInt() + + // Snapshot state before the slash + totalSupplyBefore := bankKeeper.GetSupply(ctx, bondDenom).Amount + feePoolBefore, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPoolBefore := feePoolBefore.CommunityPool.AmountOf(bondDenom) + bondedPoolBefore := bankKeeper.GetBalance(ctx, authtypes.NewModuleAddress(stakingtypes.BondedPoolName), bondDenom).Amount + + // Trigger the oracle slash: miss enough votes to fall below MinValidPerWindow + votePeriodsPerWindow := math.LegacyNewDec(int64(params.SlashWindow)).QuoInt64(int64(params.VotePeriod)).TruncateInt64() + minValidVotes := params.MinValidPerWindow.MulInt64(votePeriodsPerWindow).TruncateInt64() + require.NoError(t, oracleKeeper.VotePenaltyCounter.Set(ctx, ValAddrs[0], types.NewVotePenaltyCounter( + uint64(votePeriodsPerWindow-minValidVotes+1), // misses + 0, + uint64(minValidVotes-1), // successes (below threshold) + ))) + require.NoError(t, oracleKeeper.SlashAndResetCounters(ctx)) + + // Snapshot state after the slash + totalSupplyAfter := bankKeeper.GetSupply(ctx, bondDenom).Amount + feePoolAfter, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPoolAfter := feePoolAfter.CommunityPool.AmountOf(bondDenom) + bondedPoolAfter := bankKeeper.GetBalance(ctx, authtypes.NewModuleAddress(stakingtypes.BondedPoolName), bondDenom).Amount + + // Previous behavior would have reduced supply by expectedSlash: + // require.Equal(t, totalSupplyBefore.Sub(expectedSlash), totalSupplyAfter) // BURNS + // + // Current behavior: supply is unchanged + require.Equal(t, totalSupplyBefore, totalSupplyAfter, "total supply must not change after oracle slash") + + // The slashed tokens moved from the bonded pool to the community pool + require.Equal(t, bondedPoolBefore.Sub(expectedSlash), bondedPoolAfter, "bonded pool must decrease by slash amount") + require.Equal(t, communityPoolBefore.Add(math.LegacyNewDecFromInt(expectedSlash)), communityPoolAfter, "community pool must increase by slash amount") + + // The validator's bonded token count is reduced + validator, err := stakingKeeper.GetValidator(ctx, ValAddrs[0]) + require.NoError(t, err) + require.Equal(t, stakedAmount.Sub(expectedSlash), validator.GetBondedTokens()) +} + +// TestSlashToPool_TokensClamped verifies that when the calculated slash amount +// exceeds the validator's actual token balance, the slash is capped to what +// the validator actually holds. +func TestSlashToPool_TokensClamped(t *testing.T) { + input := CreateTestInput(t) + ctx := input.Ctx + bankKeeper := input.BankKeeper + stakingKeeper := input.StakingKeeper + oracleKeeper := input.OracleKeeper + distKeeper := input.DistKeeper + + // Create a validator with only 5 consensus power units + smallStake := sdk.TokensFromConsensusPower(5, sdk.DefaultPowerReduction) + msgServer := stakingkeeper.NewMsgServerImpl(&stakingKeeper) + _, err := msgServer.CreateValidator(ctx, NewTestMsgCreateValidator(ValAddrs[0], ValPubKeys[0], smallStake)) + require.NoError(t, err) + _, err = stakingKeeper.EndBlocker(ctx) + require.NoError(t, err) + + validator, err := stakingKeeper.GetValidator(ctx, ValAddrs[0]) + require.NoError(t, err) + consAddr, err := validator.GetConsAddr() + require.NoError(t, err) + + bondDenom := utils.KiiDenom + totalSupplyBefore := bankKeeper.GetSupply(ctx, bondDenom).Amount + feePoolBefore, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPoolBefore := feePoolBefore.CommunityPool.AmountOf(bondDenom) + + // Call slashToPool directly with an overstated power of 100 and 100% fraction. + // Calculated slash = 100 * powerReduction, but validator only holds 5 * powerReduction. + // The clamp fires: actual slash = smallStake. + slashedAmount, err := oracleKeeper.slashToPool(ctx, consAddr, 100, math.LegacyOneDec()) + require.NoError(t, err) + + require.Equal(t, smallStake, slashedAmount, "slash must be clamped to the validator's actual token balance") + + // Supply is still preserved even though the calculated amount was much larger + require.Equal(t, totalSupplyBefore, bankKeeper.GetSupply(ctx, bondDenom).Amount) + + // Community pool received exactly the clamped amount, not the overstated calculation + feePoolAfter, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPoolAfter := feePoolAfter.CommunityPool.AmountOf(bondDenom) + require.Equal(t, communityPoolBefore.Add(math.LegacyNewDecFromInt(smallStake)), communityPoolAfter) + + // Validator is fully drained + validatorAfter, err := stakingKeeper.GetValidator(ctx, ValAddrs[0]) + require.NoError(t, err) + require.True(t, validatorAfter.Tokens.IsZero()) +} + +// TestSlashToPool_ConsecutiveWindows verifies that across multiple slash windows +// the total supply never decreases and the community pool accumulates each slash. +func TestSlashToPool_ConsecutiveWindows(t *testing.T) { + input := CreateTestInput(t) + ctx := input.Ctx + bankKeeper := input.BankKeeper + stakingKeeper := input.StakingKeeper + oracleKeeper := input.OracleKeeper + distKeeper := input.DistKeeper + + stakedAmount := sdk.TokensFromConsensusPower(100, sdk.DefaultPowerReduction) + msgServer := stakingkeeper.NewMsgServerImpl(&stakingKeeper) + _, err := msgServer.CreateValidator(ctx, NewTestMsgCreateValidator(ValAddrs[0], ValPubKeys[0], stakedAmount)) + require.NoError(t, err) + _, err = stakingKeeper.EndBlocker(ctx) + require.NoError(t, err) + + params, err := oracleKeeper.Params.Get(ctx) + require.NoError(t, err) + params.SlashFraction = math.LegacyNewDecWithPrec(1, 2) // 1% + require.NoError(t, oracleKeeper.Params.Set(ctx, params)) + + bondDenom := utils.KiiDenom + votePeriodsPerWindow := math.LegacyNewDec(int64(params.SlashWindow)).QuoInt64(int64(params.VotePeriod)).TruncateInt64() + minValidVotes := params.MinValidPerWindow.MulInt64(votePeriodsPerWindow).TruncateInt64() + missCounter := func() types.VotePenaltyCounter { + return types.NewVotePenaltyCounter( + uint64(votePeriodsPerWindow-minValidVotes+1), + 0, + uint64(minValidVotes-1), + ) + } + + totalSupplyBefore := bankKeeper.GetSupply(ctx, bondDenom).Amount + + // --- Window 1 --- + require.NoError(t, oracleKeeper.VotePenaltyCounter.Set(ctx, ValAddrs[0], missCounter())) + require.NoError(t, oracleKeeper.SlashAndResetCounters(ctx)) + + validatorAfterW1, err := stakingKeeper.GetValidator(ctx, ValAddrs[0]) + require.NoError(t, err) + slashAmount1 := stakedAmount.Sub(validatorAfterW1.Tokens) + + feePoolAfterW1, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + require.Equal(t, math.LegacyNewDecFromInt(slashAmount1), feePoolAfterW1.CommunityPool.AmountOf(bondDenom)) + require.Equal(t, totalSupplyBefore, bankKeeper.GetSupply(ctx, bondDenom).Amount, "supply must be unchanged after window 1") + + // Unjail the validator so it is eligible for slashing in window 2 + validatorAfterW1.Jailed = false + require.NoError(t, stakingKeeper.SetValidator(ctx, validatorAfterW1)) + + // --- Window 2 --- + require.NoError(t, oracleKeeper.VotePenaltyCounter.Set(ctx, ValAddrs[0], missCounter())) + require.NoError(t, oracleKeeper.SlashAndResetCounters(ctx)) + + validatorAfterW2, err := stakingKeeper.GetValidator(ctx, ValAddrs[0]) + require.NoError(t, err) + slashAmount2 := validatorAfterW1.Tokens.Sub(validatorAfterW2.Tokens) + + feePoolAfterW2, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPoolAfterW2 := feePoolAfterW2.CommunityPool.AmountOf(bondDenom) + + // Supply is preserved across both windows + require.Equal(t, totalSupplyBefore, bankKeeper.GetSupply(ctx, bondDenom).Amount, "supply must be unchanged after window 2") + + // Community pool holds the sum of both slash amounts + require.Equal(t, math.LegacyNewDecFromInt(slashAmount1.Add(slashAmount2)), communityPoolAfterW2) +} + +// TestSlashToPool_AccumulatesOnExistingCommunityPool verifies that when the +// community pool already holds funds, the slash amount is added on top rather +// than replacing the existing balance. +func TestSlashToPool_AccumulatesOnExistingCommunityPool(t *testing.T) { + input := CreateTestInput(t) + ctx := input.Ctx + bankKeeper := input.BankKeeper + stakingKeeper := input.StakingKeeper + oracleKeeper := input.OracleKeeper + distKeeper := input.DistKeeper + + stakedAmount := sdk.TokensFromConsensusPower(100, sdk.DefaultPowerReduction) + msgServer := stakingkeeper.NewMsgServerImpl(&stakingKeeper) + _, err := msgServer.CreateValidator(ctx, NewTestMsgCreateValidator(ValAddrs[0], ValPubKeys[0], stakedAmount)) + require.NoError(t, err) + _, err = stakingKeeper.EndBlocker(ctx) + require.NoError(t, err) + + params, err := oracleKeeper.Params.Get(ctx) + require.NoError(t, err) + params.SlashFraction = math.LegacyNewDecWithPrec(1, 2) // 1% + require.NoError(t, oracleKeeper.Params.Set(ctx, params)) + + bondDenom := utils.KiiDenom + + // Pre-fund the community pool using Addrs[1] (a non-validator address with InitialCoins) + existingFunds := sdk.NewCoins(sdk.NewCoin(bondDenom, sdk.TokensFromConsensusPower(1, sdk.DefaultPowerReduction))) + require.NoError(t, distKeeper.FundCommunityPool(ctx, existingFunds, Addrs[1])) + + feePoolBefore, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPoolBefore := feePoolBefore.CommunityPool.AmountOf(bondDenom) + require.Equal(t, math.LegacyNewDecFromInt(existingFunds[0].Amount), communityPoolBefore, "community pool must reflect the pre-funded amount") + + // Capture supply after the FundCommunityPool call (coins moved from Addrs[1], supply unchanged) + totalSupplyBefore := bankKeeper.GetSupply(ctx, bondDenom).Amount + expectedSlash := params.SlashFraction.MulInt(stakedAmount).TruncateInt() + + // Trigger oracle slash + votePeriodsPerWindow := math.LegacyNewDec(int64(params.SlashWindow)).QuoInt64(int64(params.VotePeriod)).TruncateInt64() + minValidVotes := params.MinValidPerWindow.MulInt64(votePeriodsPerWindow).TruncateInt64() + require.NoError(t, oracleKeeper.VotePenaltyCounter.Set(ctx, ValAddrs[0], types.NewVotePenaltyCounter( + uint64(votePeriodsPerWindow-minValidVotes+1), + 0, + uint64(minValidVotes-1), + ))) + require.NoError(t, oracleKeeper.SlashAndResetCounters(ctx)) + + // The slash amount must be added to the existing balance, not replace it + feePoolAfter, err := distKeeper.FeePool.Get(ctx) + require.NoError(t, err) + communityPoolAfter := feePoolAfter.CommunityPool.AmountOf(bondDenom) + require.Equal(t, communityPoolBefore.Add(math.LegacyNewDecFromInt(expectedSlash)), communityPoolAfter, + "slash must add to the existing community pool balance") + + // Supply is unchanged + require.Equal(t, totalSupplyBefore, bankKeeper.GetSupply(ctx, bondDenom).Amount) +} + +// TestSlashToPool_ZeroSlashFraction verifies that when SlashFraction is 0, +// no tokens move and supply is unchanged. +func TestSlashToPool_ZeroSlashFraction(t *testing.T) { + input := CreateTestInput(t) + ctx := input.Ctx + bankKeeper := input.BankKeeper + stakingKeeper := input.StakingKeeper + oracleKeeper := input.OracleKeeper + + stakedAmount := sdk.TokensFromConsensusPower(100, sdk.DefaultPowerReduction) + msgServer := stakingkeeper.NewMsgServerImpl(&stakingKeeper) + _, err := msgServer.CreateValidator(ctx, NewTestMsgCreateValidator(ValAddrs[0], ValPubKeys[0], stakedAmount)) + require.NoError(t, err) + _, err = stakingKeeper.EndBlocker(ctx) + require.NoError(t, err) + + params, err := oracleKeeper.Params.Get(ctx) + require.NoError(t, err) + params.SlashFraction = math.LegacyZeroDec() // 0% — no slash + require.NoError(t, oracleKeeper.Params.Set(ctx, params)) + + totalSupplyBefore := bankKeeper.GetSupply(ctx, utils.KiiDenom).Amount + + votePeriodsPerWindow := math.LegacyNewDec(int64(params.SlashWindow)).QuoInt64(int64(params.VotePeriod)).TruncateInt64() + minValidVotes := params.MinValidPerWindow.MulInt64(votePeriodsPerWindow).TruncateInt64() + require.NoError(t, oracleKeeper.VotePenaltyCounter.Set(ctx, ValAddrs[0], types.NewVotePenaltyCounter( + uint64(votePeriodsPerWindow-minValidVotes+1), + 0, + uint64(minValidVotes-1), + ))) + require.NoError(t, oracleKeeper.SlashAndResetCounters(ctx)) + + totalSupplyAfter := bankKeeper.GetSupply(ctx, utils.KiiDenom).Amount + require.Equal(t, totalSupplyBefore, totalSupplyAfter) + + // Validator tokens are untouched when slash fraction is zero + validator, err := stakingKeeper.GetValidator(ctx, ValAddrs[0]) + require.NoError(t, err) + require.Equal(t, stakedAmount, validator.GetBondedTokens()) +} diff --git a/x/oracle/keeper/test_utils.go b/x/oracle/keeper/test_utils.go index 70937c45..962994e8 100644 --- a/x/oracle/keeper/test_utils.go +++ b/x/oracle/keeper/test_utils.go @@ -269,7 +269,7 @@ func CreateTestInput(t *testing.T) TestInput { // Set Oracle module oracleKeeper := NewKeeper(appCodec, runtime.NewKVStoreService(keys[types.StoreKey]), - accountKeeper, bankKeeper, stakingKeeper, authority.String()) + accountKeeper, bankKeeper, stakingKeeper, distKeeper, authority.String()) oracleParams := types.DefaultParams() diff --git a/x/oracle/types/expected_keepers.go b/x/oracle/types/expected_keepers.go index 93489471..bb42c21f 100644 --- a/x/oracle/types/expected_keepers.go +++ b/x/oracle/types/expected_keepers.go @@ -15,13 +15,21 @@ import ( // StakingKeeper is expected keeper for staking module, because I need to handle // reward and slashink on my oracle module type StakingKeeper interface { - Validator(ctx context.Context, address sdk.ValAddress) (stakingtypes.ValidatorI, error) // Retrieves a validator's information - TotalBondedTokens(ctx context.Context) (math.Int, error) // Retrieves total staked tokens (useful for slashing calculations) - Slash(ctx context.Context, consAddr sdk.ConsAddress, infractionHeight, power int64, slashFactor math.LegacyDec) (math.Int, error) // Slashes a validator or delegate who fails to vote in the oracle - Jail(ctx context.Context, consAddr sdk.ConsAddress) error // Jail validators - ValidatorsPowerStoreIterator(ctx context.Context) (corestore.Iterator, error) // Used to computing validator rankings or total power - MaxValidators(ctx context.Context) (uint32, error) // Return the maximum amount of bonded validators - PowerReduction(ctx context.Context) (res math.Int) // Returns the power reduction factor, + Validator(ctx context.Context, address sdk.ValAddress) (stakingtypes.ValidatorI, error) // Retrieves a validator's information + TotalBondedTokens(ctx context.Context) (math.Int, error) // Retrieves total staked tokens (useful for slashing calculations) + Jail(ctx context.Context, consAddr sdk.ConsAddress) error // Jail validators + ValidatorsPowerStoreIterator(ctx context.Context) (corestore.Iterator, error) // Used to computing validator rankings or total power + MaxValidators(ctx context.Context) (uint32, error) // Return the maximum amount of bonded validators + PowerReduction(ctx context.Context) (res math.Int) // Returns the power reduction factor + GetValidatorByConsAddr(ctx context.Context, consAddr sdk.ConsAddress) (stakingtypes.Validator, error) // Retrieves a validator by consensus address + RemoveValidatorTokens(ctx context.Context, validator stakingtypes.Validator, tokensToRemove math.Int) (stakingtypes.Validator, error) // Removes tokens from a validator without burning + TokensFromConsensusPower(ctx context.Context, power int64) math.Int // Converts consensus power to token amount + BondDenom(ctx context.Context) (string, error) // Returns the bond denomination +} + +// DistributionKeeper is the expected keeper for the distribution module +type DistributionKeeper interface { + FundCommunityPool(ctx context.Context, amount sdk.Coins, sender sdk.AccAddress) error // Sends coins to the community pool } // AccountKeeper is expected keeper for auth module, because I need to handle diff --git a/x/tokenfactory/keeper/createdenom_test.go b/x/tokenfactory/keeper/createdenom_test.go index cb16ddd0..b413af9e 100644 --- a/x/tokenfactory/keeper/createdenom_test.go +++ b/x/tokenfactory/keeper/createdenom_test.go @@ -58,6 +58,44 @@ func (suite *KeeperTestSuite) TestMsgCreateDenom() { suite.Require().Error(err) } +func (suite *KeeperTestSuite) TestCreateDenomCommunityPoolFunding() { + tokenFactoryKeeper := suite.App.TokenFactoryKeeper + bankKeeper := suite.App.BankKeeper + distrKeeper := suite.App.DistrKeeper + + // Enable community pool fee funding capability + tokenFactoryKeeper.SetEnabledCapabilities(suite.Ctx, []string{types.EnableCommunityPoolFeeFunding}) + + // Set a non-zero denom creation fee + fee := sdk.NewCoins(sdk.NewCoin(types.DefaultParams().DenomCreationFee[0].Denom, sdkmath.NewInt(50000000))) + err := tokenFactoryKeeper.SetParams(suite.Ctx, types.Params{DenomCreationFee: fee}) + suite.Require().NoError(err) + + // Record community pool balance and total supply before creating denom + feePoolBefore, err := distrKeeper.FeePool.Get(suite.Ctx) + suite.Require().NoError(err) + communityPoolBefore := feePoolBefore.CommunityPool.AmountOf(fee[0].Denom) + + totalSupplyBefore := bankKeeper.GetSupply(suite.Ctx, fee[0].Denom) + + // Create a denom, paying the fee + _, err = suite.msgServer.CreateDenom(suite.Ctx, types.NewMsgCreateDenom(suite.TestAccs[0].String(), "poolcoin")) + suite.Require().NoError(err) + + // Community pool should have increased by the fee amount + feePoolAfter, err := distrKeeper.FeePool.Get(suite.Ctx) + suite.Require().NoError(err) + communityPoolAfter := feePoolAfter.CommunityPool.AmountOf(fee[0].Denom) + suite.Require().Equal(communityPoolBefore.Add(sdkmath.LegacyNewDecFromInt(fee[0].Amount)), communityPoolAfter) + + // Total supply must NOT have decreased (no tokens were burned) + totalSupplyAfter := bankKeeper.GetSupply(suite.Ctx, fee[0].Denom) + suite.Require().True(totalSupplyAfter.Amount.Equal(totalSupplyBefore.Amount), + "supply should not change when fee goes to community pool, got before=%s after=%s", + totalSupplyBefore, totalSupplyAfter, + ) +} + func (suite *KeeperTestSuite) TestCreateDenom() { var ( primaryDenom = types.DefaultParams().DenomCreationFee[0].Denom