diff --git a/README.md b/README.md index f2a53d4..8650649 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ and [`app/consumer/app.go`](app/consumer/app.go) for reference. | 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 | @@ -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/chain_test.go b/tests/e2e/chain_test.go index 608f9d1..c1ae10d 100644 --- a/tests/e2e/chain_test.go +++ b/tests/e2e/chain_test.go @@ -1,13 +1,15 @@ package e2e const ( - providerBinary = "provider" - consumerBinary = "consumer" - providerHomePath = "/home/nonroot/.provider" - consumerHomePath = "/home/nonroot/.consumer" - bondDenom = "uatone" - providerChainID = "provider-e2e" - consumerChainID = "consumer-e2e" + providerBinary = "provider" + consumerBinary = "consumer" + providerHomePath = "/home/nonroot/.provider" + providerVal1HomePath = "/home/nonroot/.provider-val1" + consumerHomePath = "/home/nonroot/.consumer" + consumerVal1HomePath = "/home/nonroot/.consumer-val1" + bondDenom = "uatone" + providerChainID = "provider-e2e" + consumerChainID = "consumer-e2e" ) // chain represents a Cosmos chain instance (either provider or consumer). diff --git a/tests/e2e/e2e_downtime_slash_test.go b/tests/e2e/e2e_downtime_slash_test.go new file mode 100644 index 0000000..2d27bf0 --- /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 val0 container to simulate downtime (val1 keeps chain alive)...") + err := s.dkrPool.Client.PauseContainer(s.consumerValRes[0].Container.ID) + s.Require().NoError(err, "failed to pause consumer val0 container") + + time.Sleep(30 * time.Second) + + s.T().Log("unpausing consumer val0 container...") + err = s.dkrPool.Client.UnpauseContainer(s.consumerValRes[0].Container.ID) + s.Require().NoError(err, "failed to unpause consumer val0 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..4c619f6 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -3,9 +3,12 @@ package e2e import ( "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -35,7 +38,9 @@ type IntegrationTestSuite struct { cdc codec.Codec tmpDirs []string provider *chain + providerVal1 *chain consumer *chain + consumerVal1 *chain dkrPool *dockertest.Pool dkrNet *dockertest.Network providerValRes []*dockertest.Resource @@ -90,6 +95,7 @@ func (s *IntegrationTestSuite) SetupSuite() { s.T().Log("step 1: initializing provider chain...") s.provider = &chain{id: providerChainID} + s.providerVal1 = &chain{id: providerChainID} s.initAndStartProvider() s.T().Log("step 2: registering consumer chain on provider...") @@ -100,6 +106,7 @@ func (s *IntegrationTestSuite) SetupSuite() { s.T().Log("step 4: initializing consumer chain...") s.consumer = &chain{id: consumerChainID} + s.consumerVal1 = &chain{id: consumerChainID} s.initAndStartConsumer(consumerGenesisJSON) s.T().Log("step 5: starting ts-relayer and creating IBC v2 path...") @@ -155,7 +162,9 @@ func (s *IntegrationTestSuite) cleanupStaleContainers() { "provider-init", "consumer-init", fmt.Sprintf("%s-val0", providerChainID), + fmt.Sprintf("%s-val1", providerChainID), fmt.Sprintf("%s-val0", consumerChainID), + fmt.Sprintf("%s-val1", consumerChainID), fmt.Sprintf("%s-%s-ts-relayer", providerChainID, consumerChainID), } for _, name := range staleNames { @@ -174,9 +183,9 @@ func (s *IntegrationTestSuite) cleanupStaleContainers() { _ = s.dkrPool.Client.RemoveNetwork(dockerNetwork) } -// runInitContainer starts an init container with the given script mounted, +// runInitContainer starts an init container with the given mounts and entrypoint, // waits for it to exit, checks the exit code, and purges it. -func (s *IntegrationTestSuite) runInitContainer(name, scriptPath, containerScriptPath, dataDir, homePath string, env []string) { +func (s *IntegrationTestSuite) runInitContainer(name, containerScriptPath string, mounts []string, env []string) { initResource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ Name: name, @@ -184,10 +193,7 @@ func (s *IntegrationTestSuite) runInitContainer(name, scriptPath, containerScrip NetworkID: s.dkrNet.Network.ID, User: "nonroot", Env: env, - Mounts: []string{ - fmt.Sprintf("%s:%s", dataDir, homePath), - fmt.Sprintf("%s:%s", scriptPath, containerScriptPath), - }, + Mounts: mounts, Entrypoint: []string{"sh", containerScriptPath}, }, func(config *docker.HostConfig) { @@ -216,35 +222,48 @@ func (s *IntegrationTestSuite) runInitContainer(name, scriptPath, containerScrip } // initAndStartProvider initializes the provider chain using a temporary Docker -// container that runs provider-init.sh, then starts the actual chain container. +// container that runs provider-init.sh, then starts the actual chain containers. +// It creates 2 validators (val, val2) with separate consensus keys. func (s *IntegrationTestSuite) initAndStartProvider() { - // Create host directory for provider data + // Create host directories for provider data providerDir, err := os.MkdirTemp("", "vaas-e2e-provider-") s.Require().NoError(err) s.tmpDirs = append(s.tmpDirs, providerDir) s.provider.dataDir = providerDir - // Make writable + providerVal1Dir, err := os.MkdirTemp("", "vaas-e2e-provider-val1-") + s.Require().NoError(err) + s.tmpDirs = append(s.tmpDirs, providerVal1Dir) + s.providerVal1.dataDir = providerVal1Dir + s.Require().NoError(os.Chmod(providerDir, 0o777)) + s.Require().NoError(os.Chmod(providerVal1Dir, 0o777)) - // Run init script in a temporary container + // Run init script in a temporary container (mounts both provider dirs) scriptPath := filepath.Join(testDir(), "scripts", "provider-init.sh") - s.runInitContainer("provider-init", scriptPath, "/scripts/provider-init.sh", providerDir, providerHomePath, []string{ - "BINARY=" + providerBinary, - "HOME_DIR=" + providerHomePath, - "CHAIN_ID=" + providerChainID, - "DENOM=" + bondDenom, - "MNEMONIC=" + relayerMnemonic, - }) + s.runInitContainer("provider-init", "/scripts/provider-init.sh", + []string{ + fmt.Sprintf("%s:%s", providerDir, providerHomePath), + fmt.Sprintf("%s:%s", providerVal1Dir, providerVal1HomePath), + fmt.Sprintf("%s:%s", scriptPath, "/scripts/provider-init.sh"), + }, + []string{ + "BINARY=" + providerBinary, + "HOME_DIR=" + providerHomePath, + "CHAIN_ID=" + providerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }, + ) // Modify genesis on the host: set fast voting period and small blocks_per_epoch genesisFile := filepath.Join(providerDir, "config", "genesis.json") - 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 +271,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", } @@ -263,8 +282,14 @@ func (s *IntegrationTestSuite) initAndStartProvider() { } }) - // Now start the actual provider container - s.T().Log("starting provider chain container...") + // Copy patched genesis to val1 directory (they share the same genesis) + s.Require().NoError(copyFile( + filepath.Join(providerDir, "config", "genesis.json"), + filepath.Join(providerVal1Dir, "config", "genesis.json"), + )) + + // Start provider val0 container + s.T().Log("starting provider val0 container...") resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ @@ -289,17 +314,50 @@ func (s *IntegrationTestSuite) initAndStartProvider() { config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) - s.Require().NoError(err, "failed to start provider container") + s.Require().NoError(err, "failed to start provider val0 container") s.providerValRes = append(s.providerValRes, resource) - s.T().Logf("provider container started: %s", resource.Container.ID[:12]) + s.T().Logf("provider val0 container started: %s", resource.Container.ID[:12]) - // Wait for provider to produce blocks + // Wait for provider val0 to produce blocks and get its node ID waitCtx, waitCancel := context.WithTimeout(context.Background(), 2*time.Minute) defer waitCancel() err = s.waitForChainHeight(waitCtx, "http://localhost:26657", 3) s.Require().NoError(err, "provider failed to produce blocks") - s.T().Log("provider chain is producing blocks") + s.T().Log("provider val0 is producing blocks") + + // Get val0's node ID for p2p peering + val0NodeID := s.getNodeID(providerDir) + + // Start provider val1 container with persistent peer to val0 + s.T().Log("starting provider val1 container...") + + val1Resource, err := s.dkrPool.RunWithOptions( + &dockertest.RunOptions{ + Name: fmt.Sprintf("%s-val1", providerChainID), + Repository: e2eChainImage, + NetworkID: s.dkrNet.Network.ID, + Mounts: []string{ + fmt.Sprintf("%s:%s", providerVal1Dir, providerVal1HomePath), + }, + Cmd: []string{ + providerBinary, "start", + "--home", providerVal1HomePath, + "--p2p.persistent-peers", fmt.Sprintf("%s@%s-val0:26656", val0NodeID, providerChainID), + }, + }, + func(config *docker.HostConfig) { + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }, + ) + s.Require().NoError(err, "failed to start provider val1 container") + + s.providerValRes = append(s.providerValRes, val1Resource) + s.T().Logf("provider val1 container started: %s", val1Resource.Container.ID[:12]) + + // Wait for val1 to sync + time.Sleep(5 * time.Second) + s.T().Log("provider chain is running with 2 validators") } // registerConsumerOnProvider creates a consumer chain registration on the provider. @@ -340,7 +398,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, @@ -361,47 +419,121 @@ func (s *IntegrationTestSuite) fetchConsumerGenesis() []byte { return []byte(output) } -// initAndStartConsumer initializes the consumer chain and starts it. +// initAndStartConsumer initializes the consumer chain and starts it with 2 +// validator nodes. Node 0 uses val's consensus key, node 1 uses val2's key. func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) { - // Create host directory for consumer data + // Create host directories for consumer data consumerDir, err := os.MkdirTemp("", "vaas-e2e-consumer-") s.Require().NoError(err) s.tmpDirs = append(s.tmpDirs, consumerDir) s.consumer.dataDir = consumerDir + consumerVal1Dir, err := os.MkdirTemp("", "vaas-e2e-consumer-val1-") + s.Require().NoError(err) + s.tmpDirs = append(s.tmpDirs, consumerVal1Dir) + s.consumerVal1.dataDir = consumerVal1Dir + s.Require().NoError(os.Chmod(consumerDir, 0o777)) + s.Require().NoError(os.Chmod(consumerVal1Dir, 0o777)) - // Run init script in a temporary container + // Run init script in a temporary container (creates node 0's keys) scriptPath := filepath.Join(testDir(), "scripts", "consumer-init.sh") - s.runInitContainer("consumer-init", scriptPath, "/scripts/consumer-init.sh", consumerDir, consumerHomePath, []string{ - "BINARY=" + consumerBinary, - "HOME_DIR=" + consumerHomePath, - "CHAIN_ID=" + consumerChainID, - "DENOM=" + bondDenom, - "MNEMONIC=" + relayerMnemonic, - }) + s.runInitContainer("consumer-init", "/scripts/consumer-init.sh", + []string{ + fmt.Sprintf("%s:%s", consumerDir, consumerHomePath), + fmt.Sprintf("%s:%s", scriptPath, "/scripts/consumer-init.sh"), + }, + []string{ + "BINARY=" + consumerBinary, + "HOME_DIR=" + consumerHomePath, + "CHAIN_ID=" + consumerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }, + ) + + // Initialize node 1's directory with separate keys + s.runInitContainer("consumer-init-val1", "/scripts/consumer-init.sh", + []string{ + fmt.Sprintf("%s:%s", consumerVal1Dir, consumerVal1HomePath), + fmt.Sprintf("%s:%s", scriptPath, "/scripts/consumer-init.sh"), + }, + []string{ + "BINARY=" + consumerBinary, + "HOME_DIR=" + consumerVal1HomePath, + "CHAIN_ID=" + consumerChainID, + "DENOM=" + bondDenom, + "MNEMONIC=" + relayerMnemonic, + }, + ) // Patch consumer genesis with provider's consumer-genesis data on the host genesisFile := filepath.Join(consumerDir, "config", "genesis.json") err = patchConsumerGenesisWithProviderData(genesisFile, consumerGenesisJSON) s.Require().NoError(err, "failed to patch consumer genesis") + // Copy the patched genesis to val1 directory + s.Require().NoError(copyFile( + filepath.Join(consumerDir, "config", "genesis.json"), + filepath.Join(consumerVal1Dir, "config", "genesis.json"), + )) + + // Patch consumer slashing params for aggressive downtime detection + s.patchConsumerSlashingParams() + // Also patch val1's genesis (same params) + genesisVal1File := filepath.Join(consumerVal1Dir, "config", "genesis.json") + s.patchGenesisJSON(genesisVal1File, func(genesis map[string]any) { + appState, ok := genesis["app_state"].(map[string]any) + if !ok { + return + } + slashing, ok := appState["slashing"].(map[string]any) + if !ok { + slashing = make(map[string]any) + } + params, ok := slashing["params"].(map[string]any) + if !ok { + params = make(map[string]any) + } + params["signed_blocks_window"] = "5" + params["min_signed_per_window"] = "0.050000000000000000" + params["slash_fraction_downtime"] = "0.000000000000000000" + params["downtime_jail_duration"] = "60s" + slashing["params"] = params + appState["slashing"] = slashing + }) + // Copy validator keys from provider to consumer + // Node 0 gets val's key, node 1 gets val2's key providerDir := s.provider.dataDir + providerVal1Dir := s.providerVal1.dataDir + err = copyFile( filepath.Join(providerDir, "config", "priv_validator_key.json"), filepath.Join(consumerDir, "config", "priv_validator_key.json"), ) - s.Require().NoError(err, "failed to copy priv_validator_key.json") + s.Require().NoError(err, "failed to copy priv_validator_key.json for val0") err = copyFile( filepath.Join(providerDir, "config", "node_key.json"), filepath.Join(consumerDir, "config", "node_key.json"), ) - s.Require().NoError(err, "failed to copy node_key.json") + s.Require().NoError(err, "failed to copy node_key.json for val0") - // Start the actual consumer container - s.T().Log("starting consumer chain container...") + err = copyFile( + filepath.Join(providerVal1Dir, "config", "priv_validator_key.json"), + filepath.Join(consumerVal1Dir, "config", "priv_validator_key.json"), + ) + s.Require().NoError(err, "failed to copy priv_validator_key.json for val1") + + err = copyFile( + filepath.Join(providerVal1Dir, "config", "node_key.json"), + filepath.Join(consumerVal1Dir, "config", "node_key.json"), + ) + s.Require().NoError(err, "failed to copy node_key.json for val1") + + // Start consumer val0 container + s.T().Log("starting consumer val0 container...") resource, err := s.dkrPool.RunWithOptions( &dockertest.RunOptions{ @@ -426,17 +558,78 @@ func (s *IntegrationTestSuite) initAndStartConsumer(consumerGenesisJSON []byte) config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) - s.Require().NoError(err, "failed to start consumer container") + s.Require().NoError(err, "failed to start consumer val0 container") s.consumerValRes = append(s.consumerValRes, resource) - s.T().Logf("consumer container started: %s", resource.Container.ID[:12]) + s.T().Logf("consumer val0 container started: %s", resource.Container.ID[:12]) - // Wait for consumer to produce blocks + // Wait for consumer val0 to produce blocks waitCtx, waitCancel := context.WithTimeout(context.Background(), 2*time.Minute) defer waitCancel() err = s.waitForChainHeight(waitCtx, "http://localhost:26667", 3) s.Require().NoError(err, "consumer failed to produce blocks") - s.T().Log("consumer chain is producing blocks") + s.T().Log("consumer val0 is producing blocks") + + // Get val0's node ID for p2p peering + consumerVal0NodeID := s.getNodeID(consumerDir) + + // Start consumer val1 container with persistent peer to val0 + s.T().Log("starting consumer val1 container...") + + val1Resource, err := s.dkrPool.RunWithOptions( + &dockertest.RunOptions{ + Name: fmt.Sprintf("%s-val1", consumerChainID), + Repository: e2eChainImage, + NetworkID: s.dkrNet.Network.ID, + Mounts: []string{ + fmt.Sprintf("%s:%s", consumerVal1Dir, consumerVal1HomePath), + }, + Cmd: []string{ + consumerBinary, "start", + "--home", consumerVal1HomePath, + "--p2p.persistent-peers", fmt.Sprintf("%s@%s-val0:26656", consumerVal0NodeID, consumerChainID), + }, + }, + func(config *docker.HostConfig) { + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }, + ) + s.Require().NoError(err, "failed to start consumer val1 container") + + s.consumerValRes = append(s.consumerValRes, val1Resource) + s.T().Logf("consumer val1 container started: %s", val1Resource.Container.ID[:12]) + + time.Sleep(5 * time.Second) + s.T().Log("consumer chain is running with 2 validators") +} + +// getNodeID reads the node ID from a node_key.json file in the given data directory. +func (s *IntegrationTestSuite) getNodeID(dataDir string) string { + nodeKeyPath := filepath.Join(dataDir, "config", "node_key.json") + bz, err := os.ReadFile(nodeKeyPath) + s.Require().NoError(err, "failed to read node_key.json") + + var nodeKey struct { + PrivKey struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"priv_key"` + } + s.Require().NoError(json.Unmarshal(bz, &nodeKey), "failed to unmarshal node_key.json") + + // For CometBFT, the node ID is the hex-encoded SHA256 hash of the ed25519 public key + // derived from the private key. We need to compute it. + privKeyBytes, err := base64.StdEncoding.DecodeString(nodeKey.PrivKey.Value) + s.Require().NoError(err, "failed to decode node private key") + + // The priv_key.value is base64 of the raw ed25519 private key bytes + // The public key is derived from the last 32 bytes of the 64-byte private key + var privKey [64]byte + copy(privKey[:], privKeyBytes) + pubKey := privKey[32:] + + hash := sha256.Sum256(pubKey) + return hex.EncodeToString(hash[:])[:40] } // setupTSRelayer starts the ts-relayer container, configures it with @@ -478,9 +671,3 @@ func (s *IntegrationTestSuite) waitForChainHeight(ctx context.Context, rpcEndpoi } } } - -// chmodRecursive changes permissions on a directory recursively. -func chmodRecursive(path string, mode os.FileMode) error { - cmd := exec.Command("chmod", "-R", fmt.Sprintf("%o", mode), path) - return cmd.Run() -} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index c08b5a8..1562ffc 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -1,6 +1,5 @@ package e2e - func (s *IntegrationTestSuite) TestVAAS() { s.testProviderBlockProduction() s.testConsumerBlockProduction() @@ -8,4 +7,5 @@ func (s *IntegrationTestSuite) TestVAAS() { s.testProviderOnConsumer() s.testValidatorSetSync() s.testConsumerDebtFlow() + s.testDowntimeSlash() } diff --git a/tests/e2e/e2e_tsrelayer_test.go b/tests/e2e/e2e_tsrelayer_test.go index 4607ac0..87fb3d0 100644 --- a/tests/e2e/e2e_tsrelayer_test.go +++ b/tests/e2e/e2e_tsrelayer_test.go @@ -65,7 +65,7 @@ func (s *IntegrationTestSuite) verifyTSRelayerConnectivity(chainName, rpcURL str ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - for attempt := 0; attempt < 10; attempt++ { + for attempt := range 10 { exec, err := s.dkrPool.Client.CreateExec(docker.CreateExecOptions{ Context: ctx, AttachStdout: true, diff --git a/tests/e2e/e2e_vaas_test.go b/tests/e2e/e2e_vaas_test.go index ffde8a7..7ee4c54 100644 --- a/tests/e2e/e2e_vaas_test.go +++ b/tests/e2e/e2e_vaas_test.go @@ -77,7 +77,7 @@ func (s *IntegrationTestSuite) testValidatorSetSync() { providerPubKeys, providerVP := s.extractPubKeys(providerVals) consumerPubKeys, consumerVP := s.extractPubKeys(consumerVals) - s.Require().Equal(len(providerPubKeys), 1) + s.Require().Equal(2, len(providerPubKeys)) s.Require().Equal(len(providerPubKeys), len(consumerPubKeys)) s.Require().Equal(providerPubKeys[0], consumerPubKeys[0]) s.Require().Equal(providerVP[0], consumerVP[0]) diff --git a/tests/e2e/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/scripts/provider-init.sh b/tests/e2e/scripts/provider-init.sh index 137a6e9..6d83dfb 100755 --- a/tests/e2e/scripts/provider-init.sh +++ b/tests/e2e/scripts/provider-init.sh @@ -3,35 +3,65 @@ # This script is mounted into the provider init container and executed # as the entrypoint. It initializes the chain, adds keys, and configures # the node for e2e testing. +# +# It creates 2 validators (val, val2) with separate consensus keys. +# Node 0's keys are in HOME_DIR, node 1's keys are in HOME_DIR-val1. set -e BINARY="${BINARY:-provider}" HOME_DIR="${HOME_DIR:-/home/nonroot/.provider}" +HOME_DIR_VAL1="${HOME_DIR}-val1" CHAIN_ID="${CHAIN_ID:-provider-e2e}" DENOM="${DENOM:-uatone}" MNEMONIC="${MNEMONIC:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art}" -# Initialize chain +# Initialize node 0 $BINARY init localnet --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$HOME_DIR" +# Initialize node 1 in a temp subdir to get a separate consensus key pair +$BINARY init localnet --default-denom "$DENOM" --chain-id "$CHAIN_ID" --home "$HOME_DIR_VAL1" + # Configure client $BINARY config set client chain-id "$CHAIN_ID" --home "$HOME_DIR" $BINARY config set client keyring-backend test --home "$HOME_DIR" # Add keys $BINARY keys add val --home "$HOME_DIR" --keyring-backend test +$BINARY keys add val2 --home "$HOME_DIR" --keyring-backend test $BINARY keys add user --home "$HOME_DIR" --keyring-backend test echo "$MNEMONIC" | $BINARY keys add relayer --recover --home "$HOME_DIR" --keyring-backend test # Add genesis accounts $BINARY genesis add-genesis-account val "1000000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test +$BINARY genesis add-genesis-account val2 "1000000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test $BINARY genesis add-genesis-account user "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test $BINARY genesis add-genesis-account relayer "100000000${DENOM}" --home "$HOME_DIR" --keyring-backend test -# Create and collect gentx -$BINARY genesis gentx val "1000000000${DENOM}" --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" +# Extract consensus public keys from priv_validator_key.json +VAL0_PUBKEY=$(jq -r '.pub_key.value' "$HOME_DIR/config/priv_validator_key.json") +VAL1_PUBKEY=$(jq -r '.pub_key.value' "$HOME_DIR_VAL1/config/priv_validator_key.json") + +echo "val0 pubkey: $VAL0_PUBKEY" +echo "val1 pubkey: $VAL1_PUBKEY" + +# Create gentx directory and generate gentxs with explicit consensus pubkeys +mkdir -p "$HOME_DIR/config/gentx" + +$BINARY genesis gentx val "1000000000${DENOM}" \ + --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" \ + --pubkey "{\"@type\":\"/cosmos.crypto.ed25519.PubKey\",\"key\":\"$VAL0_PUBKEY\"}" \ + --output-document "$HOME_DIR/config/gentx/gentx-val.json" + +$BINARY genesis gentx val2 "1000000000${DENOM}" \ + --home "$HOME_DIR" --keyring-backend test --chain-id "$CHAIN_ID" \ + --pubkey "{\"@type\":\"/cosmos.crypto.ed25519.PubKey\",\"key\":\"$VAL1_PUBKEY\"}" \ + --output-document "$HOME_DIR/config/gentx/gentx-val2.json" + $BINARY genesis collect-gentxs --home "$HOME_DIR" +# Copy shared genesis to node 1 +cp "$HOME_DIR/config/genesis.json" "$HOME_DIR_VAL1/config/genesis.json" + # Enable REST API $BINARY config set app api.enable true --home "$HOME_DIR" @@ -47,6 +77,11 @@ sed -i 's#address = "tcp://localhost:1317"#address = "tcp://0.0.0.0:1317"#g' "$H # Bind gRPC to all interfaces sed -i 's#address = "localhost:9090"#address = "0.0.0.0:9090"#g' "$HOME_DIR/config/app.toml" +# Also configure node 1 +sed -i "s#^minimum-gas-prices = .*#minimum-gas-prices = \"0.01${DENOM}\"#g" "$HOME_DIR_VAL1/config/app.toml" + +# Make all files writable find "$HOME_DIR" -mindepth 1 -exec chmod 777 {} + +find "$HOME_DIR_VAL1" -mindepth 1 -exec chmod 777 {} + echo "Provider init complete." 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 f5846d0..3dedb89 100644 --- a/x/vaas/provider/keeper/consumer_equivocation.go +++ b/x/vaas/provider/keeper/consumer_equivocation.go @@ -79,7 +79,7 @@ func (k Keeper) HandleConsumerDoubleVoting( return err } - if err = k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign); err != nil { + if err = k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN); err != nil { return err } if err = k.JailAndTombstoneValidator(ctx, providerAddr, infractionParams.DoubleSign); err != nil { @@ -162,6 +162,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 string, 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 %s 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 string, 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, 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 // @@ -203,7 +273,7 @@ func (k Keeper) HandleConsumerMisbehaviour(ctx sdk.Context, consumerId string, m consumerId, types.NewConsumerConsAddress(sdk.ConsAddress(v.Address.Bytes())), ) - err := k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign) + err := k.SlashValidator(ctx, providerAddr, infractionParams.DoubleSign, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN) if err != nil { logger.Error("failed to slash validator: %s", err) continue @@ -493,7 +563,7 @@ func (k Keeper) ComputePowerToSlash(ctx sdk.Context, validator stakingtypes.Vali } // SlashValidator slashes validator with given provider Address -func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr types.ProviderConsAddress, slashingParams *types.SlashJailParameters) error { +func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr types.ProviderConsAddress, slashingParams *types.SlashJailParameters, infraction stakingtypes.Infraction) error { validator, err := k.stakingKeeper.GetValidatorByConsAddr(ctx, providerAddr.ToSdkConsAddr()) if err != nil && errors.Is(err, stakingtypes.ErrNoValidatorFound) { return errorsmod.Wrapf(slashingtypes.ErrNoValidatorForAddress, "provider consensus address: %s", providerAddr.String()) @@ -535,6 +605,6 @@ func (k Keeper) SlashValidator(ctx sdk.Context, providerAddr types.ProviderConsA return err } - _, err = k.stakingKeeper.SlashWithInfractionReason(ctx, consAdrr, 0, totalPower, slashingParams.SlashFraction, stakingtypes.Infraction_INFRACTION_DOUBLE_SIGN) + _, err = k.stakingKeeper.SlashWithInfractionReason(ctx, consAdrr, 0, totalPower, slashingParams.SlashFraction, infraction) return err } diff --git a/x/vaas/provider/keeper/consumer_equivocation_test.go b/x/vaas/provider/keeper/consumer_equivocation_test.go index 97ff8ec..af43dc2 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 := "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 := "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 := "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 c2615ca..0b950b8 100644 --- a/x/vaas/provider/keeper/ibc_v2_integration_test.go +++ b/x/vaas/provider/keeper/ibc_v2_integration_test.go @@ -12,6 +12,8 @@ import ( cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + testkeeper "github.com/allinbits/vaas/testutil/keeper" providertypes "github.com/allinbits/vaas/x/vaas/provider/types" vaastypes "github.com/allinbits/vaas/x/vaas/types" @@ -20,7 +22,7 @@ import ( // TestIBCV2PacketQueueing tests that VSC packets are correctly queued // and stored for later sending via IBC v2 client-based routing. func TestIBCV2PacketQueueing(t *testing.T) { - providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) defer ctrl.Finish() consumerId := "0" @@ -48,6 +50,10 @@ func TestIBCV2PacketQueueing(t *testing.T) { require.Equal(t, uint64(1), pending[0].ValsetUpdateId) require.Len(t, pending[0].ValidatorUpdates, 2) + mocks.MockChannelV2Keeper.EXPECT(). + SendPacket(gomock.Any(), gomock.Any()). + Return(nil, clienttypes.ErrClientNotActive) + err = providerKeeper.SendVSCPacketsToChain(ctx, consumerId, clientId) require.NoError(t, err) diff --git a/x/vaas/provider/keeper/keeper.go b/x/vaas/provider/keeper/keeper.go index 82539e0..2714314 100644 --- a/x/vaas/provider/keeper/keeper.go +++ b/x/vaas/provider/keeper/keeper.go @@ -83,6 +83,7 @@ func NewKeeper( cdc codec.BinaryCodec, storeService corestoretypes.KVStoreService, clientKeeper vaastypes.ClientKeeper, clientV2Keeper vaastypes.ClientV2Keeper, + channelKeeperV2 vaastypes.ChannelV2Keeper, stakingKeeper vaastypes.StakingKeeper, slashingKeeper vaastypes.SlashingKeeper, accountKeeper vaastypes.AccountKeeper, bankKeeper vaastypes.BankKeeper, @@ -106,6 +107,7 @@ func NewKeeper( feeCollectorName: feeCollectorName, validatorAddressCodec: validatorAddressCodec, consensusAddressCodec: consensusAddressCodec, + channelKeeperV2: channelKeeperV2, govKeeper: govKeeper, // Initialize collections @@ -165,11 +167,6 @@ func NewKeeper( return k } -// SetChannelKeeperV2 sets the IBC v2 channel keeper for client-based packet sending. -func (k *Keeper) SetChannelKeeperV2(keeper vaastypes.ChannelV2Keeper) { - k.channelKeeperV2 = keeper -} - // GetAuthority returns the x/ccv/provider module's authority. func (k Keeper) GetAuthority() string { return k.authority diff --git a/x/vaas/provider/keeper/relay.go b/x/vaas/provider/keeper/relay.go index 46849fd..a14931e 100644 --- a/x/vaas/provider/keeper/relay.go +++ b/x/vaas/provider/keeper/relay.go @@ -197,13 +197,6 @@ func (k Keeper) discoverActiveConsumerClient(ctx sdk.Context, consumerId, curren } func (k Keeper) SendVSCPacketsToChain(ctx sdk.Context, consumerId, clientId string) error { - if k.channelKeeperV2 == nil { - k.Logger(ctx).Debug("IBC v2 channel keeper not configured, skipping send", - "consumerId", consumerId, - ) - return nil - } - timeoutPeriod := min(k.GetVAASTimeoutPeriod(ctx), channeltypesv2.MaxTimeoutDelta) timeoutTimestamp := uint64(ctx.BlockTime().Add(timeoutPeriod).Unix()) diff --git a/x/vaas/provider/keeper/relay_test.go b/x/vaas/provider/keeper/relay_test.go index a9631cf..1197dab 100644 --- a/x/vaas/provider/keeper/relay_test.go +++ b/x/vaas/provider/keeper/relay_test.go @@ -9,6 +9,8 @@ import ( abci "github.com/cometbft/cometbft/abci/types" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + testkeeper "github.com/allinbits/vaas/testutil/keeper" providertypes "github.com/allinbits/vaas/x/vaas/provider/types" vaastypes "github.com/allinbits/vaas/x/vaas/types" @@ -138,9 +140,9 @@ func TestClientIdToConsumerIdMapping(t *testing.T) { } // TestSendVSCPacketsToChainNoHandler tests that SendVSCPacketsToChain gracefully -// handles the case when no IBC v2 channel keeper is configured. +// handles the case when the IBC v2 channel keeper returns ErrClientNotActive. func TestSendVSCPacketsToChainNoHandler(t *testing.T) { - providerKeeper, ctx, ctrl, _ := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) + providerKeeper, ctx, ctrl, mocks := testkeeper.GetProviderKeeperAndCtx(t, testkeeper.NewInMemKeeperParams(t)) defer ctrl.Finish() consumerId := "0" @@ -156,12 +158,15 @@ func TestSendVSCPacketsToChainNoHandler(t *testing.T) { ValsetUpdateId: 1, }) - // Without setting ChannelKeeperV2, SendVSCPacketsToChain should return nil - // and not send any packets (graceful no-op) + // Simulate an inactive client so packets are not sent + mocks.MockChannelV2Keeper.EXPECT(). + SendPacket(gomock.Any(), gomock.Any()). + Return(nil, clienttypes.ErrClientNotActive) + err := providerKeeper.SendVSCPacketsToChain(ctx, consumerId, clientId) require.NoError(t, err) - // Pending packets should still be there since no keeper was configured + // Pending packets should still be there since the client was not active pending := providerKeeper.GetPendingVSCPackets(ctx, consumerId) require.Len(t, pending, 1) } 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 +}