From f76ccf242fb084023980e237478a23668f2fb648 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 28 May 2026 13:07:53 +0200 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 } From d7036e8096e9a0c50734f6ba7671f242f29637f9 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 3 Jun 2026 11:17:13 +0200 Subject: [PATCH 08/11] tests: add multi validators tests setup --- tests/e2e/e2e_downtime_slash_test.go | 52 ++++++- tests/e2e/e2e_setup_test.go | 205 +++++++++++++++++++++++---- tests/e2e/scripts/provider-init.sh | 52 +++++-- 3 files changed, 268 insertions(+), 41 deletions(-) diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go index 4e251b2..f88e5bb 100644 --- a/tests/e2e/e2e_downtime_slash_test.go +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -46,9 +46,55 @@ func (s *IntegrationTestSuite) testDowntimeSlash() { s.Require().False(jailed, "validator should not be jailed after downtime evidence") }) - // 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. + s.Run("individual validator downtime slash", func() { + s.Require().GreaterOrEqual(len(s.consumerValRes), 2, + "need at least 2 consumer validator containers for individual downtime test") + + 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") + + // Pause only consumer val1 (the second validator node). Consumer val0 + // keeps running so the chain continues producing blocks while val1 is + // offline, triggering downtime detection on the consumer. + s.T().Log("pausing consumer val1 container to simulate individual validator downtime...") + err := s.dkrPool.Client.PauseContainer(s.consumerValRes[1].Container.ID) + s.Require().NoError(err, "failed to pause consumer val1 container") + + // Wait long enough for the consumer's slashing module to detect the + // downtime (signed_blocks_window=5, min_signed_per_window=0.05) and + // for the evidence packet to be relayed to the provider. + time.Sleep(30 * time.Second) + + s.T().Log("unpausing consumer val1 container...") + err = s.dkrPool.Client.UnpauseContainer(s.consumerValRes[1].Container.ID) + s.Require().NoError(err, "failed to unpause consumer val1 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 jailed after downtime slash...") + s.Require().Eventuallyf(func() bool { + return s.isProviderValidatorJailed() + }, + 2*time.Minute, + 5*time.Second, + "validator should be jailed after downtime slash", + ) + }) } func (s *IntegrationTestSuite) patchConsumerSlashingParams() { diff --git a/tests/e2e/e2e_setup_test.go b/tests/e2e/e2e_setup_test.go index d1ea1fe..41c664a 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -41,6 +41,9 @@ type IntegrationTestSuite struct { providerValRes []*dockertest.Resource consumerValRes []*dockertest.Resource tsRelayerResource *dockertest.Resource + + providerVal2DataDir string + consumerVal2DataDir string } // makeCodec creates a proto codec with the standard cosmos SDK interfaces registered. @@ -154,8 +157,11 @@ func (s *IntegrationTestSuite) cleanupStaleContainers() { staleNames := []string{ "provider-init", "consumer-init", + "consumer-val2-init", fmt.Sprintf("%s-val0", providerChainID), + fmt.Sprintf("%s-val1", providerChainID), fmt.Sprintf("%s-val0", consumerChainID), + fmt.Sprintf("%s-val1", consumerChainID), fmt.Sprintf("%s-%s-ts-relayer", providerChainID, consumerChainID), } for _, name := range staleNames { @@ -216,26 +222,68 @@ func (s *IntegrationTestSuite) runInitContainer(name, scriptPath, containerScrip } // initAndStartProvider initializes the provider chain using a temporary Docker -// container that runs provider-init.sh, then starts the actual chain container. +// container that runs provider-init.sh, then starts the actual chain containers. func (s *IntegrationTestSuite) initAndStartProvider() { - // Create host directory for provider data + // Create host directory for provider val0 data providerDir, err := os.MkdirTemp("", "vaas-e2e-provider-") s.Require().NoError(err) s.tmpDirs = append(s.tmpDirs, providerDir) s.provider.dataDir = providerDir + // Create host directory for provider val2 data + providerVal2Dir, err := os.MkdirTemp("", "vaas-e2e-provider-val2-") + s.Require().NoError(err) + s.tmpDirs = append(s.tmpDirs, providerVal2Dir) + s.providerVal2DataDir = providerVal2Dir + // Make writable s.Require().NoError(os.Chmod(providerDir, 0o777)) + s.Require().NoError(os.Chmod(providerVal2Dir, 0o777)) - // Run init script in a temporary container + // Run init script in a temporary container with both data dirs mounted scriptPath := filepath.Join(testDir(), "scripts", "provider-init.sh") - s.runInitContainer("provider-init", scriptPath, "/scripts/provider-init.sh", providerDir, providerHomePath, []string{ - "BINARY=" + providerBinary, - "HOME_DIR=" + providerHomePath, - "CHAIN_ID=" + providerChainID, - "DENOM=" + bondDenom, - "MNEMONIC=" + relayerMnemonic, + providerVal2HomePath := providerHomePath + "-val2" + initResource, err := s.dkrPool.RunWithOptions( + &dockertest.RunOptions{ + Name: "provider-init", + Repository: e2eChainImage, + NetworkID: s.dkrNet.Network.ID, + User: "nonroot", + Env: []string{ + "BINARY=" + providerBinary, + "HOME_DIR=" + providerHomePath, + "CHAIN_ID=" + providerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }, + Mounts: []string{ + fmt.Sprintf("%s:%s", providerDir, providerHomePath), + fmt.Sprintf("%s:%s", providerVal2Dir, providerVal2HomePath), + fmt.Sprintf("%s:/scripts/provider-init.sh", scriptPath), + }, + Entrypoint: []string{"sh", "/scripts/provider-init.sh"}, + }, + func(config *docker.HostConfig) { + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }, + ) + s.Require().NoError(err, "failed to start provider-init container") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + exitCode, err := s.dkrPool.Client.WaitContainerWithContext(initResource.Container.ID, ctx) + s.Require().NoError(err, "provider-init container wait failed") + + var logBuf bytes.Buffer + _ = s.dkrPool.Client.Logs(docker.LogsOptions{ + Container: initResource.Container.ID, + OutputStream: &logBuf, + ErrorStream: &logBuf, + Stdout: true, + Stderr: true, }) + s.Require().Equal(0, exitCode, "provider-init exited with code %d\noutput:\n%s", exitCode, logBuf.String()) + s.Require().NoError(s.dkrPool.Purge(initResource), "failed to purge provider-init container") // Modify genesis on the host: set fast voting period and small blocks_per_epoch genesisFile := filepath.Join(providerDir, "config", "genesis.json") @@ -263,10 +311,16 @@ func (s *IntegrationTestSuite) initAndStartProvider() { } }) - // Now start the actual provider container - s.T().Log("starting provider chain container...") + // Copy the patched genesis to val2's data dir + s.Require().NoError( + copyFile(genesisFile, filepath.Join(providerVal2Dir, "config", "genesis.json")), + "failed to copy patched genesis to val2 data dir", + ) + + // Now start the provider val0 container + s.T().Log("starting provider val0 container...") - resource, err := s.dkrPool.RunWithOptions( + val0Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val0", providerChainID), Repository: e2eChainImage, @@ -289,17 +343,53 @@ func (s *IntegrationTestSuite) initAndStartProvider() { config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) - s.Require().NoError(err, "failed to start provider container") + s.Require().NoError(err, "failed to start provider val0 container") - s.providerValRes = append(s.providerValRes, resource) - s.T().Logf("provider container started: %s", resource.Container.ID[:12]) + s.providerValRes = append(s.providerValRes, val0Resource) + s.T().Logf("provider val0 container started: %s", val0Resource.Container.ID[:12]) - // Wait for provider to produce blocks + // Wait for provider val0 to produce blocks waitCtx, waitCancel := context.WithTimeout(context.Background(), 2*time.Minute) defer waitCancel() err = s.waitForChainHeight(waitCtx, "http://localhost:26657", 3) s.Require().NoError(err, "provider failed to produce blocks") s.T().Log("provider chain is producing blocks") + + // Start provider val1 (second validator node) connecting to val0 as persistent peer + s.T().Log("starting provider val1 container...") + + val0P2PAddr := fmt.Sprintf("%s:26656", val0Resource.Container.Name) + val1Resource, err := s.dkrPool.RunWithOptions( + &dockertest.RunOptions{ + Name: fmt.Sprintf("%s-val1", providerChainID), + Repository: e2eChainImage, + NetworkID: s.dkrNet.Network.ID, + Mounts: []string{ + fmt.Sprintf("%s:%s", providerVal2Dir, providerVal2HomePath), + }, + PortBindings: map[docker.Port][]docker.PortBinding{ + "26657/tcp": {{HostIP: "", HostPort: "26658"}}, + "9090/tcp": {{HostIP: "", HostPort: "9091"}}, + "1317/tcp": {{HostIP: "", HostPort: "1318"}}, + "26656/tcp": {{HostIP: "", HostPort: "26658"}}, + }, + Cmd: []string{ + providerBinary, "start", + "--home", providerVal2HomePath, + "--p2p.persistent-peers", val0P2PAddr, + }, + }, + func(config *docker.HostConfig) { + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }, + ) + s.Require().NoError(err, "failed to start provider val1 container") + + s.providerValRes = append(s.providerValRes, val1Resource) + s.T().Logf("provider val1 container started: %s", val1Resource.Container.ID[:12]) + + // Wait briefly for val1 to connect and catch up + time.Sleep(5 * time.Second) } // registerConsumerOnProvider creates a consumer chain registration on the provider. @@ -363,7 +453,7 @@ func (s *IntegrationTestSuite) fetchConsumerGenesis() []byte { // initAndStartConsumer initializes the consumer chain and starts it. func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) { - // Create host directory for consumer data + // Create host directory for consumer val0 data consumerDir, err := os.MkdirTemp("", "vaas-e2e-consumer-") s.Require().NoError(err) s.tmpDirs = append(s.tmpDirs, consumerDir) @@ -389,7 +479,7 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) // Patch consumer slashing params for aggressive downtime detection s.patchConsumerSlashingParams() - // Copy validator keys from provider to consumer + // Copy validator keys from provider val0 to consumer val0 providerDir := s.provider.dataDir err = copyFile( filepath.Join(providerDir, "config", "priv_validator_key.json"), @@ -403,10 +493,10 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) ) s.Require().NoError(err, "failed to copy node_key.json") - // Start the actual consumer container - s.T().Log("starting consumer chain container...") + // Start the consumer val0 container + s.T().Log("starting consumer val0 container...") - resource, err := s.dkrPool.RunWithOptions( + consumerVal0Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val0", consumerChainID), Repository: e2eChainImage, @@ -429,10 +519,10 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) - s.Require().NoError(err, "failed to start consumer container") + s.Require().NoError(err, "failed to start consumer val0 container") - s.consumerValRes = append(s.consumerValRes, resource) - s.T().Logf("consumer container started: %s", resource.Container.ID[:12]) + s.consumerValRes = append(s.consumerValRes, consumerVal0Resource) + s.T().Logf("consumer val0 container started: %s", consumerVal0Resource.Container.ID[:12]) // Wait for consumer to produce blocks waitCtx, waitCancel := context.WithTimeout(context.Background(), 2*time.Minute) @@ -440,6 +530,73 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) err = s.waitForChainHeight(waitCtx, "http://localhost:26667", 3) s.Require().NoError(err, "consumer failed to produce blocks") s.T().Log("consumer chain is producing blocks") + + // Create consumer val1 (second validator node with val2's keys) + s.T().Log("setting up consumer val1 node...") + + consumerVal2Dir, err := os.MkdirTemp("", "vaas-e2e-consumer-val2-") + s.Require().NoError(err) + s.tmpDirs = append(s.tmpDirs, consumerVal2Dir) + s.consumerVal2DataDir = consumerVal2Dir + s.Require().NoError(os.Chmod(consumerVal2Dir, 0o777)) + + // Run consumer init for val2's node + consumerVal2HomePath := consumerHomePath + "-val2" + s.runInitContainer("consumer-val2-init", scriptPath, "/scripts/consumer-init.sh", consumerVal2Dir, consumerVal2HomePath, []string{ + "BINARY=" + consumerBinary, + "HOME_DIR=" + consumerVal2HomePath, + "CHAIN_ID=" + consumerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }) + + // Copy the patched genesis from consumer val0 + s.Require().NoError( + copyFile(genesisFile, filepath.Join(consumerVal2Dir, "config", "genesis.json")), + "failed to copy consumer genesis to val2 data dir", + ) + + // Copy val2's validator keys from provider val2 to consumer val2 + s.Require().NoError( + copyFile( + filepath.Join(s.providerVal2DataDir, "config", "priv_validator_key.json"), + filepath.Join(consumerVal2Dir, "config", "priv_validator_key.json"), + ), + "failed to copy val2 priv_validator_key.json to consumer val2", + ) + + // Start consumer val1 connecting to val0 as persistent peer + consumerVal0P2PAddr := fmt.Sprintf("%s:26656", consumerVal0Resource.Container.Name) + consumerVal1Resource, err := s.dkrPool.RunWithOptions( + &dockertest.RunOptions{ + Name: fmt.Sprintf("%s-val1", consumerChainID), + Repository: e2eChainImage, + NetworkID: s.dkrNet.Network.ID, + Mounts: []string{ + fmt.Sprintf("%s:%s", consumerVal2Dir, consumerVal2HomePath), + }, + PortBindings: map[docker.Port][]docker.PortBinding{ + "26657/tcp": {{HostIP: "", HostPort: "26668"}}, + "9090/tcp": {{HostIP: "", HostPort: "9093"}}, + "1317/tcp": {{HostIP: "", HostPort: "1328"}}, + "26656/tcp": {{HostIP: "", HostPort: "26669"}}, + }, + Cmd: []string{ + consumerBinary, "start", + "--home", consumerVal2HomePath, + "--p2p.persistent-peers", consumerVal0P2PAddr, + }, + }, + func(config *docker.HostConfig) { + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }, + ) + s.Require().NoError(err, "failed to start consumer val1 container") + + s.consumerValRes = append(s.consumerValRes, consumerVal1Resource) + s.T().Logf("consumer val1 container started: %s", consumerVal1Resource.Container.ID[:12]) + + time.Sleep(5 * time.Second) } // setupTSRelayer starts the ts-relayer container, configures it with diff --git a/tests/e2e/scripts/provider-init.sh b/tests/e2e/scripts/provider-init.sh index 137a6e9..8a276e7 100755 --- a/tests/e2e/scripts/provider-init.sh +++ b/tests/e2e/scripts/provider-init.sh @@ -7,46 +7,70 @@ set -e BINARY="${BINARY:-provider}" HOME_DIR="${HOME_DIR:-/home/nonroot/.provider}" +VAL2_HOME_DIR="${HOME_DIR}-val2" CHAIN_ID="${CHAIN_ID:-provider-e2e}" DENOM="${DENOM:-uatone}" MNEMONIC="${MNEMONIC:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art}" -# Initialize chain +# Initialize chain (val0) $BINARY init localnet --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$HOME_DIR" # Configure client $BINARY config set client chain-id "$CHAIN_ID" --home "$HOME_DIR" $BINARY config set client keyring-backend test --home "$HOME_DIR" -# Add keys +# Add keys for val0 $BINARY keys add val --home "$HOME_DIR" --keyring-backend test $BINARY keys add user --home "$HOME_DIR" --keyring-backend test echo "$MNEMONIC" | $BINARY keys add relayer --recover --home "$HOME_DIR" --keyring-backend test -# Add genesis accounts +# Add genesis accounts for val0 $BINARY genesis add-genesis-account val "1000000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test $BINARY genesis add-genesis-account user "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test $BINARY genesis add-genesis-account relayer "100000000${DENOM}" --home "$HOME_DIR" --keyring-backend test -# Create and collect gentx +# Initialize val2 node (separate home with its own consensus key) +$BINARY init val2 --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$VAL2_HOME_DIR" +$BINARY keys add val2 --home "$VAL2_HOME_DIR" --keyring-backend test + +# Copy val0's genesis to val2 so both share the same genesis state +cp "$HOME_DIR/config/genesis.json" "$VAL2_HOME_DIR/config/genesis.json" + +# Add val2 genesis account (operates on the shared genesis) +$BINARY genesis add-genesis-account val2 "1000000000000${DENOM}" --home "$VAL2_HOME_DIR" --keyring-backend test + +# Copy updated genesis back to val0 (now includes val2's account) +cp "$VAL2_HOME_DIR/config/genesis.json" "$HOME_DIR/config/genesis.json" + +# Create gentx for val0 $BINARY genesis gentx val "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" + +# Create gentx for val2 +$BINARY genesis gentx val2 "1000000000${DENOM}" --home "$VAL2_HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" + +# Copy val2's gentx to val0's gentx directory +cp "$VAL2_HOME_DIR"/config/gentx/*.json "$HOME_DIR/config/gentx/" + +# Collect all gentxs $BINARY genesis collect-gentxs --home "$HOME_DIR" -# Enable REST API -$BINARY config set app api.enable true --home "$HOME_DIR" +# Copy final genesis to val2 +cp "$HOME_DIR/config/genesis.json" "$VAL2_HOME_DIR/config/genesis.json" -# Set minimum gas prices +# Configure val0 node +$BINARY config set app api.enable true --home "$HOME_DIR" sed -i "s#^minimum-gas-prices = .*#minimum-gas-prices = \"0.01${DENOM}\"#g" "$HOME_DIR/config/app.toml" - -# Bind RPC to all interfaces sed -i 's#laddr = "tcp://127.0.0.1:26657"#laddr = "tcp://0.0.0.0:26657"#g' "$HOME_DIR/config/config.toml" - -# Bind REST API to all interfaces sed -i 's#address = "tcp://localhost:1317"#address = "tcp://0.0.0.0:1317"#g' "$HOME_DIR/config/app.toml" - -# Bind gRPC to all interfaces sed -i 's#address = "localhost:9090"#address = "0.0.0.0:9090"#g' "$HOME_DIR/config/app.toml" +# Configure val2 node +sed -i "s#^minimum-gas-prices = .*#minimum-gas-prices = \"0.01${DENOM}\"#g" "$VAL2_HOME_DIR/config/app.toml" +sed -i 's#laddr = "tcp://127.0.0.1:26657"#laddr = "tcp://0.0.0.0:26657"#g' "$VAL2_HOME_DIR/config/config.toml" +sed -i 's#address = "tcp://localhost:1317"#address = "tcp://0.0.0.0:1317"#g' "$VAL2_HOME_DIR/config/app.toml" +sed -i 's#address = "localhost:9090"#address = "0.0.0.0:9090"#g' "$VAL2_HOME_DIR/config/app.toml" + find "$HOME_DIR" -mindepth 1 -exec chmod 777 {} + +find "$VAL2_HOME_DIR" -mindepth 1 -exec chmod 777 {} + -echo "Provider init complete." +echo "Provider init complete (2 validators)." From e4da663293f68b64ef75338b2ad990925788b889 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 3 Jun 2026 15:56:42 +0200 Subject: [PATCH 09/11] feedback --- tests/e2e/e2e_downtime_slash_test.go | 16 +++- tests/e2e/e2e_setup_test.go | 121 ++++++++++++++++++--------- tests/e2e/scripts/provider-init.sh | 44 +++++----- 3 files changed, 117 insertions(+), 64 deletions(-) diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go index f88e5bb..2101e10 100644 --- a/tests/e2e/e2e_downtime_slash_test.go +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -133,18 +133,26 @@ func (s *IntegrationTestSuite) isProviderValidatorJailed() bool { return false } -// getProviderValidatorTokens returns the first bonded validator's operator address and token amount. +// getProviderValidatorTokens returns the minority bonded validator's operator +// address and token amount (the one with the least tokens). This is the +// validator expected to be slashed when taken offline individually. func (s *IntegrationTestSuite) getProviderValidatorTokens() (string, math.Int) { vals, err := s.queryProviderValidators() if err != nil { return "", math.ZeroInt() } + var minTokens math.Int + var minAddr string for _, v := range vals { - if v.Status == stakingtypes.Bonded { - return v.OperatorAddress, v.Tokens + if v.Status != stakingtypes.Bonded { + continue + } + if minAddr == "" || v.Tokens.LT(minTokens) { + minTokens = v.Tokens + minAddr = v.OperatorAddress } } - return "", math.ZeroInt() + return minAddr, minTokens } // getProviderValidatorTokensByAddr returns the token amount for a specific validator by operator address. diff --git a/tests/e2e/e2e_setup_test.go b/tests/e2e/e2e_setup_test.go index 41c664a..c1224f7 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -3,6 +3,9 @@ package e2e import ( "bytes" "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" "fmt" "os" "os/exec" @@ -42,8 +45,8 @@ type IntegrationTestSuite struct { consumerValRes []*dockertest.Resource tsRelayerResource *dockertest.Resource - providerVal2DataDir string - consumerVal2DataDir string + providerVal1DataDir string + consumerVal1DataDir string } // makeCodec creates a proto codec with the standard cosmos SDK interfaces registered. @@ -157,7 +160,7 @@ func (s *IntegrationTestSuite) cleanupStaleContainers() { staleNames := []string{ "provider-init", "consumer-init", - "consumer-val2-init", + "consumer-val1-init", fmt.Sprintf("%s-val0", providerChainID), fmt.Sprintf("%s-val1", providerChainID), fmt.Sprintf("%s-val0", consumerChainID), @@ -230,19 +233,19 @@ func (s *IntegrationTestSuite) initAndStartProvider() { s.tmpDirs = append(s.tmpDirs, providerDir) s.provider.dataDir = providerDir - // Create host directory for provider val2 data - providerVal2Dir, err := os.MkdirTemp("", "vaas-e2e-provider-val2-") + // Create host directory for provider val1 data + providerVal1Dir, err := os.MkdirTemp("", "vaas-e2e-provider-val1-") s.Require().NoError(err) - s.tmpDirs = append(s.tmpDirs, providerVal2Dir) - s.providerVal2DataDir = providerVal2Dir + s.tmpDirs = append(s.tmpDirs, providerVal1Dir) + s.providerVal1DataDir = providerVal1Dir // Make writable s.Require().NoError(os.Chmod(providerDir, 0o777)) - s.Require().NoError(os.Chmod(providerVal2Dir, 0o777)) + s.Require().NoError(os.Chmod(providerVal1Dir, 0o777)) // Run init script in a temporary container with both data dirs mounted scriptPath := filepath.Join(testDir(), "scripts", "provider-init.sh") - providerVal2HomePath := providerHomePath + "-val2" + providerVal1HomePath := providerHomePath + "-val1" initResource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: "provider-init", @@ -258,7 +261,7 @@ func (s *IntegrationTestSuite) initAndStartProvider() { }, Mounts: []string{ fmt.Sprintf("%s:%s", providerDir, providerHomePath), - fmt.Sprintf("%s:%s", providerVal2Dir, providerVal2HomePath), + fmt.Sprintf("%s:%s", providerVal1Dir, providerVal1HomePath), fmt.Sprintf("%s:/scripts/provider-init.sh", scriptPath), }, Entrypoint: []string{"sh", "/scripts/provider-init.sh"}, @@ -311,10 +314,10 @@ func (s *IntegrationTestSuite) initAndStartProvider() { } }) - // Copy the patched genesis to val2's data dir + // Copy the patched genesis to val1's data dir s.Require().NoError( - copyFile(genesisFile, filepath.Join(providerVal2Dir, "config", "genesis.json")), - "failed to copy patched genesis to val2 data dir", + copyFile(genesisFile, filepath.Join(providerVal1Dir, "config", "genesis.json")), + "failed to copy patched genesis to val1 data dir", ) // Now start the provider val0 container @@ -358,24 +361,26 @@ func (s *IntegrationTestSuite) initAndStartProvider() { // Start provider val1 (second validator node) connecting to val0 as persistent peer s.T().Log("starting provider val1 container...") - val0P2PAddr := fmt.Sprintf("%s:26656", val0Resource.Container.Name) + val0NodeID, err := getNodeID(filepath.Join(providerDir, "config", "node_key.json")) + s.Require().NoError(err, "failed to get val0 node ID") + val0P2PAddr := fmt.Sprintf("%s@%s:26656", val0NodeID, val0Resource.Container.Name[1:]) val1Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val1", providerChainID), Repository: e2eChainImage, NetworkID: s.dkrNet.Network.ID, Mounts: []string{ - fmt.Sprintf("%s:%s", providerVal2Dir, providerVal2HomePath), + fmt.Sprintf("%s:%s", providerVal1Dir, providerVal1HomePath), }, PortBindings: map[docker.Port][]docker.PortBinding{ "26657/tcp": {{HostIP: "", HostPort: "26658"}}, "9090/tcp": {{HostIP: "", HostPort: "9091"}}, "1317/tcp": {{HostIP: "", HostPort: "1318"}}, - "26656/tcp": {{HostIP: "", HostPort: "26658"}}, + "26656/tcp": {{HostIP: "", HostPort: "26659"}}, }, Cmd: []string{ providerBinary, "start", - "--home", providerVal2HomePath, + "--home", providerVal1HomePath, "--p2p.persistent-peers", val0P2PAddr, }, }, @@ -388,8 +393,12 @@ func (s *IntegrationTestSuite) initAndStartProvider() { s.providerValRes = append(s.providerValRes, val1Resource) s.T().Logf("provider val1 container started: %s", val1Resource.Container.ID[:12]) - // Wait briefly for val1 to connect and catch up - time.Sleep(5 * time.Second) + // Wait for provider val1 to catch up + waitCtx1, waitCancel1 := context.WithTimeout(context.Background(), 2*time.Minute) + defer waitCancel1() + err = s.waitForChainHeight(waitCtx1, "http://localhost:26658", 3) + s.Require().NoError(err, "provider val1 failed to catch up") + s.T().Log("provider val1 is caught up") } // registerConsumerOnProvider creates a consumer chain registration on the provider. @@ -531,20 +540,20 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) s.Require().NoError(err, "consumer failed to produce blocks") s.T().Log("consumer chain is producing blocks") - // Create consumer val1 (second validator node with val2's keys) + // Create consumer val1 (second validator node with val1's keys) s.T().Log("setting up consumer val1 node...") - consumerVal2Dir, err := os.MkdirTemp("", "vaas-e2e-consumer-val2-") + consumerVal1Dir, err := os.MkdirTemp("", "vaas-e2e-consumer-val1-") s.Require().NoError(err) - s.tmpDirs = append(s.tmpDirs, consumerVal2Dir) - s.consumerVal2DataDir = consumerVal2Dir - s.Require().NoError(os.Chmod(consumerVal2Dir, 0o777)) + s.tmpDirs = append(s.tmpDirs, consumerVal1Dir) + s.consumerVal1DataDir = consumerVal1Dir + s.Require().NoError(os.Chmod(consumerVal1Dir, 0o777)) - // Run consumer init for val2's node - consumerVal2HomePath := consumerHomePath + "-val2" - s.runInitContainer("consumer-val2-init", scriptPath, "/scripts/consumer-init.sh", consumerVal2Dir, consumerVal2HomePath, []string{ + // Run consumer init for val1's node + consumerVal1HomePath := consumerHomePath + "-val1" + s.runInitContainer("consumer-val1-init", scriptPath, "/scripts/consumer-init.sh", consumerVal1Dir, consumerVal1HomePath, []string{ "BINARY=" + consumerBinary, - "HOME_DIR=" + consumerVal2HomePath, + "HOME_DIR=" + consumerVal1HomePath, "CHAIN_ID=" + consumerChainID, "DENOM=" + bondDenom, "MNEMONIC=" + relayerMnemonic, @@ -552,28 +561,30 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) // Copy the patched genesis from consumer val0 s.Require().NoError( - copyFile(genesisFile, filepath.Join(consumerVal2Dir, "config", "genesis.json")), - "failed to copy consumer genesis to val2 data dir", + copyFile(genesisFile, filepath.Join(consumerVal1Dir, "config", "genesis.json")), + "failed to copy consumer genesis to val1 data dir", ) - // Copy val2's validator keys from provider val2 to consumer val2 + // Copy val1's validator keys from provider val1 to consumer val1 s.Require().NoError( copyFile( - filepath.Join(s.providerVal2DataDir, "config", "priv_validator_key.json"), - filepath.Join(consumerVal2Dir, "config", "priv_validator_key.json"), + filepath.Join(s.providerVal1DataDir, "config", "priv_validator_key.json"), + filepath.Join(consumerVal1Dir, "config", "priv_validator_key.json"), ), - "failed to copy val2 priv_validator_key.json to consumer val2", + "failed to copy val1 priv_validator_key.json to consumer val1", ) // Start consumer val1 connecting to val0 as persistent peer - consumerVal0P2PAddr := fmt.Sprintf("%s:26656", consumerVal0Resource.Container.Name) + consumerVal0NodeID, err := getNodeID(filepath.Join(consumerDir, "config", "node_key.json")) + s.Require().NoError(err, "failed to get consumer val0 node ID") + consumerVal0P2PAddr := fmt.Sprintf("%s@%s:26656", consumerVal0NodeID, consumerVal0Resource.Container.Name[1:]) consumerVal1Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val1", consumerChainID), Repository: e2eChainImage, NetworkID: s.dkrNet.Network.ID, Mounts: []string{ - fmt.Sprintf("%s:%s", consumerVal2Dir, consumerVal2HomePath), + fmt.Sprintf("%s:%s", consumerVal1Dir, consumerVal1HomePath), }, PortBindings: map[docker.Port][]docker.PortBinding{ "26657/tcp": {{HostIP: "", HostPort: "26668"}}, @@ -583,7 +594,7 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) }, Cmd: []string{ consumerBinary, "start", - "--home", consumerVal2HomePath, + "--home", consumerVal1HomePath, "--p2p.persistent-peers", consumerVal0P2PAddr, }, }, @@ -596,7 +607,12 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) s.consumerValRes = append(s.consumerValRes, consumerVal1Resource) s.T().Logf("consumer val1 container started: %s", consumerVal1Resource.Container.ID[:12]) - time.Sleep(5 * time.Second) + // Wait for consumer val1 to catch up + waitCtx2, waitCancel2 := context.WithTimeout(context.Background(), 2*time.Minute) + defer waitCancel2() + err = s.waitForChainHeight(waitCtx2, "http://localhost:26668", 3) + s.Require().NoError(err, "consumer val1 failed to catch up") + s.T().Log("consumer val1 is caught up") } // setupTSRelayer starts the ts-relayer container, configures it with @@ -639,6 +655,35 @@ func (s *IntegrationTestSuite) waitForChainHeight(ctx context.Context, rpcEndpoi } } +// getNodeID reads a CometBFT node_key.json file and returns the hex-encoded node ID. +func getNodeID(nodeKeyPath string) (string, error) { + data, err := os.ReadFile(nodeKeyPath) + if err != nil { + return "", fmt.Errorf("failed to read node_key.json: %w", err) + } + + var nodeKey struct { + PrivKey struct { + Value string `json:"value"` + } `json:"priv_key"` + } + if err := json.Unmarshal(data, &nodeKey); err != nil { + return "", fmt.Errorf("failed to parse node_key.json: %w", err) + } + + privBytes, err := base64.StdEncoding.DecodeString(nodeKey.PrivKey.Value) + if err != nil { + return "", fmt.Errorf("failed to decode privkey: %w", err) + } + + if len(privBytes) != ed25519.PrivateKeySize { + return "", fmt.Errorf("invalid privkey size: %d", len(privBytes)) + } + + pubKey := ed25519.PrivateKey(privBytes).Public().(ed25519.PublicKey) + return fmt.Sprintf("%x", pubKey), nil +} + // chmodRecursive changes permissions on a directory recursively. func chmodRecursive(path string, mode os.FileMode) error { cmd := exec.Command("chmod", "-R", fmt.Sprintf("%o", mode), path) diff --git a/tests/e2e/scripts/provider-init.sh b/tests/e2e/scripts/provider-init.sh index 8a276e7..efd45c3 100755 --- a/tests/e2e/scripts/provider-init.sh +++ b/tests/e2e/scripts/provider-init.sh @@ -7,7 +7,7 @@ set -e BINARY="${BINARY:-provider}" HOME_DIR="${HOME_DIR:-/home/nonroot/.provider}" -VAL2_HOME_DIR="${HOME_DIR}-val2" +VAL1_HOME_DIR="${HOME_DIR}-val1" CHAIN_ID="${CHAIN_ID:-provider-e2e}" DENOM="${DENOM:-uatone}" MNEMONIC="${MNEMONIC:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art}" @@ -29,33 +29,33 @@ $BINARY genesis add-genesis-account val "1000000000000${DENOM}" --home "$HOME_DI $BINARY genesis add-genesis-account user "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test $BINARY genesis add-genesis-account relayer "100000000${DENOM}" --home "$HOME_DIR" --keyring-backend test -# Initialize val2 node (separate home with its own consensus key) -$BINARY init val2 --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$VAL2_HOME_DIR" -$BINARY keys add val2 --home "$VAL2_HOME_DIR" --keyring-backend test +# Initialize val1 node (separate home with its own consensus key) +$BINARY init val1 --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$VAL1_HOME_DIR" +$BINARY keys add val1 --home "$VAL1_HOME_DIR" --keyring-backend test -# Copy val0's genesis to val2 so both share the same genesis state -cp "$HOME_DIR/config/genesis.json" "$VAL2_HOME_DIR/config/genesis.json" +# Copy val0's genesis to val1 so both share the same genesis state +cp "$HOME_DIR/config/genesis.json" "$VAL1_HOME_DIR/config/genesis.json" -# Add val2 genesis account (operates on the shared genesis) -$BINARY genesis add-genesis-account val2 "1000000000000${DENOM}" --home "$VAL2_HOME_DIR" --keyring-backend test +# Add val1 genesis account (operates on the shared genesis) +$BINARY genesis add-genesis-account val1 "1000000000000${DENOM}" --home "$VAL1_HOME_DIR" --keyring-backend test -# Copy updated genesis back to val0 (now includes val2's account) -cp "$VAL2_HOME_DIR/config/genesis.json" "$HOME_DIR/config/genesis.json" +# Copy updated genesis back to val0 (now includes val1's account) +cp "$VAL1_HOME_DIR/config/genesis.json" "$HOME_DIR/config/genesis.json" # Create gentx for val0 $BINARY genesis gentx val "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" -# Create gentx for val2 -$BINARY genesis gentx val2 "1000000000${DENOM}" --home "$VAL2_HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" +# Create gentx for val1 (smaller self-delegation so it has <2/3 of voting power) +$BINARY genesis gentx val1 "100000000${DENOM}" --home "$VAL1_HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" -# Copy val2's gentx to val0's gentx directory -cp "$VAL2_HOME_DIR"/config/gentx/*.json "$HOME_DIR/config/gentx/" +# Copy val1's gentx to val0's gentx directory +cp "$VAL1_HOME_DIR"/config/gentx/*.json "$HOME_DIR/config/gentx/" # Collect all gentxs $BINARY genesis collect-gentxs --home "$HOME_DIR" -# Copy final genesis to val2 -cp "$HOME_DIR/config/genesis.json" "$VAL2_HOME_DIR/config/genesis.json" +# Copy final genesis to val1 +cp "$HOME_DIR/config/genesis.json" "$VAL1_HOME_DIR/config/genesis.json" # Configure val0 node $BINARY config set app api.enable true --home "$HOME_DIR" @@ -64,13 +64,13 @@ sed -i 's#laddr = "tcp://127.0.0.1:26657"#laddr = "tcp://0.0.0.0:26657"#g' "$HOM sed -i 's#address = "tcp://localhost:1317"#address = "tcp://0.0.0.0:1317"#g' "$HOME_DIR/config/app.toml" sed -i 's#address = "localhost:9090"#address = "0.0.0.0:9090"#g' "$HOME_DIR/config/app.toml" -# Configure val2 node -sed -i "s#^minimum-gas-prices = .*#minimum-gas-prices = \"0.01${DENOM}\"#g" "$VAL2_HOME_DIR/config/app.toml" -sed -i 's#laddr = "tcp://127.0.0.1:26657"#laddr = "tcp://0.0.0.0:26657"#g' "$VAL2_HOME_DIR/config/config.toml" -sed -i 's#address = "tcp://localhost:1317"#address = "tcp://0.0.0.0:1317"#g' "$VAL2_HOME_DIR/config/app.toml" -sed -i 's#address = "localhost:9090"#address = "0.0.0.0:9090"#g' "$VAL2_HOME_DIR/config/app.toml" +# Configure val1 node +sed -i "s#^minimum-gas-prices = .*#minimum-gas-prices = \"0.01${DENOM}\"#g" "$VAL1_HOME_DIR/config/app.toml" +sed -i 's#laddr = "tcp://127.0.0.1:26657"#laddr = "tcp://0.0.0.0:26657"#g' "$VAL1_HOME_DIR/config/config.toml" +sed -i 's#address = "tcp://localhost:1317"#address = "tcp://0.0.0.0:1317"#g' "$VAL1_HOME_DIR/config/app.toml" +sed -i 's#address = "localhost:9090"#address = "0.0.0.0:9090"#g' "$VAL1_HOME_DIR/config/app.toml" find "$HOME_DIR" -mindepth 1 -exec chmod 777 {} + -find "$VAL2_HOME_DIR" -mindepth 1 -exec chmod 777 {} + +find "$VAL1_HOME_DIR" -mindepth 1 -exec chmod 777 {} + echo "Provider init complete (2 validators)." From 51bd7899709d9a4de8916213d8586b5cdb4f937c Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 3 Jun 2026 16:19:39 +0200 Subject: [PATCH 10/11] wip --- tests/e2e/e2e_setup_test.go | 47 ++----------------------------------- tests/e2e/http_util_test.go | 22 +++++++++++++++++ 2 files changed, 24 insertions(+), 45 deletions(-) diff --git a/tests/e2e/e2e_setup_test.go b/tests/e2e/e2e_setup_test.go index c1224f7..a40a522 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -3,12 +3,8 @@ package e2e import ( "bytes" "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -361,9 +357,7 @@ func (s *IntegrationTestSuite) initAndStartProvider() { // Start provider val1 (second validator node) connecting to val0 as persistent peer s.T().Log("starting provider val1 container...") - val0NodeID, err := getNodeID(filepath.Join(providerDir, "config", "node_key.json")) - s.Require().NoError(err, "failed to get val0 node ID") - val0P2PAddr := fmt.Sprintf("%s@%s:26656", val0NodeID, val0Resource.Container.Name[1:]) + val0P2PAddr := fmt.Sprintf("%s@%s:26656", queryNodeID("http://localhost:26657"), val0Resource.Container.Name[1:]) val1Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val1", providerChainID), @@ -575,9 +569,7 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) ) // Start consumer val1 connecting to val0 as persistent peer - consumerVal0NodeID, err := getNodeID(filepath.Join(consumerDir, "config", "node_key.json")) - s.Require().NoError(err, "failed to get consumer val0 node ID") - consumerVal0P2PAddr := fmt.Sprintf("%s@%s:26656", consumerVal0NodeID, consumerVal0Resource.Container.Name[1:]) + consumerVal0P2PAddr := fmt.Sprintf("%s@%s:26656", queryNodeID("http://localhost:26667"), consumerVal0Resource.Container.Name[1:]) consumerVal1Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val1", consumerChainID), @@ -654,38 +646,3 @@ func (s *IntegrationTestSuite) waitForChainHeight(ctx context.Context, rpcEndpoi } } } - -// getNodeID reads a CometBFT node_key.json file and returns the hex-encoded node ID. -func getNodeID(nodeKeyPath string) (string, error) { - data, err := os.ReadFile(nodeKeyPath) - if err != nil { - return "", fmt.Errorf("failed to read node_key.json: %w", err) - } - - var nodeKey struct { - PrivKey struct { - Value string `json:"value"` - } `json:"priv_key"` - } - if err := json.Unmarshal(data, &nodeKey); err != nil { - return "", fmt.Errorf("failed to parse node_key.json: %w", err) - } - - privBytes, err := base64.StdEncoding.DecodeString(nodeKey.PrivKey.Value) - if err != nil { - return "", fmt.Errorf("failed to decode privkey: %w", err) - } - - if len(privBytes) != ed25519.PrivateKeySize { - return "", fmt.Errorf("invalid privkey size: %d", len(privBytes)) - } - - pubKey := ed25519.PrivateKey(privBytes).Public().(ed25519.PublicKey) - return fmt.Sprintf("%x", pubKey), nil -} - -// chmodRecursive changes permissions on a directory recursively. -func chmodRecursive(path string, mode os.FileMode) error { - cmd := exec.Command("chmod", "-R", fmt.Sprintf("%o", mode), path) - return cmd.Run() -} diff --git a/tests/e2e/http_util_test.go b/tests/e2e/http_util_test.go index a32fa82..a5033a6 100644 --- a/tests/e2e/http_util_test.go +++ b/tests/e2e/http_util_test.go @@ -80,3 +80,25 @@ func queryBlockHeight(rpcEndpoint string) (int64, error) { return height, nil } + +// queryNodeID queries a CometBFT RPC /status endpoint and returns the node ID. +func queryNodeID(rpcEndpoint string) string { + bz, err := httpGet(fmt.Sprintf("%s/status", rpcEndpoint)) + if err != nil { + return "" + } + + var status struct { + Result struct { + NodeInfo struct { + ID string `json:"id"` + } `json:"node_info"` + } `json:"result"` + } + + if err := json.Unmarshal(bz, &status); err != nil { + return "" + } + + return status.Result.NodeInfo.ID +} From eb1323b1c7dd6422a14e0e65c2e0f97ca3b64b07 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 3 Jun 2026 16:37:10 +0200 Subject: [PATCH 11/11] set via node config --- tests/e2e/e2e_setup_test.go | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/e2e/e2e_setup_test.go b/tests/e2e/e2e_setup_test.go index a40a522..eb50a4c 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -357,7 +357,14 @@ func (s *IntegrationTestSuite) initAndStartProvider() { // Start provider val1 (second validator node) connecting to val0 as persistent peer s.T().Log("starting provider val1 container...") - val0P2PAddr := fmt.Sprintf("%s@%s:26656", queryNodeID("http://localhost:26657"), val0Resource.Container.Name[1:]) + val0NodeID := queryNodeID("http://localhost:26657") + s.Require().NotEmpty(val0NodeID, "failed to query val0 node ID") + val0P2PAddr := fmt.Sprintf("%s@%s:26656", val0NodeID, val0Resource.Container.Name[1:]) + s.T().Logf("provider val1 persistent peer: %s", val0P2PAddr) + + // Set persistent_peers in val1's config.toml so it connects to val0 on startup + s.setPersistentPeers(filepath.Join(providerVal1Dir, "config", "config.toml"), val0P2PAddr) + val1Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val1", providerChainID), @@ -375,7 +382,6 @@ func (s *IntegrationTestSuite) initAndStartProvider() { Cmd: []string{ providerBinary, "start", "--home", providerVal1HomePath, - "--p2p.persistent-peers", val0P2PAddr, }, }, func(config *docker.HostConfig) { @@ -569,7 +575,14 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) ) // Start consumer val1 connecting to val0 as persistent peer - consumerVal0P2PAddr := fmt.Sprintf("%s@%s:26656", queryNodeID("http://localhost:26667"), consumerVal0Resource.Container.Name[1:]) + consumerVal0NodeID := queryNodeID("http://localhost:26667") + s.Require().NotEmpty(consumerVal0NodeID, "failed to query consumer val0 node ID") + consumerVal0P2PAddr := fmt.Sprintf("%s@%s:26656", consumerVal0NodeID, consumerVal0Resource.Container.Name[1:]) + s.T().Logf("consumer val1 persistent peer: %s", consumerVal0P2PAddr) + + // Set persistent_peers in consumer val1's config.toml + s.setPersistentPeers(filepath.Join(consumerVal1Dir, "config", "config.toml"), consumerVal0P2PAddr) + consumerVal1Resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: fmt.Sprintf("%s-val1", consumerChainID), @@ -587,7 +600,6 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) Cmd: []string{ consumerBinary, "start", "--home", consumerVal1HomePath, - "--p2p.persistent-peers", consumerVal0P2PAddr, }, }, func(config *docker.HostConfig) { @@ -625,6 +637,18 @@ func (s *IntegrationTestSuite) setupTSRelayer() { s.T().Log("ts-relayer IBC v2 path configured") } +// setPersistentPeers sets the persistent_peers field in a CometBFT config.toml. +func (s *IntegrationTestSuite) setPersistentPeers(configPath, peers string) { + data, err := os.ReadFile(configPath) + s.Require().NoError(err, "failed to read config.toml") + data = []byte(strings.Replace(string(data), + "persistent_peers = \"\"", + fmt.Sprintf("persistent_peers = \"%s\"", peers), + 1, + )) + s.Require().NoError(os.WriteFile(configPath, data, 0o644), "failed to write config.toml") +} + // waitForChainHeight polls a CometBFT RPC endpoint until the chain reaches // the given block height. func (s *IntegrationTestSuite) waitForChainHeight(ctx context.Context, rpcEndpoint string, minHeight int64) error {