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..946e99f 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 diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go new file mode 100644 index 0000000..4e251b2 --- /dev/null +++ b/tests/e2e/e2e_downtime_slash_test.go @@ -0,0 +1,116 @@ +package e2e + +import ( + "fmt" + "time" + + "cosmossdk.io/math" + + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +func (s *IntegrationTestSuite) testDowntimeSlash() { + s.Run("no downtime slash, consumer down", 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.Equal(tokensBefore) + }, + 3*time.Minute, + 5*time.Second, + "validator tokens were incorrectly slashed during whole consumer chain downtime (before: %s, valoper: %s)", + tokensBefore.String(), valoperAddr, + ) + + 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 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. +} + +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..692fba6 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,6 +7,7 @@ func (s *IntegrationTestSuite) TestVAAS() { s.testProviderOnConsumer() 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/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/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/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 b657bdc..bb62e1e 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. @@ -49,19 +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] + 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 @@ -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, @@ -92,19 +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), + 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() @@ -131,19 +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), + 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/validators.go b/x/vaas/consumer/keeper/validators.go index a0c6bb2..60f89d7 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 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 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) - // 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.PendingEvidencePackets.Has(ctx, addr) + if err != nil { + 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 evidence packet", + "validator", addr.String(), + "infraction_height", infractionHeight, + ) + return math.ZeroInt(), nil + } + + 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 evidence 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..b05eef7 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.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.GetPendingEvidencePacketCount(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.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.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.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 53278a3..4fc4828 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 evidence packets. func (am AppModule) EndBlock(goCtx context.Context) ([]abci.ValidatorUpdate, error) { ctx := sdk.UnwrapSDKContext(goCtx) + // 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) if !ok { return []abci.ValidatorUpdate{}, nil 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 68505ca..df51b4f 100644 --- a/x/vaas/consumer/types/keys.go +++ b/x/vaas/consumer/types/keys.go @@ -20,18 +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) + 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/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..d6add39 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,134 @@ 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 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 + } + + 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 +642,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 +684,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..e1ccff0 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" @@ -18,9 +19,14 @@ 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" + vaastypes "github.com/allinbits/vaas/x/vaas/types" ) func TestVerifyDoubleVotingEvidence(t *testing.T) { @@ -755,7 +761,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 +789,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) } @@ -815,8 +821,283 @@ func getTestInfractionParameters() *types.InfractionParameters { }, Downtime: &types.SlashJailParameters{ JailDuration: 600 * time.Second, - SlashFraction: math.LegacyNewDec(0), + SlashFraction: math.LegacyNewDecWithPrec(5, 4), Tombstone: false, }, } } + +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") + 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) + 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, + stakingtypes.Infraction_INFRACTION_DOWNTIME, + ) + + 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), + mocks.MockStakingKeeper.EXPECT(). + GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()). + Return(validator, nil), + mocks.MockSlashingKeeper.EXPECT(). + IsTombstoned(ctx, providerAddr.ToSdkConsAddr()). + 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, providerAddr.ToSdkConsAddr(), int64(0), int64(1000), math.LegacyNewDecWithPrec(5, 4), stakingtypes.Infraction_INFRACTION_DOWNTIME). + Return(math.NewInt(0), nil), + } + + gomock.InOrder(expectedCalls...) + err = providerKeeper.HandleConsumerEvidencePacket(ctx, consumerId, evidencePacket) + 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) + 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 TestEvidencePacketDataJSONRoundTrip(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..8651075 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,7 +23,6 @@ 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() 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/msg_server_test.go b/x/vaas/provider/keeper/msg_server_test.go index 375d6ec..cddfa0c 100644 --- a/x/vaas/provider/keeper/msg_server_test.go +++ b/x/vaas/provider/keeper/msg_server_test.go @@ -322,9 +322,8 @@ 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. - mocks.MockSlashingKeeper.EXPECT().DowntimeJailDuration(ctx).Return(10*time.Minute, nil).Times(1) + // 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) // SlashValidator path. 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..fc38df0 100644 --- a/x/vaas/provider/types/provider.go +++ b/x/vaas/provider/types/provider.go @@ -27,11 +27,6 @@ func DefaultConsumerInitializationParameters() ConsumerInitializationParameters } func DefaultConsumerInfractionParameters(ctx context.Context, slashingKeeper vaastypes.SlashingKeeper) (InfractionParameters, error) { - jailDuration, err := slashingKeeper.DowntimeJailDuration(ctx) - if err != nil { - return InfractionParameters{}, err - } - doubleSignSlashingFraction, err := slashingKeeper.SlashFractionDoubleSign(ctx) if err != nil { return InfractionParameters{}, err @@ -39,13 +34,13 @@ func DefaultConsumerInfractionParameters(ctx context.Context, slashingKeeper vaa 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: math.LegacyNewDecWithPrec(5, 4), Tombstone: false, }, }, nil 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/wire.go b/x/vaas/types/wire.go index 1d7d106..fa74dde 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 evidence 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 +}