Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 6 additions & 43 deletions x/vaas/provider/keeper/fees.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 26 additions & 82 deletions x/vaas/provider/keeper/fees_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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).
Expand Down Expand Up @@ -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)
Expand All @@ -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).
Expand All @@ -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)
Expand All @@ -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).
Expand All @@ -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()
Expand All @@ -359,70 +337,36 @@ 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()

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).
Return(sdk.NewInt64Coin("uphoton", 100))
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))
Expand Down
13 changes: 1 addition & 12 deletions x/vaas/provider/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)).
Expand Down
Loading