Skip to content
Open
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
9 changes: 9 additions & 0 deletions proto/vaas/provider/v1/genesis.proto
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ message ConsumerState {
// re-derive the keeper's pause-expiration queue.
google.protobuf.Timestamp pause_expiration_time = 16
[ (gogoproto.stdtime) = true ];

// PrevConsumerValsetHash is the CometBFT hash of the validator set the
// provider had computed for this consumer immediately before the currently
// stored one. Client discovery accepts a candidate IBC client only if the
// client's latest consensus state carries the hash of the current set or
// this one (the set still running on the consumer while the latest VSC
// packet is in flight). Absent until the consumer's validator set has
// rotated at least once.
bytes prev_consumer_valset_hash = 17;
}

// ValsetUpdateIdToHeight defines the genesis information for the mapping
Expand Down
15 changes: 14 additions & 1 deletion testutil/keeper/unit_test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ type MockedKeepers struct {
*MockAccountKeeper
*MockBankKeeper
*MockDistributionKeeper

// ClientCounterparties backs the mocked ClientV2Keeper's
// GetClientCounterparty: a client id present in the map has a registered
// counterparty, any other id does not. Tests exercising client
// authentication (the provider's discovery content check, the consumer's
// provider-client pin) populate it to mark specific clients routable;
// the empty default preserves the historical "no counterparty" behavior.
ClientCounterparties map[string]clientv2types.CounterpartyInfo
}

// NewMockedKeepers instantiates a struct with pointers to properly instantiated mocked keepers.
Expand All @@ -94,8 +102,13 @@ func NewMockedKeepers(ctrl *gomock.Controller) MockedKeepers {
MockAccountKeeper: NewMockAccountKeeper(ctrl),
MockBankKeeper: NewMockBankKeeper(ctrl),
MockDistributionKeeper: NewMockDistributionKeeper(ctrl),
ClientCounterparties: map[string]clientv2types.CounterpartyInfo{},
}
mocks.MockClientV2Keeper.EXPECT().GetClientCounterparty(gomock.Any(), gomock.Any()).Return(clientv2types.CounterpartyInfo{}, false).AnyTimes()
mocks.MockClientV2Keeper.EXPECT().GetClientCounterparty(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ sdk.Context, clientID string) (clientv2types.CounterpartyInfo, bool) {
cp, found := mocks.ClientCounterparties[clientID]
return cp, found
}).AnyTimes()
mocks.MockClientV2Keeper.EXPECT().SetClientCounterparty(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
return mocks
}
Expand Down
31 changes: 18 additions & 13 deletions x/vaas/consumer/ibc_module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,20 @@ import (
// TestIBCModuleOnRecvPacketStoresDestinationClientAsProviderClient guards
// against regressing to storing the packet's SourceClient (the provider's
// own client, meaningless to the consumer for outbound sends) as the
// consumer's ProviderClientID. It must store DestinationClient: the
// consumer's own client that received the packet, which ibc-go's RecvPacket
// handler has already verified carries a registered counterparty, and which
// SendEvidencePackets later needs to address packets back to the provider.
// consumer's ProviderClientID when the bootstrap adoption re-pins from the
// genesis client. It must store DestinationClient: the consumer's own client
// that received the packet, which ibc-go's RecvPacket handler has already
// verified carries a registered counterparty, and which SendEvidencePackets
// later needs to address packets back to the provider.
func TestIBCModuleOnRecvPacketStoresDestinationClientAsProviderClient(t *testing.T) {
consumerKeeper, ctx, ctrl, mocks := testkeeper.GetConsumerKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t))
defer ctrl.Finish()
testkeeper.StubClientState(mocks, "provider-0")

// The unroutable client pinned at genesis; neither the packet's source
// nor its destination, so storing the wrong one is observable.
consumerKeeper.SetProviderClientID(ctx, "07-tendermint-9")

module := consumer.NewIBCModule(&consumerKeeper)

pk, err := cryptocodec.ToCmtProtoPublicKey(ed25519.GenPrivKey().PubKey())
Expand All @@ -53,18 +58,18 @@ func TestIBCModuleOnRecvPacketStoresDestinationClientAsProviderClient(t *testing
"ProviderClientID must be the consumer's own (destination) client, not the provider's own (source) client")
}

// TestIBCModuleOnRecvPacketHealsStaleProviderClient guards against the
// consumer latching onto a genesis-time placeholder client (self-created
// before any relayer-established, counterparty-linked client exists) and
// never correcting it: every accepted VSC packet must resync ProviderClientID
// to whichever client actually delivered it.
func TestIBCModuleOnRecvPacketHealsStaleProviderClient(t *testing.T) {
// TestIBCModuleOnRecvPacketBootstrapReplacesGenesisClient covers the one-time
// bootstrap adoption through the full IBC module callback: the consumer is
// pinned to the genesis-time client (self-created, no registered counterparty,
// unreachable by packet routing), and the first VSC packet delivered over the
// relayer's counterparty-linked client re-pins ProviderClientID to it.
func TestIBCModuleOnRecvPacketBootstrapReplacesGenesisClient(t *testing.T) {
consumerKeeper, ctx, ctrl, mocks := testkeeper.GetConsumerKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t))
defer ctrl.Finish()
testkeeper.StubClientState(mocks, "provider-0")

staleGenesisClientID := "07-tendermint-0"
consumerKeeper.SetProviderClientID(ctx, staleGenesisClientID)
genesisClientID := "07-tendermint-0"
consumerKeeper.SetProviderClientID(ctx, genesisClientID)

module := consumer.NewIBCModule(&consumerKeeper)

Expand All @@ -86,7 +91,7 @@ func TestIBCModuleOnRecvPacketHealsStaleProviderClient(t *testing.T) {
clientID, found := consumerKeeper.GetProviderClientID(ctx)
require.True(t, found)
require.Equal(t, liveClientID, clientID,
"ProviderClientID must heal to the client actually delivering VSC packets, not stay stuck on the stale genesis client")
"ProviderClientID must re-pin to the client actually delivering VSC packets, not stay stuck on the unroutable genesis client")
}

// TestIBCModuleOnRecvPacketRejectsWrongSourcePort guards against accepting a
Expand Down
6 changes: 6 additions & 0 deletions x/vaas/consumer/keeper/genesis.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ func (k Keeper) InitGenesis(ctx sdk.Context, state *types.GenesisState) []abci.V
panic(err)
}

// This genesis client is built from provider-authored state, but it can
// never carry packets (created outside a MsgCreateClient, it has no
// recorded creator, so its IBC v2 counterparty can never be
// registered). The pin moves off it exactly once, to the first client
// that actually delivers a VSC packet, and is permanent from then on;
// see enforcePinnedProviderClient in relay.go for the trust model.
k.SetProviderClientID(ctx, cid)
k.SetHeightValsetUpdateID(ctx, uint64(ctx.BlockHeight()), uint64(0))

Expand Down
34 changes: 22 additions & 12 deletions x/vaas/consumer/keeper/ibc_v2_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (

abci "github.com/cometbft/cometbft/abci/types"

clientv2types "github.com/cosmos/ibc-go/v10/modules/core/02-client/v2/types"

cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec"
"github.com/cosmos/cosmos-sdk/crypto/keys/ed25519"

Expand All @@ -15,7 +17,7 @@ import (
)

// TestIBCV2ConsumerFullVSCFlow tests the complete consumer-side IBC v2 VSC packet flow:
// 1. Consumer receives first VSC packet and establishes provider client
// 1. Consumer receives first VSC packet over the pinned provider client
// 2. Consumer accumulates validator updates
// 3. Consumer tracks highest valset update ID for out-of-order handling
func TestIBCV2ConsumerFullVSCFlow(t *testing.T) {
Expand All @@ -24,14 +26,15 @@ func TestIBCV2ConsumerFullVSCFlow(t *testing.T) {
testkeeper.StubClientState(mocks, "provider-0")

providerClientID := "07-tendermint-0"
consumerKeeper.SetProviderClientID(ctx, providerClientID)

// Create validator updates
pk1, err := cryptocodec.ToCmtProtoPublicKey(ed25519.GenPrivKey().PubKey())
require.NoError(t, err)
pk2, err := cryptocodec.ToCmtProtoPublicKey(ed25519.GenPrivKey().PubKey())
require.NoError(t, err)

// Step 1: Receive first VSC packet - should establish provider client
// Step 1: Receive first VSC packet over the pinned client
valUpdates1 := []abci.ValidatorUpdate{
{PubKey: pk1, Power: 100},
}
Expand All @@ -40,7 +43,7 @@ func TestIBCV2ConsumerFullVSCFlow(t *testing.T) {
err = consumerKeeper.OnRecvVSCPacketV2(ctx, providerClientID, vscPacket1)
require.NoError(t, err)

// Verify provider client was established
// Verify the pin is unchanged
clientID, found := consumerKeeper.GetProviderClientID(ctx)
require.True(t, found)
require.Equal(t, providerClientID, clientID)
Expand Down Expand Up @@ -84,6 +87,7 @@ func TestIBCV2ConsumerOutOfOrderHandling(t *testing.T) {
testkeeper.StubClientState(mocks, "provider-0")

providerClientID := "07-tendermint-0"
consumerKeeper.SetProviderClientID(ctx, providerClientID)

pk1, err := cryptocodec.ToCmtProtoPublicKey(ed25519.GenPrivKey().PubKey())
require.NoError(t, err)
Expand Down Expand Up @@ -134,41 +138,46 @@ func TestIBCV2ConsumerOutOfOrderHandling(t *testing.T) {
require.Len(t, pendingChanges.ValidatorUpdates, 2)
}

// TestIBCV2ConsumerRejectsUnknownProvider tests that packets from an unknown
// provider client are rejected after the provider is established.
// TestIBCV2ConsumerRejectsUnknownProvider tests that packets arriving over a
// client other than the pinned, routable provider client are rejected.
func TestIBCV2ConsumerRejectsUnknownProvider(t *testing.T) {
consumerKeeper, ctx, ctrl, mocks := testkeeper.GetConsumerKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t))
defer ctrl.Finish()
testkeeper.StubClientState(mocks, "provider-0")

providerClientID := "07-tendermint-0"
unknownClientID := "07-tendermint-999"
consumerKeeper.SetProviderClientID(ctx, providerClientID)
mocks.ClientCounterparties[providerClientID] = clientv2types.CounterpartyInfo{ClientId: "07-tendermint-7"}

pk, err := cryptocodec.ToCmtProtoPublicKey(ed25519.GenPrivKey().PubKey())
require.NoError(t, err)

// Establish provider with first packet
// Traffic over the pinned client flows normally.
valUpdates := []abci.ValidatorUpdate{{PubKey: pk, Power: 100}}
vscPacket := types.NewValidatorSetChangePacketData(valUpdates, 1)

err = consumerKeeper.OnRecvVSCPacketV2(ctx, providerClientID, vscPacket)
require.NoError(t, err)

// Verify provider is established
clientID, found := consumerKeeper.GetProviderClientID(ctx)
require.True(t, found)
require.Equal(t, providerClientID, clientID)

// Try to send packet from different client - should succeed
// (IBC v2 layer handles counterparty validation)
// A packet over any other client is rejected: the pin is routable, so
// there is no legitimate reason for VSC traffic to arrive anywhere else.
vscPacket2 := types.NewValidatorSetChangePacketData(valUpdates, 2)
err = consumerKeeper.OnRecvVSCPacketV2(ctx, unknownClientID, vscPacket2)
require.NoError(t, err, "packet from different client should succeed (IBC v2 validates counterparties)")
require.Error(t, err, "packet over a non-pinned client must be rejected")

// Highest ID should be updated to 2
// Highest ID must still be 1, and the pin must not have moved.
highestID, _, err := consumerKeeper.GetHighestValsetUpdateID(ctx)
require.NoError(t, err)
require.Equal(t, uint64(2), highestID)
require.Equal(t, uint64(1), highestID)

clientID, found = consumerKeeper.GetProviderClientID(ctx)
require.True(t, found)
require.Equal(t, providerClientID, clientID)
}

// TestIBCV2ConsumerProviderInfoQuery tests the v2 provider info query.
Expand Down Expand Up @@ -201,6 +210,7 @@ func TestIBCV2ConsumerDuplicatePacketHandling(t *testing.T) {
testkeeper.StubClientState(mocks, "provider-0")

providerClientID := "07-tendermint-0"
consumerKeeper.SetProviderClientID(ctx, providerClientID)

pk, err := cryptocodec.ToCmtProtoPublicKey(ed25519.GenPrivKey().PubKey())
require.NoError(t, err)
Expand Down
5 changes: 4 additions & 1 deletion x/vaas/consumer/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,10 @@ func (k Keeper) SetPort(ctx context.Context, portID string) {
}

// SetProviderClientID sets the clientID for the client to the provider.
// Set in InitGenesis
// Written at InitGenesis (the genesis client on NewChain, the exported pin on
// a restart) and by the one-time bootstrap adoption in
// enforcePinnedProviderClient (relay.go), which also documents why the pin
// never moves after that.
func (k Keeper) SetProviderClientID(ctx context.Context, clientID string) {
if err := k.ProviderClientID.Set(ctx, clientID); err != nil {
panic(fmt.Errorf("failed to set provider client ID: %w", err))
Expand Down
Loading