From d0bb78162cc4c81975c53f1c8cb13b87952de376 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Mon, 11 May 2026 14:12:41 +0200 Subject: [PATCH 01/15] feat: slash provider for downtime --- app/consumer/app.go | 3 + proto/vaas/v1/wire.proto | 13 ++ testutil/keeper/mocks.go | 15 ++ x/vaas/consumer/ibc_module.go | 21 ++- x/vaas/consumer/keeper/keeper.go | 20 ++- x/vaas/consumer/keeper/slash_packet.go | 136 ++++++++++++++++++ x/vaas/consumer/keeper/validators.go | 33 +++-- x/vaas/consumer/keeper/validators_test.go | 17 ++- x/vaas/consumer/module.go | 7 +- x/vaas/consumer/types/keys.go | 2 + x/vaas/provider/ibc_module.go | 62 +++++++- .../provider/keeper/consumer_equivocation.go | 76 +++++++++- .../keeper/consumer_equivocation_test.go | 130 ++++++++++++++++- x/vaas/types/expected_keepers.go | 1 + x/vaas/types/wire.go | 46 ++++++ 15 files changed, 551 insertions(+), 31 deletions(-) create mode 100644 x/vaas/consumer/keeper/slash_packet.go diff --git a/app/consumer/app.go b/app/consumer/app.go index b1c1af3..b059174 100644 --- a/app/consumer/app.go +++ b/app/consumer/app.go @@ -370,6 +370,9 @@ func New( ibcRouterV2.AddRoute(vaastypes.ConsumerAppID, ibcconsumer.NewIBCModule(&app.ConsumerKeeper)) app.IBCKeeper.SetRouterV2(ibcRouterV2) + // wire the channel keeper v2 so the consumer can send slash packets to the provider + app.ConsumerKeeper.SetChannelKeeperV2(app.IBCKeeper.ChannelKeeperV2) + tmLightClientModule := ibctm.NewLightClientModule(appCodec, app.IBCKeeper.ClientKeeper.GetStoreProvider()) app.IBCKeeper.ClientKeeper.AddRoute(ibctm.ModuleName, tmLightClientModule) diff --git a/proto/vaas/v1/wire.proto b/proto/vaas/v1/wire.proto index b71c9cf..b0f6885 100644 --- a/proto/vaas/v1/wire.proto +++ b/proto/vaas/v1/wire.proto @@ -8,6 +8,7 @@ import "cosmos/staking/v1beta1/staking.proto"; import "gogoproto/gogo.proto"; import "tendermint/abci/types.proto"; +import "cosmos_proto/cosmos.proto"; // // Note any type defined in this file is used by both the consumer and provider @@ -39,3 +40,15 @@ message ValidatorSetChangePacketData { // collects fees successfully and propagates false on the next VSC. bool consumer_in_debt = 3; } + +// SlashPacketData is sent from a consumer chain to the provider chain +// to report a validator infraction (e.g., downtime) detected on the consumer. +// The provider uses this to slash and jail the validator on the provider. +message SlashPacketData { + // validator consensus address on the consumer chain + bytes validator_addr = 1; + // infraction height on the consumer chain + int64 infraction_height = 2; + // infraction type (DOWNTIME or DOUBLE_SIGN) + cosmos.staking.v1beta1.Infraction infraction = 3; +} diff --git a/testutil/keeper/mocks.go b/testutil/keeper/mocks.go index 0f38214..a09c3c8 100644 --- a/testutil/keeper/mocks.go +++ b/testutil/keeper/mocks.go @@ -456,6 +456,21 @@ func (mr *MockSlashingKeeperMockRecorder) SlashFractionDoubleSign(arg0 any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SlashFractionDoubleSign", reflect.TypeOf((*MockSlashingKeeper)(nil).SlashFractionDoubleSign), arg0) } +// SlashFractionDowntime mocks base method. +func (m *MockSlashingKeeper) SlashFractionDowntime(arg0 context.Context) (math.LegacyDec, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SlashFractionDowntime", arg0) + ret0, _ := ret[0].(math.LegacyDec) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SlashFractionDowntime indicates an expected call of SlashFractionDowntime. +func (mr *MockSlashingKeeperMockRecorder) SlashFractionDowntime(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SlashFractionDowntime", reflect.TypeOf((*MockSlashingKeeper)(nil).SlashFractionDowntime), arg0) +} + // Tombstone mocks base method. func (m *MockSlashingKeeper) Tombstone(arg0 context.Context, arg1 types.ConsAddress) error { m.ctrl.T.Helper() diff --git a/x/vaas/consumer/ibc_module.go b/x/vaas/consumer/ibc_module.go index 1a77aa2..a1c2906 100644 --- a/x/vaas/consumer/ibc_module.go +++ b/x/vaas/consumer/ibc_module.go @@ -35,13 +35,30 @@ func (im IBCModule) OnSendPacket( payload channeltypesv2.Payload, signer sdk.AccAddress, ) error { - im.keeper.Logger(ctx).Error("consumer attempted to send packet", + if payload.SourcePort != vaastypes.ConsumerAppID { + return errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, + "invalid source port: expected %s, got %s", vaastypes.ConsumerAppID, payload.SourcePort) + } + if payload.DestinationPort != vaastypes.ProviderAppID { + return errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, + "invalid destination port: expected %s, got %s", vaastypes.ProviderAppID, payload.DestinationPort) + } + if signer.String() != im.keeper.GetAuthority() { + return errorsmod.Wrapf( + sdkerrors.ErrUnauthorized, + "signer %s is different from authority %s", + signer.String(), + im.keeper.GetAuthority(), + ) + } + + im.keeper.Logger(ctx).Debug("OnSendPacket", "sourceClient", sourceClient, "destinationClient", destinationClient, "sequence", sequence, ) - return errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "consumer does not send packets") + return nil } func (im IBCModule) OnRecvPacket( diff --git a/x/vaas/consumer/keeper/keeper.go b/x/vaas/consumer/keeper/keeper.go index b657bdc..bb0e626 100644 --- a/x/vaas/consumer/keeper/keeper.go +++ b/x/vaas/consumer/keeper/keeper.go @@ -28,10 +28,11 @@ type Keeper struct { // should be the x/gov module account. authority string - storeService corestoretypes.KVStoreService - cdc codec.BinaryCodec - clientKeeper vaastypes.ClientKeeper - clientV2Keeper vaastypes.ClientV2Keeper + storeService corestoretypes.KVStoreService + cdc codec.BinaryCodec + clientKeeper vaastypes.ClientKeeper + clientV2Keeper vaastypes.ClientV2Keeper + channelKeeperV2 vaastypes.ChannelV2Keeper // standaloneStakingKeeper is the staking keeper that managed proof of stake for a previously standalone chain, // before the chain went through a standalone to consumer changeover. // This keeper is not used for consumers that launched with ICS, and is therefore set after the constructor. @@ -62,6 +63,8 @@ type Keeper struct { CrossChainValidators collections.Map[[]byte, types.CrossChainValidator] HistoricalInfos collections.Map[int64, stakingtypes.HistoricalInfo] HighestValsetUpdateID collections.Item[uint64] + PendingSlashPackets collections.Map[uint64, []byte] + PendingSlashSeq collections.Sequence } // NewKeeper creates a new Consumer Keeper instance @@ -105,6 +108,8 @@ func NewKeeper( CrossChainValidators: collections.NewMap(sb, types.CrossChainValidatorPrefix, "cross_chain_validators", collections.BytesKey, codec.CollValue[types.CrossChainValidator](cdc)), HistoricalInfos: collections.NewMap(sb, types.HistoricalInfoPrefix, "historical_infos", collections.Int64Key, codec.CollValue[stakingtypes.HistoricalInfo](cdc)), HighestValsetUpdateID: collections.NewItem(sb, types.HighestValsetUpdateIDPrefix, "highest_valset_update_id", collections.Uint64Value), + PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.Uint64Key, collections.BytesValue), + PendingSlashSeq: collections.NewSequence(sb, types.PendingSlashSeqPrefix, "pending_slash_seq"), } schema, err := sb.Build() @@ -144,6 +149,8 @@ func NewNonZeroKeeper(cdc codec.BinaryCodec, storeService corestoretypes.KVStore CrossChainValidators: collections.NewMap(sb, types.CrossChainValidatorPrefix, "cross_chain_validators", collections.BytesKey, codec.CollValue[types.CrossChainValidator](cdc)), HistoricalInfos: collections.NewMap(sb, types.HistoricalInfoPrefix, "historical_infos", collections.Int64Key, codec.CollValue[stakingtypes.HistoricalInfo](cdc)), HighestValsetUpdateID: collections.NewItem(sb, types.HighestValsetUpdateIDPrefix, "highest_valset_update_id", collections.Uint64Value), + PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.Uint64Key, collections.BytesValue), + PendingSlashSeq: collections.NewSequence(sb, types.PendingSlashSeqPrefix, "pending_slash_seq"), } schema, err := sb.Build() @@ -188,6 +195,11 @@ func (k *Keeper) SetHooks(sh vaastypes.ConsumerHooks) *Keeper { return k } +// SetChannelKeeperV2 sets the IBC v2 channel keeper for sending slash packets. +func (k *Keeper) SetChannelKeeperV2(keeper vaastypes.ChannelV2Keeper) { + k.channelKeeperV2 = keeper +} + // GetPort returns the portID for the transfer module. Used in ExportGenesis func (k Keeper) GetPort(ctx context.Context) string { port, err := k.Port.Get(ctx) diff --git a/x/vaas/consumer/keeper/slash_packet.go b/x/vaas/consumer/keeper/slash_packet.go new file mode 100644 index 0000000..378551e --- /dev/null +++ b/x/vaas/consumer/keeper/slash_packet.go @@ -0,0 +1,136 @@ +package keeper + +import ( + "encoding/json" + "fmt" + + "github.com/allinbits/vaas/x/vaas/consumer/types" + vaastypes "github.com/allinbits/vaas/x/vaas/types" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// QueueSlashPacket queues a slash packet to be sent to the provider chain. +func (k Keeper) QueueSlashPacket(ctx sdk.Context, packet vaastypes.SlashPacketData) error { + bz, err := json.Marshal(&packet) + if err != nil { + return fmt.Errorf("failed to marshal slash packet: %w", err) + } + + seq, err := k.PendingSlashSeq.Next(ctx) + if err != nil { + return fmt.Errorf("failed to get slash packet sequence: %w", err) + } + + if err := k.PendingSlashPackets.Set(ctx, seq, bz); err != nil { + return fmt.Errorf("failed to store slash packet: %w", err) + } + + return nil +} + +// SendSlashPackets sends all pending slash packets to the provider chain. +func (k Keeper) SendSlashPackets(ctx sdk.Context) error { + if k.channelKeeperV2 == nil { + k.Logger(ctx).Debug("IBC v2 channel keeper not configured, skipping slash packet send") + return nil + } + + providerClientID, found := k.GetProviderClientID(ctx) + if !found { + k.Logger(ctx).Debug("provider client not established, skipping slash packet send") + return nil + } + + iter, err := k.PendingSlashPackets.Iterate(ctx, nil) + if err != nil { + return fmt.Errorf("failed to iterate pending slash packets: %w", err) + } + defer iter.Close() + + var keysToDelete []uint64 + for ; iter.Valid(); iter.Next() { + kv, err := iter.KeyValue() + if err != nil { + continue + } + + var slashPacket vaastypes.SlashPacketData + if err := json.Unmarshal(kv.Value, &slashPacket); err != nil { + k.Logger(ctx).Error("failed to unmarshal slash packet", "error", err) + keysToDelete = append(keysToDelete, kv.Key) + continue + } + + payload := channeltypesv2.NewPayload( + vaastypes.ConsumerAppID, + vaastypes.ProviderAppID, + "vaas-v1", + "application/json", + slashPacket.GetBytes(), + ) + + timeoutPeriod := k.GetVAASTimeoutPeriod(ctx) + timeoutTimestamp := uint64(ctx.BlockTime().Add(timeoutPeriod).Unix()) + + msg := channeltypesv2.NewMsgSendPacket( + providerClientID, + timeoutTimestamp, + k.authority, + payload, + ) + + resp, err := k.channelKeeperV2.SendPacket(ctx, msg) + if err != nil { + k.Logger(ctx).Error("failed to send slash packet", + "error", err, + "validator", slashPacket.ValidatorAddr.String(), + ) + continue + } + + k.Logger(ctx).Info("slash packet sent", + "sequence", resp.Sequence, + "validator", slashPacket.ValidatorAddr.String(), + "infraction", slashPacket.Infraction.String(), + "infraction_height", slashPacket.InfractionHeight, + ) + + keysToDelete = append(keysToDelete, kv.Key) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + vaastypes.EventTypeConsumerSlashRequest, + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(vaastypes.AttributeValidatorAddress, slashPacket.ValidatorAddr.String()), + sdk.NewAttribute(vaastypes.AttributeInfractionHeight, fmt.Sprintf("%d", slashPacket.InfractionHeight)), + sdk.NewAttribute(vaastypes.AttributeInfractionType, slashPacket.Infraction.String()), + ), + ) + } + + for _, key := range keysToDelete { + if err := k.PendingSlashPackets.Remove(ctx, key); err != nil { + k.Logger(ctx).Error("failed to delete sent slash packet", "error", err) + } + } + + return nil +} + +// GetPendingSlashPacketCount returns the number of pending slash packets. +func (k Keeper) GetPendingSlashPacketCount(ctx sdk.Context) int { + iter, err := k.PendingSlashPackets.Iterate(ctx, nil) + if err != nil { + return 0 + } + defer iter.Close() + + count := 0 + for ; iter.Valid(); iter.Next() { + count++ + } + return count +} diff --git a/x/vaas/consumer/keeper/validators.go b/x/vaas/consumer/keeper/validators.go index a0c6bb2..6628431 100644 --- a/x/vaas/consumer/keeper/validators.go +++ b/x/vaas/consumer/keeper/validators.go @@ -6,6 +6,7 @@ import ( "time" "github.com/allinbits/vaas/x/vaas/consumer/types" + vaastypes "github.com/allinbits/vaas/x/vaas/types" abci "github.com/cometbft/cometbft/abci/types" @@ -111,19 +112,33 @@ func (k Keeper) Slash(ctx context.Context, addr sdk.ConsAddress, infractionHeigh return k.SlashWithInfractionReason(ctx, addr, infractionHeight, power, slashFactor, stakingtypes.Infraction_INFRACTION_UNSPECIFIED) } -// SlashWithInfractionReason is a no-op as slash functionality has been removed. -// Note: Slash packets are no longer sent to the provider. +// SlashWithInfractionReason queues a slash packet for downtime infractions +// to be sent to the provider chain. Double-sign and other infractions are logged but not forwarded. func (k Keeper) SlashWithInfractionReason(goCtx context.Context, addr sdk.ConsAddress, infractionHeight, power int64, slashFactor math.LegacyDec, infraction stakingtypes.Infraction) (math.Int, error) { ctx := sdk.UnwrapSDKContext(goCtx) - // Log the slash request but don't send slash packet - k.Logger(ctx).Info("slash request received but slash packets are disabled", - "validator", addr.String(), - "infraction_height", infractionHeight, - "infraction", infraction.String(), - ) + if infraction == stakingtypes.Infraction_INFRACTION_DOWNTIME { + slashPacket := vaastypes.NewSlashPacketData(addr, infractionHeight, infraction) + if err := k.QueueSlashPacket(ctx, slashPacket); err != nil { + k.Logger(ctx).Error("failed to queue downtime slash packet", + "validator", addr.String(), + "infraction_height", infractionHeight, + "error", err, + ) + return math.ZeroInt(), nil + } + k.Logger(ctx).Info("queued downtime slash packet", + "validator", addr.String(), + "infraction_height", infractionHeight, + ) + } else { + k.Logger(ctx).Info("slash request received but not forwarded", + "validator", addr.String(), + "infraction_height", infractionHeight, + "infraction", infraction.String(), + ) + } - // Only return to comply with the interface restriction return math.ZeroInt(), nil } diff --git a/x/vaas/consumer/keeper/validators_test.go b/x/vaas/consumer/keeper/validators_test.go index 247b542..7cffc7e 100644 --- a/x/vaas/consumer/keeper/validators_test.go +++ b/x/vaas/consumer/keeper/validators_test.go @@ -135,13 +135,20 @@ func TestSlash(t *testing.T) { consumerKeeper, ctx, ctrl, _ := testkeeper.GetConsumerKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) defer ctrl.Finish() - // Slash functionality removed - SlashWithInfractionReason is now a no-op - // that logs but doesn't send slash packets - slashed, err := consumerKeeper.SlashWithInfractionReason(ctx, []byte{0x01, 0x02, 0x03}, 5, 6, math.LegacyNewDec(9.0), stakingtypes.Infraction_INFRACTION_DOWNTIME) + addr := sdk.ConsAddress([]byte{0x01, 0x02, 0x03}) + + slashed, err := consumerKeeper.SlashWithInfractionReason(ctx, addr, 5, 6, math.LegacyNewDec(9.0), stakingtypes.Infraction_INFRACTION_DOWNTIME) require.NoError(t, err) - require.True(t, slashed.IsZero()) // Returns zero since no actual slashing happens + require.True(t, slashed.IsZero()) - // Standalone changeover functionality removed + require.Equal(t, 1, consumerKeeper.GetPendingSlashPacketCount(ctx)) + + // double-sign should not queue a packet + slashed, err = consumerKeeper.SlashWithInfractionReason(ctx, addr, 5, 6, math.LegacyNewDec(9.0), stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN) + require.NoError(t, err) + require.True(t, slashed.IsZero()) + + require.Equal(t, 1, consumerKeeper.GetPendingSlashPacketCount(ctx)) } // Tests the getter and setter behavior for historical info diff --git a/x/vaas/consumer/module.go b/x/vaas/consumer/module.go index 53278a3..e3de615 100644 --- a/x/vaas/consumer/module.go +++ b/x/vaas/consumer/module.go @@ -161,7 +161,7 @@ func (am AppModule) BeginBlock(goCtx context.Context) error { } // EndBlock implements the AppModule interface -// Flush PendingChanges to ABCI. +// Flush PendingChanges to ABCI and send pending slash packets. func (am AppModule) EndBlock(goCtx context.Context) ([]abci.ValidatorUpdate, error) { ctx := sdk.UnwrapSDKContext(goCtx) @@ -173,6 +173,11 @@ func (am AppModule) EndBlock(goCtx context.Context) ([]abci.ValidatorUpdate, err tendermintUpdates := am.keeper.ApplyCCValidatorChanges(ctx, data.ValidatorUpdates) am.keeper.DeletePendingChanges(ctx) + // send any queued slash packets to the provider + if err := am.keeper.SendSlashPackets(ctx); err != nil { + am.keeper.Logger(ctx).Error("failed to send slash packets", "error", err) + } + am.keeper.Logger(ctx).Debug("sending validator updates to consensus engine", "len updates", len(tendermintUpdates)) return tendermintUpdates, nil diff --git a/x/vaas/consumer/types/keys.go b/x/vaas/consumer/types/keys.go index 82f4c15..fef4166 100644 --- a/x/vaas/consumer/types/keys.go +++ b/x/vaas/consumer/types/keys.go @@ -34,4 +34,6 @@ var ( ParametersPrefix = collections.NewPrefix(22) HighestValsetUpdateIDPrefix = collections.NewPrefix(23) ConsumerDebtPrefix = collections.NewPrefix(24) + PendingSlashPacketsPrefix = collections.NewPrefix(25) + PendingSlashSeqPrefix = collections.NewPrefix(26) ) diff --git a/x/vaas/provider/ibc_module.go b/x/vaas/provider/ibc_module.go index 2ecc60e..5ad0a66 100644 --- a/x/vaas/provider/ibc_module.go +++ b/x/vaas/provider/ibc_module.go @@ -2,6 +2,7 @@ package provider import ( "bytes" + "encoding/json" "strconv" "github.com/allinbits/vaas/x/vaas/provider/keeper" @@ -69,14 +70,67 @@ func (im IBCModule) OnRecvPacket( payload channeltypesv2.Payload, relayer sdk.AccAddress, ) channeltypesv2.RecvPacketResult { - im.keeper.Logger(ctx).Error("provider received unexpected packet", - "sourceClient", sourceClient, - "destinationClient", destinationClient, + logger := im.keeper.Logger(ctx) + + if payload.DestinationPort != vaastypes.ProviderAppID { + logger.Error("invalid destination port", + "expected", vaastypes.ProviderAppID, + "got", payload.DestinationPort, + ) + return channeltypesv2.RecvPacketResult{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + if payload.SourcePort != vaastypes.ConsumerAppID { + logger.Error("invalid source port", + "expected", vaastypes.ConsumerAppID, + "got", payload.SourcePort, + ) + return channeltypesv2.RecvPacketResult{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + // look up consumer id from source client + consumerId, found := im.keeper.GetClientIdToConsumerId(ctx, sourceClient) + if !found { + logger.Error("received packet from unknown client", + "sourceClient", sourceClient, + ) + return channeltypesv2.RecvPacketResult{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + var slashPacket vaastypes.SlashPacketData + if err := json.Unmarshal(payload.Value, &slashPacket); err != nil { + logger.Error("cannot unmarshal slash packet data", "error", err) + return channeltypesv2.RecvPacketResult{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + if err := im.keeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket); err != nil { + logger.Error("failed to handle slash packet", + "consumerId", consumerId, + "error", err, + ) + return channeltypesv2.RecvPacketResult{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + logger.Info("successfully handled slash packet", + "consumerId", consumerId, "sequence", sequence, + "validator", slashPacket.ValidatorAddr.String(), + "infraction", slashPacket.Infraction.String(), ) return channeltypesv2.RecvPacketResult{ - Status: channeltypesv2.PacketStatus_Failure, + Status: channeltypesv2.PacketStatus_Success, + Acknowledgement: []byte{byte(1)}, } } diff --git a/x/vaas/provider/keeper/consumer_equivocation.go b/x/vaas/provider/keeper/consumer_equivocation.go index f5846d0..c1b52c7 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -79,7 +79,7 @@ func (k Keeper) HandleConsumerDoubleVoting( return err } - if err = k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign); err != nil { + if err = k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN); err != nil { return err } if err = k.JailAndTombstoneValidator(ctx, providerAddr, infractionParams.DoubleSign); err != nil { @@ -162,6 +162,74 @@ func (k Keeper) VerifyDoubleVotingEvidence( return nil } +// +// Consumer-initiated slashing section +// + +// HandleConsumerSlashPacket handles a slash packet received from a consumer chain. +// It dispatches to the appropriate handler based on the infraction type. +func (k Keeper) HandleConsumerSlashPacket(ctx sdk.Context, consumerId string, slashPacket vaastypes.SlashPacketData) error { + if err := slashPacket.Validate(); err != nil { + return errorsmod.Wrapf(vaastypes.ErrInvalidPacketData, "invalid slash packet: %s", err) + } + + if k.GetConsumerPhase(ctx, consumerId) != types.CONSUMER_PHASE_LAUNCHED { + return errorsmod.Wrapf( + vaastypes.ErrInvalidConsumerState, + "consumer chain %s is not launched (phase: %s)", + consumerId, + k.GetConsumerPhase(ctx, consumerId), + ) + } + + switch slashPacket.Infraction { + case stakingtypes.Infraction_INFRACTION_DOWNTIME: + return k.HandleConsumerDowntime(ctx, consumerId, slashPacket) + default: + return fmt.Errorf("unsupported infraction type in slash packet: %s", slashPacket.Infraction) + } +} + +// HandleConsumerDowntime slashes and jails a validator that was offline on a consumer chain. +func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, slashPacket vaastypes.SlashPacketData) error { + consumerAddr := types.NewConsumerConsAddress(slashPacket.ValidatorAddr) + + providerAddr := k.GetProviderAddrFromConsumerAddr(ctx, consumerId, consumerAddr) + + infractionParams, err := types.DefaultConsumerInfractionParameters(ctx, k.slashingKeeper) + if err != nil { + return err + } + + if err = k.SlashValidator(ctx, providerAddr, infractionParams.Downtime, stakingtypes.Infraction_INFRACTION_DOWNTIME); err != nil { + return err + } + if err = k.JailAndTombstoneValidator(ctx, providerAddr, infractionParams.Downtime); err != nil { + return err + } + + k.Logger(ctx).Info( + "handled consumer downtime", + "consumerId", consumerId, + "consumerAddr", consumerAddr.String(), + "providerAddr", providerAddr.String(), + "infractionHeight", slashPacket.InfractionHeight, + ) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + vaastypes.EventTypeExecuteConsumerChainSlash, + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(types.AttributeConsumerId, consumerId), + sdk.NewAttribute(vaastypes.AttributeProviderValidatorAddress, providerAddr.String()), + sdk.NewAttribute(vaastypes.AttributeInfractionHeight, fmt.Sprintf("%d", slashPacket.InfractionHeight)), + sdk.NewAttribute(vaastypes.AttributeInfractionType, stakingtypes.Infraction_INFRACTION_DOWNTIME.String()), + ), + ) + + return nil +} + // // Light Client Attack (IBC misbehavior) section // @@ -203,7 +271,7 @@ func (k Keeper) HandleConsumerMisbehaviour(ctx sdk.Context, consumerId string, m consumerId, types.NewConsumerConsAddress(sdk.ConsAddress(v.Address.Bytes())), ) - err := k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign) + err := k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN) if err != nil { logger.Error("failed to slash validator: %s", err) continue @@ -493,7 +561,7 @@ func (k Keeper) ComputePowerToSlash(ctx sdk.Context, validator stakingtypes.Vali } // SlashValidator slashes validator with given provider Address -func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr types.ProviderConsAddress, slashingParams *types.SlashJailParameters) error { +func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr types.ProviderConsAddress, slashingParams *types.SlashJailParameters, infraction stakingtypes.Infraction) error { validator, err := k.stakingKeeper.GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()) if err != nil && errors.Is(err, stakingtypes.ErrNoValidatorFound) { return errorsmod.Wrapf(slashingtypes.ErrNoValidatorForAddress, "provider consensus address: %s", providerAddr.String()) @@ -535,6 +603,6 @@ func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr types.ProviderConsA return err } - _, err = k.stakingKeeper.SlashWithInfractionReason(ctx, consAdrr, 0, totalPower, slashingParams.SlashFraction, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN) + _, err = k.stakingKeeper.SlashWithInfractionReason(ctx, consAdrr, 0, totalPower, slashingParams.SlashFraction, infraction) return err } diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index 97ff8ec..29dcae6 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -1,6 +1,7 @@ package keeper_test import ( + "encoding/json" "fmt" "testing" "time" @@ -21,6 +22,7 @@ import ( cryptotestutil "github.com/allinbits/vaas/testutil/crypto" testkeeper "github.com/allinbits/vaas/testutil/keeper" "github.com/allinbits/vaas/x/vaas/provider/types" + vaastypes "github.com/allinbits/vaas/x/vaas/types" ) func TestVerifyDoubleVotingEvidence(t *testing.T) { @@ -755,7 +757,7 @@ func TestSlashValidator(t *testing.T) { } gomock.InOrder(expectedCalls...) - err = keeper.SlashValidator(ctx, providerAddr, getTestInfractionParameters().DoubleSign) + err = keeper.SlashValidator(ctx, providerAddr, getTestInfractionParameters().DoubleSign, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN) require.NoError(t, err) } @@ -783,7 +785,7 @@ func TestSlashValidatorDoesNotSlashIfValidatorIsUnbonded(t *testing.T) { } gomock.InOrder(expectedCalls...) - err := keeper.SlashValidator(ctx, providerAddr, getTestInfractionParameters().DoubleSign) + err := keeper.SlashValidator(ctx, providerAddr, getTestInfractionParameters().DoubleSign, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN) require.Error(t, err) require.ErrorIs(t, stakingtypes.ErrNoUnbondingDelegation, err) } @@ -820,3 +822,127 @@ func getTestInfractionParameters() *types.InfractionParameters { }, } } + +func TestHandleConsumerSlashPacket(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := "0" + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + providerKeeper.SetConsumerChainId(ctx, consumerId, "consumer-chain") + + pubKey, _ := cryptocodec.FromCmtPubKeyInterface(tmtypes.NewMockPV().PrivKey.PubKey()) + validator, err := stakingtypes.NewValidator( + sdk.ValAddress(pubKey.Address()).String(), + pubKey, + stakingtypes.NewDescription("", "", "", "", ""), + ) + require.NoError(t, err) + validator.Status = stakingtypes.Bonded + consAddr, _ := validator.GetConsAddr() + + slashPacket := vaastypes.NewSlashPacketData( + sdk.ConsAddress(consAddr), + 100, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + valAddr, _ := providerKeeper.ValidatorAddressCodec().StringToBytes(validator.GetOperator()) + + expectedCalls := []any{ + mocks.MockSlashingKeeper.EXPECT(). + DowntimeJailDuration(ctx). + Return(600*time.Second, nil), + mocks.MockSlashingKeeper.EXPECT(). + SlashFractionDoubleSign(ctx). + Return(math.LegacyNewDecWithPrec(5, 1), nil), + mocks.MockStakingKeeper.EXPECT(). + GetValidatorByConsAddr(ctx, gomock.Any()). + Return(validator, nil), + mocks.MockSlashingKeeper.EXPECT(). + IsTombstoned(ctx, gomock.Any()). + Return(false), + mocks.MockStakingKeeper.EXPECT(). + GetUnbondingDelegationsFromValidator(ctx, valAddr). + Return([]stakingtypes.UnbondingDelegation{}, nil), + mocks.MockStakingKeeper.EXPECT(). + GetRedelegationsFromSrcValidator(ctx, valAddr). + Return([]stakingtypes.Redelegation{}, nil), + mocks.MockStakingKeeper.EXPECT(). + GetLastValidatorPower(ctx, valAddr). + Return(int64(1000), nil), + mocks.MockStakingKeeper.EXPECT(). + PowerReduction(ctx). + Return(math.NewInt(1000000)), + mocks.MockStakingKeeper.EXPECT(). + SlashWithInfractionReason(ctx, gomock.Any(), int64(0), int64(1000), math.LegacyNewDec(0), stakingtypes.Infraction_INFRACTION_DOWNTIME). + Return(math.NewInt(0), nil), + mocks.MockStakingKeeper.EXPECT(). + GetValidatorByConsAddr(ctx, gomock.Any()). + Return(validator, nil), + mocks.MockSlashingKeeper.EXPECT(). + IsTombstoned(ctx, gomock.Any()). + Return(false), + mocks.MockStakingKeeper.EXPECT(). + Jail(ctx, gomock.Any()). + Return(nil), + mocks.MockSlashingKeeper.EXPECT(). + JailUntil(ctx, gomock.Any(), gomock.Any()). + Return(nil), + } + + gomock.InOrder(expectedCalls...) + err = providerKeeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket) + require.NoError(t, err) +} + +func TestHandleConsumerSlashPacketRejectsDoubleSign(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := "0" + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + + slashPacket := vaastypes.NewSlashPacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, + stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN, + ) + + err := providerKeeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket) + require.Error(t, err) +} + +func TestHandleConsumerSlashPacketRejectsNonLaunchedConsumer(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := "0" + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_REGISTERED) + + slashPacket := vaastypes.NewSlashPacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + err := providerKeeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket) + require.Error(t, err) +} + +func TestSlashPacketDataJSONRoundTrip(t *testing.T) { + addr := sdk.ConsAddress([]byte{0x01, 0x02, 0x03, 0x04, 0x05}) + packet := vaastypes.NewSlashPacketData(addr, 42, stakingtypes.Infraction_INFRACTION_DOWNTIME) + + bz := packet.GetBytes() + + var decoded vaastypes.SlashPacketData + err := json.Unmarshal(bz, &decoded) + require.NoError(t, err) + require.Equal(t, packet.ValidatorAddr, decoded.ValidatorAddr) + require.Equal(t, packet.InfractionHeight, decoded.InfractionHeight) + require.Equal(t, packet.Infraction, decoded.Infraction) +} diff --git a/x/vaas/types/expected_keepers.go b/x/vaas/types/expected_keepers.go index 9f002ba..d61d23c 100644 --- a/x/vaas/types/expected_keepers.go +++ b/x/vaas/types/expected_keepers.go @@ -55,6 +55,7 @@ type SlashingKeeper interface { JailUntil(context.Context, sdk.ConsAddress, time.Time) error // called from provider keeper only DowntimeJailDuration(context.Context) (time.Duration, error) SlashFractionDoubleSign(context.Context) (math.LegacyDec, error) + SlashFractionDowntime(context.Context) (math.LegacyDec, error) Tombstone(context.Context, sdk.ConsAddress) error IsTombstoned(context.Context, sdk.ConsAddress) bool } diff --git a/x/vaas/types/wire.go b/x/vaas/types/wire.go index 1d7d106..af36421 100644 --- a/x/vaas/types/wire.go +++ b/x/vaas/types/wire.go @@ -1,9 +1,15 @@ package types import ( + "encoding/json" + "fmt" + abci "github.com/cometbft/cometbft/abci/types" errorsmod "cosmossdk.io/errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" ) func NewValidatorSetChangePacketData(valUpdates []abci.ValidatorUpdate, valUpdateID uint64) ValidatorSetChangePacketData { @@ -31,3 +37,43 @@ func (vsc ValidatorSetChangePacketData) GetBytes() []byte { valUpdateBytes := ModuleCdc.MustMarshalJSON(&vsc) return valUpdateBytes } + +// SlashPacketData is sent from a consumer chain to the provider chain +// to report a validator infraction (e.g., downtime) detected on the consumer. +type SlashPacketData struct { + ValidatorAddr sdk.ConsAddress `json:"validator_addr"` + InfractionHeight int64 `json:"infraction_height"` + Infraction stakingtypes.Infraction `json:"infraction"` +} + +// NewSlashPacketData creates a new SlashPacketData. +func NewSlashPacketData(validatorAddr sdk.ConsAddress, infractionHeight int64, infraction stakingtypes.Infraction) SlashPacketData { + return SlashPacketData{ + ValidatorAddr: validatorAddr, + InfractionHeight: infractionHeight, + Infraction: infraction, + } +} + +// Validate returns an error if the SlashPacketData is invalid. +func (spd SlashPacketData) Validate() error { + if len(spd.ValidatorAddr) == 0 { + return errorsmod.Wrap(ErrInvalidPacketData, "validator address cannot be empty") + } + if spd.InfractionHeight <= 0 { + return errorsmod.Wrap(ErrInvalidPacketData, "infraction height must be positive") + } + if spd.Infraction != stakingtypes.Infraction_INFRACTION_DOWNTIME { + return fmt.Errorf("only DOWNTIME infractions can be sent as slash packets, got %s", spd.Infraction) + } + return nil +} + +// GetBytes marshals the SlashPacketData into JSON bytes for IBC transport. +func (spd SlashPacketData) GetBytes() []byte { + bz, err := json.Marshal(&spd) + if err != nil { + panic(fmt.Sprintf("failed to marshal SlashPacketData: %v", err)) + } + return bz +} From 7b4464194fdbb1050d0c97b1a94ee0ed45b6fef4 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Mon, 11 May 2026 17:51:31 +0200 Subject: [PATCH 02/15] updates --- README.md | 3 ++- app/consumer/app.go | 4 +--- testutil/keeper/unit_test_helpers.go | 1 + x/vaas/consumer/keeper/keeper.go | 7 ++----- x/vaas/consumer/keeper/slash_packet.go | 6 ++++-- x/vaas/consumer/module.go | 10 +++++----- 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 425c09d..82f3f42 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ IBC v2 is easily wireable by adding the IBC router v2 in a ibc-go >= 10.x.y comp | Per-Consumer Infraction Parameters | Customizable slash/jail parameters per consumer | | VSC Packets | Validator set updates sent at epoch boundaries | | Double Voting Evidence | Handle double voting evidence from consumers | +| Downtime Slashing | Consumer detects offline validators, sends slash packet to provider via IBC | | Light Client Misbehavior | Detection and logging of misbehavior | | Consumer Metadata | Name, description, metadata for chain discovery | | Client/Connection Reuse | Reuse existing IBC client when creating consumer | @@ -34,7 +35,7 @@ IBC v2 is easily wireable by adding the IBC router v2 in a ibc-go >= 10.x.y comp | Top N / Opt-In Chains | No validator selection per consumer | | Power Shaping | No caps, allowlists, denylists, priority lists | | Consumer Reward Distribution | No cross-chain rewards | -| Slash Packet Throttling | Simplified slash handling | +| Slash Packet Throttling | No rate-limiting across consumers | | Per-Consumer Commission Rates | Validators use same commission as provider | | IBC v1 Channel Support | IBC v2 only | | Standalone-to-Consumer Changeover | Only new chains as consumers | diff --git a/app/consumer/app.go b/app/consumer/app.go index b059174..d0bae97 100644 --- a/app/consumer/app.go +++ b/app/consumer/app.go @@ -333,6 +333,7 @@ func New( runtime.NewKVStoreService(keys[ibcconsumertypes.StoreKey]), app.IBCKeeper.ClientKeeper, app.IBCKeeper.ClientV2Keeper, + app.IBCKeeper.ChannelKeeperV2, app.SlashingKeeper, app.BankKeeper, app.AccountKeeper, @@ -370,9 +371,6 @@ func New( ibcRouterV2.AddRoute(vaastypes.ConsumerAppID, ibcconsumer.NewIBCModule(&app.ConsumerKeeper)) app.IBCKeeper.SetRouterV2(ibcRouterV2) - // wire the channel keeper v2 so the consumer can send slash packets to the provider - app.ConsumerKeeper.SetChannelKeeperV2(app.IBCKeeper.ChannelKeeperV2) - tmLightClientModule := ibctm.NewLightClientModule(appCodec, app.IBCKeeper.ClientKeeper.GetStoreProvider()) app.IBCKeeper.ClientKeeper.AddRoute(ibctm.ModuleName, tmLightClientModule) diff --git a/testutil/keeper/unit_test_helpers.go b/testutil/keeper/unit_test_helpers.go index 4c00289..31b8be0 100644 --- a/testutil/keeper/unit_test_helpers.go +++ b/testutil/keeper/unit_test_helpers.go @@ -121,6 +121,7 @@ func NewInMemConsumerKeeper(params InMemKeeperParams, mocks MockedKeepers) consu storeService, mocks.MockClientKeeper, mocks.MockClientV2Keeper, + nil, // channelKeeperV2 - not needed for unit tests mocks.MockSlashingKeeper, mocks.MockBankKeeper, mocks.MockAccountKeeper, diff --git a/x/vaas/consumer/keeper/keeper.go b/x/vaas/consumer/keeper/keeper.go index bb0e626..1c2af07 100644 --- a/x/vaas/consumer/keeper/keeper.go +++ b/x/vaas/consumer/keeper/keeper.go @@ -74,6 +74,7 @@ func NewKeeper( cdc codec.BinaryCodec, storeService corestoretypes.KVStoreService, clientKeeper vaastypes.ClientKeeper, clientV2Keeper vaastypes.ClientV2Keeper, + channelKeeperV2 vaastypes.ChannelV2Keeper, slashingKeeper vaastypes.SlashingKeeper, bankKeeper vaastypes.BankKeeper, accountKeeper vaastypes.AccountKeeper, feeCollectorName, authority string, validatorAddressCodec, consensusAddressCodec addresscodec.Codec, @@ -86,6 +87,7 @@ func NewKeeper( cdc: cdc, clientKeeper: clientKeeper, clientV2Keeper: clientV2Keeper, + channelKeeperV2: channelKeeperV2, slashingKeeper: slashingKeeper, bankKeeper: bankKeeper, authKeeper: accountKeeper, @@ -195,11 +197,6 @@ func (k *Keeper) SetHooks(sh vaastypes.ConsumerHooks) *Keeper { return k } -// SetChannelKeeperV2 sets the IBC v2 channel keeper for sending slash packets. -func (k *Keeper) SetChannelKeeperV2(keeper vaastypes.ChannelV2Keeper) { - k.channelKeeperV2 = keeper -} - // GetPort returns the portID for the transfer module. Used in ExportGenesis func (k Keeper) GetPort(ctx context.Context) string { port, err := k.Port.Get(ctx) diff --git a/x/vaas/consumer/keeper/slash_packet.go b/x/vaas/consumer/keeper/slash_packet.go index 378551e..66ce86f 100644 --- a/x/vaas/consumer/keeper/slash_packet.go +++ b/x/vaas/consumer/keeper/slash_packet.go @@ -34,13 +34,11 @@ func (k Keeper) QueueSlashPacket(ctx sdk.Context, packet vaastypes.SlashPacketDa // SendSlashPackets sends all pending slash packets to the provider chain. func (k Keeper) SendSlashPackets(ctx sdk.Context) error { if k.channelKeeperV2 == nil { - k.Logger(ctx).Debug("IBC v2 channel keeper not configured, skipping slash packet send") return nil } providerClientID, found := k.GetProviderClientID(ctx) if !found { - k.Logger(ctx).Debug("provider client not established, skipping slash packet send") return nil } @@ -50,6 +48,10 @@ func (k Keeper) SendSlashPackets(ctx sdk.Context) error { } defer iter.Close() + if !iter.Valid() { + return nil + } + var keysToDelete []uint64 for ; iter.Valid(); iter.Next() { kv, err := iter.KeyValue() diff --git a/x/vaas/consumer/module.go b/x/vaas/consumer/module.go index e3de615..5f5512c 100644 --- a/x/vaas/consumer/module.go +++ b/x/vaas/consumer/module.go @@ -165,6 +165,11 @@ func (am AppModule) BeginBlock(goCtx context.Context) error { func (am AppModule) EndBlock(goCtx context.Context) ([]abci.ValidatorUpdate, error) { ctx := sdk.UnwrapSDKContext(goCtx) + // send any queued slash packets to the provider every block + if err := am.keeper.SendSlashPackets(ctx); err != nil { + am.keeper.Logger(ctx).Error("failed to send slash packets", "error", err) + } + data, ok := am.keeper.GetPendingChanges(ctx) if !ok { return []abci.ValidatorUpdate{}, nil @@ -173,11 +178,6 @@ func (am AppModule) EndBlock(goCtx context.Context) ([]abci.ValidatorUpdate, err tendermintUpdates := am.keeper.ApplyCCValidatorChanges(ctx, data.ValidatorUpdates) am.keeper.DeletePendingChanges(ctx) - // send any queued slash packets to the provider - if err := am.keeper.SendSlashPackets(ctx); err != nil { - am.keeper.Logger(ctx).Error("failed to send slash packets", "error", err) - } - am.keeper.Logger(ctx).Debug("sending validator updates to consensus engine", "len updates", len(tendermintUpdates)) return tendermintUpdates, nil From 243dffbf74306f17ba96b69be7df756261e2064b Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 12 May 2026 11:52:24 +0200 Subject: [PATCH 03/15] lint+test --- tests/e2e/e2e_downtime_slash_test.go | 67 ++++++++++++++++++++++++++++ tests/e2e/e2e_setup_test.go | 19 ++++---- tests/e2e/e2e_test.go | 2 +- tests/e2e/e2e_tsrelayer_test.go | 2 +- tests/e2e/genesis_test.go | 10 ++--- tests/e2e/http_util_test.go | 2 +- 6 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/e2e_downtime_slash_test.go diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go new file mode 100644 index 0000000..2e105e9 --- /dev/null +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -0,0 +1,67 @@ +package e2e + +import ( + "time" +) + +func (s *IntegrationTestSuite) testDowntimeSlash() { + s.Run("downtime slash", func() { + jailed := s.isProviderValidatorJailed() + s.Require().False(jailed, "validator should not be jailed before downtime test") + + s.T().Log("pausing consumer container to simulate downtime...") + err := s.dkrPool.Client.PauseContainer(s.consumerValRes[0].Container.ID) + s.Require().NoError(err, "failed to pause consumer container") + + time.Sleep(10 * time.Second) + + s.T().Log("unpausing consumer container...") + err = s.dkrPool.Client.UnpauseContainer(s.consumerValRes[0].Container.ID) + s.Require().NoError(err, "failed to unpause consumer container") + + s.T().Log("waiting for provider to jail validator for downtime...") + s.Require().Eventuallyf(func() bool { + return s.isProviderValidatorJailed() + }, + 3*time.Minute, + 5*time.Second, + "validator was not jailed on provider after consumer downtime", + ) + }) +} + +func (s *IntegrationTestSuite) patchConsumerSlashingParams() { + s.patchGenesisJSON(s.consumer.dataDir+"/config/genesis.json", func(genesis map[string]any) { + appState, ok := genesis["app_state"].(map[string]any) + if !ok { + return + } + slashing, ok := appState["slashing"].(map[string]any) + if !ok { + slashing = make(map[string]any) + } + params, ok := slashing["params"].(map[string]any) + if !ok { + params = make(map[string]any) + } + params["signed_blocks_window"] = "5" + params["min_signed_per_window"] = "0.050000000000000000" + params["slash_fraction_downtime"] = "0.000000000000000000" + params["downtime_jail_duration"] = "60s" + slashing["params"] = params + appState["slashing"] = slashing + }) +} + +func (s *IntegrationTestSuite) isProviderValidatorJailed() bool { + vals, err := s.queryProviderValidators() + if err != nil { + return false + } + for _, v := range vals { + if v.Jailed { + return true + } + } + return false +} diff --git a/tests/e2e/e2e_setup_test.go b/tests/e2e/e2e_setup_test.go index 76eaf17..d1ea1fe 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -239,12 +239,12 @@ func (s *IntegrationTestSuite) initAndStartProvider() { // Modify genesis on the host: set fast voting period and small blocks_per_epoch genesisFile := filepath.Join(providerDir, "config", "genesis.json") - s.patchGenesisJSON(genesisFile, func(genesis map[string]interface{}) { - appState := genesis["app_state"].(map[string]interface{}) + s.patchGenesisJSON(genesisFile, func(genesis map[string]any) { + appState := genesis["app_state"].(map[string]any) // Set fast voting period - if gov, ok := appState["gov"].(map[string]interface{}); ok { - if params, ok := gov["params"].(map[string]interface{}); ok { + if gov, ok := appState["gov"].(map[string]any); ok { + if params, ok := gov["params"].(map[string]any); ok { params["voting_period"] = "15s" } } @@ -252,10 +252,10 @@ func (s *IntegrationTestSuite) initAndStartProvider() { // Set fast epoch for VSC, and override fees_per_block to use the // bond denom so the e2e debt-flow test can fund the consumer fee // pool from existing genesis accounts. - if provider, ok := appState["provider"].(map[string]interface{}); ok { - if params, ok := provider["params"].(map[string]interface{}); ok { + if provider, ok := appState["provider"].(map[string]any); ok { + if params, ok := provider["params"].(map[string]any); ok { params["blocks_per_epoch"] = "5" - params["fees_per_block"] = map[string]interface{}{ + params["fees_per_block"] = map[string]any{ "denom": bondDenom, "amount": "1000", } @@ -340,7 +340,7 @@ func (s *IntegrationTestSuite) fetchConsumerGenesis() []byte { var lastErr error // Retry fetching consumer genesis (it may take a few blocks) - for i := 0; i < 30; i++ { + for range 30 { stdout, _, err := s.dockerExec(s.providerValRes[0].Container.ID, []string{ providerBinary, "query", "provider", "consumer-genesis", "0", "--home", providerHomePath, @@ -386,6 +386,9 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) err = patchConsumerGenesisWithProviderData(genesisFile, consumerGenesisJSON) s.Require().NoError(err, "failed to patch consumer genesis") + // Patch consumer slashing params for aggressive downtime detection + s.patchConsumerSlashingParams() + // Copy validator keys from provider to consumer providerDir := s.provider.dataDir err = copyFile( diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index c08b5a8..1562ffc 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -1,6 +1,5 @@ package e2e - func (s *IntegrationTestSuite) TestVAAS() { s.testProviderBlockProduction() s.testConsumerBlockProduction() @@ -8,4 +7,5 @@ func (s *IntegrationTestSuite) TestVAAS() { s.testProviderOnConsumer() s.testValidatorSetSync() s.testConsumerDebtFlow() + s.testDowntimeSlash() } diff --git a/tests/e2e/e2e_tsrelayer_test.go b/tests/e2e/e2e_tsrelayer_test.go index 4607ac0..87fb3d0 100644 --- a/tests/e2e/e2e_tsrelayer_test.go +++ b/tests/e2e/e2e_tsrelayer_test.go @@ -65,7 +65,7 @@ func (s *IntegrationTestSuite) verifyTSRelayerConnectivity(chainName, rpcURL str ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - for attempt := 0; attempt < 10; attempt++ { + for attempt := range 10 { exec, err := s.dkrPool.Client.CreateExec(docker.CreateExecOptions{ Context: ctx, AttachStdout: true, diff --git a/tests/e2e/genesis_test.go b/tests/e2e/genesis_test.go index 7c41aa6..79641e1 100644 --- a/tests/e2e/genesis_test.go +++ b/tests/e2e/genesis_test.go @@ -15,17 +15,17 @@ func patchConsumerGenesisWithProviderData(genesisFilePath string, consumerGenesi return fmt.Errorf("failed to read genesis file: %w", err) } - var genesis map[string]interface{} + var genesis map[string]any if err := json.Unmarshal(bz, &genesis); err != nil { return fmt.Errorf("failed to unmarshal genesis: %w", err) } - appState, ok := genesis["app_state"].(map[string]interface{}) + appState, ok := genesis["app_state"].(map[string]any) if !ok { return fmt.Errorf("app_state not found or not a map") } - var consumerGenesis interface{} + var consumerGenesis any if err := json.Unmarshal(consumerGenesisJSON, &consumerGenesis); err != nil { return fmt.Errorf("failed to unmarshal consumer genesis: %w", err) } @@ -43,11 +43,11 @@ func patchConsumerGenesisWithProviderData(genesisFilePath string, consumerGenesi // patchGenesisJSON reads a genesis.json file, applies a mutation function, // and writes it back. -func (s *IntegrationTestSuite) patchGenesisJSON(path string, mutate func(map[string]interface{})) { +func (s *IntegrationTestSuite) patchGenesisJSON(path string, mutate func(map[string]any)) { bz, err := os.ReadFile(path) s.Require().NoError(err, "failed to read genesis file") - var genesis map[string]interface{} + var genesis map[string]any s.Require().NoError(json.Unmarshal(bz, &genesis), "failed to unmarshal genesis") mutate(genesis) diff --git a/tests/e2e/http_util_test.go b/tests/e2e/http_util_test.go index eaf861d..a32fa82 100644 --- a/tests/e2e/http_util_test.go +++ b/tests/e2e/http_util_test.go @@ -22,7 +22,7 @@ func httpGet(endpoint string) ([]byte, error) { func httpGetWithRetry(endpoint string, maxAttempts int) ([]byte, error) { var lastErr error - for attempt := 0; attempt < maxAttempts; attempt++ { + for range maxAttempts { resp, err := http.Get(endpoint) //nolint:gosec if err != nil { lastErr = err From be7dde7be5f441b8d0442bd626a3ccc631f9461c Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 12 May 2026 13:59:46 +0200 Subject: [PATCH 04/15] remove jailing --- x/vaas/provider/keeper/consumer_equivocation.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/x/vaas/provider/keeper/consumer_equivocation.go b/x/vaas/provider/keeper/consumer_equivocation.go index c1b52c7..9e5f42e 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -204,10 +204,6 @@ func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, slash if err = k.SlashValidator(ctx, providerAddr, infractionParams.Downtime, stakingtypes.Infraction_INFRACTION_DOWNTIME); err != nil { return err } - if err = k.JailAndTombstoneValidator(ctx, providerAddr, infractionParams.Downtime); err != nil { - return err - } - k.Logger(ctx).Info( "handled consumer downtime", "consumerId", consumerId, From bd405420d593eefc9eb5e026dbfceb2090cfaf8e Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 12 May 2026 15:22:10 +0200 Subject: [PATCH 05/15] greenwashing --- x/vaas/provider/ibc_module.go | 16 +++++----- .../provider/keeper/consumer_equivocation.go | 22 +++++++------- .../keeper/consumer_equivocation_test.go | 30 ++++++------------- 3 files changed, 28 insertions(+), 40 deletions(-) diff --git a/x/vaas/provider/ibc_module.go b/x/vaas/provider/ibc_module.go index 5ad0a66..5d5fd73 100644 --- a/x/vaas/provider/ibc_module.go +++ b/x/vaas/provider/ibc_module.go @@ -103,16 +103,16 @@ func (im IBCModule) OnRecvPacket( } } - var slashPacket vaastypes.SlashPacketData - if err := json.Unmarshal(payload.Value, &slashPacket); err != nil { - logger.Error("cannot unmarshal slash packet data", "error", err) + var evidencePacket vaastypes.SlashPacketData + if err := json.Unmarshal(payload.Value, &evidencePacket); err != nil { + logger.Error("cannot unmarshal evidence packet data", "error", err) return channeltypesv2.RecvPacketResult{ Status: channeltypesv2.PacketStatus_Failure, } } - if err := im.keeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket); err != nil { - logger.Error("failed to handle slash packet", + if err := im.keeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket); err != nil { + logger.Error("failed to handle evidence packet", "consumerId", consumerId, "error", err, ) @@ -121,11 +121,11 @@ func (im IBCModule) OnRecvPacket( } } - logger.Info("successfully handled slash packet", + logger.Info("successfully handled evidence packet", "consumerId", consumerId, "sequence", sequence, - "validator", slashPacket.ValidatorAddr.String(), - "infraction", slashPacket.Infraction.String(), + "validator", evidencePacket.ValidatorAddr.String(), + "infraction", evidencePacket.Infraction.String(), ) return channeltypesv2.RecvPacketResult{ diff --git a/x/vaas/provider/keeper/consumer_equivocation.go b/x/vaas/provider/keeper/consumer_equivocation.go index 9e5f42e..2a513aa 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -166,11 +166,11 @@ func (k Keeper) VerifyDoubleVotingEvidence( // Consumer-initiated slashing section // -// HandleConsumerSlashPacket handles a slash packet received from a consumer chain. +// HandleConsumerEvidencePacket handles an evidence packet received from a consumer chain. // It dispatches to the appropriate handler based on the infraction type. -func (k Keeper) HandleConsumerSlashPacket(ctx sdk.Context, consumerId string, slashPacket vaastypes.SlashPacketData) error { - if err := slashPacket.Validate(); err != nil { - return errorsmod.Wrapf(vaastypes.ErrInvalidPacketData, "invalid slash packet: %s", err) +func (k Keeper) HandleConsumerEvidencePacket(ctx sdk.Context, consumerId string, evidencePacket vaastypes.SlashPacketData) error { + if err := evidencePacket.Validate(); err != nil { + return errorsmod.Wrapf(vaastypes.ErrInvalidPacketData, "invalid evidence packet: %s", err) } if k.GetConsumerPhase(ctx, consumerId) != types.CONSUMER_PHASE_LAUNCHED { @@ -182,17 +182,17 @@ func (k Keeper) HandleConsumerSlashPacket(ctx sdk.Context, consumerId string, sl ) } - switch slashPacket.Infraction { + switch evidencePacket.Infraction { case stakingtypes.Infraction_INFRACTION_DOWNTIME: - return k.HandleConsumerDowntime(ctx, consumerId, slashPacket) + return k.HandleConsumerDowntime(ctx, consumerId, evidencePacket) default: - return fmt.Errorf("unsupported infraction type in slash packet: %s", slashPacket.Infraction) + return fmt.Errorf("unsupported infraction type in evidence packet: %s", evidencePacket.Infraction) } } // HandleConsumerDowntime slashes and jails a validator that was offline on a consumer chain. -func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, slashPacket vaastypes.SlashPacketData) error { - consumerAddr := types.NewConsumerConsAddress(slashPacket.ValidatorAddr) +func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, evidencePacket vaastypes.SlashPacketData) error { + consumerAddr := types.NewConsumerConsAddress(evidencePacket.ValidatorAddr) providerAddr := k.GetProviderAddrFromConsumerAddr(ctx, consumerId, consumerAddr) @@ -209,7 +209,7 @@ func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, slash "consumerId", consumerId, "consumerAddr", consumerAddr.String(), "providerAddr", providerAddr.String(), - "infractionHeight", slashPacket.InfractionHeight, + "infractionHeight", evidencePacket.InfractionHeight, ) ctx.EventManager().EmitEvent( @@ -218,7 +218,7 @@ func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, slash sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), sdk.NewAttribute(types.AttributeConsumerId, consumerId), sdk.NewAttribute(vaastypes.AttributeProviderValidatorAddress, providerAddr.String()), - sdk.NewAttribute(vaastypes.AttributeInfractionHeight, fmt.Sprintf("%d", slashPacket.InfractionHeight)), + sdk.NewAttribute(vaastypes.AttributeInfractionHeight, fmt.Sprintf("%d", evidencePacket.InfractionHeight)), sdk.NewAttribute(vaastypes.AttributeInfractionType, stakingtypes.Infraction_INFRACTION_DOWNTIME.String()), ), ) diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index 29dcae6..2c411b4 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -823,7 +823,7 @@ func getTestInfractionParameters() *types.InfractionParameters { } } -func TestHandleConsumerSlashPacket(t *testing.T) { +func TestHandleConsumerEvidencePacket(t *testing.T) { keeperParams := testkeeper.NewInMemKeeperParams(t) providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) defer ctrl.Finish() @@ -842,7 +842,7 @@ func TestHandleConsumerSlashPacket(t *testing.T) { validator.Status = stakingtypes.Bonded consAddr, _ := validator.GetConsAddr() - slashPacket := vaastypes.NewSlashPacketData( + evidencePacket := vaastypes.NewSlashPacketData( sdk.ConsAddress(consAddr), 100, stakingtypes.Infraction_INFRACTION_DOWNTIME, @@ -878,26 +878,14 @@ func TestHandleConsumerSlashPacket(t *testing.T) { mocks.MockStakingKeeper.EXPECT(). SlashWithInfractionReason(ctx, gomock.Any(), int64(0), int64(1000), math.LegacyNewDec(0), stakingtypes.Infraction_INFRACTION_DOWNTIME). Return(math.NewInt(0), nil), - mocks.MockStakingKeeper.EXPECT(). - GetValidatorByConsAddr(ctx, gomock.Any()). - Return(validator, nil), - mocks.MockSlashingKeeper.EXPECT(). - IsTombstoned(ctx, gomock.Any()). - Return(false), - mocks.MockStakingKeeper.EXPECT(). - Jail(ctx, gomock.Any()). - Return(nil), - mocks.MockSlashingKeeper.EXPECT(). - JailUntil(ctx, gomock.Any(), gomock.Any()). - Return(nil), } gomock.InOrder(expectedCalls...) - err = providerKeeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket) + err = providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) require.NoError(t, err) } -func TestHandleConsumerSlashPacketRejectsDoubleSign(t *testing.T) { +func TestHandleConsumerEvidencePacketRejectsDoubleSign(t *testing.T) { keeperParams := testkeeper.NewInMemKeeperParams(t) providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) defer ctrl.Finish() @@ -905,17 +893,17 @@ func TestHandleConsumerSlashPacketRejectsDoubleSign(t *testing.T) { consumerId := "0" providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) - slashPacket := vaastypes.NewSlashPacketData( + evidencePacket := vaastypes.NewSlashPacketData( sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), 100, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN, ) - err := providerKeeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket) + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) require.Error(t, err) } -func TestHandleConsumerSlashPacketRejectsNonLaunchedConsumer(t *testing.T) { +func TestHandleConsumerEvidencePacketRejectsNonLaunchedConsumer(t *testing.T) { keeperParams := testkeeper.NewInMemKeeperParams(t) providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) defer ctrl.Finish() @@ -923,13 +911,13 @@ func TestHandleConsumerSlashPacketRejectsNonLaunchedConsumer(t *testing.T) { consumerId := "0" providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_REGISTERED) - slashPacket := vaastypes.NewSlashPacketData( + evidencePacket := vaastypes.NewSlashPacketData( sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), 100, stakingtypes.Infraction_INFRACTION_DOWNTIME, ) - err := providerKeeper.HandleConsumerSlashPacket(ctx, consumerId, slashPacket) + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) require.Error(t, err) } From 6a9d3aad39192f7e6999a77bc3f6f4a902a0f589 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 19 May 2026 20:02:56 +0200 Subject: [PATCH 06/15] Switch PendingSlashPackets to validator-address key and deduplicate downtime slashes --- x/vaas/consumer/keeper/keeper.go | 9 +++----- x/vaas/consumer/keeper/slash_packet.go | 11 ++++------ x/vaas/consumer/keeper/validators.go | 18 ++++++++++++++++ x/vaas/consumer/keeper/validators_test.go | 26 +++++++++++++++++++++++ x/vaas/consumer/types/keys.go | 3 +-- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/x/vaas/consumer/keeper/keeper.go b/x/vaas/consumer/keeper/keeper.go index 1c2af07..8a43535 100644 --- a/x/vaas/consumer/keeper/keeper.go +++ b/x/vaas/consumer/keeper/keeper.go @@ -63,8 +63,7 @@ type Keeper struct { CrossChainValidators collections.Map[[]byte, types.CrossChainValidator] HistoricalInfos collections.Map[int64, stakingtypes.HistoricalInfo] HighestValsetUpdateID collections.Item[uint64] - PendingSlashPackets collections.Map[uint64, []byte] - PendingSlashSeq collections.Sequence + PendingSlashPackets collections.Map[[]byte, []byte] } // NewKeeper creates a new Consumer Keeper instance @@ -110,8 +109,7 @@ func NewKeeper( CrossChainValidators: collections.NewMap(sb, types.CrossChainValidatorPrefix, "cross_chain_validators", collections.BytesKey, codec.CollValue[types.CrossChainValidator](cdc)), HistoricalInfos: collections.NewMap(sb, types.HistoricalInfoPrefix, "historical_infos", collections.Int64Key, codec.CollValue[stakingtypes.HistoricalInfo](cdc)), HighestValsetUpdateID: collections.NewItem(sb, types.HighestValsetUpdateIDPrefix, "highest_valset_update_id", collections.Uint64Value), - PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.Uint64Key, collections.BytesValue), - PendingSlashSeq: collections.NewSequence(sb, types.PendingSlashSeqPrefix, "pending_slash_seq"), + PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.BytesKey, collections.BytesValue), } schema, err := sb.Build() @@ -151,8 +149,7 @@ func NewNonZeroKeeper(cdc codec.BinaryCodec, storeService corestoretypes.KVStore CrossChainValidators: collections.NewMap(sb, types.CrossChainValidatorPrefix, "cross_chain_validators", collections.BytesKey, codec.CollValue[types.CrossChainValidator](cdc)), HistoricalInfos: collections.NewMap(sb, types.HistoricalInfoPrefix, "historical_infos", collections.Int64Key, codec.CollValue[stakingtypes.HistoricalInfo](cdc)), HighestValsetUpdateID: collections.NewItem(sb, types.HighestValsetUpdateIDPrefix, "highest_valset_update_id", collections.Uint64Value), - PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.Uint64Key, collections.BytesValue), - PendingSlashSeq: collections.NewSequence(sb, types.PendingSlashSeqPrefix, "pending_slash_seq"), + PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.BytesKey, collections.BytesValue), } schema, err := sb.Build() diff --git a/x/vaas/consumer/keeper/slash_packet.go b/x/vaas/consumer/keeper/slash_packet.go index 66ce86f..c4ffb6c 100644 --- a/x/vaas/consumer/keeper/slash_packet.go +++ b/x/vaas/consumer/keeper/slash_packet.go @@ -13,18 +13,15 @@ import ( ) // QueueSlashPacket queues a slash packet to be sent to the provider chain. +// The packet is keyed by the validator's consensus address, so at most one +// pending packet exists per validator. func (k Keeper) QueueSlashPacket(ctx sdk.Context, packet vaastypes.SlashPacketData) error { bz, err := json.Marshal(&packet) if err != nil { return fmt.Errorf("failed to marshal slash packet: %w", err) } - seq, err := k.PendingSlashSeq.Next(ctx) - if err != nil { - return fmt.Errorf("failed to get slash packet sequence: %w", err) - } - - if err := k.PendingSlashPackets.Set(ctx, seq, bz); err != nil { + if err := k.PendingSlashPackets.Set(ctx, packet.ValidatorAddr, bz); err != nil { return fmt.Errorf("failed to store slash packet: %w", err) } @@ -52,7 +49,7 @@ func (k Keeper) SendSlashPackets(ctx sdk.Context) error { return nil } - var keysToDelete []uint64 + var keysToDelete [][]byte for ; iter.Valid(); iter.Next() { kv, err := iter.KeyValue() if err != nil { diff --git a/x/vaas/consumer/keeper/validators.go b/x/vaas/consumer/keeper/validators.go index 6628431..c877810 100644 --- a/x/vaas/consumer/keeper/validators.go +++ b/x/vaas/consumer/keeper/validators.go @@ -114,10 +114,28 @@ func (k Keeper) Slash(ctx context.Context, addr sdk.ConsAddress, infractionHeigh // SlashWithInfractionReason queues a slash packet for downtime infractions // to be sent to the provider chain. Double-sign and other infractions are logged but not forwarded. +// Only one slash packet is sent per downtime incident — if the validator already has a pending +// slash packet, the request is skipped to avoid duplicate reporting. func (k Keeper) SlashWithInfractionReason(goCtx context.Context, addr sdk.ConsAddress, infractionHeight, power int64, slashFactor math.LegacyDec, infraction stakingtypes.Infraction) (math.Int, error) { ctx := sdk.UnwrapSDKContext(goCtx) if infraction == stakingtypes.Infraction_INFRACTION_DOWNTIME { + has, err := k.PendingSlashPackets.Has(ctx, addr) + if err != nil { + k.Logger(ctx).Error("failed to check pending slash packet", + "validator", addr.String(), + "error", err, + ) + return math.ZeroInt(), nil + } + if has { + k.Logger(ctx).Debug("skipping duplicate downtime slash packet", + "validator", addr.String(), + "infraction_height", infractionHeight, + ) + return math.ZeroInt(), nil + } + slashPacket := vaastypes.NewSlashPacketData(addr, infractionHeight, infraction) if err := k.QueueSlashPacket(ctx, slashPacket); err != nil { k.Logger(ctx).Error("failed to queue downtime slash packet", diff --git a/x/vaas/consumer/keeper/validators_test.go b/x/vaas/consumer/keeper/validators_test.go index 7cffc7e..87809a8 100644 --- a/x/vaas/consumer/keeper/validators_test.go +++ b/x/vaas/consumer/keeper/validators_test.go @@ -151,6 +151,32 @@ func TestSlash(t *testing.T) { require.Equal(t, 1, consumerKeeper.GetPendingSlashPacketCount(ctx)) } +func TestSlashSkipsDuplicateDowntime(t *testing.T) { + consumerKeeper, ctx, ctrl, _ := testkeeper.GetConsumerKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) + defer ctrl.Finish() + + addr := sdk.ConsAddress([]byte{0x01, 0x02, 0x03}) + + // First downtime slash → queues packet + slashed, err := consumerKeeper.SlashWithInfractionReason(ctx, addr, 5, 6, math.LegacyNewDec(9.0), stakingtypes.Infraction_INFRACTION_DOWNTIME) + require.NoError(t, err) + require.True(t, slashed.IsZero()) + require.Equal(t, 1, consumerKeeper.GetPendingSlashPacketCount(ctx)) + + // Duplicate downtime → skipped (validator already has pending packet) + slashed, err = consumerKeeper.SlashWithInfractionReason(ctx, addr, 5, 6, math.LegacyNewDec(9.0), stakingtypes.Infraction_INFRACTION_DOWNTIME) + require.NoError(t, err) + require.True(t, slashed.IsZero()) + require.Equal(t, 1, consumerKeeper.GetPendingSlashPacketCount(ctx)) + + // Different validator → queues packet + addr2 := sdk.ConsAddress([]byte{0x04, 0x05, 0x06}) + slashed, err = consumerKeeper.SlashWithInfractionReason(ctx, addr2, 5, 6, math.LegacyNewDec(9.0), stakingtypes.Infraction_INFRACTION_DOWNTIME) + require.NoError(t, err) + require.True(t, slashed.IsZero()) + require.Equal(t, 2, consumerKeeper.GetPendingSlashPacketCount(ctx)) +} + // Tests the getter and setter behavior for historical info func TestHistoricalInfo(t *testing.T) { keeperParams := testkeeper.NewInMemKeeperParams(t) diff --git a/x/vaas/consumer/types/keys.go b/x/vaas/consumer/types/keys.go index fef4166..759d821 100644 --- a/x/vaas/consumer/types/keys.go +++ b/x/vaas/consumer/types/keys.go @@ -34,6 +34,5 @@ var ( ParametersPrefix = collections.NewPrefix(22) HighestValsetUpdateIDPrefix = collections.NewPrefix(23) ConsumerDebtPrefix = collections.NewPrefix(24) - PendingSlashPacketsPrefix = collections.NewPrefix(25) - PendingSlashSeqPrefix = collections.NewPrefix(26) + PendingSlashPacketsPrefix = collections.NewPrefix(25) ) From 5c7bfae4bc22c338346863ad77b6a4384d99d8f4 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 20 May 2026 12:47:25 +0200 Subject: [PATCH 07/15] Cleanup constructor and rename packet --- app/provider/app.go | 9 ++++----- testutil/keeper/unit_test_helpers.go | 1 + x/vaas/consumer/keeper/keeper.go | 6 +++--- x/vaas/consumer/keeper/slash_packet.go | 4 ++-- x/vaas/consumer/keeper/validators.go | 2 +- x/vaas/consumer/types/keys.go | 2 +- x/vaas/provider/ibc_module.go | 2 +- .../provider/keeper/consumer_equivocation.go | 10 ++++++++-- .../keeper/consumer_equivocation_test.go | 10 +++++----- x/vaas/provider/keeper/keeper.go | 7 ++----- x/vaas/types/wire.go | 20 +++++++++---------- 11 files changed, 38 insertions(+), 35 deletions(-) diff --git a/app/provider/app.go b/app/provider/app.go index 40e11d1..402f111 100644 --- a/app/provider/app.go +++ b/app/provider/app.go @@ -388,6 +388,7 @@ func New( runtime.NewKVStoreService(keys[providertypes.StoreKey]), app.IBCKeeper.ClientKeeper, app.IBCKeeper.ClientV2Keeper, + app.IBCKeeper.ChannelKeeperV2, app.StakingKeeper, app.SlashingKeeper, app.AccountKeeper, @@ -422,10 +423,6 @@ func New( authtypes.NewModuleAddress(govtypes.ModuleName).String(), ) - providerModule := ibcprovider.NewAppModule( - &app.ProviderKeeper, - ) - app.TransferKeeper = ibctransferkeeper.NewKeeper( appCodec, runtime.NewKVStoreService(keys[ibctransfertypes.StoreKey]), @@ -449,7 +446,9 @@ func New( ibcRouterV2.AddRoute(vaastypes.ProviderAppID, ibcprovider.NewIBCModule(&app.ProviderKeeper)) app.IBCKeeper.SetRouterV2(ibcRouterV2) - app.ProviderKeeper.SetChannelKeeperV2(app.IBCKeeper.ChannelKeeperV2) + providerModule := ibcprovider.NewAppModule( + &app.ProviderKeeper, + ) govRouter := govv1beta1.NewRouter() govRouter. diff --git a/testutil/keeper/unit_test_helpers.go b/testutil/keeper/unit_test_helpers.go index 31b8be0..a843e48 100644 --- a/testutil/keeper/unit_test_helpers.go +++ b/testutil/keeper/unit_test_helpers.go @@ -100,6 +100,7 @@ func NewInMemProviderKeeper(params InMemKeeperParams, mocks MockedKeepers) provi storeService, mocks.MockClientKeeper, mocks.MockClientV2Keeper, + nil, // channelKeeperV2 - not needed for unit tests mocks.MockStakingKeeper, mocks.MockSlashingKeeper, mocks.MockAccountKeeper, diff --git a/x/vaas/consumer/keeper/keeper.go b/x/vaas/consumer/keeper/keeper.go index 8a43535..504afdd 100644 --- a/x/vaas/consumer/keeper/keeper.go +++ b/x/vaas/consumer/keeper/keeper.go @@ -63,7 +63,7 @@ type Keeper struct { CrossChainValidators collections.Map[[]byte, types.CrossChainValidator] HistoricalInfos collections.Map[int64, stakingtypes.HistoricalInfo] HighestValsetUpdateID collections.Item[uint64] - PendingSlashPackets collections.Map[[]byte, []byte] + PendingSlashPackets collections.Map[[]byte, []byte] } // NewKeeper creates a new Consumer Keeper instance @@ -109,7 +109,7 @@ func NewKeeper( CrossChainValidators: collections.NewMap(sb, types.CrossChainValidatorPrefix, "cross_chain_validators", collections.BytesKey, codec.CollValue[types.CrossChainValidator](cdc)), HistoricalInfos: collections.NewMap(sb, types.HistoricalInfoPrefix, "historical_infos", collections.Int64Key, codec.CollValue[stakingtypes.HistoricalInfo](cdc)), HighestValsetUpdateID: collections.NewItem(sb, types.HighestValsetUpdateIDPrefix, "highest_valset_update_id", collections.Uint64Value), - PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.BytesKey, collections.BytesValue), + PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.BytesKey, collections.BytesValue), } schema, err := sb.Build() @@ -149,7 +149,7 @@ func NewNonZeroKeeper(cdc codec.BinaryCodec, storeService corestoretypes.KVStore CrossChainValidators: collections.NewMap(sb, types.CrossChainValidatorPrefix, "cross_chain_validators", collections.BytesKey, codec.CollValue[types.CrossChainValidator](cdc)), HistoricalInfos: collections.NewMap(sb, types.HistoricalInfoPrefix, "historical_infos", collections.Int64Key, codec.CollValue[stakingtypes.HistoricalInfo](cdc)), HighestValsetUpdateID: collections.NewItem(sb, types.HighestValsetUpdateIDPrefix, "highest_valset_update_id", collections.Uint64Value), - PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.BytesKey, collections.BytesValue), + PendingSlashPackets: collections.NewMap(sb, types.PendingSlashPacketsPrefix, "pending_slash_packets", collections.BytesKey, collections.BytesValue), } schema, err := sb.Build() diff --git a/x/vaas/consumer/keeper/slash_packet.go b/x/vaas/consumer/keeper/slash_packet.go index c4ffb6c..10e523c 100644 --- a/x/vaas/consumer/keeper/slash_packet.go +++ b/x/vaas/consumer/keeper/slash_packet.go @@ -15,7 +15,7 @@ import ( // QueueSlashPacket queues a slash packet to be sent to the provider chain. // The packet is keyed by the validator's consensus address, so at most one // pending packet exists per validator. -func (k Keeper) QueueSlashPacket(ctx sdk.Context, packet vaastypes.SlashPacketData) error { +func (k Keeper) QueueSlashPacket(ctx sdk.Context, packet vaastypes.EvidencePacketData) error { bz, err := json.Marshal(&packet) if err != nil { return fmt.Errorf("failed to marshal slash packet: %w", err) @@ -56,7 +56,7 @@ func (k Keeper) SendSlashPackets(ctx sdk.Context) error { continue } - var slashPacket vaastypes.SlashPacketData + var slashPacket vaastypes.EvidencePacketData if err := json.Unmarshal(kv.Value, &slashPacket); err != nil { k.Logger(ctx).Error("failed to unmarshal slash packet", "error", err) keysToDelete = append(keysToDelete, kv.Key) diff --git a/x/vaas/consumer/keeper/validators.go b/x/vaas/consumer/keeper/validators.go index c877810..08ef31c 100644 --- a/x/vaas/consumer/keeper/validators.go +++ b/x/vaas/consumer/keeper/validators.go @@ -136,7 +136,7 @@ func (k Keeper) SlashWithInfractionReason(goCtx context.Context, addr sdk.ConsAd return math.ZeroInt(), nil } - slashPacket := vaastypes.NewSlashPacketData(addr, infractionHeight, infraction) + slashPacket := vaastypes.NewEvidencePacketData(addr, infractionHeight, infraction) if err := k.QueueSlashPacket(ctx, slashPacket); err != nil { k.Logger(ctx).Error("failed to queue downtime slash packet", "validator", addr.String(), diff --git a/x/vaas/consumer/types/keys.go b/x/vaas/consumer/types/keys.go index 759d821..ef5f31b 100644 --- a/x/vaas/consumer/types/keys.go +++ b/x/vaas/consumer/types/keys.go @@ -34,5 +34,5 @@ var ( ParametersPrefix = collections.NewPrefix(22) HighestValsetUpdateIDPrefix = collections.NewPrefix(23) ConsumerDebtPrefix = collections.NewPrefix(24) - PendingSlashPacketsPrefix = collections.NewPrefix(25) + PendingSlashPacketsPrefix = collections.NewPrefix(25) ) diff --git a/x/vaas/provider/ibc_module.go b/x/vaas/provider/ibc_module.go index 5d5fd73..a34a2f9 100644 --- a/x/vaas/provider/ibc_module.go +++ b/x/vaas/provider/ibc_module.go @@ -103,7 +103,7 @@ func (im IBCModule) OnRecvPacket( } } - var evidencePacket vaastypes.SlashPacketData + var evidencePacket vaastypes.EvidencePacketData if err := json.Unmarshal(payload.Value, &evidencePacket); err != nil { logger.Error("cannot unmarshal evidence packet data", "error", err) return channeltypesv2.RecvPacketResult{ diff --git a/x/vaas/provider/keeper/consumer_equivocation.go b/x/vaas/provider/keeper/consumer_equivocation.go index 2a513aa..3dedb89 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -168,7 +168,7 @@ func (k Keeper) VerifyDoubleVotingEvidence( // HandleConsumerEvidencePacket handles an evidence packet received from a consumer chain. // It dispatches to the appropriate handler based on the infraction type. -func (k Keeper) HandleConsumerEvidencePacket(ctx sdk.Context, consumerId string, evidencePacket vaastypes.SlashPacketData) error { +func (k Keeper) HandleConsumerEvidencePacket(ctx sdk.Context, consumerId string, evidencePacket vaastypes.EvidencePacketData) error { if err := evidencePacket.Validate(); err != nil { return errorsmod.Wrapf(vaastypes.ErrInvalidPacketData, "invalid evidence packet: %s", err) } @@ -191,7 +191,9 @@ func (k Keeper) HandleConsumerEvidencePacket(ctx sdk.Context, consumerId string, } // HandleConsumerDowntime slashes and jails a validator that was offline on a consumer chain. -func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, evidencePacket vaastypes.SlashPacketData) error { +// CONTRACT: A downtime infraction must be verified by the provider before slashing is applied. +// CONTRACT: A downtime infraction must never jail a validator. +func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, evidencePacket vaastypes.EvidencePacketData) error { consumerAddr := types.NewConsumerConsAddress(evidencePacket.ValidatorAddr) providerAddr := k.GetProviderAddrFromConsumerAddr(ctx, consumerId, consumerAddr) @@ -201,9 +203,13 @@ func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId string, evide return err } + // TODO: add slashing factor + // TODO: add verification of actual downtime on consumer + if err = k.SlashValidator(ctx, providerAddr, infractionParams.Downtime, stakingtypes.Infraction_INFRACTION_DOWNTIME); err != nil { return err } + k.Logger(ctx).Info( "handled consumer downtime", "consumerId", consumerId, diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index 2c411b4..eb85521 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -842,7 +842,7 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { validator.Status = stakingtypes.Bonded consAddr, _ := validator.GetConsAddr() - evidencePacket := vaastypes.NewSlashPacketData( + evidencePacket := vaastypes.NewEvidencePacketData( sdk.ConsAddress(consAddr), 100, stakingtypes.Infraction_INFRACTION_DOWNTIME, @@ -893,7 +893,7 @@ func TestHandleConsumerEvidencePacketRejectsDoubleSign(t *testing.T) { consumerId := "0" providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) - evidencePacket := vaastypes.NewSlashPacketData( + evidencePacket := vaastypes.NewEvidencePacketData( sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), 100, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN, @@ -911,7 +911,7 @@ func TestHandleConsumerEvidencePacketRejectsNonLaunchedConsumer(t *testing.T) { consumerId := "0" providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_REGISTERED) - evidencePacket := vaastypes.NewSlashPacketData( + evidencePacket := vaastypes.NewEvidencePacketData( sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), 100, stakingtypes.Infraction_INFRACTION_DOWNTIME, @@ -923,11 +923,11 @@ func TestHandleConsumerEvidencePacketRejectsNonLaunchedConsumer(t *testing.T) { func TestSlashPacketDataJSONRoundTrip(t *testing.T) { addr := sdk.ConsAddress([]byte{0x01, 0x02, 0x03, 0x04, 0x05}) - packet := vaastypes.NewSlashPacketData(addr, 42, stakingtypes.Infraction_INFRACTION_DOWNTIME) + packet := vaastypes.NewEvidencePacketData(addr, 42, stakingtypes.Infraction_INFRACTION_DOWNTIME) bz := packet.GetBytes() - var decoded vaastypes.SlashPacketData + var decoded vaastypes.EvidencePacketData err := json.Unmarshal(bz, &decoded) require.NoError(t, err) require.Equal(t, packet.ValidatorAddr, decoded.ValidatorAddr) diff --git a/x/vaas/provider/keeper/keeper.go b/x/vaas/provider/keeper/keeper.go index 82539e0..2714314 100644 --- a/x/vaas/provider/keeper/keeper.go +++ b/x/vaas/provider/keeper/keeper.go @@ -83,6 +83,7 @@ func NewKeeper( cdc codec.BinaryCodec, storeService corestoretypes.KVStoreService, clientKeeper vaastypes.ClientKeeper, clientV2Keeper vaastypes.ClientV2Keeper, + channelKeeperV2 vaastypes.ChannelV2Keeper, stakingKeeper vaastypes.StakingKeeper, slashingKeeper vaastypes.SlashingKeeper, accountKeeper vaastypes.AccountKeeper, bankKeeper vaastypes.BankKeeper, @@ -106,6 +107,7 @@ func NewKeeper( feeCollectorName: feeCollectorName, validatorAddressCodec: validatorAddressCodec, consensusAddressCodec: consensusAddressCodec, + channelKeeperV2: channelKeeperV2, govKeeper: govKeeper, // Initialize collections @@ -165,11 +167,6 @@ func NewKeeper( return k } -// SetChannelKeeperV2 sets the IBC v2 channel keeper for client-based packet sending. -func (k *Keeper) SetChannelKeeperV2(keeper vaastypes.ChannelV2Keeper) { - k.channelKeeperV2 = keeper -} - // GetAuthority returns the x/ccv/provider module's authority. func (k Keeper) GetAuthority() string { return k.authority diff --git a/x/vaas/types/wire.go b/x/vaas/types/wire.go index af36421..e7f7798 100644 --- a/x/vaas/types/wire.go +++ b/x/vaas/types/wire.go @@ -38,25 +38,25 @@ func (vsc ValidatorSetChangePacketData) GetBytes() []byte { return valUpdateBytes } -// SlashPacketData is sent from a consumer chain to the provider chain +// EvidencePacketData is sent from a consumer chain to the provider chain // to report a validator infraction (e.g., downtime) detected on the consumer. -type SlashPacketData struct { +type EvidencePacketData struct { ValidatorAddr sdk.ConsAddress `json:"validator_addr"` InfractionHeight int64 `json:"infraction_height"` Infraction stakingtypes.Infraction `json:"infraction"` } -// NewSlashPacketData creates a new SlashPacketData. -func NewSlashPacketData(validatorAddr sdk.ConsAddress, infractionHeight int64, infraction stakingtypes.Infraction) SlashPacketData { - return SlashPacketData{ +// NewEvidencePacketData creates a new EvidencePacketData. +func NewEvidencePacketData(validatorAddr sdk.ConsAddress, infractionHeight int64, infraction stakingtypes.Infraction) EvidencePacketData { + return EvidencePacketData{ ValidatorAddr: validatorAddr, InfractionHeight: infractionHeight, Infraction: infraction, } } -// Validate returns an error if the SlashPacketData is invalid. -func (spd SlashPacketData) Validate() error { +// Validate returns an error if the EvidencePacketData is invalid. +func (spd EvidencePacketData) Validate() error { if len(spd.ValidatorAddr) == 0 { return errorsmod.Wrap(ErrInvalidPacketData, "validator address cannot be empty") } @@ -69,11 +69,11 @@ func (spd SlashPacketData) Validate() error { return nil } -// GetBytes marshals the SlashPacketData into JSON bytes for IBC transport. -func (spd SlashPacketData) GetBytes() []byte { +// GetBytes marshals the EvidencePacketData into JSON bytes for IBC transport. +func (spd EvidencePacketData) GetBytes() []byte { bz, err := json.Marshal(&spd) if err != nil { - panic(fmt.Sprintf("failed to marshal SlashPacketData: %v", err)) + panic(fmt.Sprintf("failed to marshal EvidencePacketData: %v", err)) } return bz } From 892716dbb687f4d24f78d5cc3ecd8586d782e5f0 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 20 May 2026 12:48:57 +0200 Subject: [PATCH 08/15] cleanup defensive programming --- x/vaas/consumer/keeper/slash_packet.go | 4 ---- x/vaas/provider/keeper/relay.go | 7 ------- 2 files changed, 11 deletions(-) diff --git a/x/vaas/consumer/keeper/slash_packet.go b/x/vaas/consumer/keeper/slash_packet.go index 10e523c..27b6eb4 100644 --- a/x/vaas/consumer/keeper/slash_packet.go +++ b/x/vaas/consumer/keeper/slash_packet.go @@ -30,10 +30,6 @@ func (k Keeper) QueueSlashPacket(ctx sdk.Context, packet vaastypes.EvidencePacke // SendSlashPackets sends all pending slash packets to the provider chain. func (k Keeper) SendSlashPackets(ctx sdk.Context) error { - if k.channelKeeperV2 == nil { - return nil - } - providerClientID, found := k.GetProviderClientID(ctx) if !found { return nil diff --git a/x/vaas/provider/keeper/relay.go b/x/vaas/provider/keeper/relay.go index 237f579..40168dc 100644 --- a/x/vaas/provider/keeper/relay.go +++ b/x/vaas/provider/keeper/relay.go @@ -197,13 +197,6 @@ func (k Keeper) discoverActiveConsumerClient(ctx sdk.Context, consumerId, curren } func (k Keeper) SendVSCPacketsToChain(ctx sdk.Context, consumerId, clientId string) error { - if k.channelKeeperV2 == nil { - k.Logger(ctx).Debug("IBC v2 channel keeper not configured, skipping send", - "consumerId", consumerId, - ) - return nil - } - timeoutPeriod := min(k.GetVAASTimeoutPeriod(ctx), channeltypesv2.MaxTimeoutDelta) timeoutTimestamp := uint64(ctx.BlockTime().Add(timeoutPeriod).Unix()) From 43ebec96eac0c91b9d61cc34ed8bab01161d6d4b Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 20 May 2026 12:53:38 +0200 Subject: [PATCH 09/15] improve downtime slashing tests --- tests/e2e/e2e_downtime_slash_test.go | 51 +++++++++++++++++-- .../keeper/consumer_equivocation_test.go | 5 +- x/vaas/provider/types/provider.go | 12 ++--- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go index 2e105e9..73f1578 100644 --- a/tests/e2e/e2e_downtime_slash_test.go +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -1,11 +1,19 @@ package e2e import ( + "fmt" "time" + + "cosmossdk.io/math" + + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" ) func (s *IntegrationTestSuite) testDowntimeSlash() { s.Run("downtime slash", func() { + valoperAddr, tokensBefore := s.getProviderValidatorTokens() + s.Require().False(tokensBefore.IsZero(), "validator should have tokens before downtime test") + jailed := s.isProviderValidatorJailed() s.Require().False(jailed, "validator should not be jailed before downtime test") @@ -19,14 +27,23 @@ func (s *IntegrationTestSuite) testDowntimeSlash() { err = s.dkrPool.Client.UnpauseContainer(s.consumerValRes[0].Container.ID) s.Require().NoError(err, "failed to unpause consumer container") - s.T().Log("waiting for provider to jail validator for downtime...") + s.T().Log("waiting for provider to process downtime evidence from consumer...") s.Require().Eventuallyf(func() bool { - return s.isProviderValidatorJailed() + tokensAfter, err := s.getProviderValidatorTokensByAddr(valoperAddr) + if err != nil { + return false + } + return tokensAfter.LT(tokensBefore) }, 3*time.Minute, 5*time.Second, - "validator was not jailed on provider after consumer downtime", + "validator tokens were not slashed on provider after consumer downtime (before: %s, valoper: %s)", + tokensBefore.String(), valoperAddr, ) + + s.T().Log("verifying validator was not jailed after downtime slash...") + jailed = s.isProviderValidatorJailed() + s.Require().False(jailed, "validator should not be jailed after downtime slash") }) } @@ -65,3 +82,31 @@ func (s *IntegrationTestSuite) isProviderValidatorJailed() bool { } return false } + +// getProviderValidatorTokens returns the first bonded validator's operator address and token amount. +func (s *IntegrationTestSuite) getProviderValidatorTokens() (string, math.Int) { + vals, err := s.queryProviderValidators() + if err != nil { + return "", math.ZeroInt() + } + for _, v := range vals { + if v.Status == stakingtypes.Bonded { + return v.OperatorAddress, v.Tokens + } + } + return "", math.ZeroInt() +} + +// getProviderValidatorTokensByAddr returns the token amount for a specific validator by operator address. +func (s *IntegrationTestSuite) getProviderValidatorTokensByAddr(valoperAddr string) (math.Int, error) { + vals, err := s.queryProviderValidators() + if err != nil { + return math.ZeroInt(), err + } + for _, v := range vals { + if v.OperatorAddress == valoperAddr { + return v.Tokens, nil + } + } + return math.ZeroInt(), fmt.Errorf("validator %s not found", valoperAddr) +} diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index eb85521..db23e89 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -857,6 +857,9 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { mocks.MockSlashingKeeper.EXPECT(). SlashFractionDoubleSign(ctx). Return(math.LegacyNewDecWithPrec(5, 1), nil), + mocks.MockSlashingKeeper.EXPECT(). + SlashFractionDowntime(ctx). + Return(math.LegacyNewDecWithPrec(5, 2), nil), mocks.MockStakingKeeper.EXPECT(). GetValidatorByConsAddr(ctx, gomock.Any()). Return(validator, nil), @@ -876,7 +879,7 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { PowerReduction(ctx). Return(math.NewInt(1000000)), mocks.MockStakingKeeper.EXPECT(). - SlashWithInfractionReason(ctx, gomock.Any(), int64(0), int64(1000), math.LegacyNewDec(0), stakingtypes.Infraction_INFRACTION_DOWNTIME). + SlashWithInfractionReason(ctx, gomock.Any(), int64(0), int64(1000), math.LegacyNewDecWithPrec(5, 2), stakingtypes.Infraction_INFRACTION_DOWNTIME). Return(math.NewInt(0), nil), } diff --git a/x/vaas/provider/types/provider.go b/x/vaas/provider/types/provider.go index c5bb5b3..bacf31c 100644 --- a/x/vaas/provider/types/provider.go +++ b/x/vaas/provider/types/provider.go @@ -7,8 +7,6 @@ import ( vaastypes "github.com/allinbits/vaas/x/vaas/types" clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" - - "cosmossdk.io/math" ) func DefaultConsumerInitializationParameters() ConsumerInitializationParameters { @@ -27,25 +25,25 @@ func DefaultConsumerInitializationParameters() ConsumerInitializationParameters } func DefaultConsumerInfractionParameters(ctx context.Context, slashingKeeper vaastypes.SlashingKeeper) (InfractionParameters, error) { - jailDuration, err := slashingKeeper.DowntimeJailDuration(ctx) + doubleSignSlashingFraction, err := slashingKeeper.SlashFractionDoubleSign(ctx) if err != nil { return InfractionParameters{}, err } - doubleSignSlashingFraction, err := slashingKeeper.SlashFractionDoubleSign(ctx) + downtimeSlashingFraction, err := slashingKeeper.SlashFractionDowntime(ctx) if err != nil { return InfractionParameters{}, err } return InfractionParameters{ DoubleSign: &SlashJailParameters{ - JailDuration: time.Duration(1<<63 - 1), // the largest value a time.Duration can hold 9223372036854775807 (approximately 292 years) + JailDuration: time.Duration(1<<63 - 1), SlashFraction: doubleSignSlashingFraction, Tombstone: true, }, Downtime: &SlashJailParameters{ - JailDuration: jailDuration, - SlashFraction: math.LegacyNewDec(0), + JailDuration: 0, + SlashFraction: downtimeSlashingFraction, Tombstone: false, }, }, nil From 46102f9f790ec5dc69e552ab371c9c579367930b Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 20 May 2026 12:59:49 +0200 Subject: [PATCH 10/15] wire proper mocks --- testutil/keeper/unit_test_helpers.go | 24 ++++++++++++------- .../keeper/consumer_equivocation_test.go | 3 --- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/testutil/keeper/unit_test_helpers.go b/testutil/keeper/unit_test_helpers.go index a843e48..c528b52 100644 --- a/testutil/keeper/unit_test_helpers.go +++ b/testutil/keeper/unit_test_helpers.go @@ -22,6 +22,7 @@ import ( "cosmossdk.io/store/metrics" storetypes "cosmossdk.io/store/types" + providertypes "github.com/allinbits/vaas/x/vaas/provider/types" "github.com/cosmos/cosmos-sdk/codec" "github.com/cosmos/cosmos-sdk/codec/address" codectypes "github.com/cosmos/cosmos-sdk/codec/types" @@ -71,6 +72,7 @@ func NewInMemKeeperParams(tb testing.TB) InMemKeeperParams { type MockedKeepers struct { *MockClientKeeper *MockClientV2Keeper + *MockChannelV2Keeper *MockStakingKeeper *MockSlashingKeeper *MockAccountKeeper @@ -80,12 +82,13 @@ type MockedKeepers struct { // NewMockedKeepers instantiates a struct with pointers to properly instantiated mocked keepers. func NewMockedKeepers(ctrl *gomock.Controller) MockedKeepers { mocks := MockedKeepers{ - MockClientKeeper: NewMockClientKeeper(ctrl), - MockClientV2Keeper: NewMockClientV2Keeper(ctrl), - MockStakingKeeper: NewMockStakingKeeper(ctrl), - MockSlashingKeeper: NewMockSlashingKeeper(ctrl), - MockAccountKeeper: NewMockAccountKeeper(ctrl), - MockBankKeeper: NewMockBankKeeper(ctrl), + MockClientKeeper: NewMockClientKeeper(ctrl), + MockClientV2Keeper: NewMockClientV2Keeper(ctrl), + MockChannelV2Keeper: NewMockChannelV2Keeper(ctrl), + MockStakingKeeper: NewMockStakingKeeper(ctrl), + MockSlashingKeeper: NewMockSlashingKeeper(ctrl), + MockAccountKeeper: NewMockAccountKeeper(ctrl), + MockBankKeeper: NewMockBankKeeper(ctrl), } mocks.MockClientV2Keeper.EXPECT().GetClientCounterparty(gomock.Any(), gomock.Any()).Return(clientv2types.CounterpartyInfo{}, false).AnyTimes() mocks.MockClientV2Keeper.EXPECT().SetClientCounterparty(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() @@ -100,7 +103,7 @@ func NewInMemProviderKeeper(params InMemKeeperParams, mocks MockedKeepers) provi storeService, mocks.MockClientKeeper, mocks.MockClientV2Keeper, - nil, // channelKeeperV2 - not needed for unit tests + mocks.MockChannelV2Keeper, mocks.MockStakingKeeper, mocks.MockSlashingKeeper, mocks.MockAccountKeeper, @@ -122,7 +125,7 @@ func NewInMemConsumerKeeper(params InMemKeeperParams, mocks MockedKeepers) consu storeService, mocks.MockClientKeeper, mocks.MockClientV2Keeper, - nil, // channelKeeperV2 - not needed for unit tests + mocks.MockChannelV2Keeper, mocks.MockSlashingKeeper, mocks.MockBankKeeper, mocks.MockAccountKeeper, @@ -143,7 +146,10 @@ func GetProviderKeeperAndCtx(t *testing.T, params InMemKeeperParams) ( t.Helper() ctrl := gomock.NewController(t) mocks := NewMockedKeepers(ctrl) - return NewInMemProviderKeeper(params, mocks), params.Ctx, ctrl, mocks + providerKeeper := NewInMemProviderKeeper(params, mocks) + providerKeeper.SetParams(params.Ctx, providertypes.DefaultParams()) + + return providerKeeper, params.Ctx, ctrl, mocks } // Return an in-memory consumer keeper, context, controller, and mocks, given a test instance and parameters. diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index db23e89..af43dc2 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -851,9 +851,6 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { valAddr, _ := providerKeeper.ValidatorAddressCodec().StringToBytes(validator.GetOperator()) expectedCalls := []any{ - mocks.MockSlashingKeeper.EXPECT(). - DowntimeJailDuration(ctx). - Return(600*time.Second, nil), mocks.MockSlashingKeeper.EXPECT(). SlashFractionDoubleSign(ctx). Return(math.LegacyNewDecWithPrec(5, 1), nil), From e17f65afbc9e151adc18df827426a09091394b9a Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 20 May 2026 13:04:55 +0200 Subject: [PATCH 11/15] fix unit tests --- x/vaas/provider/keeper/grpc_query_test.go | 3 +-- x/vaas/provider/keeper/ibc_v2_integration_test.go | 8 +++++++- x/vaas/provider/keeper/relay_test.go | 15 ++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/x/vaas/provider/keeper/grpc_query_test.go b/x/vaas/provider/keeper/grpc_query_test.go index 49b85e5..75bd6ec 100644 --- a/x/vaas/provider/keeper/grpc_query_test.go +++ b/x/vaas/provider/keeper/grpc_query_test.go @@ -2,7 +2,6 @@ package keeper_test import ( "testing" - "time" "cosmossdk.io/math" "github.com/stretchr/testify/require" @@ -24,8 +23,8 @@ func TestQueryConsumerChainIncludesFeePoolAddress(t *testing.T) { Name: "name", Description: "description", Metadata: "metadata", })) - mocks.MockSlashingKeeper.EXPECT().DowntimeJailDuration(gomock.Any()).Return(time.Hour, nil).AnyTimes() mocks.MockSlashingKeeper.EXPECT().SlashFractionDoubleSign(gomock.Any()).Return(math.LegacyNewDec(0), nil).AnyTimes() + mocks.MockSlashingKeeper.EXPECT().SlashFractionDowntime(gomock.Any()).Return(math.LegacyNewDec(0), nil).AnyTimes() expected := k.GetConsumerFeePoolAddress(consumerId).String() diff --git a/x/vaas/provider/keeper/ibc_v2_integration_test.go b/x/vaas/provider/keeper/ibc_v2_integration_test.go index c2615ca..0b950b8 100644 --- a/x/vaas/provider/keeper/ibc_v2_integration_test.go +++ b/x/vaas/provider/keeper/ibc_v2_integration_test.go @@ -12,6 +12,8 @@ import ( cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + testkeeper "github.com/allinbits/vaas/testutil/keeper" providertypes "github.com/allinbits/vaas/x/vaas/provider/types" vaastypes "github.com/allinbits/vaas/x/vaas/types" @@ -20,7 +22,7 @@ import ( // TestIBCV2PacketQueueing tests that VSC packets are correctly queued // and stored for later sending via IBC v2 client-based routing. func TestIBCV2PacketQueueing(t *testing.T) { - providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) defer ctrl.Finish() consumerId := "0" @@ -48,6 +50,10 @@ func TestIBCV2PacketQueueing(t *testing.T) { require.Equal(t, uint64(1), pending[0].ValsetUpdateId) require.Len(t, pending[0].ValidatorUpdates, 2) + mocks.MockChannelV2Keeper.EXPECT(). + SendPacket(gomock.Any(), gomock.Any()). + Return(nil, clienttypes.ErrClientNotActive) + err = providerKeeper.SendVSCPacketsToChain(ctx, consumerId, clientId) require.NoError(t, err) diff --git a/x/vaas/provider/keeper/relay_test.go b/x/vaas/provider/keeper/relay_test.go index a9631cf..1197dab 100644 --- a/x/vaas/provider/keeper/relay_test.go +++ b/x/vaas/provider/keeper/relay_test.go @@ -9,6 +9,8 @@ import ( abci "github.com/cometbft/cometbft/abci/types" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + testkeeper "github.com/allinbits/vaas/testutil/keeper" providertypes "github.com/allinbits/vaas/x/vaas/provider/types" vaastypes "github.com/allinbits/vaas/x/vaas/types" @@ -138,9 +140,9 @@ func TestClientIdToConsumerIdMapping(t *testing.T) { } // TestSendVSCPacketsToChainNoHandler tests that SendVSCPacketsToChain gracefully -// handles the case when no IBC v2 channel keeper is configured. +// handles the case when the IBC v2 channel keeper returns ErrClientNotActive. func TestSendVSCPacketsToChainNoHandler(t *testing.T) { - providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) defer ctrl.Finish() consumerId := "0" @@ -156,12 +158,15 @@ func TestSendVSCPacketsToChainNoHandler(t *testing.T) { ValsetUpdateId: 1, }) - // Without setting ChannelKeeperV2, SendVSCPacketsToChain should return nil - // and not send any packets (graceful no-op) + // Simulate an inactive client so packets are not sent + mocks.MockChannelV2Keeper.EXPECT(). + SendPacket(gomock.Any(), gomock.Any()). + Return(nil, clienttypes.ErrClientNotActive) + err := providerKeeper.SendVSCPacketsToChain(ctx, consumerId, clientId) require.NoError(t, err) - // Pending packets should still be there since no keeper was configured + // Pending packets should still be there since the client was not active pending := providerKeeper.GetPendingVSCPackets(ctx, consumerId) require.Len(t, pending, 1) } From 729fd225e75d2275e0ee63f6f7bb691929ceb901 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 20 May 2026 13:06:40 +0200 Subject: [PATCH 12/15] lint --- testutil/keeper/unit_test_helpers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testutil/keeper/unit_test_helpers.go b/testutil/keeper/unit_test_helpers.go index c528b52..49c4567 100644 --- a/testutil/keeper/unit_test_helpers.go +++ b/testutil/keeper/unit_test_helpers.go @@ -8,6 +8,7 @@ import ( consumerkeeper "github.com/allinbits/vaas/x/vaas/consumer/keeper" consumertypes "github.com/allinbits/vaas/x/vaas/consumer/types" providerkeeper "github.com/allinbits/vaas/x/vaas/provider/keeper" + providertypes "github.com/allinbits/vaas/x/vaas/provider/types" "github.com/allinbits/vaas/x/vaas/types" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -22,7 +23,6 @@ import ( "cosmossdk.io/store/metrics" storetypes "cosmossdk.io/store/types" - providertypes "github.com/allinbits/vaas/x/vaas/provider/types" "github.com/cosmos/cosmos-sdk/codec" "github.com/cosmos/cosmos-sdk/codec/address" codectypes "github.com/cosmos/cosmos-sdk/codec/types" From 9bf0cdd301a2ce7a5979bb392b154a0524936a86 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 20 May 2026 13:58:03 +0200 Subject: [PATCH 13/15] fix wrong lookup --- x/vaas/provider/ibc_module.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x/vaas/provider/ibc_module.go b/x/vaas/provider/ibc_module.go index a34a2f9..e869b25 100644 --- a/x/vaas/provider/ibc_module.go +++ b/x/vaas/provider/ibc_module.go @@ -92,10 +92,11 @@ func (im IBCModule) OnRecvPacket( } } - // look up consumer id from source client - consumerId, found := im.keeper.GetClientIdToConsumerId(ctx, sourceClient) + // destinationClient is the provider's own client pointing to the consumer. + consumerId, found := im.keeper.GetClientIdToConsumerId(ctx, destinationClient) if !found { logger.Error("received packet from unknown client", + "destinationClient", destinationClient, "sourceClient", sourceClient, ) return channeltypesv2.RecvPacketResult{ From 6b229737977d8ac96f476cb3f4cd51ed0038f5f6 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 21 May 2026 11:20:03 +0200 Subject: [PATCH 14/15] add multi validator in tests to properly check downtime. --- tests/e2e/chain_test.go | 16 +- tests/e2e/e2e_downtime_slash_test.go | 10 +- tests/e2e/e2e_setup_test.go | 280 ++++++++++++++++++++++----- tests/e2e/e2e_vaas_test.go | 2 +- tests/e2e/scripts/provider-init.sh | 39 +++- 5 files changed, 283 insertions(+), 64 deletions(-) diff --git a/tests/e2e/chain_test.go b/tests/e2e/chain_test.go index 608f9d1..c1ae10d 100644 --- a/tests/e2e/chain_test.go +++ b/tests/e2e/chain_test.go @@ -1,13 +1,15 @@ package e2e const ( - providerBinary = "provider" - consumerBinary = "consumer" - providerHomePath = "/home/nonroot/.provider" - consumerHomePath = "/home/nonroot/.consumer" - bondDenom = "uatone" - providerChainID = "provider-e2e" - consumerChainID = "consumer-e2e" + providerBinary = "provider" + consumerBinary = "consumer" + providerHomePath = "/home/nonroot/.provider" + providerVal1HomePath = "/home/nonroot/.provider-val1" + consumerHomePath = "/home/nonroot/.consumer" + consumerVal1HomePath = "/home/nonroot/.consumer-val1" + bondDenom = "uatone" + providerChainID = "provider-e2e" + consumerChainID = "consumer-e2e" ) // chain represents a Cosmos chain instance (either provider or consumer). diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go index 73f1578..2d27bf0 100644 --- a/tests/e2e/e2e_downtime_slash_test.go +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -17,15 +17,15 @@ func (s *IntegrationTestSuite) testDowntimeSlash() { jailed := s.isProviderValidatorJailed() s.Require().False(jailed, "validator should not be jailed before downtime test") - s.T().Log("pausing consumer container to simulate downtime...") + s.T().Log("pausing consumer val0 container to simulate downtime (val1 keeps chain alive)...") err := s.dkrPool.Client.PauseContainer(s.consumerValRes[0].Container.ID) - s.Require().NoError(err, "failed to pause consumer container") + s.Require().NoError(err, "failed to pause consumer val0 container") - time.Sleep(10 * time.Second) + time.Sleep(30 * time.Second) - s.T().Log("unpausing consumer container...") + s.T().Log("unpausing consumer val0 container...") err = s.dkrPool.Client.UnpauseContainer(s.consumerValRes[0].Container.ID) - s.Require().NoError(err, "failed to unpause consumer container") + s.Require().NoError(err, "failed to unpause consumer val0 container") s.T().Log("waiting for provider to process downtime evidence from consumer...") s.Require().Eventuallyf(func() bool { diff --git a/tests/e2e/e2e_setup_test.go b/tests/e2e/e2e_setup_test.go index d1ea1fe..4c619f6 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -3,9 +3,12 @@ package e2e import ( "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -35,7 +38,9 @@ type IntegrationTestSuite struct { cdc codec.Codec tmpDirs []string provider *chain + providerVal1 *chain consumer *chain + consumerVal1 *chain dkrPool *dockertest.Pool dkrNet *dockertest.Network providerValRes []*dockertest.Resource @@ -90,6 +95,7 @@ func (s *IntegrationTestSuite) SetupSuite() { s.T().Log("step 1: initializing provider chain...") s.provider = &chain{id: providerChainID} + s.providerVal1 = &chain{id: providerChainID} s.initAndStartProvider() s.T().Log("step 2: registering consumer chain on provider...") @@ -100,6 +106,7 @@ func (s *IntegrationTestSuite) SetupSuite() { s.T().Log("step 4: initializing consumer chain...") s.consumer = &chain{id: consumerChainID} + s.consumerVal1 = &chain{id: consumerChainID} s.initAndStartConsumer(consumerGenesisJSON) s.T().Log("step 5: starting ts-relayer and creating IBC v2 path...") @@ -155,7 +162,9 @@ func (s *IntegrationTestSuite) cleanupStaleContainers() { "provider-init", "consumer-init", fmt.Sprintf("%s-val0", providerChainID), + fmt.Sprintf("%s-val1", providerChainID), fmt.Sprintf("%s-val0", consumerChainID), + fmt.Sprintf("%s-val1", consumerChainID), fmt.Sprintf("%s-%s-ts-relayer", providerChainID, consumerChainID), } for _, name := range staleNames { @@ -174,9 +183,9 @@ func (s *IntegrationTestSuite) cleanupStaleContainers() { _ = s.dkrPool.Client.RemoveNetwork(dockerNetwork) } -// runInitContainer starts an init container with the given script mounted, +// runInitContainer starts an init container with the given mounts and entrypoint, // waits for it to exit, checks the exit code, and purges it. -func (s *IntegrationTestSuite) runInitContainer(name, scriptPath, containerScriptPath, dataDir, homePath string, env []string) { +func (s *IntegrationTestSuite) runInitContainer(name, containerScriptPath string, mounts []string, env []string) { initResource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: name, @@ -184,10 +193,7 @@ func (s *IntegrationTestSuite) runInitContainer(name, scriptPath, containerScrip NetworkID: s.dkrNet.Network.ID, User: "nonroot", Env: env, - Mounts: []string{ - fmt.Sprintf("%s:%s", dataDir, homePath), - fmt.Sprintf("%s:%s", scriptPath, containerScriptPath), - }, + Mounts: mounts, Entrypoint: []string{"sh", containerScriptPath}, }, func(config *docker.HostConfig) { @@ -216,26 +222,39 @@ func (s *IntegrationTestSuite) runInitContainer(name, scriptPath, containerScrip } // initAndStartProvider initializes the provider chain using a temporary Docker -// container that runs provider-init.sh, then starts the actual chain container. +// container that runs provider-init.sh, then starts the actual chain containers. +// It creates 2 validators (val, val2) with separate consensus keys. func (s *IntegrationTestSuite) initAndStartProvider() { - // Create host directory for provider data + // Create host directories for provider data providerDir, err := os.MkdirTemp("", "vaas-e2e-provider-") s.Require().NoError(err) s.tmpDirs = append(s.tmpDirs, providerDir) s.provider.dataDir = providerDir - // Make writable + providerVal1Dir, err := os.MkdirTemp("", "vaas-e2e-provider-val1-") + s.Require().NoError(err) + s.tmpDirs = append(s.tmpDirs, providerVal1Dir) + s.providerVal1.dataDir = providerVal1Dir + s.Require().NoError(os.Chmod(providerDir, 0o777)) + s.Require().NoError(os.Chmod(providerVal1Dir, 0o777)) - // Run init script in a temporary container + // Run init script in a temporary container (mounts both provider dirs) scriptPath := filepath.Join(testDir(), "scripts", "provider-init.sh") - s.runInitContainer("provider-init", scriptPath, "/scripts/provider-init.sh", providerDir, providerHomePath, []string{ - "BINARY=" + providerBinary, - "HOME_DIR=" + providerHomePath, - "CHAIN_ID=" + providerChainID, - "DENOM=" + bondDenom, - "MNEMONIC=" + relayerMnemonic, - }) + s.runInitContainer("provider-init", "/scripts/provider-init.sh", + []string{ + fmt.Sprintf("%s:%s", providerDir, providerHomePath), + fmt.Sprintf("%s:%s", providerVal1Dir, providerVal1HomePath), + fmt.Sprintf("%s:%s", scriptPath, "/scripts/provider-init.sh"), + }, + []string{ + "BINARY=" + providerBinary, + "HOME_DIR=" + providerHomePath, + "CHAIN_ID=" + providerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }, + ) // Modify genesis on the host: set fast voting period and small blocks_per_epoch genesisFile := filepath.Join(providerDir, "config", "genesis.json") @@ -263,8 +282,14 @@ func (s *IntegrationTestSuite) initAndStartProvider() { } }) - // Now start the actual provider container - s.T().Log("starting provider chain container...") + // Copy patched genesis to val1 directory (they share the same genesis) + s.Require().NoError(copyFile( + filepath.Join(providerDir, "config", "genesis.json"), + filepath.Join(providerVal1Dir, "config", "genesis.json"), + )) + + // Start provider val0 container + s.T().Log("starting provider val0 container...") resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ @@ -289,17 +314,50 @@ func (s *IntegrationTestSuite) initAndStartProvider() { config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) - s.Require().NoError(err, "failed to start provider container") + s.Require().NoError(err, "failed to start provider val0 container") s.providerValRes = append(s.providerValRes, resource) - s.T().Logf("provider container started: %s", resource.Container.ID[:12]) + s.T().Logf("provider val0 container started: %s", resource.Container.ID[:12]) - // Wait for provider to produce blocks + // Wait for provider val0 to produce blocks and get its node ID waitCtx, waitCancel := context.WithTimeout(context.Background(), 2*time.Minute) defer waitCancel() err = s.waitForChainHeight(waitCtx, "http://localhost:26657", 3) s.Require().NoError(err, "provider failed to produce blocks") - s.T().Log("provider chain is producing blocks") + s.T().Log("provider val0 is producing blocks") + + // Get val0's node ID for p2p peering + val0NodeID := s.getNodeID(providerDir) + + // Start provider val1 container with persistent peer to val0 + s.T().Log("starting provider val1 container...") + + val1Resource, err := s.dkrPool.RunWithOptions( + &dockertest.RunOptions{ + Name: fmt.Sprintf("%s-val1", providerChainID), + Repository: e2eChainImage, + NetworkID: s.dkrNet.Network.ID, + Mounts: []string{ + fmt.Sprintf("%s:%s", providerVal1Dir, providerVal1HomePath), + }, + Cmd: []string{ + providerBinary, "start", + "--home", providerVal1HomePath, + "--p2p.persistent-peers", fmt.Sprintf("%s@%s-val0:26656", val0NodeID, providerChainID), + }, + }, + func(config *docker.HostConfig) { + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }, + ) + s.Require().NoError(err, "failed to start provider val1 container") + + s.providerValRes = append(s.providerValRes, val1Resource) + s.T().Logf("provider val1 container started: %s", val1Resource.Container.ID[:12]) + + // Wait for val1 to sync + time.Sleep(5 * time.Second) + s.T().Log("provider chain is running with 2 validators") } // registerConsumerOnProvider creates a consumer chain registration on the provider. @@ -361,50 +419,121 @@ func (s *IntegrationTestSuite) fetchConsumerGenesis() []byte { return []byte(output) } -// initAndStartConsumer initializes the consumer chain and starts it. +// initAndStartConsumer initializes the consumer chain and starts it with 2 +// validator nodes. Node 0 uses val's consensus key, node 1 uses val2's key. func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) { - // Create host directory for consumer data + // Create host directories for consumer data consumerDir, err := os.MkdirTemp("", "vaas-e2e-consumer-") s.Require().NoError(err) s.tmpDirs = append(s.tmpDirs, consumerDir) s.consumer.dataDir = consumerDir + consumerVal1Dir, err := os.MkdirTemp("", "vaas-e2e-consumer-val1-") + s.Require().NoError(err) + s.tmpDirs = append(s.tmpDirs, consumerVal1Dir) + s.consumerVal1.dataDir = consumerVal1Dir + s.Require().NoError(os.Chmod(consumerDir, 0o777)) + s.Require().NoError(os.Chmod(consumerVal1Dir, 0o777)) - // Run init script in a temporary container + // Run init script in a temporary container (creates node 0's keys) scriptPath := filepath.Join(testDir(), "scripts", "consumer-init.sh") - s.runInitContainer("consumer-init", scriptPath, "/scripts/consumer-init.sh", consumerDir, consumerHomePath, []string{ - "BINARY=" + consumerBinary, - "HOME_DIR=" + consumerHomePath, - "CHAIN_ID=" + consumerChainID, - "DENOM=" + bondDenom, - "MNEMONIC=" + relayerMnemonic, - }) + s.runInitContainer("consumer-init", "/scripts/consumer-init.sh", + []string{ + fmt.Sprintf("%s:%s", consumerDir, consumerHomePath), + fmt.Sprintf("%s:%s", scriptPath, "/scripts/consumer-init.sh"), + }, + []string{ + "BINARY=" + consumerBinary, + "HOME_DIR=" + consumerHomePath, + "CHAIN_ID=" + consumerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }, + ) + + // Initialize node 1's directory with separate keys + s.runInitContainer("consumer-init-val1", "/scripts/consumer-init.sh", + []string{ + fmt.Sprintf("%s:%s", consumerVal1Dir, consumerVal1HomePath), + fmt.Sprintf("%s:%s", scriptPath, "/scripts/consumer-init.sh"), + }, + []string{ + "BINARY=" + consumerBinary, + "HOME_DIR=" + consumerVal1HomePath, + "CHAIN_ID=" + consumerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }, + ) // Patch consumer genesis with provider's consumer-genesis data on the host genesisFile := filepath.Join(consumerDir, "config", "genesis.json") err = patchConsumerGenesisWithProviderData(genesisFile, consumerGenesisJSON) s.Require().NoError(err, "failed to patch consumer genesis") + // Copy the patched genesis to val1 directory + s.Require().NoError(copyFile( + filepath.Join(consumerDir, "config", "genesis.json"), + filepath.Join(consumerVal1Dir, "config", "genesis.json"), + )) + // Patch consumer slashing params for aggressive downtime detection s.patchConsumerSlashingParams() + // Also patch val1's genesis (same params) + genesisVal1File := filepath.Join(consumerVal1Dir, "config", "genesis.json") + s.patchGenesisJSON(genesisVal1File, func(genesis map[string]any) { + appState, ok := genesis["app_state"].(map[string]any) + if !ok { + return + } + slashing, ok := appState["slashing"].(map[string]any) + if !ok { + slashing = make(map[string]any) + } + params, ok := slashing["params"].(map[string]any) + if !ok { + params = make(map[string]any) + } + params["signed_blocks_window"] = "5" + params["min_signed_per_window"] = "0.050000000000000000" + params["slash_fraction_downtime"] = "0.000000000000000000" + params["downtime_jail_duration"] = "60s" + slashing["params"] = params + appState["slashing"] = slashing + }) // Copy validator keys from provider to consumer + // Node 0 gets val's key, node 1 gets val2's key providerDir := s.provider.dataDir + providerVal1Dir := s.providerVal1.dataDir + err = copyFile( filepath.Join(providerDir, "config", "priv_validator_key.json"), filepath.Join(consumerDir, "config", "priv_validator_key.json"), ) - s.Require().NoError(err, "failed to copy priv_validator_key.json") + s.Require().NoError(err, "failed to copy priv_validator_key.json for val0") err = copyFile( filepath.Join(providerDir, "config", "node_key.json"), filepath.Join(consumerDir, "config", "node_key.json"), ) - s.Require().NoError(err, "failed to copy node_key.json") + s.Require().NoError(err, "failed to copy node_key.json for val0") - // Start the actual consumer container - s.T().Log("starting consumer chain container...") + err = copyFile( + filepath.Join(providerVal1Dir, "config", "priv_validator_key.json"), + filepath.Join(consumerVal1Dir, "config", "priv_validator_key.json"), + ) + s.Require().NoError(err, "failed to copy priv_validator_key.json for val1") + + err = copyFile( + filepath.Join(providerVal1Dir, "config", "node_key.json"), + filepath.Join(consumerVal1Dir, "config", "node_key.json"), + ) + s.Require().NoError(err, "failed to copy node_key.json for val1") + + // Start consumer val0 container + s.T().Log("starting consumer val0 container...") resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ @@ -429,17 +558,78 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) - s.Require().NoError(err, "failed to start consumer container") + s.Require().NoError(err, "failed to start consumer val0 container") s.consumerValRes = append(s.consumerValRes, resource) - s.T().Logf("consumer container started: %s", resource.Container.ID[:12]) + s.T().Logf("consumer val0 container started: %s", resource.Container.ID[:12]) - // Wait for consumer to produce blocks + // Wait for consumer val0 to produce blocks waitCtx, waitCancel := context.WithTimeout(context.Background(), 2*time.Minute) defer waitCancel() err = s.waitForChainHeight(waitCtx, "http://localhost:26667", 3) s.Require().NoError(err, "consumer failed to produce blocks") - s.T().Log("consumer chain is producing blocks") + s.T().Log("consumer val0 is producing blocks") + + // Get val0's node ID for p2p peering + consumerVal0NodeID := s.getNodeID(consumerDir) + + // Start consumer val1 container with persistent peer to val0 + s.T().Log("starting consumer val1 container...") + + val1Resource, err := s.dkrPool.RunWithOptions( + &dockertest.RunOptions{ + Name: fmt.Sprintf("%s-val1", consumerChainID), + Repository: e2eChainImage, + NetworkID: s.dkrNet.Network.ID, + Mounts: []string{ + fmt.Sprintf("%s:%s", consumerVal1Dir, consumerVal1HomePath), + }, + Cmd: []string{ + consumerBinary, "start", + "--home", consumerVal1HomePath, + "--p2p.persistent-peers", fmt.Sprintf("%s@%s-val0:26656", consumerVal0NodeID, consumerChainID), + }, + }, + func(config *docker.HostConfig) { + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }, + ) + s.Require().NoError(err, "failed to start consumer val1 container") + + s.consumerValRes = append(s.consumerValRes, val1Resource) + s.T().Logf("consumer val1 container started: %s", val1Resource.Container.ID[:12]) + + time.Sleep(5 * time.Second) + s.T().Log("consumer chain is running with 2 validators") +} + +// getNodeID reads the node ID from a node_key.json file in the given data directory. +func (s *IntegrationTestSuite) getNodeID(dataDir string) string { + nodeKeyPath := filepath.Join(dataDir, "config", "node_key.json") + bz, err := os.ReadFile(nodeKeyPath) + s.Require().NoError(err, "failed to read node_key.json") + + var nodeKey struct { + PrivKey struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"priv_key"` + } + s.Require().NoError(json.Unmarshal(bz, &nodeKey), "failed to unmarshal node_key.json") + + // For CometBFT, the node ID is the hex-encoded SHA256 hash of the ed25519 public key + // derived from the private key. We need to compute it. + privKeyBytes, err := base64.StdEncoding.DecodeString(nodeKey.PrivKey.Value) + s.Require().NoError(err, "failed to decode node private key") + + // The priv_key.value is base64 of the raw ed25519 private key bytes + // The public key is derived from the last 32 bytes of the 64-byte private key + var privKey [64]byte + copy(privKey[:], privKeyBytes) + pubKey := privKey[32:] + + hash := sha256.Sum256(pubKey) + return hex.EncodeToString(hash[:])[:40] } // setupTSRelayer starts the ts-relayer container, configures it with @@ -481,9 +671,3 @@ func (s *IntegrationTestSuite) waitForChainHeight(ctx context.Context, rpcEndpoi } } } - -// chmodRecursive changes permissions on a directory recursively. -func chmodRecursive(path string, mode os.FileMode) error { - cmd := exec.Command("chmod", "-R", fmt.Sprintf("%o", mode), path) - return cmd.Run() -} diff --git a/tests/e2e/e2e_vaas_test.go b/tests/e2e/e2e_vaas_test.go index ffde8a7..7ee4c54 100644 --- a/tests/e2e/e2e_vaas_test.go +++ b/tests/e2e/e2e_vaas_test.go @@ -77,7 +77,7 @@ func (s *IntegrationTestSuite) testValidatorSetSync() { providerPubKeys, providerVP := s.extractPubKeys(providerVals) consumerPubKeys, consumerVP := s.extractPubKeys(consumerVals) - s.Require().Equal(len(providerPubKeys), 1) + s.Require().Equal(2, len(providerPubKeys)) s.Require().Equal(len(providerPubKeys), len(consumerPubKeys)) s.Require().Equal(providerPubKeys[0], consumerPubKeys[0]) s.Require().Equal(providerVP[0], consumerVP[0]) diff --git a/tests/e2e/scripts/provider-init.sh b/tests/e2e/scripts/provider-init.sh index 137a6e9..5fee3bc 100755 --- a/tests/e2e/scripts/provider-init.sh +++ b/tests/e2e/scripts/provider-init.sh @@ -3,35 +3,63 @@ # This script is mounted into the provider init container and executed # as the entrypoint. It initializes the chain, adds keys, and configures # the node for e2e testing. +# +# It creates 2 validators (val, val2) with separate consensus keys. +# Node 0's keys are in HOME_DIR, node 1's keys are in HOME_DIR-val1. set -e BINARY="${BINARY:-provider}" HOME_DIR="${HOME_DIR:-/home/nonroot/.provider}" +HOME_DIR_VAL1="${HOME_DIR}-val1" CHAIN_ID="${CHAIN_ID:-provider-e2e}" DENOM="${DENOM:-uatone}" MNEMONIC="${MNEMONIC:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art}" -# Initialize chain +# Initialize node 0 $BINARY init localnet --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$HOME_DIR" +# Initialize node 1 in a temp subdir to get a separate consensus key pair +$BINARY init localnet --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$HOME_DIR_VAL1" + # Configure client $BINARY config set client chain-id "$CHAIN_ID" --home "$HOME_DIR" $BINARY config set client keyring-backend test --home "$HOME_DIR" # Add keys $BINARY keys add val --home "$HOME_DIR" --keyring-backend test +$BINARY keys add val2 --home "$HOME_DIR" --keyring-backend test $BINARY keys add user --home "$HOME_DIR" --keyring-backend test echo "$MNEMONIC" | $BINARY keys add relayer --recover --home "$HOME_DIR" --keyring-backend test # Add genesis accounts $BINARY genesis add-genesis-account val "1000000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test +$BINARY genesis add-genesis-account val2 "1000000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test $BINARY genesis add-genesis-account user "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test $BINARY genesis add-genesis-account relayer "100000000${DENOM}" --home "$HOME_DIR" --keyring-backend test -# Create and collect gentx -$BINARY genesis gentx val "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" +# Extract consensus public keys from priv_validator_key.json +VAL0_PUBKEY=$(jq -r '.pub_key.value' "$HOME_DIR/config/priv_validator_key.json") +VAL1_PUBKEY=$(jq -r '.pub_key.value' "$HOME_DIR_VAL1/config/priv_validator_key.json") + +echo "val0 pubkey: $VAL0_PUBKEY" +echo "val1 pubkey: $VAL1_PUBKEY" + +# Create gentxs with explicit consensus pubkeys +$BINARY genesis gentx val "1000000000${DENOM}" \ + --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" \ + --pubkey "{\"@type\":\"/cosmos.crypto.ed25519.PubKey\",\"key\":\"$VAL0_PUBKEY\"}" \ + --output-document "$HOME_DIR/config/gentx-val.json" + +$BINARY genesis gentx val2 "1000000000${DENOM}" \ + --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" \ + --pubkey "{\"@type\":\"/cosmos.crypto.ed25519.PubKey\",\"key\":\"$VAL1_PUBKEY\"}" \ + --output-document "$HOME_DIR/config/gentx-val2.json" + $BINARY genesis collect-gentxs --home "$HOME_DIR" +# Copy shared genesis to node 1 +cp "$HOME_DIR/config/genesis.json" "$HOME_DIR_VAL1/config/genesis.json" + # Enable REST API $BINARY config set app api.enable true --home "$HOME_DIR" @@ -47,6 +75,11 @@ sed -i 's#address = "tcp://localhost:1317"#address = "tcp://0.0.0.0:1317"#g' "$H # Bind gRPC to all interfaces sed -i 's#address = "localhost:9090"#address = "0.0.0.0:9090"#g' "$HOME_DIR/config/app.toml" +# Also configure node 1 +sed -i "s#^minimum-gas-prices = .*#minimum-gas-prices = \"0.01${DENOM}\"#g" "$HOME_DIR_VAL1/config/app.toml" + +# Make all files writable find "$HOME_DIR" -mindepth 1 -exec chmod 777 {} + +find "$HOME_DIR_VAL1" -mindepth 1 -exec chmod 777 {} + echo "Provider init complete." From 4f5bcb45fec16eaa0588a9985256da536675b1dc Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 21 May 2026 12:02:13 +0200 Subject: [PATCH 15/15] Update provider-init.sh --- tests/e2e/scripts/provider-init.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/e2e/scripts/provider-init.sh b/tests/e2e/scripts/provider-init.sh index 5fee3bc..6d83dfb 100755 --- a/tests/e2e/scripts/provider-init.sh +++ b/tests/e2e/scripts/provider-init.sh @@ -44,16 +44,18 @@ VAL1_PUBKEY=$(jq -r '.pub_key.value' "$HOME_DIR_VAL1/config/priv_validator_key.j echo "val0 pubkey: $VAL0_PUBKEY" echo "val1 pubkey: $VAL1_PUBKEY" -# Create gentxs with explicit consensus pubkeys +# Create gentx directory and generate gentxs with explicit consensus pubkeys +mkdir -p "$HOME_DIR/config/gentx" + $BINARY genesis gentx val "1000000000${DENOM}" \ --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" \ --pubkey "{\"@type\":\"/cosmos.crypto.ed25519.PubKey\",\"key\":\"$VAL0_PUBKEY\"}" \ - --output-document "$HOME_DIR/config/gentx-val.json" + --output-document "$HOME_DIR/config/gentx/gentx-val.json" $BINARY genesis gentx val2 "1000000000${DENOM}" \ --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" \ --pubkey "{\"@type\":\"/cosmos.crypto.ed25519.PubKey\",\"key\":\"$VAL1_PUBKEY\"}" \ - --output-document "$HOME_DIR/config/gentx-val2.json" + --output-document "$HOME_DIR/config/gentx/gentx-val2.json" $BINARY genesis collect-gentxs --home "$HOME_DIR"