diff --git a/e2e/tests/canton/common.go b/e2e/tests/canton/common.go index 1f15a515..578a0561 100644 --- a/e2e/tests/canton/common.go +++ b/e2e/tests/canton/common.go @@ -119,7 +119,7 @@ func (s *TestSuite) createMCMS(ctx context.Context, participant testhelpers.Part UserID: participant.UserName, CommandID: commandID, ActAs: []string{participant.Party}, - Commands: []*model.Command{{Command: mcmsContract.CreateCommandWithPackageID(s.packageIDs[0])}}, + Commands: []*model.Command{{Command: mcmsContract.CreateCommand()}}, }, } diff --git a/e2e/tests/canton/inspector.go b/e2e/tests/canton/inspector.go new file mode 100644 index 00000000..cbcdc008 --- /dev/null +++ b/e2e/tests/canton/inspector.go @@ -0,0 +1,238 @@ +//go:build e2e + +package canton + +import ( + "context" + "io" + "slices" + "testing" + + apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/suite" + + cantonsdk "github.com/smartcontractkit/mcms/sdk/canton" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +type MCMSInspectorTestSuite struct { + TestSuite + inspector *cantonsdk.Inspector +} + +// SetupSuite runs before the test suite +func (s *MCMSInspectorTestSuite) SetupSuite() { + s.TestSuite.SetupSuite() + s.DeployMCMSContract() + + // Create inspector instance using participant's StateServiceClient + s.inspector = cantonsdk.NewInspector(s.participant.StateServiceClient, s.participant.Party) +} + +func (s *MCMSInspectorTestSuite) TestGetConfig() { + ctx := s.T().Context() + + // Signers in each group need to be sorted alphabetically + signers := [30]common.Address{} + for i := range signers { + key, _ := crypto.GenerateKey() + signers[i] = crypto.PubkeyToAddress(key.PublicKey) + } + slices.SortFunc(signers[:], func(a, b common.Address) int { + return a.Cmp(b) + }) + + expectedConfig := &mcmstypes.Config{ + Quorum: 2, + Signers: []common.Address{ + signers[0], + signers[1], + signers[2], + }, + GroupSigners: []mcmstypes.Config{ + { + Quorum: 4, + Signers: []common.Address{ + signers[3], + signers[4], + signers[5], + signers[6], + signers[7], + }, + GroupSigners: []mcmstypes.Config{ + { + Quorum: 1, + Signers: []common.Address{ + signers[8], + signers[9], + }, + GroupSigners: []mcmstypes.Config{}, + }, + }, + }, + { + Quorum: 3, + Signers: []common.Address{ + signers[10], + signers[11], + signers[12], + signers[13], + }, + GroupSigners: []mcmstypes.Config{}, + }, + }, + } + + // Set config using configurer + configurer, err := cantonsdk.NewConfigurer(s.client, s.participant.UserName, s.participant.Party) + s.Require().NoError(err, "creating configurer") + + _, err = configurer.SetConfig(ctx, s.mcmsContractID, expectedConfig, true) + s.Require().NoError(err, "setting config") + + // Get the new contract ID after SetConfig (which archives old and creates new) + newContractID, err := s.getLatestMCMSContractID(ctx) + s.Require().NoError(err, "getting latest MCMS contract ID") + + // Now test the inspector + actualConfig, err := s.inspector.GetConfig(ctx, newContractID) + s.Require().NoError(err, "getting config from inspector") + s.Require().NotNil(actualConfig, "config should not be nil") + + // Verify the config matches what we set + s.verifyConfigMatch(expectedConfig, actualConfig) +} + +func (s *MCMSInspectorTestSuite) TestGetOpCount() { + ctx := s.T().Context() + + // Get the latest contract ID + contractID, err := s.getLatestMCMSContractID(ctx) + s.Require().NoError(err, "getting latest MCMS contract ID") + + // Get op count + opCount, err := s.inspector.GetOpCount(ctx, contractID) + s.Require().NoError(err, "getting op count") + + // Initially should be 0 + s.Require().Equal(uint64(0), opCount, "initial op count should be 0") +} + +func (s *MCMSInspectorTestSuite) TestGetRoot() { + ctx := s.T().Context() + + // Get the latest contract ID + contractID, err := s.getLatestMCMSContractID(ctx) + s.Require().NoError(err, "getting latest MCMS contract ID") + + // Get root + root, validUntil, err := s.inspector.GetRoot(ctx, contractID) + s.Require().NoError(err, "getting root") + + // Initially root should be empty and validUntil should be 0 + s.Require().Equal(common.Hash{}, root, "initial root should be empty") + s.Require().Equal(uint32(0), validUntil, "initial validUntil should be 0") +} + +func (s *MCMSInspectorTestSuite) TestGetRootMetadata() { + ctx := s.T().Context() + + // Get the latest contract ID + contractID, err := s.getLatestMCMSContractID(ctx) + s.Require().NoError(err, "getting latest MCMS contract ID") + + // Get root metadata + metadata, err := s.inspector.GetRootMetadata(ctx, contractID) + s.Require().NoError(err, "getting root metadata") + + // Verify metadata structure + s.Require().Equal(uint64(0), metadata.StartingOpCount, "initial starting op count should be 0") + s.Require().NotEmpty(metadata.MCMAddress, "MCM address should not be empty") +} + +// Helper function to get the latest MCMS contract ID +func (s *MCMSInspectorTestSuite) getLatestMCMSContractID(ctx context.Context) (string, error) { + // Get current ledger offset + ledgerEndResp, err := s.participant.StateServiceClient.GetLedgerEnd(ctx, &apiv2.GetLedgerEndRequest{}) + if err != nil { + return "", err + } + + // Query active contracts + activeContractsResp, err := s.participant.StateServiceClient.GetActiveContracts(ctx, &apiv2.GetActiveContractsRequest{ + ActiveAtOffset: ledgerEndResp.GetOffset(), + EventFormat: &apiv2.EventFormat{ + FiltersByParty: map[string]*apiv2.Filters{ + s.participant.Party: { + Cumulative: []*apiv2.CumulativeFilter{ + { + IdentifierFilter: &apiv2.CumulativeFilter_TemplateFilter{ + TemplateFilter: &apiv2.TemplateFilter{ + TemplateId: &apiv2.Identifier{ + PackageId: "#mcms", + ModuleName: "MCMS.Main", + EntityName: "MCMS", + }, + IncludeCreatedEventBlob: false, + }, + }, + }, + }, + }, + }, + Verbose: true, + }, + }) + if err != nil { + return "", err + } + defer activeContractsResp.CloseSend() + + // Get the first (and should be only) active MCMS contract + for { + resp, err := activeContractsResp.Recv() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + + activeContract, ok := resp.GetContractEntry().(*apiv2.GetActiveContractsResponse_ActiveContract) + if !ok { + continue + } + + createdEvent := activeContract.ActiveContract.GetCreatedEvent() + if createdEvent == nil { + continue + } + + return createdEvent.ContractId, nil + } + + return "", nil +} + +// Helper to verify config matches +func (s *MCMSInspectorTestSuite) verifyConfigMatch(expected, actual *mcmstypes.Config) { + s.Require().Equal(expected.Quorum, actual.Quorum, "quorum should match") + s.Require().Equal(len(expected.Signers), len(actual.Signers), "number of signers should match") + + // Verify signers + for i, expectedSigner := range expected.Signers { + s.Require().Equal(expectedSigner, actual.Signers[i], "signer %d should match", i) + } + + // Verify group signers recursively + s.Require().Equal(len(expected.GroupSigners), len(actual.GroupSigners), "number of group signers should match") + for i, expectedGroup := range expected.GroupSigners { + s.verifyConfigMatch(&expectedGroup, &actual.GroupSigners[i]) + } +} + +func TestMCMSInspectorSuite(t *testing.T) { + suite.Run(t, new(MCMSInspectorTestSuite)) +} diff --git a/e2e/tests/runner_test.go b/e2e/tests/runner_test.go index cd0aa434..4d23bd6e 100644 --- a/e2e/tests/runner_test.go +++ b/e2e/tests/runner_test.go @@ -53,4 +53,5 @@ func TestTONSuite(t *testing.T) { func TestCantonSuite(t *testing.T) { suite.Run(t, new(cantone2e.MCMSConfigurerTestSuite)) + suite.Run(t, new(cantone2e.MCMSInspectorTestSuite)) } diff --git a/go.mod b/go.mod index 682484f8..3061ab6b 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,12 @@ replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlin replace github.com/digital-asset/dazl-client/v8 => github.com/noders-team/dazl-client/v8 v8.7.1-2 -replace github.com/noders-team/go-daml => github.com/stackman27/go-daml v0.0.0-20260129035354-bee9c994446f +replace github.com/noders-team/go-daml => github.com/stackman27/go-daml v0.0.0-20260204001938-550ee9d8ab10 require ( github.com/aptos-labs/aptos-go-sdk v1.11.0 github.com/block-vision/sui-go-sdk v1.1.4 + github.com/digital-asset/dazl-client/v8 v8.8.0 github.com/ethereum/go-ethereum v1.16.8 github.com/gagliardetto/binary v0.8.0 github.com/gagliardetto/solana-go v1.13.0 @@ -96,7 +97,6 @@ require ( github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/deepmap/oapi-codegen v1.8.2 // indirect - github.com/digital-asset/dazl-client/v8 v8.8.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect diff --git a/go.sum b/go.sum index 2898866e..cbb286aa 100644 --- a/go.sum +++ b/go.sum @@ -743,8 +743,8 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stackman27/go-daml v0.0.0-20260129035354-bee9c994446f h1:I79FWYle5/t1QxAZHClHc8OWJoKeUc97PNEX0XV9meM= -github.com/stackman27/go-daml v0.0.0-20260129035354-bee9c994446f/go.mod h1:yi458NGE4dlDOhlyCZvQ2XgsIOdHHvepwoHRgEusbo8= +github.com/stackman27/go-daml v0.0.0-20260204001938-550ee9d8ab10 h1:vihbDjQcH7ipRkIWQSIWcmjQ/wJQn2G4aBVc+erx4fM= +github.com/stackman27/go-daml v0.0.0-20260204001938-550ee9d8ab10/go.mod h1:yi458NGE4dlDOhlyCZvQ2XgsIOdHHvepwoHRgEusbo8= github.com/stephenlacy/go-ethereum-hdwallet v0.0.0-20230913225845-a4fa94429863 h1:ba4VRWSkRzgdP5hB5OxexIzBXZbSwgcw8bEu06ivGQI= github.com/stephenlacy/go-ethereum-hdwallet v0.0.0-20230913225845-a4fa94429863/go.mod h1:oPTjPNrRucLv9mU27iNPj6n0CWWcNFhoXFOLVGJwHCA= github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= diff --git a/sdk/canton/configurer.go b/sdk/canton/configurer.go index edf7d3c4..14ba3a3b 100644 --- a/sdk/canton/configurer.go +++ b/sdk/canton/configurer.go @@ -93,7 +93,7 @@ func (c Configurer) SetConfig(ctx context.Context, mcmsAddr string, cfg *types.C ActAs: []string{c.party}, Commands: []*model.Command{{ Command: &model.ExerciseCommand{ - TemplateID: fmt.Sprintf("%s:%s:%s", mcmsPkgID, "MCMS.Main", "MCMS"), + TemplateID: mcmsContract.GetTemplateID(), ContractID: exerciseCmd.ContractID, Choice: exerciseCmd.Choice, Arguments: exerciseCmd.Arguments, diff --git a/sdk/canton/inspector.go b/sdk/canton/inspector.go new file mode 100644 index 00000000..25ff2e2e --- /dev/null +++ b/sdk/canton/inspector.go @@ -0,0 +1,233 @@ +package canton + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" + + apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-canton/bindings" + "github.com/smartcontractkit/chainlink-canton/bindings/mcms" + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Inspector = &Inspector{} + +type Inspector struct { + stateClient apiv2.StateServiceClient + party string + contractCache *mcms.MCMS // Cache MCMS to avoid repeated RPC calls +} + +func NewInspector(stateClient apiv2.StateServiceClient, party string) *Inspector { + return &Inspector{ + stateClient: stateClient, + party: party, + } +} + +func (i *Inspector) GetConfig(ctx context.Context, mcmsAddr string) (*types.Config, error) { + if i.contractCache == nil { + mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) + if err != nil { + return nil, fmt.Errorf("failed to get MCMS contract: %w", err) + } + i.contractCache = mcmsContract + } + + return toConfig(i.contractCache.Config) +} + +func (i *Inspector) GetOpCount(ctx context.Context, mcmsAddr string) (uint64, error) { + if i.contractCache == nil { + mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) + if err != nil { + return 0, fmt.Errorf("failed to get MCMS contract: %w", err) + } + i.contractCache = mcmsContract + } + + return uint64(i.contractCache.ExpiringRoot.OpCount), nil +} + +func (i *Inspector) GetRoot(ctx context.Context, mcmsAddr string) (common.Hash, uint32, error) { + if i.contractCache == nil { + mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) + if err != nil { + return common.Hash{}, 0, fmt.Errorf("failed to get MCMS contract: %w", err) + } + i.contractCache = mcmsContract + } + + // Parse the root from hex string + rootStr := string(i.contractCache.ExpiringRoot.Root) + rootStr = strings.TrimPrefix(rootStr, "0x") + rootBytes, err := hex.DecodeString(rootStr) + if err != nil { + return common.Hash{}, 0, fmt.Errorf("failed to decode root hash: %w", err) + } + + root := common.BytesToHash(rootBytes) + + // validUntil is a TIMESTAMP (which wraps time.Time) + // Convert to Unix timestamp (uint32) + timeVal := time.Time(i.contractCache.ExpiringRoot.ValidUntil) + validUntil := uint32(timeVal.Unix()) + + return root, validUntil, nil +} + +func (i *Inspector) GetRootMetadata(ctx context.Context, mcmsAddr string) (types.ChainMetadata, error) { + if i.contractCache == nil { + mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) + if err != nil { + return types.ChainMetadata{}, fmt.Errorf("failed to get MCMS contract: %w", err) + } + i.contractCache = mcmsContract + } + + return types.ChainMetadata{ + StartingOpCount: uint64(i.contractCache.RootMetadata.PreOpCount), + MCMAddress: string(i.contractCache.McmsId), + }, nil +} + +// getMCMSContract queries the active MCMS contract by contract ID +func (i *Inspector) getMCMSContract(ctx context.Context, mcmsAddr string) (*mcms.MCMS, error) { + // Get current ledger offset + ledgerEndResp, err := i.stateClient.GetLedgerEnd(ctx, &apiv2.GetLedgerEndRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get ledger end: %w", err) + } + + // Query active contracts at current offset + activeContractsResp, err := i.stateClient.GetActiveContracts(ctx, &apiv2.GetActiveContractsRequest{ + ActiveAtOffset: ledgerEndResp.GetOffset(), + EventFormat: &apiv2.EventFormat{ + FiltersByParty: map[string]*apiv2.Filters{ + i.party: { + Cumulative: []*apiv2.CumulativeFilter{ + { + IdentifierFilter: &apiv2.CumulativeFilter_TemplateFilter{ + TemplateFilter: &apiv2.TemplateFilter{ + TemplateId: &apiv2.Identifier{ + PackageId: "#mcms", + ModuleName: "MCMS.Main", + EntityName: "MCMS", + }, + IncludeCreatedEventBlob: false, + }, + }, + }, + }, + }, + }, + Verbose: true, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get active contracts: %w", err) + } + defer activeContractsResp.CloseSend() + + // Stream through active contracts to find the MCMS contract with matching ID + for { + resp, err := activeContractsResp.Recv() + if errors.Is(err, io.EOF) { + // Stream ended without finding the contract + return nil, fmt.Errorf("MCMS contract with ID %s not found", mcmsAddr) + } + if err != nil { + return nil, fmt.Errorf("failed to receive active contracts: %w", err) + } + + activeContract, ok := resp.GetContractEntry().(*apiv2.GetActiveContractsResponse_ActiveContract) + if !ok { + continue + } + + createdEvent := activeContract.ActiveContract.GetCreatedEvent() + if createdEvent == nil { + continue + } + + // Check if contract ID matches + if createdEvent.ContractId != mcmsAddr { + continue + } + + // Use bindings package to unmarshal the contract + mcmsContract, err := bindings.UnmarshalActiveContract[mcms.MCMS](activeContract) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal MCMS contract: %w", err) + } + + return mcmsContract, nil + } +} + +// toConfig converts a Canton MultisigConfig to the chain-agnostic types.Config +func toConfig(bindConfig mcms.MultisigConfig) (*types.Config, error) { + // Group signers by group index + signersByGroup := make([][]common.Address, 32) // MCMS supports up to 32 groups + + for _, signer := range bindConfig.Signers { + groupIdx := int(signer.SignerGroup) + if groupIdx >= 32 { + return nil, fmt.Errorf("signer group index %d exceeds maximum of 31", groupIdx) + } + + // Parse signer address + addr := common.HexToAddress(string(signer.SignerAddress)) + signersByGroup[groupIdx] = append(signersByGroup[groupIdx], addr) + } + + // Build the group configs + groups := make([]types.Config, 32) + for i := 0; i < 32; i++ { + signers := signersByGroup[i] + if signers == nil { + signers = []common.Address{} + } + + quorum := uint8(0) + if i < len(bindConfig.GroupQuorums) { + quorum = uint8(bindConfig.GroupQuorums[i]) + } + + groups[i] = types.Config{ + Signers: signers, + GroupSigners: []types.Config{}, + Quorum: quorum, + } + } + + // Link the group signers; this assumes a group's parent always has a lower index + // Process in reverse order to build the tree from leaves to root + for i := 31; i >= 0; i-- { + parent := uint8(0) + if i < len(bindConfig.GroupParents) { + parent = uint8(bindConfig.GroupParents[i]) + } + + // Add non-empty child groups to their parent + // Skip the root group (i == 0) and empty groups (quorum == 0) + if i > 0 && groups[i].Quorum > 0 { + groups[parent].GroupSigners = append([]types.Config{groups[i]}, groups[parent].GroupSigners...) + } + } + + // Validate the root group config + if err := groups[0].Validate(); err != nil { + return nil, fmt.Errorf("invalid MCMS config: %w", err) + } + + return &groups[0], nil +} diff --git a/sdk/canton/inspector_test.go b/sdk/canton/inspector_test.go new file mode 100644 index 00000000..7aa3442b --- /dev/null +++ b/sdk/canton/inspector_test.go @@ -0,0 +1,193 @@ +//go:build e2e + +package canton + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/noders-team/go-daml/pkg/types" + "github.com/smartcontractkit/chainlink-canton/bindings/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" +) + +func TestToConfig(t *testing.T) { + tests := []struct { + name string + description string + input mcms.MultisigConfig + expected mcmstypes.Config + }{ + { + name: "simple_2of3", + description: "Simple 2-of-3 multisig with all signers in root group (group 0)", + input: mcms.MultisigConfig{ + Signers: []mcms.SignerInfo{ + {SignerAddress: types.TEXT("0x1111111111111111111111111111111111111111"), SignerIndex: types.INT64(0), SignerGroup: types.INT64(0)}, + {SignerAddress: types.TEXT("0x2222222222222222222222222222222222222222"), SignerIndex: types.INT64(1), SignerGroup: types.INT64(0)}, + {SignerAddress: types.TEXT("0x3333333333333333333333333333333333333333"), SignerIndex: types.INT64(2), SignerGroup: types.INT64(0)}, + }, + GroupQuorums: []types.INT64{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + GroupParents: []types.INT64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + }, + expected: mcmstypes.Config{ + Quorum: 2, + Signers: []common.Address{ + common.HexToAddress("1111111111111111111111111111111111111111"), + common.HexToAddress("2222222222222222222222222222222222222222"), + common.HexToAddress("3333333333333333333333333333333333333333"), + }, + GroupSigners: []mcmstypes.Config{}, + }, + }, + { + name: "hierarchical_2level", + description: "2-level hierarchy: root group 0 has 1 direct signer + group 1 as child. Group 1 has 3 signers with quorum 2. Root quorum is 1 (can be satisfied by direct signer OR group 1 reaching quorum).", + input: mcms.MultisigConfig{ + Signers: []mcms.SignerInfo{ + {SignerAddress: types.TEXT("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), SignerIndex: types.INT64(0), SignerGroup: types.INT64(0)}, + {SignerAddress: types.TEXT("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), SignerIndex: types.INT64(1), SignerGroup: types.INT64(1)}, + {SignerAddress: types.TEXT("0xcccccccccccccccccccccccccccccccccccccccc"), SignerIndex: types.INT64(2), SignerGroup: types.INT64(1)}, + {SignerAddress: types.TEXT("0xdddddddddddddddddddddddddddddddddddddddd"), SignerIndex: types.INT64(3), SignerGroup: types.INT64(1)}, + }, + GroupQuorums: []types.INT64{1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + GroupParents: []types.INT64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + }, + expected: mcmstypes.Config{ + Quorum: 1, + Signers: []common.Address{ + common.HexToAddress("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + }, + GroupSigners: []mcmstypes.Config{ + { + Quorum: 2, + Signers: []common.Address{ + common.HexToAddress("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + common.HexToAddress("cccccccccccccccccccccccccccccccccccccccc"), + common.HexToAddress("dddddddddddddddddddddddddddddddddddddddd"), + }, + GroupSigners: []mcmstypes.Config{}, + }, + }, + }, + }, + { + name: "complex_3level", + description: "3-level hierarchy: Group 0 (root) quorum 2, Group 1 (parent 0) quorum 2, Group 2 (parent 0) quorum 1, Group 3 (parent 1) quorum 2. Tests deeper nesting with multiple child groups at same level.", + input: mcms.MultisigConfig{ + Signers: []mcms.SignerInfo{ + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000001"), SignerIndex: types.INT64(0), SignerGroup: types.INT64(0)}, + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000002"), SignerIndex: types.INT64(1), SignerGroup: types.INT64(1)}, + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000003"), SignerIndex: types.INT64(2), SignerGroup: types.INT64(1)}, + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000004"), SignerIndex: types.INT64(3), SignerGroup: types.INT64(2)}, + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000005"), SignerIndex: types.INT64(4), SignerGroup: types.INT64(2)}, + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000006"), SignerIndex: types.INT64(5), SignerGroup: types.INT64(3)}, + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000007"), SignerIndex: types.INT64(6), SignerGroup: types.INT64(3)}, + {SignerAddress: types.TEXT("0x1000000000000000000000000000000000000008"), SignerIndex: types.INT64(7), SignerGroup: types.INT64(3)}, + }, + GroupQuorums: []types.INT64{2, 2, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + GroupParents: []types.INT64{0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + }, + expected: mcmstypes.Config{ + Quorum: 2, + Signers: []common.Address{ + common.HexToAddress("1000000000000000000000000000000000000001"), + }, + GroupSigners: []mcmstypes.Config{ + { + Quorum: 2, + Signers: []common.Address{ + common.HexToAddress("1000000000000000000000000000000000000002"), + common.HexToAddress("1000000000000000000000000000000000000003"), + }, + GroupSigners: []mcmstypes.Config{ + { + Quorum: 2, + Signers: []common.Address{ + common.HexToAddress("1000000000000000000000000000000000000006"), + common.HexToAddress("1000000000000000000000000000000000000007"), + common.HexToAddress("1000000000000000000000000000000000000008"), + }, + GroupSigners: []mcmstypes.Config{}, + }, + }, + }, + { + Quorum: 1, + Signers: []common.Address{ + common.HexToAddress("1000000000000000000000000000000000000004"), + common.HexToAddress("1000000000000000000000000000000000000005"), + }, + GroupSigners: []mcmstypes.Config{}, + }, + }, + }, + }, + { + name: "empty_groups_edge_case", + description: "Edge case: groups with quorum 0 (disabled) interspersed with active groups. Group 0 active (quorum 1), Group 1 disabled (quorum 0), Group 2 active (quorum 2, parent 0). The toConfig function should skip disabled groups.", + input: mcms.MultisigConfig{ + Signers: []mcms.SignerInfo{ + {SignerAddress: types.TEXT("0xdead000000000000000000000000000000000001"), SignerIndex: types.INT64(0), SignerGroup: types.INT64(0)}, + {SignerAddress: types.TEXT("0xdead000000000000000000000000000000000002"), SignerIndex: types.INT64(1), SignerGroup: types.INT64(2)}, + {SignerAddress: types.TEXT("0xdead000000000000000000000000000000000003"), SignerIndex: types.INT64(2), SignerGroup: types.INT64(2)}, + }, + GroupQuorums: []types.INT64{1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + GroupParents: []types.INT64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + }, + expected: mcmstypes.Config{ + Quorum: 1, + Signers: []common.Address{ + common.HexToAddress("dead000000000000000000000000000000000001"), + }, + GroupSigners: []mcmstypes.Config{ + { + Quorum: 2, + Signers: []common.Address{ + common.HexToAddress("dead000000000000000000000000000000000002"), + common.HexToAddress("dead000000000000000000000000000000000003"), + }, + GroupSigners: []mcmstypes.Config{}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := toConfig(tt.input) + require.NoError(t, err, tt.description) + require.NotNil(t, result) + + // Compare the result with expected + require.Equal(t, tt.expected.Quorum, result.Quorum, "quorum mismatch") + require.Equal(t, len(tt.expected.Signers), len(result.Signers), "signers count mismatch") + + // Compare signers + for i, expectedSigner := range tt.expected.Signers { + require.Equal(t, expectedSigner, result.Signers[i], "signer mismatch at index %d", i) + } + + // Compare group signers recursively + compareGroupSigners(t, tt.expected.GroupSigners, result.GroupSigners) + }) + } +} + +func compareGroupSigners(t *testing.T, expected, actual []mcmstypes.Config) { + require.Equal(t, len(expected), len(actual), "group signers count mismatch") + + for i := range expected { + require.Equal(t, expected[i].Quorum, actual[i].Quorum, "group %d quorum mismatch", i) + require.Equal(t, len(expected[i].Signers), len(actual[i].Signers), "group %d signers count mismatch", i) + + for j, expectedSigner := range expected[i].Signers { + require.Equal(t, expectedSigner, actual[i].Signers[j], "group %d signer mismatch at index %d", i, j) + } + + // Recursively compare nested group signers + compareGroupSigners(t, expected[i].GroupSigners, actual[i].GroupSigners) + } +}