From f76ccf242fb084023980e237478a23668f2fb648 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 28 May 2026 13:07:53 +0200 Subject: [PATCH 1/7] feat: slash provider for downtime --- README.md | 23 +-- app/consumer/app.go | 1 + app/provider/app.go | 9 +- proto/vaas/v1/wire.proto | 13 ++ tests/e2e/e2e_downtime_slash_test.go | 112 +++++++++++++++ tests/e2e/e2e_setup_test.go | 19 +-- tests/e2e/e2e_test.go | 5 +- tests/e2e/e2e_tsrelayer_test.go | 2 +- tests/e2e/genesis_test.go | 10 +- tests/e2e/http_util_test.go | 2 +- tests/e2e/testdata/create_consumer.json | 12 ++ testutil/keeper/mocks.go | 15 ++ testutil/keeper/unit_test_helpers.go | 22 ++- x/vaas/consumer/ibc_module.go | 21 ++- x/vaas/consumer/keeper/keeper.go | 14 +- x/vaas/consumer/keeper/slash_packet.go | 131 ++++++++++++++++++ x/vaas/consumer/keeper/validators.go | 51 +++++-- x/vaas/consumer/keeper/validators_test.go | 43 +++++- x/vaas/consumer/module.go | 7 +- x/vaas/consumer/types/keys.go | 1 + x/vaas/provider/ibc_module.go | 63 ++++++++- .../provider/keeper/consumer_equivocation.go | 77 +++++++++- .../keeper/consumer_equivocation_test.go | 118 +++++++++++++++- x/vaas/provider/keeper/grpc_query_test.go | 3 +- .../keeper/ibc_v2_integration_test.go | 8 +- x/vaas/provider/keeper/keeper.go | 7 +- x/vaas/provider/keeper/relay_test.go | 15 +- x/vaas/provider/types/provider.go | 12 +- x/vaas/types/expected_keepers.go | 1 + x/vaas/types/wire.go | 46 ++++++ 30 files changed, 771 insertions(+), 92 deletions(-) create mode 100644 tests/e2e/e2e_downtime_slash_test.go create mode 100644 x/vaas/consumer/keeper/slash_packet.go diff --git a/README.md b/README.md index 8bad2fa..8650649 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,17 @@ and [`app/consumer/app.go`](app/consumer/app.go) for reference. ### Kept from ICS -| Feature | Description | -| ------------------------ | ----------------------------------------------------------------------------------- | -| Consumer Lifecycle | Full lifecycle management (REGISTERED → INITIALIZED → LAUNCHED → STOPPED → DELETED) | -| Key Assignment | Validators can use different consensus keys per consumer chain | -| Slashing Parameters | Uses provider defaults; per-consumer customization removed | -| VSC Packets | Validator set updates sent at epoch boundaries | -| Double Voting Evidence | Handle double voting evidence from consumers | -| 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 | +| Feature | Description | +| ---------------------------------- | ----------------------------------------------------------------------------------- | +| Consumer Lifecycle | Full lifecycle management (REGISTERED → INITIALIZED → LAUNCHED → STOPPED → DELETED) | +| Key Assignment | Validators can use different consensus keys per consumer chain | +| 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 | ### Removed from ICS @@ -42,7 +43,7 @@ and [`app/consumer/app.go`](app/consumer/app.go) for reference. | 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 | Not currently supported (future work) | diff --git a/app/consumer/app.go b/app/consumer/app.go index b1c1af3..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, 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/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/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go new file mode 100644 index 0000000..73f1578 --- /dev/null +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -0,0 +1,112 @@ +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") + + 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 process downtime evidence from consumer...") + s.Require().Eventuallyf(func() bool { + tokensAfter, err := s.getProviderValidatorTokensByAddr(valoperAddr) + if err != nil { + return false + } + return tokensAfter.LT(tokensBefore) + }, + 3*time.Minute, + 5*time.Second, + "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") + }) +} + +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 +} + +// 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/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 b176c90..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,7 +7,5 @@ func (s *IntegrationTestSuite) TestVAAS() { s.testProviderOnConsumer() s.testValidatorSetSync() s.testConsumerDebtFlow() - // Run last: stops the provider container and replaces it with a fresh - // one started from the exported genesis. - s.testGenesisRoundTrip() + 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 diff --git a/tests/e2e/testdata/create_consumer.json b/tests/e2e/testdata/create_consumer.json index 488d5d7..48aeb1c 100644 --- a/tests/e2e/testdata/create_consumer.json +++ b/tests/e2e/testdata/create_consumer.json @@ -16,5 +16,17 @@ "unbonding_period": 1728000000000000, "vaas_timeout_period": 2419200000000000, "historical_entries": 10000 + }, + "infraction_parameters": { + "double_sign": { + "slash_fraction": "0.05", + "jail_duration": 9223372036854775807, + "tombstone": true + }, + "downtime": { + "slash_fraction": "0.0001", + "jail_duration": 600000000000, + "tombstone": false + } } } 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/testutil/keeper/unit_test_helpers.go b/testutil/keeper/unit_test_helpers.go index 4c00289..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" @@ -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,6 +103,7 @@ func NewInMemProviderKeeper(params InMemKeeperParams, mocks MockedKeepers) provi storeService, mocks.MockClientKeeper, mocks.MockClientV2Keeper, + mocks.MockChannelV2Keeper, mocks.MockStakingKeeper, mocks.MockSlashingKeeper, mocks.MockAccountKeeper, @@ -121,6 +125,7 @@ func NewInMemConsumerKeeper(params InMemKeeperParams, mocks MockedKeepers) consu storeService, mocks.MockClientKeeper, mocks.MockClientV2Keeper, + mocks.MockChannelV2Keeper, mocks.MockSlashingKeeper, mocks.MockBankKeeper, mocks.MockAccountKeeper, @@ -141,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/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..504afdd 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,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] } // NewKeeper creates a new Consumer Keeper instance @@ -71,6 +73,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, @@ -83,6 +86,7 @@ func NewKeeper( cdc: cdc, clientKeeper: clientKeeper, clientV2Keeper: clientV2Keeper, + channelKeeperV2: channelKeeperV2, slashingKeeper: slashingKeeper, bankKeeper: bankKeeper, authKeeper: accountKeeper, @@ -105,6 +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), } schema, err := sb.Build() @@ -144,6 +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), } schema, err := sb.Build() diff --git a/x/vaas/consumer/keeper/slash_packet.go b/x/vaas/consumer/keeper/slash_packet.go new file mode 100644 index 0000000..27b6eb4 --- /dev/null +++ b/x/vaas/consumer/keeper/slash_packet.go @@ -0,0 +1,131 @@ +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. +// 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.EvidencePacketData) error { + bz, err := json.Marshal(&packet) + if err != nil { + return fmt.Errorf("failed to marshal slash packet: %w", err) + } + + if err := k.PendingSlashPackets.Set(ctx, packet.ValidatorAddr, 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 { + providerClientID, found := k.GetProviderClientID(ctx) + if !found { + 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() + + if !iter.Valid() { + return nil + } + + var keysToDelete [][]byte + for ; iter.Valid(); iter.Next() { + kv, err := iter.KeyValue() + if err != nil { + continue + } + + 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) + 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..08ef31c 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,51 @@ 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. +// 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) - // 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 { + 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.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(), + "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..87809a8 100644 --- a/x/vaas/consumer/keeper/validators_test.go +++ b/x/vaas/consumer/keeper/validators_test.go @@ -135,13 +135,46 @@ 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)) +} + +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 diff --git a/x/vaas/consumer/module.go b/x/vaas/consumer/module.go index 53278a3..5f5512c 100644 --- a/x/vaas/consumer/module.go +++ b/x/vaas/consumer/module.go @@ -161,10 +161,15 @@ 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) + // 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 diff --git a/x/vaas/consumer/types/keys.go b/x/vaas/consumer/types/keys.go index 68505ca..2bd1eec 100644 --- a/x/vaas/consumer/types/keys.go +++ b/x/vaas/consumer/types/keys.go @@ -34,4 +34,5 @@ var ( ParametersPrefix = collections.NewPrefix(11) HighestValsetUpdateIDPrefix = collections.NewPrefix(12) ConsumerDebtPrefix = collections.NewPrefix(13) + PendingSlashPacketsPrefix = collections.NewPrefix(14) ) diff --git a/x/vaas/provider/ibc_module.go b/x/vaas/provider/ibc_module.go index 2ecc60e..e869b25 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,68 @@ 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, + } + } + + // 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{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + 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{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + if err := im.keeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket); err != nil { + logger.Error("failed to handle evidence packet", + "consumerId", consumerId, + "error", err, + ) + return channeltypesv2.RecvPacketResult{ + Status: channeltypesv2.PacketStatus_Failure, + } + } + + logger.Info("successfully handled evidence packet", + "consumerId", consumerId, "sequence", sequence, + "validator", evidencePacket.ValidatorAddr.String(), + "infraction", evidencePacket.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 0f3dc67..ca3218a 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -80,7 +80,7 @@ func (k Keeper) HandleConsumerDoubleVoting( } alreadyTombstoned := false - 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 { // Make repeated (already-processed) evidence submissions idempotent. if errors.Is(err, slashingtypes.ErrValidatorTombstoned) { alreadyTombstoned = true @@ -88,6 +88,7 @@ func (k Keeper) HandleConsumerDoubleVoting( return err } } + if !alreadyTombstoned { if err = k.JailAndTombstoneValidator(ctx, providerAddr, infractionParams.DoubleSign); err != nil { if errors.Is(err, slashingtypes.ErrValidatorTombstoned) { @@ -175,6 +176,76 @@ func (k Keeper) VerifyDoubleVotingEvidence( return nil } +// +// Consumer-initiated slashing section +// + +// 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 uint64, evidencePacket vaastypes.EvidencePacketData) 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 { + return errorsmod.Wrapf( + vaastypes.ErrInvalidConsumerState, + "consumer chain %d is not launched (phase: %s)", + consumerId, + k.GetConsumerPhase(ctx, consumerId), + ) + } + + switch evidencePacket.Infraction { + case stakingtypes.Infraction_INFRACTION_DOWNTIME: + return k.HandleConsumerDowntime(ctx, consumerId, evidencePacket) + default: + 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. +// 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 uint64, evidencePacket vaastypes.EvidencePacketData) error { + consumerAddr := types.NewConsumerConsAddress(evidencePacket.ValidatorAddr) + + providerAddr := k.GetProviderAddrFromConsumerAddr(ctx, consumerId, consumerAddr) + + infractionParams, err := types.DefaultConsumerInfractionParameters(ctx, k.slashingKeeper) + if err != nil { + 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, + "consumerAddr", consumerAddr.String(), + "providerAddr", providerAddr.String(), + "infractionHeight", evidencePacket.InfractionHeight, + ) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + vaastypes.EventTypeExecuteConsumerChainSlash, + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(types.AttributeConsumerId, fmt.Sprintf("%d", consumerId)), + sdk.NewAttribute(vaastypes.AttributeProviderValidatorAddress, providerAddr.String()), + sdk.NewAttribute(vaastypes.AttributeInfractionHeight, fmt.Sprintf("%d", evidencePacket.InfractionHeight)), + sdk.NewAttribute(vaastypes.AttributeInfractionType, stakingtypes.Infraction_INFRACTION_DOWNTIME.String()), + ), + ) + + return nil +} + // // Light Client Attack (IBC misbehavior) section // @@ -513,7 +584,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()) @@ -555,6 +626,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 5e9803a..3d29bd5 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,115 @@ func getTestInfractionParameters() *types.InfractionParameters { }, } } + +func TestHandleConsumerEvidencePacket(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(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() + + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress(consAddr), + 100, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + valAddr, _ := providerKeeper.ValidatorAddressCodec().StringToBytes(validator.GetOperator()) + + expectedCalls := []any{ + 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), + 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.LegacyNewDecWithPrec(5, 2), stakingtypes.Infraction_INFRACTION_DOWNTIME). + Return(math.NewInt(0), nil), + } + + gomock.InOrder(expectedCalls...) + err = providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.NoError(t, err) +} + +func TestHandleConsumerEvidencePacketRejectsDoubleSign(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(0) + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, + stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN, + ) + + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.Error(t, err) +} + +func TestHandleConsumerEvidencePacketRejectsNonLaunchedConsumer(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(0) + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_REGISTERED) + + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.Error(t, err) +} + +func TestSlashPacketDataJSONRoundTrip(t *testing.T) { + addr := sdk.ConsAddress([]byte{0x01, 0x02, 0x03, 0x04, 0x05}) + packet := vaastypes.NewEvidencePacketData(addr, 42, stakingtypes.Infraction_INFRACTION_DOWNTIME) + + bz := packet.GetBytes() + + var decoded vaastypes.EvidencePacketData + 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/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 52d8176..11dff43 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 := uint64(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/keeper.go b/x/vaas/provider/keeper/keeper.go index a3db49e..8a5c9a7 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/provider/keeper/relay_test.go b/x/vaas/provider/keeper/relay_test.go index c1eeac5..d76275a 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 := uint64(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) } 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 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..e7f7798 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 } + +// EvidencePacketData is sent from a consumer chain to the provider chain +// to report a validator infraction (e.g., downtime) detected on the consumer. +type EvidencePacketData struct { + ValidatorAddr sdk.ConsAddress `json:"validator_addr"` + InfractionHeight int64 `json:"infraction_height"` + Infraction stakingtypes.Infraction `json:"infraction"` +} + +// 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 EvidencePacketData is invalid. +func (spd EvidencePacketData) 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 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 EvidencePacketData: %v", err)) + } + return bz +} From 3655dd13d484c8847e9da1d1b4e54f26ef8c5dc1 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 28 May 2026 13:20:23 +0200 Subject: [PATCH 2/7] fix test --- tests/e2e/e2e_test.go | 3 +++ x/vaas/provider/keeper/msg_server_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 1562ffc..692fba6 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -8,4 +8,7 @@ func (s *IntegrationTestSuite) TestVAAS() { s.testValidatorSetSync() s.testConsumerDebtFlow() s.testDowntimeSlash() + // Run last: stops the provider container and replaces it with a fresh + // one started from the exported genesis. + s.testGenesisRoundTrip() } diff --git a/x/vaas/provider/keeper/msg_server_test.go b/x/vaas/provider/keeper/msg_server_test.go index 375d6ec..ea8383f 100644 --- a/x/vaas/provider/keeper/msg_server_test.go +++ b/x/vaas/provider/keeper/msg_server_test.go @@ -324,8 +324,8 @@ func TestSubmitConsumerDoubleVotingHappyPath(t *testing.T) { // DefaultConsumerInfractionParameters reads both fractions even though only // DoubleSign is used for this evidence type. - mocks.MockSlashingKeeper.EXPECT().DowntimeJailDuration(ctx).Return(10*time.Minute, nil).Times(1) mocks.MockSlashingKeeper.EXPECT().SlashFractionDoubleSign(ctx).Return(math.LegacyNewDecWithPrec(5, 2), nil).Times(1) + mocks.MockSlashingKeeper.EXPECT().SlashFractionDowntime(ctx).Return(math.LegacyNewDecWithPrec(5, 2), nil).Times(1) // SlashValidator path. mocks.MockStakingKeeper.EXPECT().GetValidatorByConsAddr(ctx, consAddr).Return(stakingValidator, nil).Times(1) From 8d11fec98e44bae131dbb5e0585de05cc73eae79 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 28 May 2026 16:09:11 +0200 Subject: [PATCH 3/7] fix downtime test --- tests/e2e/e2e_downtime_slash_test.go | 44 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go index 73f1578..5d0c5fe 100644 --- a/tests/e2e/e2e_downtime_slash_test.go +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -10,7 +10,7 @@ import ( ) func (s *IntegrationTestSuite) testDowntimeSlash() { - s.Run("downtime slash", func() { + s.Run("no downtime slash, consumer down", func() { valoperAddr, tokensBefore := s.getProviderValidatorTokens() s.Require().False(tokensBefore.IsZero(), "validator should have tokens before downtime test") @@ -33,11 +33,11 @@ func (s *IntegrationTestSuite) testDowntimeSlash() { if err != nil { return false } - return tokensAfter.LT(tokensBefore) + return tokensAfter.Equal(tokensBefore) }, 3*time.Minute, 5*time.Second, - "validator tokens were not slashed on provider after consumer downtime (before: %s, valoper: %s)", + "validator tokens were incorrectly slashed during whole consumer chain downtime (before: %s, valoper: %s)", tokensBefore.String(), valoperAddr, ) @@ -45,6 +45,44 @@ func (s *IntegrationTestSuite) testDowntimeSlash() { jailed = s.isProviderValidatorJailed() s.Require().False(jailed, "validator should not be jailed after downtime slash") }) + + 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") + + 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") + + // TODO: create multiple validators in e2e test and only shutdown one validator on the consumer chain. + _ = valoperAddr + // s.T().Log("waiting for provider to process downtime evidence from consumer...") + // s.Require().Eventuallyf(func() bool { + // tokensAfter, err := s.getProviderValidatorTokensByAddr(valoperAddr) + // if err != nil { + // return false + // } + // return tokensAfter.LT(tokensBefore) + // }, + // 3*time.Minute, + // 5*time.Second, + // "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") + }) } func (s *IntegrationTestSuite) patchConsumerSlashingParams() { From 598f50598bdf932b8faee6430d521f8d4a885c49 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 28 May 2026 20:42:18 +0200 Subject: [PATCH 4/7] harden evidence checks --- .../provider/keeper/consumer_equivocation.go | 68 ++++++- .../keeper/consumer_equivocation_test.go | 176 +++++++++++++++++- 2 files changed, 236 insertions(+), 8 deletions(-) diff --git a/x/vaas/provider/keeper/consumer_equivocation.go b/x/vaas/provider/keeper/consumer_equivocation.go index ca3218a..e8f4764 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -204,22 +204,80 @@ func (k Keeper) HandleConsumerEvidencePacket(ctx sdk.Context, consumerId uint64, } } -// HandleConsumerDowntime slashes and jails a validator that was offline on a consumer chain. -// CONTRACT: A downtime infraction must be verified by the provider before slashing is applied. +// HandleConsumerDowntime slashes a validator that was offline on a consumer chain. +// The provider verifies the downtime claim by checking: +// 1. The infraction height is not older than the minimum evidence height for this consumer. +// 2. The provider's IBC client for the consumer has a consensus state at the infraction height, +// proving the consumer chain actually reached that height. +// 3. The validator was part of the consumer's validator set at the time of the infraction. +// // CONTRACT: A downtime infraction must never jail a validator. func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId uint64, evidencePacket vaastypes.EvidencePacketData) error { consumerAddr := types.NewConsumerConsAddress(evidencePacket.ValidatorAddr) providerAddr := k.GetProviderAddrFromConsumerAddr(ctx, consumerId, consumerAddr) + // Verify the infraction height is not too old. + minHeight := k.GetEquivocationEvidenceMinHeight(ctx, consumerId) + if uint64(evidencePacket.InfractionHeight) < minHeight { + return errorsmod.Wrapf( + vaastypes.ErrInvalidPacketData, + "downtime evidence for consumer chain %d is too old: infraction height (%d), min (%d)", + consumerId, + evidencePacket.InfractionHeight, + minHeight, + ) + } + + // Verify the provider's IBC client has a consensus state at the infraction height. + // This proves the consumer chain actually reached this height. + clientId, found := k.GetConsumerClientId(ctx, consumerId) + if !found { + return errorsmod.Wrapf( + vaastypes.ErrInvalidConsumerState, + "no IBC client found for consumer chain %d", + consumerId, + ) + } + + consensusHeight := ibcclienttypes.NewHeight(0, uint64(evidencePacket.InfractionHeight)) + if _, ok := k.clientKeeper.GetClientConsensusState(ctx, clientId, consensusHeight); !ok { + return errorsmod.Wrapf( + vaastypes.ErrInvalidPacketData, + "no consensus state for consumer chain %d at infraction height %d: cannot verify downtime", + consumerId, + evidencePacket.InfractionHeight, + ) + } + + // Verify the validator was part of the consumer's validator set. + validator, found := k.GetConsumerValidator(ctx, consumerId, providerAddr) + if !found { + return errorsmod.Wrapf( + vaastypes.ErrInvalidPacketData, + "validator %s is not in the validator set of consumer chain %d", + providerAddr.String(), + consumerId, + ) + } + + // Check that the infraction occurred after the validator joined the consumer set. + if evidencePacket.InfractionHeight < validator.JoinHeight { + return errorsmod.Wrapf( + vaastypes.ErrInvalidPacketData, + "downtime infraction height %d is before validator %s joined consumer chain %d at height %d", + evidencePacket.InfractionHeight, + providerAddr.String(), + consumerId, + validator.JoinHeight, + ) + } + infractionParams, err := types.DefaultConsumerInfractionParameters(ctx, k.slashingKeeper) if err != nil { 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 } diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index 3d29bd5..f10c928 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -19,6 +19,10 @@ import ( tmtypes "github.com/cometbft/cometbft/types" + ibcclienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + ibcexported "github.com/cosmos/ibc-go/v10/modules/core/exported" + ibctmtypes "github.com/cosmos/ibc-go/v10/modules/light-clients/07-tendermint" + cryptotestutil "github.com/allinbits/vaas/testutil/crypto" testkeeper "github.com/allinbits/vaas/testutil/keeper" "github.com/allinbits/vaas/x/vaas/provider/types" @@ -831,6 +835,8 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { consumerId := uint64(0) providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) providerKeeper.SetConsumerChainId(ctx, consumerId, "consumer-chain") + providerKeeper.SetConsumerClientId(ctx, consumerId, "07-tendermint-0") + providerKeeper.SetEquivocationEvidenceMinHeight(ctx, consumerId, 1) pubKey, _ := cryptocodec.FromCmtPubKeyInterface(tmtypes.NewMockPV().PrivKey.PubKey()) validator, err := stakingtypes.NewValidator( @@ -842,6 +848,17 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { validator.Status = stakingtypes.Bonded consAddr, _ := validator.GetConsAddr() + // Add the validator to the consumer's validator set with a join height of 1 + providerAddr := types.NewProviderConsAddress(consAddr) + cmtPubKey, _ := validator.CmtConsPublicKey() + err = providerKeeper.SetConsumerValidator(ctx, consumerId, types.ConsensusValidator{ + ProviderConsAddr: consAddr, + Power: 1000, + PublicKey: &cmtPubKey, + JoinHeight: 1, + }) + require.NoError(t, err) + evidencePacket := vaastypes.NewEvidencePacketData( sdk.ConsAddress(consAddr), 100, @@ -851,6 +868,9 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { valAddr, _ := providerKeeper.ValidatorAddressCodec().StringToBytes(validator.GetOperator()) expectedCalls := []any{ + mocks.MockClientKeeper.EXPECT(). + GetClientConsensusState(ctx, "07-tendermint-0", ibcclienttypes.NewHeight(0, 100)). + Return(ibcexported.ConsensusState(&ibctmtypes.ConsensusState{}), true), mocks.MockSlashingKeeper.EXPECT(). SlashFractionDoubleSign(ctx). Return(math.LegacyNewDecWithPrec(5, 1), nil), @@ -858,10 +878,10 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { SlashFractionDowntime(ctx). Return(math.LegacyNewDecWithPrec(5, 2), nil), mocks.MockStakingKeeper.EXPECT(). - GetValidatorByConsAddr(ctx, gomock.Any()). + GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()). Return(validator, nil), mocks.MockSlashingKeeper.EXPECT(). - IsTombstoned(ctx, gomock.Any()). + IsTombstoned(ctx, providerAddr.ToSdkConsAddr()). Return(false), mocks.MockStakingKeeper.EXPECT(). GetUnbondingDelegationsFromValidator(ctx, valAddr). @@ -876,7 +896,7 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { PowerReduction(ctx). Return(math.NewInt(1000000)), mocks.MockStakingKeeper.EXPECT(). - SlashWithInfractionReason(ctx, gomock.Any(), int64(0), int64(1000), math.LegacyNewDecWithPrec(5, 2), stakingtypes.Infraction_INFRACTION_DOWNTIME). + SlashWithInfractionReason(ctx, providerAddr.ToSdkConsAddr(), int64(0), int64(1000), math.LegacyNewDecWithPrec(5, 2), stakingtypes.Infraction_INFRACTION_DOWNTIME). Return(math.NewInt(0), nil), } @@ -885,6 +905,156 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { require.NoError(t, err) } +func TestHandleConsumerDowntimeRejectsTooOldEvidence(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(0) + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + providerKeeper.SetConsumerChainId(ctx, consumerId, "consumer-chain") + providerKeeper.SetEquivocationEvidenceMinHeight(ctx, consumerId, 200) + + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, // below min height of 200 + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.Error(t, err) + require.Contains(t, err.Error(), "too old") +} + +func TestHandleConsumerDowntimeRejectsNoClient(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(0) + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + providerKeeper.SetConsumerChainId(ctx, consumerId, "consumer-chain") + providerKeeper.SetEquivocationEvidenceMinHeight(ctx, consumerId, 1) + + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.Error(t, err) + require.Contains(t, err.Error(), "no IBC client found") +} + +func TestHandleConsumerDowntimeRejectsNoConsensusState(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(0) + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + providerKeeper.SetConsumerChainId(ctx, consumerId, "consumer-chain") + providerKeeper.SetConsumerClientId(ctx, consumerId, "07-tendermint-0") + providerKeeper.SetEquivocationEvidenceMinHeight(ctx, consumerId, 1) + + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + expectedCalls := []any{ + mocks.MockClientKeeper.EXPECT(). + GetClientConsensusState(ctx, "07-tendermint-0", ibcclienttypes.NewHeight(0, 100)). + Return(ibcexported.ConsensusState(nil), false), + } + + gomock.InOrder(expectedCalls...) + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.Error(t, err) + require.Contains(t, err.Error(), "no consensus state") +} + +func TestHandleConsumerDowntimeRejectsValidatorNotInSet(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(0) + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + providerKeeper.SetConsumerChainId(ctx, consumerId, "consumer-chain") + providerKeeper.SetConsumerClientId(ctx, consumerId, "07-tendermint-0") + providerKeeper.SetEquivocationEvidenceMinHeight(ctx, consumerId, 1) + + // Use a validator that is NOT in the consumer's validator set + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress([]byte{0x01, 0x02, 0x03}), + 100, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + expectedCalls := []any{ + mocks.MockClientKeeper.EXPECT(). + GetClientConsensusState(ctx, "07-tendermint-0", ibcclienttypes.NewHeight(0, 100)). + Return(ibcexported.ConsensusState(&ibctmtypes.ConsensusState{}), true), + } + + gomock.InOrder(expectedCalls...) + err := providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.Error(t, err) + require.Contains(t, err.Error(), "not in the validator set") +} + +func TestHandleConsumerDowntimeRejectsInfractionBeforeJoin(t *testing.T) { + keeperParams := testkeeper.NewInMemKeeperParams(t) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) + defer ctrl.Finish() + + consumerId := uint64(0) + providerKeeper.SetConsumerPhase(ctx, consumerId, types.CONSUMER_PHASE_LAUNCHED) + providerKeeper.SetConsumerChainId(ctx, consumerId, "consumer-chain") + providerKeeper.SetConsumerClientId(ctx, consumerId, "07-tendermint-0") + providerKeeper.SetEquivocationEvidenceMinHeight(ctx, consumerId, 1) + + pubKey, _ := cryptocodec.FromCmtPubKeyInterface(tmtypes.NewMockPV().PrivKey.PubKey()) + validator, err := stakingtypes.NewValidator( + sdk.ValAddress(pubKey.Address()).String(), + pubKey, + stakingtypes.NewDescription("", "", "", "", ""), + ) + require.NoError(t, err) + consAddr, _ := validator.GetConsAddr() + + cmtPubKey, _ := validator.CmtConsPublicKey() + + // Add the validator with join height 200, but claim downtime at height 100 + err = providerKeeper.SetConsumerValidator(ctx, consumerId, types.ConsensusValidator{ + ProviderConsAddr: consAddr, + Power: 1000, + PublicKey: &cmtPubKey, + JoinHeight: 200, + }) + require.NoError(t, err) + + evidencePacket := vaastypes.NewEvidencePacketData( + sdk.ConsAddress(consAddr), + 100, // before join height of 200 + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + expectedCalls := []any{ + mocks.MockClientKeeper.EXPECT(). + GetClientConsensusState(ctx, "07-tendermint-0", ibcclienttypes.NewHeight(0, 100)). + Return(ibcexported.ConsensusState(&ibctmtypes.ConsensusState{}), true), + } + + gomock.InOrder(expectedCalls...) + err = providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + require.Error(t, err) + require.Contains(t, err.Error(), "before validator") +} + func TestHandleConsumerEvidencePacketRejectsDoubleSign(t *testing.T) { keeperParams := testkeeper.NewInMemKeeperParams(t) providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, keeperParams) From aa82de405ca97288e70c0a9a956605715af67ceb Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 28 May 2026 20:46:01 +0200 Subject: [PATCH 5/7] add custom provider module downtime param instead provider downtime val --- x/vaas/provider/keeper/consumer_equivocation_test.go | 7 ++----- x/vaas/provider/keeper/msg_server_test.go | 5 ++--- x/vaas/provider/types/provider.go | 11 ++++------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index f10c928..dd55893 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -821,7 +821,7 @@ func getTestInfractionParameters() *types.InfractionParameters { }, Downtime: &types.SlashJailParameters{ JailDuration: 600 * time.Second, - SlashFraction: math.LegacyNewDec(0), + SlashFraction: math.LegacyNewDecWithPrec(5, 4), Tombstone: false, }, } @@ -874,9 +874,6 @@ 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, providerAddr.ToSdkConsAddr()). Return(validator, nil), @@ -896,7 +893,7 @@ func TestHandleConsumerEvidencePacket(t *testing.T) { PowerReduction(ctx). Return(math.NewInt(1000000)), mocks.MockStakingKeeper.EXPECT(). - SlashWithInfractionReason(ctx, providerAddr.ToSdkConsAddr(), int64(0), int64(1000), math.LegacyNewDecWithPrec(5, 2), stakingtypes.Infraction_INFRACTION_DOWNTIME). + SlashWithInfractionReason(ctx, providerAddr.ToSdkConsAddr(), int64(0), int64(1000), math.LegacyNewDecWithPrec(5, 4), stakingtypes.Infraction_INFRACTION_DOWNTIME). Return(math.NewInt(0), nil), } diff --git a/x/vaas/provider/keeper/msg_server_test.go b/x/vaas/provider/keeper/msg_server_test.go index ea8383f..cddfa0c 100644 --- a/x/vaas/provider/keeper/msg_server_test.go +++ b/x/vaas/provider/keeper/msg_server_test.go @@ -322,10 +322,9 @@ func TestSubmitConsumerDoubleVotingHappyPath(t *testing.T) { valOperBytes, err := providerKeeper.ValidatorAddressCodec().StringToBytes(stakingValidator.GetOperator()) require.NoError(t, err) - // DefaultConsumerInfractionParameters reads both fractions even though only - // DoubleSign is used for this evidence type. + // DefaultConsumerInfractionParameters only reads the double-sign fraction + // (downtime uses a fixed default). mocks.MockSlashingKeeper.EXPECT().SlashFractionDoubleSign(ctx).Return(math.LegacyNewDecWithPrec(5, 2), nil).Times(1) - mocks.MockSlashingKeeper.EXPECT().SlashFractionDowntime(ctx).Return(math.LegacyNewDecWithPrec(5, 2), nil).Times(1) // SlashValidator path. mocks.MockStakingKeeper.EXPECT().GetValidatorByConsAddr(ctx, consAddr).Return(stakingValidator, nil).Times(1) diff --git a/x/vaas/provider/types/provider.go b/x/vaas/provider/types/provider.go index bacf31c..d4cf5b1 100644 --- a/x/vaas/provider/types/provider.go +++ b/x/vaas/provider/types/provider.go @@ -6,6 +6,8 @@ import ( vaastypes "github.com/allinbits/vaas/x/vaas/types" + "cosmossdk.io/math" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" ) @@ -30,20 +32,15 @@ func DefaultConsumerInfractionParameters(ctx context.Context, slashingKeeper vaa return InfractionParameters{}, err } - downtimeSlashingFraction, err := slashingKeeper.SlashFractionDowntime(ctx) - if err != nil { - return InfractionParameters{}, err - } - return InfractionParameters{ DoubleSign: &SlashJailParameters{ JailDuration: time.Duration(1<<63 - 1), SlashFraction: doubleSignSlashingFraction, Tombstone: true, }, - Downtime: &SlashJailParameters{ + Downtime: &SlashJailParameters{ JailDuration: 0, - SlashFraction: downtimeSlashingFraction, + SlashFraction: math.LegacyNewDecWithPrec(5, 4), Tombstone: false, }, }, nil From add055566661e70bc6833fef9fa3692c0960e540 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 28 May 2026 20:53:29 +0200 Subject: [PATCH 6/7] lint --- x/vaas/provider/keeper/consumer_equivocation.go | 8 ++++---- x/vaas/provider/types/provider.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/x/vaas/provider/keeper/consumer_equivocation.go b/x/vaas/provider/keeper/consumer_equivocation.go index e8f4764..d6add39 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -206,10 +206,10 @@ func (k Keeper) HandleConsumerEvidencePacket(ctx sdk.Context, consumerId uint64, // HandleConsumerDowntime slashes a validator that was offline on a consumer chain. // The provider verifies the downtime claim by checking: -// 1. The infraction height is not older than the minimum evidence height for this consumer. -// 2. The provider's IBC client for the consumer has a consensus state at the infraction height, -// proving the consumer chain actually reached that height. -// 3. The validator was part of the consumer's validator set at the time of the infraction. +// 1. The infraction height is not older than the minimum evidence height for this consumer. +// 2. The provider's IBC client for the consumer has a consensus state at the infraction height, +// proving the consumer chain actually reached that height. +// 3. The validator was part of the consumer's validator set at the time of the infraction. // // CONTRACT: A downtime infraction must never jail a validator. func (k Keeper) HandleConsumerDowntime(ctx sdk.Context, consumerId uint64, evidencePacket vaastypes.EvidencePacketData) error { diff --git a/x/vaas/provider/types/provider.go b/x/vaas/provider/types/provider.go index d4cf5b1..fc38df0 100644 --- a/x/vaas/provider/types/provider.go +++ b/x/vaas/provider/types/provider.go @@ -6,9 +6,9 @@ import ( vaastypes "github.com/allinbits/vaas/x/vaas/types" - "cosmossdk.io/math" - clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + + "cosmossdk.io/math" ) func DefaultConsumerInitializationParameters() ConsumerInitializationParameters { @@ -38,7 +38,7 @@ func DefaultConsumerInfractionParameters(ctx context.Context, slashingKeeper vaa SlashFraction: doubleSignSlashingFraction, Tombstone: true, }, - Downtime: &SlashJailParameters{ + Downtime: &SlashJailParameters{ JailDuration: 0, SlashFraction: math.LegacyNewDecWithPrec(5, 4), Tombstone: false, From 2b8ed28a66b5e351bb60d565b1cfaa35fa2b8e27 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 3 Jun 2026 10:14:51 +0200 Subject: [PATCH 7/7] feedback --- proto/vaas/v1/wire.proto | 12 -- tests/e2e/e2e_downtime_slash_test.go | 44 +----- testutil/keeper/mocks.go | 15 -- x/vaas/consumer/keeper/evidence_packet.go | 132 ++++++++++++++++++ x/vaas/consumer/keeper/keeper.go | 84 +++++------ x/vaas/consumer/keeper/slash_packet.go | 131 ----------------- x/vaas/consumer/keeper/validators.go | 20 +-- x/vaas/consumer/keeper/validators_test.go | 10 +- x/vaas/consumer/module.go | 8 +- x/vaas/consumer/types/events.go | 2 +- x/vaas/consumer/types/keys.go | 30 ++-- .../keeper/consumer_equivocation_test.go | 2 +- x/vaas/provider/keeper/grpc_query_test.go | 1 - x/vaas/types/events.go | 2 +- x/vaas/types/expected_keepers.go | 1 - x/vaas/types/wire.go | 2 +- 16 files changed, 217 insertions(+), 279 deletions(-) create mode 100644 x/vaas/consumer/keeper/evidence_packet.go delete mode 100644 x/vaas/consumer/keeper/slash_packet.go diff --git a/proto/vaas/v1/wire.proto b/proto/vaas/v1/wire.proto index b0f6885..946e99f 100644 --- a/proto/vaas/v1/wire.proto +++ b/proto/vaas/v1/wire.proto @@ -40,15 +40,3 @@ 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/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go index 5d0c5fe..4e251b2 100644 --- a/tests/e2e/e2e_downtime_slash_test.go +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -41,48 +41,14 @@ func (s *IntegrationTestSuite) testDowntimeSlash() { tokensBefore.String(), valoperAddr, ) - s.T().Log("verifying validator was not jailed after downtime slash...") + s.T().Log("verifying validator was not jailed after downtime evidence...") jailed = s.isProviderValidatorJailed() - s.Require().False(jailed, "validator should not be jailed after downtime slash") + s.Require().False(jailed, "validator should not be jailed after downtime evidence") }) - 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") - - 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") - - // TODO: create multiple validators in e2e test and only shutdown one validator on the consumer chain. - _ = valoperAddr - // s.T().Log("waiting for provider to process downtime evidence from consumer...") - // s.Require().Eventuallyf(func() bool { - // tokensAfter, err := s.getProviderValidatorTokensByAddr(valoperAddr) - // if err != nil { - // return false - // } - // return tokensAfter.LT(tokensBefore) - // }, - // 3*time.Minute, - // 5*time.Second, - // "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") - }) + // TODO: add individual validator downtime slash test once multiple validators + // are supported in e2e. The test should pause a single validator on the + // consumer chain and verify the provider slashes and jails that validator. } func (s *IntegrationTestSuite) patchConsumerSlashingParams() { diff --git a/testutil/keeper/mocks.go b/testutil/keeper/mocks.go index a09c3c8..0f38214 100644 --- a/testutil/keeper/mocks.go +++ b/testutil/keeper/mocks.go @@ -456,21 +456,6 @@ 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/keeper/evidence_packet.go b/x/vaas/consumer/keeper/evidence_packet.go new file mode 100644 index 0000000..fc4cf83 --- /dev/null +++ b/x/vaas/consumer/keeper/evidence_packet.go @@ -0,0 +1,132 @@ +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" +) + +// QueueEvidencePacket queues an evidence 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) QueueEvidencePacket(ctx sdk.Context, packet vaastypes.EvidencePacketData) error { + bz, err := json.Marshal(&packet) + if err != nil { + return fmt.Errorf("failed to marshal evidence packet: %w", err) + } + + if err := k.PendingEvidencePackets.Set(ctx, packet.ValidatorAddr, bz); err != nil { + return fmt.Errorf("failed to store evidence packet: %w", err) + } + + return nil +} + +// SendEvidencePackets sends all pending evidence packets to the provider chain. +func (k Keeper) SendEvidencePackets(ctx sdk.Context) error { + providerClientID, found := k.GetProviderClientID(ctx) + if !found { + return nil + } + + iter, err := k.PendingEvidencePackets.Iterate(ctx, nil) + if err != nil { + return fmt.Errorf("failed to iterate pending evidence packets: %w", err) + } + defer iter.Close() + + if !iter.Valid() { + return nil + } + + var keysToDelete [][]byte + for ; iter.Valid(); iter.Next() { + kv, err := iter.KeyValue() + if err != nil { + continue + } + + var evidencePacket vaastypes.EvidencePacketData + if err := json.Unmarshal(kv.Value, &evidencePacket); err != nil { + k.Logger(ctx).Error("failed to unmarshal evidence packet", "error", err) + keysToDelete = append(keysToDelete, kv.Key) + continue + } + + // kv.Value is already the JSON-serialised evidence packet, use it directly. + payload := channeltypesv2.NewPayload( + vaastypes.ConsumerAppID, + vaastypes.ProviderAppID, + "vaas-v1", + "application/json", + kv.Value, + ) + + 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 evidence packet", + "error", err, + "validator", evidencePacket.ValidatorAddr.String(), + ) + continue + } + + k.Logger(ctx).Info("evidence packet sent", + "sequence", resp.Sequence, + "validator", evidencePacket.ValidatorAddr.String(), + "infraction", evidencePacket.Infraction.String(), + "infraction_height", evidencePacket.InfractionHeight, + ) + + keysToDelete = append(keysToDelete, kv.Key) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + vaastypes.EventTypeConsumerEvidenceRequest, + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(vaastypes.AttributeValidatorAddress, evidencePacket.ValidatorAddr.String()), + sdk.NewAttribute(vaastypes.AttributeInfractionHeight, fmt.Sprintf("%d", evidencePacket.InfractionHeight)), + sdk.NewAttribute(vaastypes.AttributeInfractionType, evidencePacket.Infraction.String()), + ), + ) + } + + for _, key := range keysToDelete { + if err := k.PendingEvidencePackets.Remove(ctx, key); err != nil { + k.Logger(ctx).Error("failed to delete sent evidence packet", "error", err) + } + } + + return nil +} + +// GetPendingEvidencePacketCount returns the number of pending evidence packets. +func (k Keeper) GetPendingEvidencePacketCount(ctx sdk.Context) int { + iter, err := k.PendingEvidencePackets.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/keeper.go b/x/vaas/consumer/keeper/keeper.go index 504afdd..bb62e1e 100644 --- a/x/vaas/consumer/keeper/keeper.go +++ b/x/vaas/consumer/keeper/keeper.go @@ -50,20 +50,20 @@ type Keeper struct { Schema collections.Schema // State collections - Port collections.Item[string] - ProviderClientID collections.Item[string] - PendingChanges collections.Item[vaastypes.ValidatorSetChangePacketData] - InitGenesisHeight collections.Item[uint64] - PreVAAS collections.Item[uint64] - InitialValSet collections.Item[types.GenesisState] - Params collections.Item[vaastypes.ConsumerParams] - ConsumerInDebt collections.Item[bool] - PrevStandaloneChain collections.Item[[]byte] - HeightValsetUpdateIDs collections.Map[uint64, uint64] - CrossChainValidators collections.Map[[]byte, types.CrossChainValidator] - HistoricalInfos collections.Map[int64, stakingtypes.HistoricalInfo] - HighestValsetUpdateID collections.Item[uint64] - PendingSlashPackets collections.Map[[]byte, []byte] + Port collections.Item[string] + ProviderClientID collections.Item[string] + PendingChanges collections.Item[vaastypes.ValidatorSetChangePacketData] + InitGenesisHeight collections.Item[uint64] + PreVAAS collections.Item[uint64] + InitialValSet collections.Item[types.GenesisState] + Params collections.Item[vaastypes.ConsumerParams] + ConsumerInDebt collections.Item[bool] + PrevStandaloneChain collections.Item[[]byte] + HeightValsetUpdateIDs collections.Map[uint64, uint64] + CrossChainValidators collections.Map[[]byte, types.CrossChainValidator] + HistoricalInfos collections.Map[int64, stakingtypes.HistoricalInfo] + HighestValsetUpdateID collections.Item[uint64] + PendingEvidencePackets collections.Map[[]byte, []byte] } // NewKeeper creates a new Consumer Keeper instance @@ -96,20 +96,20 @@ func NewKeeper( consensusAddressCodec: consensusAddressCodec, // Initialize collections - Port: collections.NewItem(sb, types.PortPrefix, "port", collections.StringValue), - ProviderClientID: collections.NewItem(sb, types.ProviderClientIDPrefix, "provider_client_id", collections.StringValue), - PendingChanges: collections.NewItem(sb, types.PendingChangesPrefix, "pending_changes", codec.CollValue[vaastypes.ValidatorSetChangePacketData](cdc)), - InitGenesisHeight: collections.NewItem(sb, types.InitGenesisHeightPrefix, "init_genesis_height", collections.Uint64Value), - PreVAAS: collections.NewItem(sb, types.PreVAASPrefix, "pre_vaas", collections.Uint64Value), - InitialValSet: collections.NewItem(sb, types.InitialValSetPrefix, "initial_val_set", codec.CollValue[types.GenesisState](cdc)), - Params: collections.NewItem(sb, types.ParametersPrefix, "params", codec.CollValue[vaastypes.ConsumerParams](cdc)), - ConsumerInDebt: collections.NewItem(sb, types.ConsumerDebtPrefix, "consumer_in_debt", collections.BoolValue), - PrevStandaloneChain: collections.NewItem(sb, types.PrevStandaloneChainPrefix, "prev_standalone_chain", collections.BytesValue), - HeightValsetUpdateIDs: collections.NewMap(sb, types.HeightValsetUpdateIDPrefix, "height_valset_update_ids", collections.Uint64Key, collections.Uint64Value), - 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), + Port: collections.NewItem(sb, types.PortPrefix, "port", collections.StringValue), + ProviderClientID: collections.NewItem(sb, types.ProviderClientIDPrefix, "provider_client_id", collections.StringValue), + PendingChanges: collections.NewItem(sb, types.PendingChangesPrefix, "pending_changes", codec.CollValue[vaastypes.ValidatorSetChangePacketData](cdc)), + InitGenesisHeight: collections.NewItem(sb, types.InitGenesisHeightPrefix, "init_genesis_height", collections.Uint64Value), + PreVAAS: collections.NewItem(sb, types.PreVAASPrefix, "pre_vaas", collections.Uint64Value), + InitialValSet: collections.NewItem(sb, types.InitialValSetPrefix, "initial_val_set", codec.CollValue[types.GenesisState](cdc)), + Params: collections.NewItem(sb, types.ParametersPrefix, "params", codec.CollValue[vaastypes.ConsumerParams](cdc)), + ConsumerInDebt: collections.NewItem(sb, types.ConsumerDebtPrefix, "consumer_in_debt", collections.BoolValue), + PrevStandaloneChain: collections.NewItem(sb, types.PrevStandaloneChainPrefix, "prev_standalone_chain", collections.BytesValue), + HeightValsetUpdateIDs: collections.NewMap(sb, types.HeightValsetUpdateIDPrefix, "height_valset_update_ids", collections.Uint64Key, collections.Uint64Value), + 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), + PendingEvidencePackets: collections.NewMap(sb, types.PendingEvidencePacketsPrefix, "pending_evidence_packets", collections.BytesKey, collections.BytesValue), } schema, err := sb.Build() @@ -136,20 +136,20 @@ func NewNonZeroKeeper(cdc codec.BinaryCodec, storeService corestoretypes.KVStore cdc: cdc, // Initialize collections with minimal setup for testing - Port: collections.NewItem(sb, types.PortPrefix, "port", collections.StringValue), - ProviderClientID: collections.NewItem(sb, types.ProviderClientIDPrefix, "provider_client_id", collections.StringValue), - PendingChanges: collections.NewItem(sb, types.PendingChangesPrefix, "pending_changes", codec.CollValue[vaastypes.ValidatorSetChangePacketData](cdc)), - InitGenesisHeight: collections.NewItem(sb, types.InitGenesisHeightPrefix, "init_genesis_height", collections.Uint64Value), - PreVAAS: collections.NewItem(sb, types.PreVAASPrefix, "pre_vaas", collections.Uint64Value), - InitialValSet: collections.NewItem(sb, types.InitialValSetPrefix, "initial_val_set", codec.CollValue[types.GenesisState](cdc)), - Params: collections.NewItem(sb, types.ParametersPrefix, "params", codec.CollValue[vaastypes.ConsumerParams](cdc)), - ConsumerInDebt: collections.NewItem(sb, types.ConsumerDebtPrefix, "consumer_in_debt", collections.BoolValue), - PrevStandaloneChain: collections.NewItem(sb, types.PrevStandaloneChainPrefix, "prev_standalone_chain", collections.BytesValue), - HeightValsetUpdateIDs: collections.NewMap(sb, types.HeightValsetUpdateIDPrefix, "height_valset_update_ids", collections.Uint64Key, collections.Uint64Value), - 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), + Port: collections.NewItem(sb, types.PortPrefix, "port", collections.StringValue), + ProviderClientID: collections.NewItem(sb, types.ProviderClientIDPrefix, "provider_client_id", collections.StringValue), + PendingChanges: collections.NewItem(sb, types.PendingChangesPrefix, "pending_changes", codec.CollValue[vaastypes.ValidatorSetChangePacketData](cdc)), + InitGenesisHeight: collections.NewItem(sb, types.InitGenesisHeightPrefix, "init_genesis_height", collections.Uint64Value), + PreVAAS: collections.NewItem(sb, types.PreVAASPrefix, "pre_vaas", collections.Uint64Value), + InitialValSet: collections.NewItem(sb, types.InitialValSetPrefix, "initial_val_set", codec.CollValue[types.GenesisState](cdc)), + Params: collections.NewItem(sb, types.ParametersPrefix, "params", codec.CollValue[vaastypes.ConsumerParams](cdc)), + ConsumerInDebt: collections.NewItem(sb, types.ConsumerDebtPrefix, "consumer_in_debt", collections.BoolValue), + PrevStandaloneChain: collections.NewItem(sb, types.PrevStandaloneChainPrefix, "prev_standalone_chain", collections.BytesValue), + HeightValsetUpdateIDs: collections.NewMap(sb, types.HeightValsetUpdateIDPrefix, "height_valset_update_ids", collections.Uint64Key, collections.Uint64Value), + 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), + PendingEvidencePackets: collections.NewMap(sb, types.PendingEvidencePacketsPrefix, "pending_evidence_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 deleted file mode 100644 index 27b6eb4..0000000 --- a/x/vaas/consumer/keeper/slash_packet.go +++ /dev/null @@ -1,131 +0,0 @@ -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. -// 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.EvidencePacketData) error { - bz, err := json.Marshal(&packet) - if err != nil { - return fmt.Errorf("failed to marshal slash packet: %w", err) - } - - if err := k.PendingSlashPackets.Set(ctx, packet.ValidatorAddr, 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 { - providerClientID, found := k.GetProviderClientID(ctx) - if !found { - 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() - - if !iter.Valid() { - return nil - } - - var keysToDelete [][]byte - for ; iter.Valid(); iter.Next() { - kv, err := iter.KeyValue() - if err != nil { - continue - } - - 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) - 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 08ef31c..60f89d7 100644 --- a/x/vaas/consumer/keeper/validators.go +++ b/x/vaas/consumer/keeper/validators.go @@ -112,40 +112,40 @@ func (k Keeper) Slash(ctx context.Context, addr sdk.ConsAddress, infractionHeigh return k.SlashWithInfractionReason(ctx, addr, infractionHeight, power, slashFactor, stakingtypes.Infraction_INFRACTION_UNSPECIFIED) } -// SlashWithInfractionReason queues a slash packet for downtime infractions +// SlashWithInfractionReason queues an evidence 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. +// Only one evidence packet is sent per downtime incident — if the validator already has a pending +// evidence 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) + has, err := k.PendingEvidencePackets.Has(ctx, addr) if err != nil { - k.Logger(ctx).Error("failed to check pending slash packet", + k.Logger(ctx).Error("failed to check pending evidence packet", "validator", addr.String(), "error", err, ) return math.ZeroInt(), nil } if has { - k.Logger(ctx).Debug("skipping duplicate downtime slash packet", + k.Logger(ctx).Debug("skipping duplicate downtime evidence packet", "validator", addr.String(), "infraction_height", infractionHeight, ) return math.ZeroInt(), nil } - slashPacket := vaastypes.NewEvidencePacketData(addr, infractionHeight, infraction) - if err := k.QueueSlashPacket(ctx, slashPacket); err != nil { - k.Logger(ctx).Error("failed to queue downtime slash packet", + evidencePacket := vaastypes.NewEvidencePacketData(addr, infractionHeight, infraction) + if err := k.QueueEvidencePacket(ctx, evidencePacket); err != nil { + k.Logger(ctx).Error("failed to queue downtime evidence packet", "validator", addr.String(), "infraction_height", infractionHeight, "error", err, ) return math.ZeroInt(), nil } - k.Logger(ctx).Info("queued downtime slash packet", + k.Logger(ctx).Info("queued downtime evidence packet", "validator", addr.String(), "infraction_height", infractionHeight, ) diff --git a/x/vaas/consumer/keeper/validators_test.go b/x/vaas/consumer/keeper/validators_test.go index 87809a8..b05eef7 100644 --- a/x/vaas/consumer/keeper/validators_test.go +++ b/x/vaas/consumer/keeper/validators_test.go @@ -141,14 +141,14 @@ func TestSlash(t *testing.T) { require.NoError(t, err) require.True(t, slashed.IsZero()) - require.Equal(t, 1, consumerKeeper.GetPendingSlashPacketCount(ctx)) + require.Equal(t, 1, consumerKeeper.GetPendingEvidencePacketCount(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)) + require.Equal(t, 1, consumerKeeper.GetPendingEvidencePacketCount(ctx)) } func TestSlashSkipsDuplicateDowntime(t *testing.T) { @@ -161,20 +161,20 @@ func TestSlashSkipsDuplicateDowntime(t *testing.T) { 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)) + require.Equal(t, 1, consumerKeeper.GetPendingEvidencePacketCount(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)) + require.Equal(t, 1, consumerKeeper.GetPendingEvidencePacketCount(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)) + require.Equal(t, 2, consumerKeeper.GetPendingEvidencePacketCount(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 5f5512c..4fc4828 100644 --- a/x/vaas/consumer/module.go +++ b/x/vaas/consumer/module.go @@ -161,13 +161,13 @@ func (am AppModule) BeginBlock(goCtx context.Context) error { } // EndBlock implements the AppModule interface -// Flush PendingChanges to ABCI and send pending slash packets. +// Flush PendingChanges to ABCI and send pending evidence packets. 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) + // send any queued evidence packets to the provider every block + if err := am.keeper.SendEvidencePackets(ctx); err != nil { + am.keeper.Logger(ctx).Error("failed to send evidence packets", "error", err) } data, ok := am.keeper.GetPendingChanges(ctx) diff --git a/x/vaas/consumer/types/events.go b/x/vaas/consumer/types/events.go index 7487306..1c9cb62 100644 --- a/x/vaas/consumer/types/events.go +++ b/x/vaas/consumer/types/events.go @@ -1,5 +1,5 @@ package types const ( - EventTypeConsumerSlashRequest = "consumer_slash_request" + EventTypeConsumerEvidenceRequest = "consumer_evidence_request" ) diff --git a/x/vaas/consumer/types/keys.go b/x/vaas/consumer/types/keys.go index 2bd1eec..df51b4f 100644 --- a/x/vaas/consumer/types/keys.go +++ b/x/vaas/consumer/types/keys.go @@ -20,19 +20,19 @@ const ( // Collection key prefixes for use with cosmossdk.io/collections var ( - PortPrefix = collections.NewPrefix(0) - UnbondingTimePrefix = collections.NewPrefix(1) - ProviderClientIDPrefix = collections.NewPrefix(2) - PendingChangesPrefix = collections.NewPrefix(3) - PreVAASPrefix = collections.NewPrefix(4) - InitialValSetPrefix = collections.NewPrefix(5) - HistoricalInfoPrefix = collections.NewPrefix(6) - HeightValsetUpdateIDPrefix = collections.NewPrefix(7) - CrossChainValidatorPrefix = collections.NewPrefix(8) - InitGenesisHeightPrefix = collections.NewPrefix(9) - PrevStandaloneChainPrefix = collections.NewPrefix(10) - ParametersPrefix = collections.NewPrefix(11) - HighestValsetUpdateIDPrefix = collections.NewPrefix(12) - ConsumerDebtPrefix = collections.NewPrefix(13) - PendingSlashPacketsPrefix = collections.NewPrefix(14) + PortPrefix = collections.NewPrefix(0) + UnbondingTimePrefix = collections.NewPrefix(1) + ProviderClientIDPrefix = collections.NewPrefix(2) + PendingChangesPrefix = collections.NewPrefix(3) + PreVAASPrefix = collections.NewPrefix(4) + InitialValSetPrefix = collections.NewPrefix(5) + HistoricalInfoPrefix = collections.NewPrefix(6) + HeightValsetUpdateIDPrefix = collections.NewPrefix(7) + CrossChainValidatorPrefix = collections.NewPrefix(8) + InitGenesisHeightPrefix = collections.NewPrefix(9) + PrevStandaloneChainPrefix = collections.NewPrefix(10) + ParametersPrefix = collections.NewPrefix(11) + HighestValsetUpdateIDPrefix = collections.NewPrefix(12) + ConsumerDebtPrefix = collections.NewPrefix(13) + PendingEvidencePacketsPrefix = collections.NewPrefix(14) ) diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index dd55893..e1ccff0 100644 --- a/x/vaas/provider/keeper/consumer_equivocation_test.go +++ b/x/vaas/provider/keeper/consumer_equivocation_test.go @@ -1088,7 +1088,7 @@ func TestHandleConsumerEvidencePacketRejectsNonLaunchedConsumer(t *testing.T) { require.Error(t, err) } -func TestSlashPacketDataJSONRoundTrip(t *testing.T) { +func TestEvidencePacketDataJSONRoundTrip(t *testing.T) { addr := sdk.ConsAddress([]byte{0x01, 0x02, 0x03, 0x04, 0x05}) packet := vaastypes.NewEvidencePacketData(addr, 42, stakingtypes.Infraction_INFRACTION_DOWNTIME) diff --git a/x/vaas/provider/keeper/grpc_query_test.go b/x/vaas/provider/keeper/grpc_query_test.go index 75bd6ec..8651075 100644 --- a/x/vaas/provider/keeper/grpc_query_test.go +++ b/x/vaas/provider/keeper/grpc_query_test.go @@ -24,7 +24,6 @@ func TestQueryConsumerChainIncludesFeePoolAddress(t *testing.T) { })) 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/types/events.go b/x/vaas/types/events.go index 11a0c56..34ef5a6 100644 --- a/x/vaas/types/events.go +++ b/x/vaas/types/events.go @@ -10,7 +10,7 @@ const ( EventTypeSubmitConsumerMisbehaviour = "submit_consumer_misbehaviour" EventTypeSubmitConsumerDoubleVoting = "submit_consumer_double_voting" EventTypeExecuteConsumerChainSlash = "execute_consumer_chain_slash" - EventTypeConsumerSlashRequest = "consumer_slash_request" + EventTypeConsumerEvidenceRequest = "consumer_evidence_request" AttributeKeyAckSuccess = "success" AttributeKeyAck = "acknowledgement" diff --git a/x/vaas/types/expected_keepers.go b/x/vaas/types/expected_keepers.go index d61d23c..9f002ba 100644 --- a/x/vaas/types/expected_keepers.go +++ b/x/vaas/types/expected_keepers.go @@ -55,7 +55,6 @@ 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 e7f7798..fa74dde 100644 --- a/x/vaas/types/wire.go +++ b/x/vaas/types/wire.go @@ -64,7 +64,7 @@ func (spd EvidencePacketData) Validate() error { 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 fmt.Errorf("only DOWNTIME infractions can be sent as evidence packets, got %s", spd.Infraction) } return nil }