Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions proto/hyperlane/core/interchain_security/v1/events.proto
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,70 @@ message EventCreateRoutingIsm {
];
string owner = 2 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];
}

// EventCreateAggregationIsm is emitted when a new Aggregation ISM is created
message EventCreateAggregationIsm {
// ism_id is the unique identifier of the created Aggregation ISM
string ism_id = 1 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// owner is the address that owns the ISM
string owner = 2 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// modules is the list of child ISM IDs
repeated string modules = 3 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// threshold is the number of child ISMs that must pass verification
uint32 threshold = 4;
}

// EventSetAggregationIsmModules is emitted when an Aggregation ISM's modules
// are updated
message EventSetAggregationIsmModules {
// ism_id is the identifier of the updated Aggregation ISM
string ism_id = 1 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// owner is the address that performed the update
string owner = 2 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// modules is the new list of child ISM IDs
repeated string modules = 3 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// threshold is the new number of child ISMs that must pass verification
uint32 threshold = 4;
}

// EventSetAggregationIsm is emitted when an Aggregation ISM's ownership is
// updated
message EventSetAggregationIsm {
// ism_id is the identifier of the Aggregation ISM
string ism_id = 1 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// owner is the previous owner address
string owner = 2 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// new_owner is the new owner address (empty if ownership is renounced)
string new_owner = 3 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// renounce_ownership indicates if ownership was permanently renounced
bool renounce_ownership = 4;
}
112 changes: 111 additions & 1 deletion proto/hyperlane/core/interchain_security/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ service Msg {
// AnnounceValidator ...
rpc AnnounceValidator(MsgAnnounceValidator)
returns (MsgAnnounceValidatorResponse);

// CreateAggregationIsm creates a new Aggregation ISM that requires a
// threshold of child ISMs to pass verification. The creator becomes the owner
// of the ISM. Emits EventCreateAggregationIsm on success.
rpc CreateAggregationIsm(MsgCreateAggregationIsm)
returns (MsgCreateAggregationIsmResponse);

// SetAggregationIsmModules updates the modules and threshold of an existing
// Aggregation ISM. Only the owner can perform this operation.
// Emits EventSetAggregationIsmModules on success.
rpc SetAggregationIsmModules(MsgSetAggregationIsmModules)
returns (MsgSetAggregationIsmModulesResponse);

// UpdateAggregationIsmOwner transfers ownership of an Aggregation ISM to a
// new address or renounces ownership entirely. Only the current owner can
// perform this operation. Emits EventSetAggregationIsm on success.
rpc UpdateAggregationIsmOwner(MsgUpdateAggregationIsmOwner)
returns (MsgUpdateAggregationIsmOwnerResponse);
}

// MsgCreateMessageIdMultisigIsm ...
Expand Down Expand Up @@ -229,4 +247,96 @@ message MsgUpdateRoutingIsmOwner {
}

// MsgUpdateRoutingIsmOwnerResponse ...
message MsgUpdateRoutingIsmOwnerResponse {}
message MsgUpdateRoutingIsmOwnerResponse {}

// MsgCreateAggregationIsm creates an Aggregation ISM that verifies messages by
// requiring a threshold number of child ISMs to pass verification (M-of-N
// pattern).
message MsgCreateAggregationIsm {
option (cosmos.msg.v1.signer) = "creator";
option (amino.name) = "hyperlane/v1/MsgCreateAggregationIsm";

// creator is the address that will become the owner of the ISM
string creator = 1 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// modules is the list of child ISM IDs to aggregate
repeated string modules = 2 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// threshold is the number of child ISMs that must pass (must be > 0 and <=
// len(modules))
uint32 threshold = 3;
}

// MsgCreateAggregationIsmResponse returns the ID of the newly created
// Aggregation ISM
message MsgCreateAggregationIsmResponse {
// id is the unique identifier of the created Aggregation ISM
string id = 1 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];
}

// MsgSetAggregationIsmModules updates the child ISMs and threshold of an
// existing Aggregation ISM. Only the current owner can execute this operation.
message MsgSetAggregationIsmModules {
option (cosmos.msg.v1.signer) = "owner";
option (amino.name) = "hyperlane/v1/MsgSetAggregationIsmModules";

// ism_id is the identifier of the Aggregation ISM to update
string ism_id = 1 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// modules is the new list of child ISM IDs to aggregate
repeated string modules = 2 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// threshold is the new number of child ISMs that must pass verification
uint32 threshold = 3;

// owner is the current owner address (must match the ISM's owner)
string owner = 4 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];
}

// MsgSetAggregationIsmModulesResponse ...
message MsgSetAggregationIsmModulesResponse {}

// MsgUpdateAggregationIsmOwner transfers ownership of an Aggregation ISM to a
// new address or renounces ownership entirely. Only the current owner can
// execute this.
message MsgUpdateAggregationIsmOwner {
option (cosmos.msg.v1.signer) = "owner";
option (amino.name) = "hyperlane/v1/MsgUpdateAggregationIsmOwner";

// ism_id is the identifier of the Aggregation ISM
string ism_id = 1 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// owner is the current owner address (must match the ISM's owner)
string owner = 2 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// new_owner is the address to transfer ownership to (ignored if
// renounce_ownership is true)
string new_owner = 3 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// renounce_ownership removes ownership permanently if true (new_owner must be
// empty)
bool renounce_ownership = 4;
}

// MsgUpdateAggregationIsmOwnerResponse ...
message MsgUpdateAggregationIsmOwnerResponse {}
30 changes: 30 additions & 0 deletions proto/hyperlane/core/interchain_security/v1/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ message MerkleRootMultisigISM {
uint32 threshold = 4;
}

// AggregationISM verifies messages by requiring a threshold number of child
// ISMs to pass verification (M-of-N pattern). This allows combining multiple
// security mechanisms with flexible threshold requirements.
message AggregationISM {
option (gogoproto.goproto_getters) = false;
option (cosmos_proto.implements_interface) =
"hyperlane.core.interchain_security.v1.HyperlaneInterchainSecurityModule";

// id is the unique identifier for this Aggregation ISM
string id = 1 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// owner is the address that can update the modules and threshold or transfer
// ownership
string owner = 2 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// modules is the list of child ISM IDs whose verifications are aggregated
repeated string modules = 3 [
(gogoproto.customtype) =
"github.com/bcp-innovations/hyperlane-cosmos/util.HexAddress",
(gogoproto.nullable) = false
];

// threshold is the minimum number of child ISMs that must pass verification
uint32 threshold = 4;
}

// NoopISM ...
message NoopISM {
option (gogoproto.goproto_getters) = false;
Expand Down
31 changes: 19 additions & 12 deletions tests/integration/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,25 +126,31 @@ func (m MockApp) ReceiverIsmId(_ context.Context, recipient util.HexAddress) (*u
const MOCK_ISM uint8 = 202

type MockIsm struct {
router *util.Router[util.InterchainSecurityModule]
isms map[util.HexAddress]struct{}
calls *int
moduleId uint8
router *util.Router[util.InterchainSecurityModule]
isms map[util.HexAddress]bool // maps ISM ID to verification result
calls *int
moduleId uint8
shouldVerify bool
}

func CreateMockIsm(router *util.Router[util.InterchainSecurityModule]) *MockIsm {
handler := MockIsm{
isms: make(map[util.HexAddress]struct{}),
router: router,
calls: new(int),
moduleId: MOCK_ISM,
isms: make(map[util.HexAddress]bool),
router: router,
calls: new(int),
moduleId: MOCK_ISM,
shouldVerify: true, // default to passing verification
}

router.RegisterModule(handler.moduleId, handler)

return &handler
}

func (m *MockIsm) SetShouldVerify(shouldVerify bool) {
m.shouldVerify = shouldVerify
}

func (m MockIsm) Exists(ctx context.Context, ismId util.HexAddress) (bool, error) {
_, ok := m.isms[ismId]
if !ok {
Expand All @@ -155,18 +161,19 @@ func (m MockIsm) Exists(ctx context.Context, ismId util.HexAddress) (bool, error

func (m MockIsm) Verify(ctx context.Context, ismId util.HexAddress, metadata []byte, message util.HyperlaneMessage) (bool, error) {
*m.calls++
if _, ok := m.isms[ismId]; !ok {
result, ok := m.isms[ismId]
if !ok {
return false, nil
}
return true, nil
return result, nil
}

func (m MockIsm) RegisterIsm(ctx context.Context) (util.HexAddress, error) {
func (m *MockIsm) RegisterIsm(ctx context.Context) (util.HexAddress, error) {
sequence, err := m.router.GetNextSequence(ctx, m.moduleId)
if err != nil {
return util.HexAddress{}, err
}
m.isms[sequence] = struct{}{}
m.isms[sequence] = m.shouldVerify
return sequence, nil
}

Expand Down
70 changes: 70 additions & 0 deletions x/core/01_interchain_security/keeper/aggregation_ism_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package keeper

import (
"context"

"cosmossdk.io/errors"
"github.com/bcp-innovations/hyperlane-cosmos/util"
"github.com/bcp-innovations/hyperlane-cosmos/x/core/01_interchain_security/types"
)

// AggregationISMHandler
// The AggregationISM is a special ISM that aggregates verification from multiple ISMs.
// It requires a threshold number of ISMs to pass verification for the aggregation to succeed.
type AggregationISMHandler struct {
keeper *Keeper // The ism keeper
}

// Verify implements HyperlaneInterchainSecurityModule
// Verifies the message against all configured ISMs and returns true if threshold is met.
func (m *AggregationISMHandler) Verify(ctx context.Context, ismId util.HexAddress, metadata []byte, message util.HyperlaneMessage) (bool, error) {
ism, err := m.keeper.isms.Get(ctx, ismId.GetInternalId())
if err != nil {
return false, err
}

// check if the ism is an aggregation ism
aggregationIsm, ok := ism.(*types.AggregationISM)
if !ok {
return false, errors.Wrapf(types.ErrInvalidISMType, "ISM %s is not an aggregation ISM", ismId.String())
}

// count how many ISMs pass verification
passCount := uint32(0)
var verificationErrors []error

for _, moduleId := range aggregationIsm.Modules {
// call the top level Verify method on the core module
// this method will then recursively invoke the Verify method on all the sub ISMs
verified, err := m.keeper.coreKeeper.Verify(ctx, moduleId, metadata, message)
if err != nil {
// Track errors for diagnostic purposes
verificationErrors = append(verificationErrors, errors.Wrapf(err, "ISM %s verification error", moduleId.String()))
continue
}

if verified {
passCount++
// Early exit if we've already met the threshold
if passCount >= aggregationIsm.Threshold {
return true, nil
}
}
}

// Build detailed error message with verification failures
errMsg := errors.Wrapf(types.ErrInsufficientVerifications,
"insufficient verifications: %d/%d (threshold: %d)",
Comment thread
jonas089 marked this conversation as resolved.
passCount, len(aggregationIsm.Modules), aggregationIsm.Threshold)

// Append individual verification errors for debugging
for _, verErr := range verificationErrors {
errMsg = errors.Wrap(errMsg, verErr.Error())
}

return false, errMsg
}

func (m *AggregationISMHandler) Exists(ctx context.Context, ismId util.HexAddress) (bool, error) {
return m.keeper.isms.Has(ctx, ismId.GetInternalId())
}
Loading
Loading