diff --git a/x/vaas/provider/keeper/fees.go b/x/vaas/provider/keeper/fees.go index 5a094c8..a88a907 100644 --- a/x/vaas/provider/keeper/fees.go +++ b/x/vaas/provider/keeper/fees.go @@ -5,15 +5,12 @@ import ( "github.com/allinbits/vaas/x/vaas/provider/types" - cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" - errorsmod "cosmossdk.io/errors" "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" - stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" ) // GetConsumerFeePoolAddress returns the deterministic provider-side fee pool @@ -77,67 +74,33 @@ func (k Keeper) CollectFeesFromConsumers(ctx sdk.Context) sdk.Coin { } // DistributeFeesToValidators splits the provider module account's currently -// available consumer-fee balance equally among the validators that both -// signed the previous block AND are still bonded in the current block. +// available consumer-fee balance equally among all bonded validators. // -// The service consumers pay for (being validated) is delivered roughly evenly -// by each signing validator, independent of their stake, so the pay matches -// the work rather than the stake. Bonded validators that did not sign are -// skipped, which penalizes downtime in the same way Cosmos SDK x/distribution -// does via BeginBlock VoteInfos. Signers that have since unbonded or been -// removed from the set forfeit their share — we only pay current participants. +// Every bonded validator receives an equal share regardless of whether it +// signed the previous block. Offline or absent validators are not excluded. func (k Keeper) DistributeFeesToValidators(ctx sdk.Context) error { totalFees := k.bankKeeper.GetBalance(ctx, authtypes.NewModuleAddress(types.ModuleName), k.GetFeeDenom()) if totalFees.IsZero() { return nil } - // Fetch the full bonded set once and index by consensus address. The - // signers in VoteInfos are matched against this map so we only pay - // validators that are still bonded in the current block. bonded, err := k.stakingKeeper.GetBondedValidatorsByPower(ctx) if err != nil { return fmt.Errorf("failed to get bonded validators: %w", err) } - byCons := make(map[string]stakingtypes.Validator, len(bonded)) - for _, v := range bonded { - consAddr, err := v.GetConsAddr() - if err != nil { - k.Logger(ctx).Debug("skipping bonded validator with unreadable consensus pubkey", - "operator", v.GetOperator(), - "err", err, - ) - continue - } - byCons[string(consAddr)] = v - } - - // VoteInfos carries the previous block's LastCommit with a BlockIdFlag - // indicating whether each validator signed, voted nil, or was absent. - eligible := make([]stakingtypes.Validator, 0, len(ctx.VoteInfos())) - for _, vote := range ctx.VoteInfos() { - if vote.BlockIdFlag != cmtproto.BlockIDFlagCommit { - continue - } - v, ok := byCons[string(vote.Validator.Address)] - if !ok { - continue // signer no longer bonded - } - eligible = append(eligible, v) - } - if len(eligible) == 0 { + if len(bonded) == 0 { return nil } // Equal split. Any integer-division remainder stays in the provider // module account and is picked up by the next block's GetBalance. - share := totalFees.Amount.Quo(math.NewInt(int64(len(eligible)))) + share := totalFees.Amount.Quo(math.NewInt(int64(len(bonded)))) if share.IsZero() { return nil } shareCoins := sdk.NewCoins(sdk.NewCoin(totalFees.Denom, share)) - for _, val := range eligible { + for _, val := range bonded { valAddr, err := k.stakingKeeper.ValidatorAddressCodec().StringToBytes(val.GetOperator()) if err != nil { return fmt.Errorf("failed to parse validator address: %w", err) diff --git a/x/vaas/provider/keeper/fees_test.go b/x/vaas/provider/keeper/fees_test.go index 40d2479..2857227 100644 --- a/x/vaas/provider/keeper/fees_test.go +++ b/x/vaas/provider/keeper/fees_test.go @@ -5,9 +5,6 @@ import ( "errors" "testing" - abci "github.com/cometbft/cometbft/abci/types" - cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" - addresscodec "cosmossdk.io/core/address" "cosmossdk.io/math" testkeeper "github.com/allinbits/vaas/testutil/keeper" @@ -191,11 +188,9 @@ func TestCollectFeesFromConsumersContinuesOnGenericError(t *testing.T) { // Helpers for the distribution tests. -// newBondedValidator builds a Validator with a freshly generated consensus -// keypair so that val.GetConsAddr() returns a valid address. Returns the -// validator, its operator bytes (for sdk.AccAddress cast), and the consensus -// address to stamp into VoteInfos. -func newBondedValidator(t *testing.T, codec addresscodec.Codec, opSeed byte) (stakingtypes.Validator, []byte, sdk.ConsAddress) { +// newBondedValidator builds a Validator with an operator address derived from +// opSeed. Returns the validator and its operator bytes. +func newBondedValidator(t *testing.T, codec addresscodec.Codec, opSeed byte) (stakingtypes.Validator, []byte) { t.Helper() opBytes := bytes.Repeat([]byte{opSeed}, 20) op, err := codec.BytesToString(opBytes) @@ -206,25 +201,11 @@ func newBondedValidator(t *testing.T, codec addresscodec.Codec, opSeed byte) (st val.Status = stakingtypes.Bonded val.Tokens = sdk.DefaultPowerReduction val.DelegatorShares = math.LegacyNewDecFromInt(sdk.DefaultPowerReduction) - return val, opBytes, sdk.GetConsAddress(pk) -} - -func signerVote(consAddr sdk.ConsAddress) abci.VoteInfo { - return abci.VoteInfo{ - Validator: abci.Validator{Address: consAddr, Power: 1}, - BlockIdFlag: cmtproto.BlockIDFlagCommit, - } -} - -func absentVote(consAddr sdk.ConsAddress) abci.VoteInfo { - return abci.VoteInfo{ - Validator: abci.Validator{Address: consAddr, Power: 1}, - BlockIdFlag: cmtproto.BlockIDFlagAbsent, - } + return val, opBytes } -// TestDistributeFeesToValidators splits the pool evenly among signers, -// independent of their stake (tokens differ but shares are equal). +// TestDistributeFeesToValidators splits the pool evenly among all bonded +// validators, independent of their stake (tokens differ but shares are equal). func TestDistributeFeesToValidators(t *testing.T) { params := testkeeper.NewInMemKeeperParams(t) k, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, params) @@ -233,15 +214,14 @@ func TestDistributeFeesToValidators(t *testing.T) { valAddrCodec := address.NewBech32Codec("cosmosvaloper") mocks.MockStakingKeeper.EXPECT().ValidatorAddressCodec().Return(valAddrCodec).AnyTimes() - val1, op1Bytes, cons1 := newBondedValidator(t, valAddrCodec, 1) + val1, op1Bytes := newBondedValidator(t, valAddrCodec, 1) val1.Tokens = sdk.DefaultPowerReduction.MulRaw(10) // stake differs on purpose - val2, op2Bytes, cons2 := newBondedValidator(t, valAddrCodec, 2) + val2, op2Bytes := newBondedValidator(t, valAddrCodec, 2) val2.Tokens = sdk.DefaultPowerReduction.MulRaw(20) providerParams := providertypes.DefaultParams() providerParams.FeesPerBlockAmount = math.NewInt(10) k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{signerVote(cons1), signerVote(cons2)}) mocks.MockBankKeeper.EXPECT(). GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providertypes.DefaultFeesPerBlockDenom). @@ -277,7 +257,7 @@ func TestDistributeFeesToValidatorsZeroFees(t *testing.T) { } // TestDistributeFeesToValidatorsSkipsWhenFeesTooSmall: pool holds 1 coin, -// 2 signers → floor(1/2) = 0 → nothing sent, balance stays pooled. +// 2 validators → floor(1/2) = 0 → nothing sent, balance stays pooled. func TestDistributeFeesToValidatorsSkipsWhenFeesTooSmall(t *testing.T) { params := testkeeper.NewInMemKeeperParams(t) k, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, params) @@ -286,13 +266,12 @@ func TestDistributeFeesToValidatorsSkipsWhenFeesTooSmall(t *testing.T) { valAddrCodec := address.NewBech32Codec("cosmosvaloper") mocks.MockStakingKeeper.EXPECT().ValidatorAddressCodec().Return(valAddrCodec).AnyTimes() - val1, _, cons1 := newBondedValidator(t, valAddrCodec, 1) - val2, _, cons2 := newBondedValidator(t, valAddrCodec, 2) + val1, _ := newBondedValidator(t, valAddrCodec, 1) + val2, _ := newBondedValidator(t, valAddrCodec, 2) providerParams := providertypes.DefaultParams() providerParams.FeesPerBlockAmount = math.NewInt(1) k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{signerVote(cons1), signerVote(cons2)}) mocks.MockBankKeeper.EXPECT(). GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providertypes.DefaultFeesPerBlockDenom). @@ -304,7 +283,7 @@ func TestDistributeFeesToValidatorsSkipsWhenFeesTooSmall(t *testing.T) { require.NoError(t, k.DistributeFeesToValidators(ctx)) } -// TestDistributeFeesToValidatorsRemainderStaysPooled: 10 coins / 3 signers +// TestDistributeFeesToValidatorsRemainderStaysPooled: 10 coins / 3 validators // → each gets floor(10/3) = 3, 1 coin stays in the pool. func TestDistributeFeesToValidatorsRemainderStaysPooled(t *testing.T) { params := testkeeper.NewInMemKeeperParams(t) @@ -314,14 +293,13 @@ func TestDistributeFeesToValidatorsRemainderStaysPooled(t *testing.T) { valAddrCodec := address.NewBech32Codec("cosmosvaloper") mocks.MockStakingKeeper.EXPECT().ValidatorAddressCodec().Return(valAddrCodec).AnyTimes() - val1, op1, cons1 := newBondedValidator(t, valAddrCodec, 1) - val2, op2, cons2 := newBondedValidator(t, valAddrCodec, 2) - val3, op3, cons3 := newBondedValidator(t, valAddrCodec, 3) + val1, op1 := newBondedValidator(t, valAddrCodec, 1) + val2, op2 := newBondedValidator(t, valAddrCodec, 2) + val3, op3 := newBondedValidator(t, valAddrCodec, 3) providerParams := providertypes.DefaultParams() providerParams.FeesPerBlockAmount = math.NewInt(10) k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{signerVote(cons1), signerVote(cons2), signerVote(cons3)}) mocks.MockBankKeeper.EXPECT(). GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providertypes.DefaultFeesPerBlockDenom). @@ -339,8 +317,8 @@ func TestDistributeFeesToValidatorsRemainderStaysPooled(t *testing.T) { require.NoError(t, k.DistributeFeesToValidators(ctx)) } -// TestDistributeFeesToValidatorsNoSigners: empty VoteInfos → nothing sent. -func TestDistributeFeesToValidatorsNoSigners(t *testing.T) { +// TestDistributeFeesToValidatorsNoBondedValidators: no bonded validators → nothing sent. +func TestDistributeFeesToValidatorsNoBondedValidators(t *testing.T) { params := testkeeper.NewInMemKeeperParams(t) k, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, params) defer ctrl.Finish() @@ -359,9 +337,9 @@ func TestDistributeFeesToValidatorsNoSigners(t *testing.T) { require.NoError(t, k.DistributeFeesToValidators(ctx)) } -// TestDistributeFeesToValidatorsSkipsAbsentSigners: absent / nil-voting -// validators are filtered out by BlockIdFlag; only commit votes earn. -func TestDistributeFeesToValidatorsSkipsAbsentSigners(t *testing.T) { +// TestDistributeFeesToValidatorsIncludesOfflineValidators: all bonded +// validators receive an equal share regardless of signing status. +func TestDistributeFeesToValidatorsIncludesOfflineValidators(t *testing.T) { params := testkeeper.NewInMemKeeperParams(t) k, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, params) defer ctrl.Finish() @@ -369,16 +347,12 @@ func TestDistributeFeesToValidatorsSkipsAbsentSigners(t *testing.T) { valAddrCodec := address.NewBech32Codec("cosmosvaloper") mocks.MockStakingKeeper.EXPECT().ValidatorAddressCodec().Return(valAddrCodec).AnyTimes() - val1, op1, cons1 := newBondedValidator(t, valAddrCodec, 1) - val2, _, cons2 := newBondedValidator(t, valAddrCodec, 2) + val1, op1 := newBondedValidator(t, valAddrCodec, 1) + val2, op2 := newBondedValidator(t, valAddrCodec, 2) providerParams := providertypes.DefaultParams() providerParams.FeesPerBlockAmount = math.NewInt(10) k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{ - signerVote(cons1), // will be paid - absentVote(cons2), // skipped — absent signer - }) mocks.MockBankKeeper.EXPECT(). GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providertypes.DefaultFeesPerBlockDenom). @@ -386,43 +360,13 @@ func TestDistributeFeesToValidatorsSkipsAbsentSigners(t *testing.T) { mocks.MockStakingKeeper.EXPECT(). GetBondedValidatorsByPower(gomock.Any()). Return([]stakingtypes.Validator{val1, val2}, nil) - // Sole committing signer gets the entire pool. + // Both validators get an equal share (100/2 = 50), even if one was offline. + share := sdk.NewCoins(sdk.NewInt64Coin("uphoton", 50)) mocks.MockBankKeeper.EXPECT(). - SendCoinsFromModuleToAccount(gomock.Any(), providertypes.ModuleName, sdk.AccAddress(op1), sdk.NewCoins(sdk.NewInt64Coin("uphoton", 100))). + SendCoinsFromModuleToAccount(gomock.Any(), providertypes.ModuleName, sdk.AccAddress(op1), share). Return(nil) - - require.NoError(t, k.DistributeFeesToValidators(ctx)) -} - -// TestDistributeFeesToValidatorsSkipsNonBondedSigner: a validator in VoteInfos -// whose consensus address is not in the current bonded set (because they were -// unbonded/removed since signing) forfeits its share. -func TestDistributeFeesToValidatorsSkipsNonBondedSigner(t *testing.T) { - params := testkeeper.NewInMemKeeperParams(t) - k, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, params) - defer ctrl.Finish() - - valAddrCodec := address.NewBech32Codec("cosmosvaloper") - mocks.MockStakingKeeper.EXPECT().ValidatorAddressCodec().Return(valAddrCodec).AnyTimes() - - val1, op1, cons1 := newBondedValidator(t, valAddrCodec, 1) - // val2 signed the previous block but is no longer in the bonded set. - _, _, consGone := newBondedValidator(t, valAddrCodec, 2) - - providerParams := providertypes.DefaultParams() - providerParams.FeesPerBlockAmount = math.NewInt(10) - k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{signerVote(cons1), signerVote(consGone)}) - - mocks.MockBankKeeper.EXPECT(). - GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providertypes.DefaultFeesPerBlockDenom). - Return(sdk.NewInt64Coin("uphoton", 100)) - mocks.MockStakingKeeper.EXPECT(). - GetBondedValidatorsByPower(gomock.Any()). - Return([]stakingtypes.Validator{val1}, nil) // val2 missing — unbonded since - // Only the still-bonded signer is paid, and gets the full pool. mocks.MockBankKeeper.EXPECT(). - SendCoinsFromModuleToAccount(gomock.Any(), providertypes.ModuleName, sdk.AccAddress(op1), sdk.NewCoins(sdk.NewInt64Coin("uphoton", 100))). + SendCoinsFromModuleToAccount(gomock.Any(), providertypes.ModuleName, sdk.AccAddress(op2), share). Return(nil) require.NoError(t, k.DistributeFeesToValidators(ctx)) diff --git a/x/vaas/provider/module_test.go b/x/vaas/provider/module_test.go index a849997..e20ac0f 100644 --- a/x/vaas/provider/module_test.go +++ b/x/vaas/provider/module_test.go @@ -5,9 +5,6 @@ import ( "errors" "testing" - abci "github.com/cometbft/cometbft/abci/types" - cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" - "cosmossdk.io/math" testkeeper "github.com/allinbits/vaas/testutil/keeper" providertypes "github.com/allinbits/vaas/x/vaas/provider/types" @@ -45,8 +42,7 @@ func TestBeginBlockCommitsDebtStateWhenDistributionFails(t *testing.T) { consumerInDebtFeePoolAddr := k.GetConsumerFeePoolAddress(consumerInDebt) consumerPayingFeePoolAddr := k.GetConsumerFeePoolAddress(consumerPaying) - // Prime one bonded signer with a real consensus key so GetConsAddr works; - // the bank send during distribution will be the failure point. + // Prime one bonded validator so distribution attempts to pay out. valAddrCodec := address.NewBech32Codec("cosmosvaloper") mocks.MockStakingKeeper.EXPECT().ValidatorAddressCodec().Return(valAddrCodec).AnyTimes() opBytes := bytes.Repeat([]byte{0xfe}, 20) @@ -59,13 +55,6 @@ func TestBeginBlockCommitsDebtStateWhenDistributionFails(t *testing.T) { val.Tokens = sdk.DefaultPowerReduction val.DelegatorShares = math.LegacyNewDecFromInt(sdk.DefaultPowerReduction) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{ - { - Validator: abci.Validator{Address: sdk.GetConsAddress(pk), Power: 1}, - BlockIdFlag: cmtproto.BlockIDFlagCommit, - }, - }) - // Collection phase: one consumer underfunded (ErrInsufficientFunds), one pays. mocks.MockBankKeeper.EXPECT(). SendCoinsFromAccountToModule(gomock.Any(), consumerInDebtFeePoolAddr, providertypes.ModuleName, sdk.NewCoins(feesPerBlock)).