diff --git a/CHANGELOG.md b/CHANGELOG.md index 2363df4955..9f8ab5b1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Add an entry to the unreleased section whenever merging a PR to main that is not targeted at a specific release. These entries will eventually be included in a release. +* (feat!) [#1435](https://github.com/cosmos/interchain-security/pull/1435) Add height-base filter for consumer equivocation evidence. + ## v2.3.0-provider-lsm *November 15, 2023* diff --git a/tests/integration/double_vote.go b/tests/integration/double_vote.go index 60fc0ce635..b3f2663c9b 100644 --- a/tests/integration/double_vote.go +++ b/tests/integration/double_vote.go @@ -35,6 +35,13 @@ func (s *CCVTestSuite) TestHandleConsumerDoubleVoting() { blockID1 := testutil.MakeBlockID([]byte("blockhash"), 1000, []byte("partshash")) blockID2 := testutil.MakeBlockID([]byte("blockhash2"), 1000, []byte("partshash")) + // Set the equivocation evidence min height to the previous block height + equivocationEvidenceMinHeight := uint64(s.consumerCtx().BlockHeight() - 1) + s.providerApp.GetProviderKeeper().SetEquivocationEvidenceMinHeight( + s.providerCtx(), + s.consumerChain.ChainID, + equivocationEvidenceMinHeight, + ) // Note that votes are signed along with the chain ID // see VoteSignBytes in https://github.com/cometbft/cometbft/blob/main/types/vote.go#L139 @@ -76,6 +83,17 @@ func (s *CCVTestSuite) TestHandleConsumerDoubleVoting() { s.consumerChain.ChainID, ) + // create a vote using the consumer validator key + // with block height that is smaller than the equivocation evidence min height + consuVoteOld := testutil.MakeAndSignVote( + blockID1, + int64(equivocationEvidenceMinHeight-1), + s.consumerCtx().BlockTime(), + consuValSet, + consuSigner, + s.consumerChain.ChainID, + ) + testCases := []struct { name string ev *tmtypes.DuplicateVoteEvidence @@ -84,7 +102,7 @@ func (s *CCVTestSuite) TestHandleConsumerDoubleVoting() { expPass bool }{ { - "invalid consumer chain id - shouldn't pass", + "cannot find consumer chain for the given chain ID - shouldn't pass", &tmtypes.DuplicateVoteEvidence{ VoteA: consuVote, VoteB: consuBadVote, @@ -96,6 +114,32 @@ func (s *CCVTestSuite) TestHandleConsumerDoubleVoting() { consuVal.PubKey, false, }, + { + "evidence is older than equivocation evidence min height - shouldn't pass", + &tmtypes.DuplicateVoteEvidence{ + VoteA: consuVoteOld, + VoteB: consuBadVote, + ValidatorPower: consuVal.VotingPower, + TotalVotingPower: consuVal.VotingPower, + Timestamp: s.consumerCtx().BlockTime(), + }, + s.consumerChain.ChainID, + consuVal.PubKey, + false, + }, + { + "the votes in the evidence are for different height - shouldn't pass", + &tmtypes.DuplicateVoteEvidence{ + VoteA: consuVote, + VoteB: consuVoteOld, + ValidatorPower: consuVal.VotingPower, + TotalVotingPower: consuVal.VotingPower, + Timestamp: s.consumerCtx().BlockTime(), + }, + s.consumerChain.ChainID, + consuVal.PubKey, + false, + }, { "wrong public key - shouldn't pass", &tmtypes.DuplicateVoteEvidence{ diff --git a/tests/integration/misbehaviour.go b/tests/integration/misbehaviour.go index 63b0eafb6d..d6e92e218c 100644 --- a/tests/integration/misbehaviour.go +++ b/tests/integration/misbehaviour.go @@ -408,6 +408,14 @@ func (s *CCVTestSuite) TestCheckMisbehaviour() { altSigners2, ) + // Set the equivocation evidence min height to the previous block height + equivocationEvidenceMinHeight := clientHeight.RevisionHeight + 1 + s.providerApp.GetProviderKeeper().SetEquivocationEvidenceMinHeight( + s.providerCtx(), + s.consumerChain.ChainID, + equivocationEvidenceMinHeight, + ) + testCases := []struct { name string misbehaviour *ibctmtypes.Misbehaviour @@ -476,6 +484,24 @@ func (s *CCVTestSuite) TestCheckMisbehaviour() { }, false, }, + { + "invalid misbehaviour older than the min equivocation evidence height - shouldn't pass", + &ibctmtypes.Misbehaviour{ + ClientId: s.path.EndpointA.ClientID, + Header1: s.consumerChain.CreateTMClientHeader( + s.consumerChain.ChainID, + int64(equivocationEvidenceMinHeight-1), + clientHeight, + headerTs, + altValset, + altValset, + clientTMValset, + altSigners, + ), + Header2: clientHeader, + }, + false, + }, { "one header of the misbehaviour has insufficient voting power - shouldn't pass", &ibctmtypes.Misbehaviour{ diff --git a/x/ccv/provider/keeper/consumer_equivocation.go b/x/ccv/provider/keeper/consumer_equivocation.go new file mode 100644 index 0000000000..fb9fa859ed --- /dev/null +++ b/x/ccv/provider/keeper/consumer_equivocation.go @@ -0,0 +1,505 @@ +package keeper + +import ( + "bytes" + "encoding/binary" + "fmt" + + errorsmod "cosmossdk.io/errors" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + evidencetypes "github.com/cosmos/cosmos-sdk/x/evidence/types" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + ibcclienttypes "github.com/cosmos/ibc-go/v4/modules/core/02-client/types" + ibctmtypes "github.com/cosmos/ibc-go/v4/modules/light-clients/07-tendermint/types" + providertypes "github.com/cosmos/interchain-security/v2/x/ccv/provider/types" + ccvtypes "github.com/cosmos/interchain-security/v2/x/ccv/types" + tmtypes "github.com/tendermint/tendermint/types" +) + +// +// Double Voting section +// + +// HandleConsumerDoubleVoting verifies a double voting evidence for a given a consumer chain ID +// and a public key and, if successful, executes the jailing of the malicious validator. +func (k Keeper) HandleConsumerDoubleVoting( + ctx sdk.Context, + evidence *tmtypes.DuplicateVoteEvidence, + chainID string, + pubkey cryptotypes.PubKey, +) error { + // check that the evidence is for an ICS consumer chain + if _, found := k.GetConsumerClientId(ctx, chainID); !found { + return errorsmod.Wrapf( + ccvtypes.ErrInvalidDoubleVotingEvidence, + "cannot find consumer chain %s", + chainID, + ) + } + + // check that the evidence is not too old + minHeight := k.GetEquivocationEvidenceMinHeight(ctx, chainID) + if uint64(evidence.VoteA.Height) < minHeight { + return errorsmod.Wrapf( + ccvtypes.ErrInvalidDoubleVotingEvidence, + "evidence for consumer chain %s is too old - evidence height (%d), min (%d)", + chainID, + evidence.VoteA.Height, + minHeight, + ) + } + + // verifies the double voting evidence using the consumer chain public key + if err := k.VerifyDoubleVotingEvidence(*evidence, chainID, pubkey); err != nil { + return err + } + + // get the validator's consensus address on the provider + providerAddr := k.GetProviderAddrFromConsumerAddr( + ctx, + chainID, + providertypes.NewConsumerConsAddress(sdk.ConsAddress(evidence.VoteA.ValidatorAddress.Bytes())), + ) + + if err := k.SlashValidator(ctx, providerAddr); err != nil { + return err + } + if err := k.JailAndTombstoneValidator(ctx, providerAddr); err != nil { + return err + } + + k.Logger(ctx).Info( + "confirmed equivocation", + "byzantine validator address", providerAddr.String(), + ) + + return nil +} + +// VerifyDoubleVotingEvidence verifies a double voting evidence +// for a given chain id and a validator public key +func (k Keeper) VerifyDoubleVotingEvidence( + evidence tmtypes.DuplicateVoteEvidence, + chainID string, + pubkey cryptotypes.PubKey, +) error { + if pubkey == nil { + return fmt.Errorf("validator public key cannot be empty") + } + + // check that the validator address in the evidence is derived from the provided public key + if !bytes.Equal(pubkey.Address(), evidence.VoteA.ValidatorAddress) { + return errorsmod.Wrapf( + ccvtypes.ErrInvalidDoubleVotingEvidence, + "public key %s doesn't correspond to the validator address %s in double vote evidence", + pubkey.String(), evidence.VoteA.ValidatorAddress.String(), + ) + } + + // Note that since we're only jailing validators for double voting on a consumer chain, + // the age of the evidence is irrelevant and therefore isn't checked. + + // height/round/type must be the same + if evidence.VoteA.Height != evidence.VoteB.Height || + evidence.VoteA.Round != evidence.VoteB.Round || + evidence.VoteA.Type != evidence.VoteB.Type { + return sdkerrors.Wrapf( + ccvtypes.ErrInvalidDoubleVotingEvidence, + "h/r/s does not match: %d/%d/%v vs %d/%d/%v", + evidence.VoteA.Height, evidence.VoteA.Round, evidence.VoteA.Type, + evidence.VoteB.Height, evidence.VoteB.Round, evidence.VoteB.Type) + } + + // Addresses must be the same + if !bytes.Equal(evidence.VoteA.ValidatorAddress, evidence.VoteB.ValidatorAddress) { + return sdkerrors.Wrapf( + ccvtypes.ErrInvalidDoubleVotingEvidence, + "validator addresses do not match: %X vs %X", + evidence.VoteA.ValidatorAddress, + evidence.VoteB.ValidatorAddress, + ) + } + + // BlockIDs must be different + if evidence.VoteA.BlockID.Equals(evidence.VoteB.BlockID) { + return sdkerrors.Wrapf( + ccvtypes.ErrInvalidDoubleVotingEvidence, + "block IDs are the same (%v) - not a real duplicate vote", + evidence.VoteA.BlockID, + ) + } + + va := evidence.VoteA.ToProto() + vb := evidence.VoteB.ToProto() + + // signatures must be valid + if !pubkey.VerifySignature(tmtypes.VoteSignBytes(chainID, va), evidence.VoteA.Signature) { + return fmt.Errorf("verifying VoteA: %w", tmtypes.ErrVoteInvalidSignature) + } + if !pubkey.VerifySignature(tmtypes.VoteSignBytes(chainID, vb), evidence.VoteB.Signature) { + return fmt.Errorf("verifying VoteB: %w", tmtypes.ErrVoteInvalidSignature) + } + + return nil +} + +// +// Light Client Attack (IBC misbehavior) section +// + +// HandleConsumerMisbehaviour checks if the given IBC misbehaviour corresponds to an equivocation light client attack, +// and in this case, slashes, jails, and tombstones +func (k Keeper) HandleConsumerMisbehaviour(ctx sdk.Context, misbehaviour ibctmtypes.Misbehaviour) error { + logger := k.Logger(ctx) + + // Check that the misbehaviour is valid and that the client consensus states at trusted heights are within trusting period + if err := k.CheckMisbehaviour(ctx, misbehaviour); err != nil { + logger.Info("Misbehaviour rejected", err.Error()) + + return err + } + + // Since the misbehaviour packet was received within the trusting period + // w.r.t to the trusted consensus states the infraction age + // isn't too old. see ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go + + // Get Byzantine validators from the conflicting headers + byzantineValidators, err := k.GetByzantineValidators(ctx, misbehaviour) + if err != nil { + return err + } + + provAddrs := make([]providertypes.ProviderConsAddress, len(byzantineValidators)) + + // slash, jail, and tombstone the Byzantine validators + for _, v := range byzantineValidators { + providerAddr := k.GetProviderAddrFromConsumerAddr( + ctx, + misbehaviour.Header1.Header.ChainID, + providertypes.NewConsumerConsAddress(sdk.ConsAddress(v.Address.Bytes())), + ) + err := k.SlashValidator(ctx, providerAddr) + if err != nil { + logger.Error("failed to slash validator: %s", err) + continue + } + err = k.JailAndTombstoneValidator(ctx, providerAddr) + // JailAndTombstoneValidator should never return an error if + // SlashValidator succeeded because both methods fail if the malicious + // validator is either or both !found, unbonded and tombstoned. + if err != nil { + panic(err) + } + + provAddrs = append(provAddrs, providerAddr) + } + + // Return an error if no validators were punished + if len(provAddrs) == 0 { + return fmt.Errorf("failed to slash, jail, or tombstone all validators: %v", byzantineValidators) + } + + logger.Info( + "confirmed equivocation light client attack", + "byzantine validators slashed, jailed and tombstoned", provAddrs, + ) + + return nil +} + +// GetByzantineValidators returns the validators that signed both headers. +// If the misbehavior is an equivocation light client attack, then these +// validators are the Byzantine validators. +func (k Keeper) GetByzantineValidators(ctx sdk.Context, misbehaviour ibctmtypes.Misbehaviour) (validators []*tmtypes.Validator, err error) { + // construct the trusted and conflicted light blocks + lightBlock1, err := headerToLightBlock(*misbehaviour.Header1) + if err != nil { + return + } + lightBlock2, err := headerToLightBlock(*misbehaviour.Header2) + if err != nil { + return + } + + // Check if the misbehaviour corresponds to an Amnesia attack, + // meaning that the conflicting headers have both valid state transitions + // and different commit rounds. In this case, we return no validators as + // we can't identify the byzantine validators. + // + // Note that we cannot differentiate which of the headers is trusted or malicious, + if !headersStateTransitionsAreConflicting(*lightBlock1.Header, *lightBlock2.Header) && lightBlock1.Commit.Round != lightBlock2.Commit.Round { + return + } + + // compare the signatures of the headers + // and return the intersection of validators who signed both + + // create a map with the validators' address that signed header1 + header1Signers := map[string]int{} + for idx, sign := range lightBlock1.Commit.Signatures { + if sign.Absent() { + continue + } + header1Signers[sign.ValidatorAddress.String()] = idx + } + + // iterate over the header2 signers and check if they signed header1 + for sigIdxHeader2, sign := range lightBlock2.Commit.Signatures { + if sign.Absent() { + continue + } + if sigIdxHeader1, ok := header1Signers[sign.ValidatorAddress.String()]; ok { + if err := verifyLightBlockCommitSig(*lightBlock1, sigIdxHeader1); err != nil { + return nil, err + } + + if err := verifyLightBlockCommitSig(*lightBlock2, sigIdxHeader2); err != nil { + return nil, err + } + + _, val := lightBlock1.ValidatorSet.GetByAddress(sign.ValidatorAddress) + validators = append(validators, val) + } + } + + return validators, nil +} + +// headerToLightBlock returns a CometBFT light block from the given IBC header +func headerToLightBlock(h ibctmtypes.Header) (*tmtypes.LightBlock, error) { + sh, err := tmtypes.SignedHeaderFromProto(h.SignedHeader) + if err != nil { + return nil, err + } + + vs, err := tmtypes.ValidatorSetFromProto(h.ValidatorSet) + if err != nil { + return nil, err + } + + return &tmtypes.LightBlock{ + SignedHeader: sh, + ValidatorSet: vs, + }, nil +} + +// CheckMisbehaviour checks that headers in the given misbehaviour forms +// a valid light client attack from an ICS consumer chain and that the light client isn't expired +func (k Keeper) CheckMisbehaviour(ctx sdk.Context, misbehaviour ibctmtypes.Misbehaviour) error { + consumerChainID := misbehaviour.Header1.Header.ChainID + + // check that the misbehaviour is for an ICS consumer chain + clientId, found := k.GetConsumerClientId(ctx, consumerChainID) + if !found { + return fmt.Errorf("incorrect misbehaviour with conflicting headers from a non-existent consumer chain: %s", consumerChainID) + } else if misbehaviour.ClientId != clientId { + return fmt.Errorf("incorrect misbehaviour: expected client ID for consumer chain %s is %s got %s", + consumerChainID, + clientId, + misbehaviour.ClientId, + ) + } + + // Check that the headers are at the same height to ensure that + // the misbehaviour is for a light client attack and not a time violation, + // see ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go + if !misbehaviour.Header1.GetHeight().EQ(misbehaviour.Header2.GetHeight()) { + return sdkerrors.Wrap(ibcclienttypes.ErrInvalidMisbehaviour, "headers are not at same height") + } + + // Check that the evidence is not too old + minHeight := k.GetEquivocationEvidenceMinHeight(ctx, consumerChainID) + evidenceHeight := misbehaviour.Header1.GetHeight().GetRevisionHeight() + // Note that the revision number is not relevant for checking the age of evidence + // as it's already part of the chain ID and the minimum height is mapped to chain IDs + if evidenceHeight < minHeight { + return errorsmod.Wrapf( + ccvtypes.ErrInvalidDoubleVotingEvidence, + "evidence for consumer chain %s is too old - evidence height (%d), min (%d)", + consumerChainID, + evidenceHeight, + minHeight, + ) + } + + clientState, found := k.clientKeeper.GetClientState(ctx, clientId) + if !found { + return sdkerrors.Wrapf(ibcclienttypes.ErrClientNotFound, "cannot check misbehaviour for client with ID %s", misbehaviour.GetClientID()) + } + + clientStore := k.clientKeeper.ClientStore(ctx, misbehaviour.GetClientID()) + + // CheckMisbehaviourAndUpdateState verifies the misbehaviour against the trusted consensus states + // but does NOT update the light client state. + // Note that the IBC CheckMisbehaviourAndUpdateState method returns an error if the trusted consensus states are expired, + // see ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go + _, err := clientState.CheckMisbehaviourAndUpdateState(ctx, k.cdc, clientStore, &misbehaviour) + if err != nil { + return err + } + + return nil +} + +// Check if the given block headers have conflicting state transitions. +// Note that this method was copied from ConflictingHeaderIsInvalid in CometBFT, +// see https://github.com/cometbft/cometbft/blob/v0.34.27/types/evidence.go#L285 +func headersStateTransitionsAreConflicting(h1, h2 tmtypes.Header) bool { + return !bytes.Equal(h1.ValidatorsHash, h2.ValidatorsHash) || + !bytes.Equal(h1.NextValidatorsHash, h2.NextValidatorsHash) || + !bytes.Equal(h1.ConsensusHash, h2.ConsensusHash) || + !bytes.Equal(h1.AppHash, h2.AppHash) || + !bytes.Equal(h1.LastResultsHash, h2.LastResultsHash) +} + +func verifyLightBlockCommitSig(lightBlock tmtypes.LightBlock, sigIdx int) error { + // get signature + sig := lightBlock.Commit.Signatures[sigIdx] + + // get validator + idx, val := lightBlock.ValidatorSet.GetByAddress(sig.ValidatorAddress) + if idx == -1 { + return fmt.Errorf("incorrect signature: validator address %s isn't part of the validator set", sig.ValidatorAddress.String()) + } + + // verify validator pubkey corresponds to signature validator address + if !bytes.Equal(val.PubKey.Address(), sig.ValidatorAddress) { + return fmt.Errorf("validator public key doesn't correspond to signature validator address: %s!= %s", val.PubKey.Address(), sig.ValidatorAddress) + } + + // validate signature + voteSignBytes := lightBlock.Commit.VoteSignBytes(lightBlock.ChainID, int32(sigIdx)) + if !val.PubKey.VerifySignature(voteSignBytes, sig.Signature) { + return fmt.Errorf("wrong signature (#%d): %X", sigIdx, sig.Signature) + } + + return nil +} + +// +// Punish Validator section +// + +// JailAndTombstoneValidator jails and tombstones the validator with the given provider consensus address +func (k Keeper) JailAndTombstoneValidator(ctx sdk.Context, providerAddr providertypes.ProviderConsAddress) error { + validator, found := k.stakingKeeper.GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()) + if !found { + return errorsmod.Wrapf(slashingtypes.ErrNoValidatorForAddress, "provider consensus address: %s", providerAddr.String()) + } + + if validator.IsUnbonded() { + return fmt.Errorf("validator is unbonded. provider consensus address: %s", providerAddr.String()) + } + + if k.slashingKeeper.IsTombstoned(ctx, providerAddr.ToSdkConsAddr()) { + return fmt.Errorf("validator is tombstoned. provider consensus address: %s", providerAddr.String()) + } + + // jail validator if not already + if !validator.IsJailed() { + k.stakingKeeper.Jail(ctx, providerAddr.ToSdkConsAddr()) + } + + k.slashingKeeper.JailUntil(ctx, providerAddr.ToSdkConsAddr(), evidencetypes.DoubleSignJailEndTime) + + // Tombstone the validator so that we cannot slash the validator more than once + // Note that we cannot simply use the fact that a validator is jailed to avoid slashing more than once + // because then a validator could i) perform an equivocation, ii) get jailed (e.g., through downtime) + // and in such a case the validator would not get slashed when we call `SlashValidator`. + k.slashingKeeper.Tombstone(ctx, providerAddr.ToSdkConsAddr()) + + return nil +} + +// ComputePowerToSlash computes the power to be slashed based on the tokens in non-matured `undelegations` and +// `redelegations`, as well as the current `power` of the validator. +// Note that this method does not perform any slashing. +func (k Keeper) ComputePowerToSlash(ctx sdk.Context, validator stakingtypes.Validator, undelegations []stakingtypes.UnbondingDelegation, + redelegations []stakingtypes.Redelegation, power int64, powerReduction sdk.Int, +) int64 { + // compute the total numbers of tokens currently being undelegated + undelegationsInTokens := sdk.NewInt(0) + + // Note that we use a **cached** context to avoid any actual slashing of undelegations or redelegations. + cachedCtx, _ := ctx.CacheContext() + for _, u := range undelegations { + amountSlashed := k.stakingKeeper.SlashUnbondingDelegation(cachedCtx, u, 0, sdk.NewDec(1)) + undelegationsInTokens = undelegationsInTokens.Add(amountSlashed) + } + + // compute the total numbers of tokens currently being redelegated + redelegationsInTokens := sdk.NewInt(0) + for _, r := range redelegations { + amountSlashed := k.stakingKeeper.SlashRedelegation(cachedCtx, validator, r, 0, sdk.NewDec(1)) + redelegationsInTokens = redelegationsInTokens.Add(amountSlashed) + } + + // The power we pass to staking's keeper `Slash` method is the current power of the validator together with the total + // power of all the currently undelegated and redelegated tokens (see docs/docs/adrs/adr-013-equivocation-slashing.md). + undelegationsAndRedelegationsInPower := sdk.TokensToConsensusPower( + undelegationsInTokens.Add(redelegationsInTokens), powerReduction) + + return power + undelegationsAndRedelegationsInPower +} + +// SlashValidator slashes validator with `providerAddr` +func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr providertypes.ProviderConsAddress) error { + validator, found := k.stakingKeeper.GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()) + if !found { + return errorsmod.Wrapf(slashingtypes.ErrNoValidatorForAddress, "provider consensus address: %s", providerAddr.String()) + } + + if validator.IsUnbonded() { + return fmt.Errorf("validator is unbonded. provider consensus address: %s", providerAddr.String()) + } + + if k.slashingKeeper.IsTombstoned(ctx, providerAddr.ToSdkConsAddr()) { + return fmt.Errorf("validator is tombstoned. provider consensus address: %s", providerAddr.String()) + } + + undelegations := k.stakingKeeper.GetUnbondingDelegationsFromValidator(ctx, validator.GetOperator()) + redelegations := k.stakingKeeper.GetRedelegationsFromSrcValidator(ctx, validator.GetOperator()) + lastPower := k.stakingKeeper.GetLastValidatorPower(ctx, validator.GetOperator()) + powerReduction := k.stakingKeeper.PowerReduction(ctx) + totalPower := k.ComputePowerToSlash(ctx, validator, undelegations, redelegations, lastPower, powerReduction) + slashFraction := k.slashingKeeper.SlashFractionDoubleSign(ctx) + + k.stakingKeeper.Slash(ctx, providerAddr.ToSdkConsAddr(), 0, totalPower, slashFraction, stakingtypes.DoubleSign) + return nil +} + +// +// CRUD section +// + +// SetEquivocationEvidenceMinHeight sets the the minimum height +// of a valid consumer equivocation evidence for a given consumer chain ID +func (k Keeper) SetEquivocationEvidenceMinHeight(ctx sdk.Context, chainID string, height uint64) { + store := ctx.KVStore(k.storeKey) + heightBytes := make([]byte, 8) + binary.BigEndian.PutUint64(heightBytes, height) + + store.Set(providertypes.EquivocationEvidenceMinHeightKey(chainID), heightBytes) +} + +// GetEquivocationEvidenceMinHeight returns the the minimum height +// of a valid consumer equivocation evidence for a given consumer chain ID +func (k Keeper) GetEquivocationEvidenceMinHeight(ctx sdk.Context, chainID string) uint64 { + store := ctx.KVStore(k.storeKey) + bz := store.Get(providertypes.EquivocationEvidenceMinHeightKey(chainID)) + if bz == nil { + return 0 + } + + return binary.BigEndian.Uint64(bz) +} + +// DeleteEquivocationEvidenceMinHeight deletes the the minimum height +// of a valid consumer equivocation evidence for a given consumer chain ID +func (k Keeper) DeleteEquivocationEvidenceMinHeight(ctx sdk.Context, chainID string) { + store := ctx.KVStore(k.storeKey) + store.Delete(providertypes.EquivocationEvidenceMinHeightKey(chainID)) +} diff --git a/x/ccv/provider/keeper/punish_validator_test.go b/x/ccv/provider/keeper/consumer_equivocation_test.go similarity index 73% rename from x/ccv/provider/keeper/punish_validator_test.go rename to x/ccv/provider/keeper/consumer_equivocation_test.go index 1166fbf063..08aed18fb9 100644 --- a/x/ccv/provider/keeper/punish_validator_test.go +++ b/x/ccv/provider/keeper/consumer_equivocation_test.go @@ -7,7 +7,7 @@ import ( codectypes "github.com/cosmos/cosmos-sdk/codec/types" cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" - + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" sdk "github.com/cosmos/cosmos-sdk/types" evidencetypes "github.com/cosmos/cosmos-sdk/x/evidence/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" @@ -15,11 +15,308 @@ import ( testkeeper "github.com/cosmos/interchain-security/v2/testutil/keeper" "github.com/cosmos/interchain-security/v2/x/ccv/provider/types" "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" tmtypes "github.com/tendermint/tendermint/types" ) +func TestVerifyDoubleVotingEvidence(t *testing.T) { + keeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) + defer ctrl.Finish() + + chainID := "consumer" + + signer1 := tmtypes.NewMockPV() + signer2 := tmtypes.NewMockPV() + + val1 := tmtypes.NewValidator(signer1.PrivKey.PubKey(), 1) + val2 := tmtypes.NewValidator(signer2.PrivKey.PubKey(), 1) + + valSet := tmtypes.NewValidatorSet([]*tmtypes.Validator{val1, val2}) + + blockID1 := cryptotestutil.MakeBlockID([]byte("blockhash"), 1000, []byte("partshash")) + blockID2 := cryptotestutil.MakeBlockID([]byte("blockhash2"), 1000, []byte("partshash")) + + ctx = ctx.WithBlockTime(time.Now()) + + valPubkey1, err := cryptocodec.FromTmPubKeyInterface(val1.PubKey) + require.NoError(t, err) + + valPubkey2, err := cryptocodec.FromTmPubKeyInterface(val2.PubKey) + require.NoError(t, err) + + testCases := []struct { + name string + votes []*tmtypes.Vote + chainID string + pubkey cryptotypes.PubKey + expPass bool + }{ + { + "invalid verifying public key - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + }, + chainID, + nil, + false, + }, + { + // Note that the signer1 key is used to sign the vote and + // signer2 is used to derive the validator addresss of the same vote + "verifying public key doesn't correspond to validator address", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVoteWithForgedValAddress( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + signer2, + chainID, + ), + cryptotestutil.MakeAndSignVoteWithForgedValAddress( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + signer2, + chainID, + ), + }, + chainID, + valPubkey1, + false, + }, + { + "evidence has votes with different block height - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight()+1, + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + }, + chainID, + valPubkey1, + false, + }, + { + "evidence has votes with different validator address - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer2, + chainID, + ), + }, + chainID, + valPubkey1, + false, + }, + { + "evidence has votes with same block IDs - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + }, + chainID, + valPubkey1, + false, + }, + { + "given chain ID isn't the same as the one used to sign the votes - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + }, + "WrongChainID", + valPubkey1, + false, + }, + { + "voteA is signed using the wrong chain ID - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + "WrongChainID", + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + }, + chainID, + valPubkey1, + false, + }, + { + "voteB is signed using the wrong chain ID - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + "WrongChainID", + ), + }, + chainID, + valPubkey1, + false, + }, + { + "wrong public key - shouldn't pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + }, + chainID, + valPubkey2, + false, + }, + { + "valid double voting evidence should pass", + []*tmtypes.Vote{ + cryptotestutil.MakeAndSignVote( + blockID1, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + cryptotestutil.MakeAndSignVote( + blockID2, + ctx.BlockHeight(), + ctx.BlockTime(), + valSet, + signer1, + chainID, + ), + }, + chainID, + valPubkey1, + true, + }, + } + + for _, tc := range testCases { + err = keeper.VerifyDoubleVotingEvidence( + tmtypes.DuplicateVoteEvidence{ + VoteA: tc.votes[0], + VoteB: tc.votes[1], + ValidatorPower: val1.VotingPower, + TotalVotingPower: val1.VotingPower, + Timestamp: tc.votes[0].Timestamp, + }, + tc.chainID, + tc.pubkey, + ) + if tc.expPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } +} + // TestJailAndTombstoneValidator tests that the jailing of a validator is only executed // under the conditions that the validator is neither unbonded, nor jailed, nor tombstoned. func TestJailAndTombstoneValidator(t *testing.T) { @@ -481,3 +778,21 @@ func TestSlashValidatorDoesNotSlashIfValidatorIsUnbonded(t *testing.T) { gomock.InOrder(expectedCalls...) keeper.SlashValidator(ctx, providerAddr) } + +func TestEquivocationEvidenceMinHeightCRUD(t *testing.T) { + chainID := consumer + expMinHeight := uint64(12) + keeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) + defer ctrl.Finish() + + height := keeper.GetEquivocationEvidenceMinHeight(ctx, chainID) + require.Zero(t, height, "equivocation evidence min height should be 0") + + keeper.SetEquivocationEvidenceMinHeight(ctx, chainID, expMinHeight) + height = keeper.GetEquivocationEvidenceMinHeight(ctx, chainID) + require.Equal(t, height, expMinHeight) + + keeper.DeleteEquivocationEvidenceMinHeight(ctx, chainID) + height = keeper.GetEquivocationEvidenceMinHeight(ctx, chainID) + require.Zero(t, height, "equivocation evidence min height should be 0") +} diff --git a/x/ccv/provider/keeper/double_vote.go b/x/ccv/provider/keeper/double_vote.go deleted file mode 100644 index ed1a475cf6..0000000000 --- a/x/ccv/provider/keeper/double_vote.go +++ /dev/null @@ -1,116 +0,0 @@ -package keeper - -import ( - "bytes" - "fmt" - - errorsmod "cosmossdk.io/errors" - cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" - sdk "github.com/cosmos/cosmos-sdk/types" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - "github.com/cosmos/interchain-security/v2/x/ccv/provider/types" - ccvtypes "github.com/cosmos/interchain-security/v2/x/ccv/types" - tmtypes "github.com/tendermint/tendermint/types" -) - -// HandleConsumerDoubleVoting verifies a double voting evidence for a given a consumer chain ID -// and a public key and, if successful, executes the jailing of the malicious validator. -func (k Keeper) HandleConsumerDoubleVoting( - ctx sdk.Context, - evidence *tmtypes.DuplicateVoteEvidence, - chainID string, - pubkey cryptotypes.PubKey, -) error { - // verifies the double voting evidence using the consumer chain public key - if err := k.VerifyDoubleVotingEvidence(*evidence, chainID, pubkey); err != nil { - return err - } - - // get the validator's consensus address on the provider - providerAddr := k.GetProviderAddrFromConsumerAddr( - ctx, - chainID, - types.NewConsumerConsAddress(sdk.ConsAddress(evidence.VoteA.ValidatorAddress.Bytes())), - ) - - if err := k.SlashValidator(ctx, providerAddr); err != nil { - return err - } - if err := k.JailAndTombstoneValidator(ctx, providerAddr); err != nil { - return err - } - - k.Logger(ctx).Info( - "confirmed equivocation", - "byzantine validator address", providerAddr.String(), - ) - - return nil -} - -// VerifyDoubleVotingEvidence verifies a double voting evidence -// for a given chain id and a validator public key -func (k Keeper) VerifyDoubleVotingEvidence( - evidence tmtypes.DuplicateVoteEvidence, - chainID string, - pubkey cryptotypes.PubKey, -) error { - if pubkey == nil { - return fmt.Errorf("validator public key cannot be empty") - } - - // check that the validator address in the evidence is derived from the provided public key - if !bytes.Equal(pubkey.Address(), evidence.VoteA.ValidatorAddress) { - return errorsmod.Wrapf( - ccvtypes.ErrInvalidDoubleVotingEvidence, - "public key %s doesn't correspond to the validator address %s in double vote evidence", - pubkey.String(), evidence.VoteA.ValidatorAddress.String(), - ) - } - - // Note that since we're only jailing validators for double voting on a consumer chain, - // the age of the evidence is irrelevant and therefore isn't checked. - - // height/round/type must be the same - if evidence.VoteA.Height != evidence.VoteB.Height || - evidence.VoteA.Round != evidence.VoteB.Round || - evidence.VoteA.Type != evidence.VoteB.Type { - return sdkerrors.Wrapf( - ccvtypes.ErrInvalidDoubleVotingEvidence, - "h/r/s does not match: %d/%d/%v vs %d/%d/%v", - evidence.VoteA.Height, evidence.VoteA.Round, evidence.VoteA.Type, - evidence.VoteB.Height, evidence.VoteB.Round, evidence.VoteB.Type) - } - - // Addresses must be the same - if !bytes.Equal(evidence.VoteA.ValidatorAddress, evidence.VoteB.ValidatorAddress) { - return sdkerrors.Wrapf( - ccvtypes.ErrInvalidDoubleVotingEvidence, - "validator addresses do not match: %X vs %X", - evidence.VoteA.ValidatorAddress, - evidence.VoteB.ValidatorAddress, - ) - } - - // BlockIDs must be different - if evidence.VoteA.BlockID.Equals(evidence.VoteB.BlockID) { - return sdkerrors.Wrapf( - ccvtypes.ErrInvalidDoubleVotingEvidence, - "block IDs are the same (%v) - not a real duplicate vote", - evidence.VoteA.BlockID, - ) - } - - va := evidence.VoteA.ToProto() - vb := evidence.VoteB.ToProto() - - // signatures must be valid - if !pubkey.VerifySignature(tmtypes.VoteSignBytes(chainID, va), evidence.VoteA.Signature) { - return fmt.Errorf("verifying VoteA: %w", tmtypes.ErrVoteInvalidSignature) - } - if !pubkey.VerifySignature(tmtypes.VoteSignBytes(chainID, vb), evidence.VoteB.Signature) { - return fmt.Errorf("verifying VoteB: %w", tmtypes.ErrVoteInvalidSignature) - } - - return nil -} diff --git a/x/ccv/provider/keeper/double_vote_test.go b/x/ccv/provider/keeper/double_vote_test.go deleted file mode 100644 index 0b21211fca..0000000000 --- a/x/ccv/provider/keeper/double_vote_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package keeper_test - -import ( - "testing" - "time" - - cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" - cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" - testutil "github.com/cosmos/interchain-security/v2/testutil/crypto" - testkeeper "github.com/cosmos/interchain-security/v2/testutil/keeper" - "github.com/stretchr/testify/require" - tmtypes "github.com/tendermint/tendermint/types" -) - -func TestVerifyDoubleVotingEvidence(t *testing.T) { - keeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) - defer ctrl.Finish() - - chainID := "consumer" - - signer1 := tmtypes.NewMockPV() - signer2 := tmtypes.NewMockPV() - - val1 := tmtypes.NewValidator(signer1.PrivKey.PubKey(), 1) - val2 := tmtypes.NewValidator(signer2.PrivKey.PubKey(), 1) - - valSet := tmtypes.NewValidatorSet([]*tmtypes.Validator{val1, val2}) - - blockID1 := testutil.MakeBlockID([]byte("blockhash"), 1000, []byte("partshash")) - blockID2 := testutil.MakeBlockID([]byte("blockhash2"), 1000, []byte("partshash")) - - ctx = ctx.WithBlockTime(time.Now()) - - valPubkey1, err := cryptocodec.FromTmPubKeyInterface(val1.PubKey) - require.NoError(t, err) - - valPubkey2, err := cryptocodec.FromTmPubKeyInterface(val2.PubKey) - require.NoError(t, err) - - testCases := []struct { - name string - votes []*tmtypes.Vote - chainID string - pubkey cryptotypes.PubKey - expPass bool - }{ - { - "invalid verifying public key - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - }, - chainID, - nil, - false, - }, - { - // Note that the signer1 key is used to sign the vote and - // signer2 is used to derive the validator addresss of the same vote - "verifying public key doesn't correspond to validator address", - []*tmtypes.Vote{ - testutil.MakeAndSignVoteWithForgedValAddress( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - signer2, - chainID, - ), - testutil.MakeAndSignVoteWithForgedValAddress( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - signer2, - chainID, - ), - }, - chainID, - valPubkey1, - false, - }, - { - "evidence has votes with different block height - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight()+1, - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - }, - chainID, - valPubkey1, - false, - }, - { - "evidence has votes with different validator address - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer2, - chainID, - ), - }, - chainID, - valPubkey1, - false, - }, - { - "evidence has votes with same block IDs - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - }, - chainID, - valPubkey1, - false, - }, - { - "given chain ID isn't the same as the one used to sign the votes - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - }, - "WrongChainID", - valPubkey1, - false, - }, - { - "voteA is signed using the wrong chain ID - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - "WrongChainID", - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - }, - chainID, - valPubkey1, - false, - }, - { - "voteB is signed using the wrong chain ID - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - "WrongChainID", - ), - }, - chainID, - valPubkey1, - false, - }, - { - "wrong public key - shouldn't pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - }, - chainID, - valPubkey2, - false, - }, - { - "valid double voting evidence should pass", - []*tmtypes.Vote{ - testutil.MakeAndSignVote( - blockID1, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - testutil.MakeAndSignVote( - blockID2, - ctx.BlockHeight(), - ctx.BlockTime(), - valSet, - signer1, - chainID, - ), - }, - chainID, - valPubkey1, - true, - }, - } - - for _, tc := range testCases { - err = keeper.VerifyDoubleVotingEvidence( - tmtypes.DuplicateVoteEvidence{ - VoteA: tc.votes[0], - VoteB: tc.votes[1], - ValidatorPower: val1.VotingPower, - TotalVotingPower: val1.VotingPower, - Timestamp: tc.votes[0].Timestamp, - }, - tc.chainID, - tc.pubkey, - ) - if tc.expPass { - require.NoError(t, err) - } else { - require.Error(t, err) - } - } -} diff --git a/x/ccv/provider/keeper/misbehaviour.go b/x/ccv/provider/keeper/misbehaviour.go deleted file mode 100644 index f5b63533a0..0000000000 --- a/x/ccv/provider/keeper/misbehaviour.go +++ /dev/null @@ -1,226 +0,0 @@ -package keeper - -import ( - "bytes" - "fmt" - - "github.com/cosmos/interchain-security/v2/x/ccv/provider/types" - - sdk "github.com/cosmos/cosmos-sdk/types" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - ibcclienttypes "github.com/cosmos/ibc-go/v4/modules/core/02-client/types" - ibctmtypes "github.com/cosmos/ibc-go/v4/modules/light-clients/07-tendermint/types" - tmtypes "github.com/tendermint/tendermint/types" -) - -// HandleConsumerMisbehaviour checks if the given IBC misbehaviour corresponds to an equivocation light client attack, -// and in this case, slashes, jails, and tombstones -func (k Keeper) HandleConsumerMisbehaviour(ctx sdk.Context, misbehaviour ibctmtypes.Misbehaviour) error { - logger := k.Logger(ctx) - - // Check that the misbehaviour is valid and that the client consensus states at trusted heights are within trusting period - if err := k.CheckMisbehaviour(ctx, misbehaviour); err != nil { - logger.Info("Misbehaviour rejected", err.Error()) - - return err - } - - // Since the misbehaviour packet was received within the trusting period - // w.r.t to the trusted consensus states the infraction age - // isn't too old. see ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go - - // Get Byzantine validators from the conflicting headers - byzantineValidators, err := k.GetByzantineValidators(ctx, misbehaviour) - if err != nil { - return err - } - - provAddrs := make([]types.ProviderConsAddress, len(byzantineValidators)) - - // slash, jail, and tombstone the Byzantine validators - for _, v := range byzantineValidators { - providerAddr := k.GetProviderAddrFromConsumerAddr( - ctx, - misbehaviour.Header1.Header.ChainID, - types.NewConsumerConsAddress(sdk.ConsAddress(v.Address.Bytes())), - ) - err := k.SlashValidator(ctx, providerAddr) - if err != nil { - logger.Error("failed to slash validator: %s", err) - continue - } - err = k.JailAndTombstoneValidator(ctx, providerAddr) - // JailAndTombstoneValidator should never return an error if - // SlashValidator succeeded because both methods fail if the malicious - // validator is either or both !found, unbonded and tombstoned. - if err != nil { - panic(err) - } - - provAddrs = append(provAddrs, providerAddr) - } - - // Return an error if no validators were punished - if len(provAddrs) == 0 { - return fmt.Errorf("failed to slash, jail, or tombstone all validators: %v", byzantineValidators) - } - - logger.Info( - "confirmed equivocation light client attack", - "byzantine validators slashed, jailed and tombstoned", provAddrs, - ) - - return nil -} - -// GetByzantineValidators returns the validators that signed both headers. -// If the misbehavior is an equivocation light client attack, then these -// validators are the Byzantine validators. -func (k Keeper) GetByzantineValidators(ctx sdk.Context, misbehaviour ibctmtypes.Misbehaviour) (validators []*tmtypes.Validator, err error) { - // construct the trusted and conflicted light blocks - lightBlock1, err := headerToLightBlock(*misbehaviour.Header1) - if err != nil { - return - } - lightBlock2, err := headerToLightBlock(*misbehaviour.Header2) - if err != nil { - return - } - - // Check if the misbehaviour corresponds to an Amnesia attack, - // meaning that the conflicting headers have both valid state transitions - // and different commit rounds. In this case, we return no validators as - // we can't identify the byzantine validators. - // - // Note that we cannot differentiate which of the headers is trusted or malicious, - if !headersStateTransitionsAreConflicting(*lightBlock1.Header, *lightBlock2.Header) && lightBlock1.Commit.Round != lightBlock2.Commit.Round { - return - } - - // compare the signatures of the headers - // and return the intersection of validators who signed both - - // create a map with the validators' address that signed header1 - header1Signers := map[string]int{} - for idx, sign := range lightBlock1.Commit.Signatures { - if sign.Absent() { - continue - } - header1Signers[sign.ValidatorAddress.String()] = idx - } - - // iterate over the header2 signers and check if they signed header1 - for sigIdxHeader2, sign := range lightBlock2.Commit.Signatures { - if sign.Absent() { - continue - } - if sigIdxHeader1, ok := header1Signers[sign.ValidatorAddress.String()]; ok { - if err := verifyLightBlockCommitSig(*lightBlock1, sigIdxHeader1); err != nil { - return nil, err - } - - if err := verifyLightBlockCommitSig(*lightBlock2, sigIdxHeader2); err != nil { - return nil, err - } - - _, val := lightBlock1.ValidatorSet.GetByAddress(sign.ValidatorAddress) - validators = append(validators, val) - } - } - - return validators, nil -} - -// headerToLightBlock returns a CometBFT light block from the given IBC header -func headerToLightBlock(h ibctmtypes.Header) (*tmtypes.LightBlock, error) { - sh, err := tmtypes.SignedHeaderFromProto(h.SignedHeader) - if err != nil { - return nil, err - } - - vs, err := tmtypes.ValidatorSetFromProto(h.ValidatorSet) - if err != nil { - return nil, err - } - - return &tmtypes.LightBlock{ - SignedHeader: sh, - ValidatorSet: vs, - }, nil -} - -// CheckMisbehaviour checks that headers in the given misbehaviour forms -// a valid light client attack from an ICS consumer chain and that the light client isn't expired -func (k Keeper) CheckMisbehaviour(ctx sdk.Context, misbehaviour ibctmtypes.Misbehaviour) error { - // check that the misbehaviour is for an ICS consumer chain - clientId, found := k.GetConsumerClientId(ctx, misbehaviour.Header1.Header.ChainID) - if !found { - return fmt.Errorf("incorrect misbehaviour with conflicting headers from a non-existent consumer chain: %s", misbehaviour.Header1.Header.ChainID) - } else if misbehaviour.ClientId != clientId { - return fmt.Errorf("incorrect misbehaviour: expected client ID for consumer chain %s is %s got %s", - misbehaviour.Header1.Header.ChainID, - clientId, - misbehaviour.ClientId, - ) - } - - clientState, found := k.clientKeeper.GetClientState(ctx, clientId) - if !found { - return sdkerrors.Wrapf(ibcclienttypes.ErrClientNotFound, "cannot check misbehaviour for client with ID %s", misbehaviour.GetClientID()) - } - - clientStore := k.clientKeeper.ClientStore(ctx, misbehaviour.GetClientID()) - - // Check that the headers are at the same height to ensure that - // the misbehaviour is for a light client attack and not a time violation, - // see ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go - if !misbehaviour.Header1.GetHeight().EQ(misbehaviour.Header2.GetHeight()) { - return sdkerrors.Wrap(ibcclienttypes.ErrInvalidMisbehaviour, "headers are not at same height") - } - - // CheckMisbehaviourAndUpdateState verifies the misbehaviour against the trusted consensus states - // but does NOT update the light client state. - // Note that the IBC CheckMisbehaviourAndUpdateState method returns an error if the trusted consensus states are expired, - // see ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go - _, err := clientState.CheckMisbehaviourAndUpdateState(ctx, k.cdc, clientStore, &misbehaviour) - if err != nil { - return err - } - - return nil -} - -// Check if the given block headers have conflicting state transitions. -// Note that this method was copied from ConflictingHeaderIsInvalid in CometBFT, -// see https://github.com/cometbft/cometbft/blob/v0.34.27/types/evidence.go#L285 -func headersStateTransitionsAreConflicting(h1, h2 tmtypes.Header) bool { - return !bytes.Equal(h1.ValidatorsHash, h2.ValidatorsHash) || - !bytes.Equal(h1.NextValidatorsHash, h2.NextValidatorsHash) || - !bytes.Equal(h1.ConsensusHash, h2.ConsensusHash) || - !bytes.Equal(h1.AppHash, h2.AppHash) || - !bytes.Equal(h1.LastResultsHash, h2.LastResultsHash) -} - -func verifyLightBlockCommitSig(lightBlock tmtypes.LightBlock, sigIdx int) error { - // get signature - sig := lightBlock.Commit.Signatures[sigIdx] - - // get validator - idx, val := lightBlock.ValidatorSet.GetByAddress(sig.ValidatorAddress) - if idx == -1 { - return fmt.Errorf("incorrect signature: validator address %s isn't part of the validator set", sig.ValidatorAddress.String()) - } - - // verify validator pubkey corresponds to signature validator address - if !bytes.Equal(val.PubKey.Address(), sig.ValidatorAddress) { - return fmt.Errorf("validator public key doesn't correspond to signature validator address: %s!= %s", val.PubKey.Address(), sig.ValidatorAddress) - } - - // validate signature - voteSignBytes := lightBlock.Commit.VoteSignBytes(lightBlock.ChainID, int32(sigIdx)) - if !val.PubKey.VerifySignature(voteSignBytes, sig.Signature) { - return fmt.Errorf("wrong signature (#%d): %X", sigIdx, sig.Signature) - } - - return nil -} diff --git a/x/ccv/provider/keeper/proposal.go b/x/ccv/provider/keeper/proposal.go index 6fb6e8e6f2..9c08297055 100644 --- a/x/ccv/provider/keeper/proposal.go +++ b/x/ccv/provider/keeper/proposal.go @@ -59,6 +59,9 @@ func (k Keeper) CreateConsumerClient(ctx sdk.Context, prop *types.ConsumerAdditi fmt.Sprintf("cannot create client for existent consumer chain: %s", chainID)) } + // Set minimum height for equivocation evidence from this consumer chain + k.SetEquivocationEvidenceMinHeight(ctx, chainID, prop.InitialHeight.RevisionHeight) + // Consumers start out with the unbonding period from the consumer addition prop consumerUnbondingPeriod := prop.UnbondingPeriod @@ -164,6 +167,7 @@ func (k Keeper) StopConsumerChain(ctx sdk.Context, chainID string, closeChan boo k.DeleteInitTimeoutTimestamp(ctx, chainID) // Note: this call panics if the key assignment state is invalid k.DeleteKeyAssignments(ctx, chainID) + k.DeleteEquivocationEvidenceMinHeight(ctx, chainID) // close channel and delete the mappings between chain ID and channel ID if channelID, found := k.GetChainToChannel(ctx, chainID); found { diff --git a/x/ccv/provider/keeper/proposal_test.go b/x/ccv/provider/keeper/proposal_test.go index cbaf58ef38..a522a73441 100644 --- a/x/ccv/provider/keeper/proposal_test.go +++ b/x/ccv/provider/keeper/proposal_test.go @@ -179,11 +179,12 @@ func TestCreateConsumerClient(t *testing.T) { tc.setup(&providerKeeper, ctx, &mocks) // Call method with same arbitrary values as defined above in mock expectations. - err := providerKeeper.CreateConsumerClient(ctx, testkeeper.GetTestConsumerAdditionProp()) + prop := testkeeper.GetTestConsumerAdditionProp() + err := providerKeeper.CreateConsumerClient(ctx, prop) if tc.expClientCreated { require.NoError(t, err) - testCreatedConsumerClient(t, ctx, providerKeeper, "chainID", "clientID") + testCreatedConsumerClient(t, ctx, providerKeeper, "chainID", "clientID", prop.InitialHeight.GetRevisionHeight()) } else { require.Error(t, err) } @@ -197,7 +198,8 @@ func TestCreateConsumerClient(t *testing.T) { // // Note: Separated from TestCreateConsumerClient to also be called from TestCreateConsumerChainProposal. func testCreatedConsumerClient(t *testing.T, - ctx sdk.Context, providerKeeper providerkeeper.Keeper, expectedChainID, expectedClientID string, + ctx sdk.Context, providerKeeper providerkeeper.Keeper, + expectedChainID, expectedClientID string, expectedEquivocationEvidenceMinHeight uint64, ) { t.Helper() // ClientID should be stored. @@ -205,6 +207,10 @@ func testCreatedConsumerClient(t *testing.T, require.True(t, found, "consumer client not found") require.Equal(t, expectedClientID, clientId) + // check that the equivocation evidence min height was set + h := providerKeeper.GetEquivocationEvidenceMinHeight(ctx, expectedChainID) + require.Equal(t, h, expectedEquivocationEvidenceMinHeight) + // Only assert that consumer genesis was set, // more granular tests on consumer genesis should be defined in TestMakeConsumerGenesis _, ok := providerKeeper.GetConsumerGenesis(ctx, expectedChainID) diff --git a/x/ccv/provider/keeper/punish_validator.go b/x/ccv/provider/keeper/punish_validator.go deleted file mode 100644 index e987581cb2..0000000000 --- a/x/ccv/provider/keeper/punish_validator.go +++ /dev/null @@ -1,101 +0,0 @@ -package keeper - -import ( - "fmt" - - errorsmod "cosmossdk.io/errors" - - sdk "github.com/cosmos/cosmos-sdk/types" - evidencetypes "github.com/cosmos/cosmos-sdk/x/evidence/types" - slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" - stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - "github.com/cosmos/interchain-security/v2/x/ccv/provider/types" -) - -// JailAndTombstoneValidator jails and tombstones the validator with the given provider consensus address -func (k Keeper) JailAndTombstoneValidator(ctx sdk.Context, providerAddr types.ProviderConsAddress) error { - validator, found := k.stakingKeeper.GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()) - if !found { - return errorsmod.Wrapf(slashingtypes.ErrNoValidatorForAddress, "provider consensus address: %s", providerAddr.String()) - } - - if validator.IsUnbonded() { - return fmt.Errorf("validator is unbonded. provider consensus address: %s", providerAddr.String()) - } - - if k.slashingKeeper.IsTombstoned(ctx, providerAddr.ToSdkConsAddr()) { - return fmt.Errorf("validator is tombstoned. provider consensus address: %s", providerAddr.String()) - } - - // jail validator if not already - if !validator.IsJailed() { - k.stakingKeeper.Jail(ctx, providerAddr.ToSdkConsAddr()) - } - - k.slashingKeeper.JailUntil(ctx, providerAddr.ToSdkConsAddr(), evidencetypes.DoubleSignJailEndTime) - - // Tombstone the validator so that we cannot slash the validator more than once - // Note that we cannot simply use the fact that a validator is jailed to avoid slashing more than once - // because then a validator could i) perform an equivocation, ii) get jailed (e.g., through downtime) - // and in such a case the validator would not get slashed when we call `SlashValidator`. - k.slashingKeeper.Tombstone(ctx, providerAddr.ToSdkConsAddr()) - - return nil -} - -// ComputePowerToSlash computes the power to be slashed based on the tokens in non-matured `undelegations` and -// `redelegations`, as well as the current `power` of the validator. -// Note that this method does not perform any slashing. -func (k Keeper) ComputePowerToSlash(ctx sdk.Context, validator stakingtypes.Validator, undelegations []stakingtypes.UnbondingDelegation, - redelegations []stakingtypes.Redelegation, power int64, powerReduction sdk.Int, -) int64 { - // compute the total numbers of tokens currently being undelegated - undelegationsInTokens := sdk.NewInt(0) - - // Note that we use a **cached** context to avoid any actual slashing of undelegations or redelegations. - cachedCtx, _ := ctx.CacheContext() - for _, u := range undelegations { - amountSlashed := k.stakingKeeper.SlashUnbondingDelegation(cachedCtx, u, 0, sdk.NewDec(1)) - undelegationsInTokens = undelegationsInTokens.Add(amountSlashed) - } - - // compute the total numbers of tokens currently being redelegated - redelegationsInTokens := sdk.NewInt(0) - for _, r := range redelegations { - amountSlashed := k.stakingKeeper.SlashRedelegation(cachedCtx, validator, r, 0, sdk.NewDec(1)) - redelegationsInTokens = redelegationsInTokens.Add(amountSlashed) - } - - // The power we pass to staking's keeper `Slash` method is the current power of the validator together with the total - // power of all the currently undelegated and redelegated tokens (see docs/docs/adrs/adr-013-equivocation-slashing.md). - undelegationsAndRedelegationsInPower := sdk.TokensToConsensusPower( - undelegationsInTokens.Add(redelegationsInTokens), powerReduction) - - return power + undelegationsAndRedelegationsInPower -} - -// SlashValidator slashes validator with `providerAddr` -func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr types.ProviderConsAddress) error { - validator, found := k.stakingKeeper.GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()) - if !found { - return errorsmod.Wrapf(slashingtypes.ErrNoValidatorForAddress, "provider consensus address: %s", providerAddr.String()) - } - - if validator.IsUnbonded() { - return fmt.Errorf("validator is unbonded. provider consensus address: %s", providerAddr.String()) - } - - if k.slashingKeeper.IsTombstoned(ctx, providerAddr.ToSdkConsAddr()) { - return fmt.Errorf("validator is tombstoned. provider consensus address: %s", providerAddr.String()) - } - - undelegations := k.stakingKeeper.GetUnbondingDelegationsFromValidator(ctx, validator.GetOperator()) - redelegations := k.stakingKeeper.GetRedelegationsFromSrcValidator(ctx, validator.GetOperator()) - lastPower := k.stakingKeeper.GetLastValidatorPower(ctx, validator.GetOperator()) - powerReduction := k.stakingKeeper.PowerReduction(ctx) - totalPower := k.ComputePowerToSlash(ctx, validator, undelegations, redelegations, lastPower, powerReduction) - slashFraction := k.slashingKeeper.SlashFractionDoubleSign(ctx) - - k.stakingKeeper.Slash(ctx, providerAddr.ToSdkConsAddr(), 0, totalPower, slashFraction, stakingtypes.DoubleSign) - return nil -} diff --git a/x/ccv/provider/types/keys.go b/x/ccv/provider/types/keys.go index 4e15f566ef..8edc655abd 100644 --- a/x/ccv/provider/types/keys.go +++ b/x/ccv/provider/types/keys.go @@ -138,6 +138,10 @@ const ( // handled in the current block VSCMaturedHandledThisBlockBytePrefix + // EquivocationEvidenceMinHeightBytePrefix is the byte prefix storing the mapping from consumer chain IDs + // to the minimum height of a valid consumer equivocation evidence + EquivocationEvidenceMinHeightBytePrefix + // NOTE: DO NOT ADD NEW BYTE PREFIXES HERE WITHOUT ADDING THEM TO getAllKeyPrefixes() IN keys_test.go ) @@ -377,6 +381,12 @@ func ConsumerRewardDenomsKey(denom string) []byte { return append([]byte{ConsumerRewardDenomsBytePrefix}, []byte(denom)...) } +// EquivocationEvidenceMinHeightKey returns the key storing the minimum height +// of a valid consumer equivocation evidence for a given consumer chain ID +func EquivocationEvidenceMinHeightKey(consumerChainID string) []byte { + return append([]byte{EquivocationEvidenceMinHeightBytePrefix}, []byte(consumerChainID)...) +} + // NOTE: DO NOT ADD FULLY DEFINED KEY FUNCTIONS WITHOUT ADDING THEM TO getAllFullyDefinedKeys() IN keys_test.go // diff --git a/x/ccv/provider/types/keys_test.go b/x/ccv/provider/types/keys_test.go index 03493c1138..e60b579b41 100644 --- a/x/ccv/provider/types/keys_test.go +++ b/x/ccv/provider/types/keys_test.go @@ -52,6 +52,7 @@ func getAllKeyPrefixes() []byte { providertypes.ConsumerAddrsToPruneBytePrefix, providertypes.SlashLogBytePrefix, providertypes.VSCMaturedHandledThisBlockBytePrefix, + providertypes.EquivocationEvidenceMinHeightBytePrefix, } } @@ -96,6 +97,7 @@ func getAllFullyDefinedKeys() [][]byte { providertypes.ConsumerAddrsToPruneKey("chainID", 88), providertypes.SlashLogKey(providertypes.NewProviderConsAddress([]byte{0x05})), providertypes.VSCMaturedHandledThisBlockKey(), + providertypes.EquivocationEvidenceMinHeightKey("chainID"), } }