From f084138e78b830eca11a8dd21e351f08968b5845 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 4 Jun 2026 16:30:25 +0200 Subject: [PATCH 1/2] refactor: distribute fees to all bonded validators --- x/vaas/provider/keeper/fees.go | 48 ++----------- x/vaas/provider/keeper/fees_test.go | 108 +++++++--------------------- x/vaas/provider/module_test.go | 13 +--- 3 files changed, 33 insertions(+), 136 deletions(-) diff --git a/x/vaas/provider/keeper/fees.go b/x/vaas/provider/keeper/fees.go index 9954edf..1b97ec2 100644 --- a/x/vaas/provider/keeper/fees.go +++ b/x/vaas/provider/keeper/fees.go @@ -5,7 +5,6 @@ import ( "github.com/allinbits/vaas/x/vaas/provider/types" - cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" errorsmod "cosmossdk.io/errors" "cosmossdk.io/math" @@ -13,7 +12,6 @@ import ( 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 +75,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.GetFeesPerBlock(ctx).Denom) 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 1e0957e..5740b73 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" @@ -190,11 +187,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) @@ -205,25 +200,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) @@ -232,15 +213,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.FeesPerBlock = sdk.NewInt64Coin("uphoton", 10) k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{signerVote(cons1), signerVote(cons2)}) mocks.MockBankKeeper.EXPECT(). GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providerParams.FeesPerBlock.Denom). @@ -276,7 +256,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) @@ -285,13 +265,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.FeesPerBlock = sdk.NewInt64Coin("uphoton", 1) k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{signerVote(cons1), signerVote(cons2)}) mocks.MockBankKeeper.EXPECT(). GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providerParams.FeesPerBlock.Denom). @@ -303,7 +282,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) @@ -313,14 +292,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.FeesPerBlock = sdk.NewInt64Coin("uphoton", 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), providerParams.FeesPerBlock.Denom). @@ -338,8 +316,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() @@ -358,9 +336,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() @@ -368,16 +346,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.FeesPerBlock = sdk.NewInt64Coin("uphoton", 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), providerParams.FeesPerBlock.Denom). @@ -385,43 +359,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.FeesPerBlock = sdk.NewInt64Coin("uphoton", 10) - k.SetParams(ctx, providerParams) - ctx = ctx.WithVoteInfos([]abci.VoteInfo{signerVote(cons1), signerVote(consGone)}) - - mocks.MockBankKeeper.EXPECT(). - GetBalance(gomock.Any(), authtypes.NewModuleAddress(providertypes.ModuleName), providerParams.FeesPerBlock.Denom). - 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 7d99b97..c50ed73 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" @@ -44,8 +41,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) @@ -58,13 +54,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(providerParams.FeesPerBlock)). From 230c66dc96171e36d0f3560c960cebb0b039877c Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 5 Jun 2026 15:30:57 +0200 Subject: [PATCH 2/2] lint --- x/vaas/provider/keeper/fees.go | 1 - 1 file changed, 1 deletion(-) diff --git a/x/vaas/provider/keeper/fees.go b/x/vaas/provider/keeper/fees.go index 1b97ec2..0386f87 100644 --- a/x/vaas/provider/keeper/fees.go +++ b/x/vaas/provider/keeper/fees.go @@ -5,7 +5,6 @@ import ( "github.com/allinbits/vaas/x/vaas/provider/types" - errorsmod "cosmossdk.io/errors" "cosmossdk.io/math"