diff --git a/builder.go b/builder.go index 540bb430..404bc268 100644 --- a/builder.go +++ b/builder.go @@ -47,6 +47,16 @@ func (b *BaseProposalBuilder[T]) AddChainMetadata(selector types.ChainSelector, return b.builder } +// AddAdditionalMCM appends an additional MCM instance to the chain metadata of the given +// selector. Requires the chain metadata to already exist (set via AddChainMetadata). +// Multi-MCM proposals must use version v2 (SetVersion("v2")). +func (b *BaseProposalBuilder[T]) AddAdditionalMCM(selector types.ChainSelector, metadata types.ChainMetadata) T { + entry := b.baseProposal.ChainMetadata[selector] + entry.AdditionalMCMs = append(entry.AdditionalMCMs, metadata) + b.baseProposal.ChainMetadata[selector] = entry + return b.builder +} + // SetChainMetadata sets the chain metadata of the BaseProposal. func (b *BaseProposalBuilder[T]) SetChainMetadata(metadata map[types.ChainSelector]types.ChainMetadata) T { b.baseProposal.ChainMetadata = metadata diff --git a/executable.go b/executable.go index f7cdd60c..ecc3f62c 100644 --- a/executable.go +++ b/executable.go @@ -15,11 +15,12 @@ import ( // information required to call SetRoot and Execute on the various chains that the proposal // targets. type Executable struct { - proposal *Proposal - executors map[types.ChainSelector]sdk.Executor - encoders map[types.ChainSelector]sdk.Encoder - tree *merkle.Tree - txNonces []uint64 + proposal *Proposal + executors map[types.ChainSelector]sdk.Executor + encoders map[types.ChainSelector]sdk.Encoder + instanceEncoders map[instanceKey]sdk.Encoder + tree *merkle.Tree + txNonces []uint64 } // NewExecutable creates a new Executable from a proposal and a map of executors. @@ -33,6 +34,12 @@ func NewExecutable( return nil, err } + // Generate the per-instance encoders (used for root metadata hashing) + instanceEncoders, err := proposal.GetInstanceEncoders() + if err != nil { + return nil, err + } + // Generate the tx nonces from the proposal txNonces, err := proposal.TransactionNonces() if err != nil { @@ -46,18 +53,61 @@ func NewExecutable( } return &Executable{ - proposal: proposal, - executors: executors, - encoders: encoders, - tree: tree, - txNonces: txNonces, + proposal: proposal, + executors: executors, + encoders: encoders, + instanceEncoders: instanceEncoders, + tree: tree, + txNonces: txNonces, }, nil } +// MCMAddresses returns the MCM instance addresses for a chain selector: the primary MCM +// followed by any additional MCM instances. Callers should call SetRoot once per address. +func (e *Executable) MCMAddresses(chainSelector types.ChainSelector) []string { + metadata, ok := e.proposal.ChainMetadata[chainSelector] + if !ok { + return nil + } + + instances := metadata.AllMCMs() + addresses := make([]string, 0, len(instances)) + for _, instance := range instances { + addresses = append(addresses, instance.MCMAddress) + } + + return addresses +} + +// SetRoot calls SetRoot on the chain's primary MCM instance. For chains with multiple +// MCM instances, use SetRootForMCM to target a specific instance. func (e *Executable) SetRoot(ctx context.Context, chainSelector types.ChainSelector) (types.TransactionResult, error) { - metadata := e.proposal.ChainMetadata[chainSelector] + return e.SetRootForMCM(ctx, chainSelector, "") +} - metadataHash, err := e.encoders[chainSelector].HashMetadata(metadata) +// SetRootForMCM calls SetRoot on the MCM instance identified by mcmAddress. An empty +// mcmAddress targets the chain's primary MCM instance. +func (e *Executable) SetRootForMCM( + ctx context.Context, chainSelector types.ChainSelector, mcmAddress string, +) (types.TransactionResult, error) { + metadata, ok := e.proposal.ChainMetadata[chainSelector] + if !ok { + return types.TransactionResult{}, NewChainMetadataNotFoundError(chainSelector) + } + + instanceMetadata, ok := metadata.GetMCM(mcmAddress) + if !ok { + return types.TransactionResult{}, fmt.Errorf( + "chain %d: mcmAddress %q does not match the chain's primary MCM or any additional MCM instance", + chainSelector, mcmAddress) + } + + metadata = instanceMetadata + + // Use the per-instance encoder so the metadata leaf hashes this instance's own + // postOpCount (StartingOpCount + instance op count). + metadataHash, err := e.instanceEncoders[instanceKey{chainSelector: chainSelector, mcmAddress: metadata.MCMAddress}]. + HashMetadata(metadata) if err != nil { return types.TransactionResult{}, err } @@ -81,11 +131,42 @@ func (e *Executable) SetRoot(ctx context.Context, chainSelector types.ChainSelec return recoveredSignerA.Cmp(recoveredSignerB) }) - return e.executors[chainSelector].SetRoot( + root := [32]byte(e.tree.Root.Bytes()) + executor := e.executors[chainSelector] + + // For chains with multiple MCM instances, the on-chain root metadata must carry the + // instance's own postOpCount (matching the metadata leaf hashed above). Executors + // derive postOpCount from their chain-wide tx count by default, so they must + // implement sdk.InstanceExecutor to support multi-instance set-root. + if len(e.proposal.ChainMetadata[chainSelector].AdditionalMCMs) > 0 { + instanceExecutor, ok := executor.(sdk.InstanceExecutor) + if !ok { + return types.TransactionResult{}, fmt.Errorf( + "chain %d: executor %T does not support multiple MCM instances (sdk.InstanceExecutor)", + chainSelector, executor) + } + + instanceOpCount := e.proposal.TransactionCountsByInstance()[instanceKey{ + chainSelector: chainSelector, + mcmAddress: metadata.MCMAddress, + }] + + return instanceExecutor.SetRootForInstance( + ctx, + metadata, + instanceOpCount, + proof, + root, + e.proposal.ValidUntil, + sortedSignatures, + ) + } + + return executor.SetRoot( ctx, metadata, proof, - [32]byte(e.tree.Root.Bytes()), + root, e.proposal.ValidUntil, sortedSignatures, ) @@ -94,7 +175,11 @@ func (e *Executable) SetRoot(ctx context.Context, chainSelector types.ChainSelec func (e *Executable) Execute(ctx context.Context, index int) (types.TransactionResult, error) { op := e.proposal.Operations[index] chainSelector := op.ChainSelector - metadata := e.proposal.ChainMetadata[chainSelector] + + metadata, err := e.proposal.mcmMetadataForOp(op) + if err != nil { + return types.TransactionResult{}, err + } txNonce, err := safecast.Uint64ToUint32(e.txNonces[index]) if err != nil { diff --git a/executable_test.go b/executable_test.go index acd5f0d7..05a03d31 100644 --- a/executable_test.go +++ b/executable_test.go @@ -732,3 +732,144 @@ func TestExecutable_TxNonce(t *testing.T) { }) } } + +// TestExecutor_MultiMCM_SetRootForMCM_And_Execute covers a v2 proposal spanning two MCM +// instances on the same chain: one signing ceremony, one global Merkle root, one +// SetRootForMCM per instance, and per-instance execution with per-instance nonces. +func TestExecutor_MultiMCM_SetRootForMCM_And_Execute(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sim := evmsim.NewSimulatedChain(t, 1) + + // Deploy two MCM instances on the same chain, both configured with the same signers + mcmPrimary, _ := sim.DeployMCMContract(t, sim.Signers[0]) + mcmSecond, _ := sim.DeployMCMContract(t, sim.Signers[0]) + sim.SetMCMSConfig(t, sim.Signers[0], mcmPrimary) + sim.SetMCMSConfig(t, sim.Signers[0], mcmSecond) + + // Each instance administers its own timelock so both grantRole ops can succeed + timelockPrimary, _ := sim.DeployRBACTimelock(t, sim.Signers[0], mcmPrimary.Address(), []common.Address{}, []common.Address{}, []common.Address{}, []common.Address{}) + timelockSecond, _ := sim.DeployRBACTimelock(t, sim.Signers[0], mcmSecond.Address(), []common.Address{}, []common.Address{}, []common.Address{}, []common.Address{}) + + role, err := timelockPrimary.PROPOSERROLE(&bind.CallOpts{}) + require.NoError(t, err) + timelockAbi, err := bindings.RBACTimelockMetaData.GetAbi() + require.NoError(t, err) + grantPrimaryData, err := timelockAbi.Pack("grantRole", role, mcmPrimary.Address()) + require.NoError(t, err) + grantSecondData, err := timelockAbi.Pack("grantRole", role, mcmSecond.Address()) + require.NoError(t, err) + + // One v2 proposal covering both instances: op0 is governed by the primary instance + // (no mcmAddress), op1 by the second instance. + proposal := Proposal{ + BaseProposal: BaseProposal{ + Version: "v2", + Description: "Grants RBACTimelock 'Proposer' role via two MCM instances", + Kind: types.KindProposal, + ValidUntil: 2004259681, + Signatures: []types.Signature{}, + OverridePreviousRoot: false, + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + chaintest.Chain1Selector: { + StartingOpCount: 0, + MCMAddress: mcmPrimary.Address().Hex(), + AdditionalMCMs: []types.ChainMetadata{ + {StartingOpCount: 0, MCMAddress: mcmSecond.Address().Hex()}, + }, + }, + }, + }, + Operations: []types.Operation{ + { + ChainSelector: chaintest.Chain1Selector, + Transaction: evm.NewTransaction( + timelockPrimary.Address(), + grantPrimaryData, + big.NewInt(0), + "RBACTimelock", + []string{"RBACTimelock", "GrantRole"}, + ), + }, + { + ChainSelector: chaintest.Chain1Selector, + McmAddress: mcmSecond.Address().Hex(), + Transaction: evm.NewTransaction( + timelockSecond.Address(), + grantSecondData, + big.NewInt(0), + "RBACTimelock", + []string{"RBACTimelock", "GrantRole"}, + ), + }, + }, + } + proposal.UseSimulatedBackend(true) + + require.NoError(t, proposal.Validate()) + + tree, err := proposal.MerkleTree() + require.NoError(t, err) + + inspectors := map[types.ChainSelector]sdk.Inspector{ + chaintest.Chain1Selector: evm.NewInspector(sim.Backend.Client()), + } + signable, err := NewSignable(&proposal, inspectors) + require.NoError(t, err) + + _, err = signable.SignAndAppend(NewPrivateKeySigner(sim.Signers[0].PrivateKey)) + require.NoError(t, err) + + // Quorum must hold on every instance + quorumMet, err := signable.ValidateSignatures(ctx) + require.NoError(t, err) + require.True(t, quorumMet) + + encoders, err := proposal.GetEncoders() + require.NoError(t, err) + executors := map[types.ChainSelector]sdk.Executor{ + chaintest.Chain1Selector: evm.NewExecutor( + encoders[chaintest.Chain1Selector].(*evm.Encoder), + sim.Backend.Client(), + sim.Signers[0].NewTransactOpts(t), + ), + } + executable, err := NewExecutable(&proposal, executors) + require.NoError(t, err) + + // Set the same global root on both instances; each call carries only that instance's + // metadata proof and per-instance postOpCount. + for _, mcmAddress := range executable.MCMAddresses(chaintest.Chain1Selector) { + tx, err := executable.SetRootForMCM(ctx, chaintest.Chain1Selector, mcmAddress) + require.NoError(t, err) + require.NotEmpty(t, tx.Hash) + sim.Backend.Commit() + } + + for _, mcmC := range []*bindings.ManyChainMultiSig{mcmPrimary, mcmSecond} { + root, err := mcmC.GetRoot(&bind.CallOpts{}) + require.NoError(t, err) + require.Equal(t, [32]byte(tree.Root.Bytes()), root.Root) + require.Equal(t, proposal.ValidUntil, root.ValidUntil) + } + + // Execute each instance's op; nonces sequence per instance (both are nonce 0). + tx, err := executable.Execute(ctx, 0) + require.NoError(t, err) + require.NotEmpty(t, tx.Hash) + sim.Backend.Commit() + + tx, err = executable.Execute(ctx, 1) + require.NoError(t, err) + require.NotEmpty(t, tx.Hash) + sim.Backend.Commit() + + opCountPrimary, err := mcmPrimary.GetOpCount(&bind.CallOpts{}) + require.NoError(t, err) + require.Equal(t, uint64(1), opCountPrimary.Uint64()) + + opCountSecond, err := mcmSecond.GetOpCount(&bind.CallOpts{}) + require.NoError(t, err) + require.Equal(t, uint64(1), opCountSecond.Uint64()) +} diff --git a/merge.go b/merge.go index eef8faac..ae5e21dc 100644 --- a/merge.go +++ b/merge.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "maps" + "slices" "time" "github.com/smartcontractkit/mcms/types" @@ -45,7 +46,7 @@ func (m *TimelockProposal) Merge(ctx context.Context, other *TimelockProposal) ( continue } - mergedMetadata, err := thisMetadata.Merge(otherMetadata) + mergedMetadata, err := mergeChainMetadata(thisMetadata, otherMetadata) if err != nil { return nil, fmt.Errorf("failed to merge metadata for chain %v: %w", chainSelector, err) } @@ -83,6 +84,75 @@ func (m *TimelockProposal) Merge(ctx context.Context, other *TimelockProposal) ( return m, nil } +// mergeChainMetadata merges two ChainMetadata entries for the same chain selector, +// supporting disjoint MCM instance sets: if the primary MCM addresses differ, each +// primary must be present in the other entry's instance set (primary + AdditionalMCMs), +// and the instances are unioned. The merged primary is chosen deterministically (the +// lexicographically smallest MCM address). +func mergeChainMetadata(this, other types.ChainMetadata) (types.ChainMetadata, error) { + if this.MCMAddress == other.MCMAddress { + return this.Merge(other) + } + + // Build instance maps keyed by MCM address. + thisInstances := make(map[string]types.ChainMetadata, len(this.AdditionalMCMs)+1) + for _, instance := range this.AllMCMs() { + thisInstances[instance.MCMAddress] = instance + } + otherInstances := make(map[string]types.ChainMetadata, len(other.AdditionalMCMs)+1) + for _, instance := range other.AllMCMs() { + otherInstances[instance.MCMAddress] = instance + } + + // Each primary must be known to the other side; otherwise the instance sets are + // truly disjoint and we cannot decide on a safe merge. + if _, ok := otherInstances[this.MCMAddress]; !ok { + return types.ChainMetadata{}, fmt.Errorf( + "cannot merge ChainMetadata with different MCMAddress: %s vs %s", + this.MCMAddress, other.MCMAddress) + } + if _, ok := thisInstances[other.MCMAddress]; !ok { + return types.ChainMetadata{}, fmt.Errorf( + "cannot merge ChainMetadata with different MCMAddress: %s vs %s", + this.MCMAddress, other.MCMAddress) + } + + // Union instances by address, merging duplicates with per-instance rules. + addresses := make([]string, 0, len(thisInstances)+len(otherInstances)) + merged := make(map[string]types.ChainMetadata, len(thisInstances)+len(otherInstances)) + for addr, instance := range thisInstances { + merged[addr] = instance + addresses = append(addresses, addr) + } + for addr, instance := range otherInstances { + if existing, ok := merged[addr]; ok { + m, err := existing.Merge(instance) + if err != nil { + return types.ChainMetadata{}, err + } + m.AdditionalMCMs = nil // instances never nest + merged[addr] = m + continue + } + merged[addr] = instance + addresses = append(addresses, addr) + } + + slices.Sort(addresses) + + primary := merged[addresses[0]] + result := types.ChainMetadata{ + StartingOpCount: primary.StartingOpCount, + MCMAddress: primary.MCMAddress, + AdditionalFields: primary.AdditionalFields, + } + for _, addr := range addresses[1:] { + result.AdditionalMCMs = append(result.AdditionalMCMs, merged[addr]) + } + + return result, nil +} + func mergeMetadata(m1, m2 map[string]any) map[string]any { if len(m2) == 0 { return m1 diff --git a/proposal.go b/proposal.go index 97dff81d..26c36dbc 100644 --- a/proposal.go +++ b/proposal.go @@ -93,7 +93,7 @@ func LoadProposal(proposalType types.ProposalKind, filePath string) (ProposalInt // BaseProposal is the base struct for all MCMS proposals, contains shared fields for all proposal types. type BaseProposal struct { - Version string `json:"version" validate:"required,oneof=v1"` + Version string `json:"version" validate:"required,oneof=v1 v2"` Kind types.ProposalKind `json:"kind" validate:"required,oneof=Proposal TimelockProposal"` ValidUntil uint32 `json:"validUntil" validate:"required"` Signatures []types.Signature `json:"signatures" validate:"omitempty,dive,required"` @@ -124,6 +124,49 @@ func (p *BaseProposal) setChainMetadata(chainSelector types.ChainSelector, metad p.ChainMetadata[chainSelector] = metadata } +// validateMultiMCM validates the multi-MCM invariants on the chain metadata and enforces +// that multi-MCM features are only used with proposal version v2. +func (p *BaseProposal) validateMultiMCM() error { + for chainSelector, metadata := range p.ChainMetadata { + if len(metadata.AdditionalMCMs) > 0 && p.Version != "v2" { + return fmt.Errorf( + "chain %d: additional MCM instances require proposal version v2, got %q", + chainSelector, p.Version) + } + if err := metadata.ValidateMultiMCM(); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + } + + return nil +} + +// instanceKey identifies a single MCM instance on a chain. +type instanceKey struct { + chainSelector types.ChainSelector + mcmAddress string +} + +// mcmMetadataForOp resolves the governing MCM instance metadata for an operation: the +// operation's McmAddress if set, otherwise the chain's primary MCM. Returns an error if +// the operation's chain is missing from the chain metadata or the McmAddress does not +// match any instance. +func (p *BaseProposal) mcmMetadataForOp(op types.Operation) (types.ChainMetadata, error) { + metadata, ok := p.ChainMetadata[op.ChainSelector] + if !ok { + return types.ChainMetadata{}, NewChainMetadataNotFoundError(op.ChainSelector) + } + + mcmMetadata, ok := metadata.GetMCM(op.McmAddress) + if !ok { + return types.ChainMetadata{}, fmt.Errorf( + "chain %d: operation mcmAddress %q does not match the chain's primary MCM or any additional MCM instance", + op.ChainSelector, op.McmAddress) + } + + return mcmMetadata, nil +} + // Proposal is a struct where the target contract is an MCMS contract // with no forwarder contracts. This type does not support any type of atomic contract // call batching, as the MCMS contract natively doesn't support batching @@ -189,19 +232,32 @@ func (p *Proposal) Validate() error { return err } - // Validate chain metadata for each chain selector + // Validate chain metadata for each chain selector (and each MCM instance on it) // Should only be needed for timelock proposals (specifically solana proposals), // but this might change as new chain families are added for chainSelector, metadata := range p.ChainMetadata { - if err := validateChainMetadata(metadata, chainSelector); err != nil { - return fmt.Errorf("error validating proposal: %w", err) + for _, instance := range metadata.AllMCMs() { + if err := validateChainMetadata(instance, chainSelector); err != nil { + return fmt.Errorf("error validating proposal: %w", err) + } } } - // Validate all chains in operations have an entry in chain metadata + // Validate multi-MCM invariants and version gating + if err := p.validateMultiMCM(); err != nil { + return err + } + + // Validate all chains in operations have an entry in chain metadata, and that any + // operation-level MCM address resolves to a known instance for _, op := range p.Operations { - if _, ok := p.ChainMetadata[op.ChainSelector]; !ok { - return NewChainMetadataNotFoundError(op.ChainSelector) + if op.McmAddress != "" && p.Version != "v2" { + return fmt.Errorf( + "chain %d: operation mcmAddress requires proposal version v2, got %q", + op.ChainSelector, p.Version) + } + if _, err := p.mcmMetadataForOp(op); err != nil { + return err } } @@ -238,30 +294,41 @@ func (p *Proposal) MerkleTree() (*merkle.Tree, error) { return nil, wrapTreeGenErr(err) } + // Per-instance encoders carry each instance's own transaction count, so root + // metadata leaves hash the correct postOpCount for that instance. + instanceEncoders, err := p.GetInstanceEncoders() + if err != nil { + return nil, wrapTreeGenErr(err) + } + hashLeaves := make([]common.Hash, 0) for _, sel := range p.ChainSelectors() { - // Since we create encoders from the list of chain selectors provided in the ChainMetadata, - // we can be sure the encoder exists, and don't need to check for existence. - encoder := encoders[sel] - - // Similarly, we can be sure the metadata exists, as we iterate over the chain selectors, - // since the chain selectors are keys in the ChainMetadata map. - metadata := p.ChainMetadata[sel] - - encodedRootMetadata, encerr := encoder.HashMetadata(metadata) - if encerr != nil { - return nil, wrapTreeGenErr(encerr) + // One metadata leaf per MCM instance on the chain (the primary MCM plus any + // additional instances), sorted by MCM address for deterministic ordering. + // For single-MCM chains this is exactly one leaf, as before. + instances := p.ChainMetadata[sel].AllMCMs() + slices.SortFunc(instances, func(a, b types.ChainMetadata) int { + return strings.Compare(a.MCMAddress, b.MCMAddress) + }) + + for _, metadata := range instances { + encoder := instanceEncoders[instanceKey{chainSelector: sel, mcmAddress: metadata.MCMAddress}] + + encodedRootMetadata, encerr := encoder.HashMetadata(metadata) + if encerr != nil { + return nil, wrapTreeGenErr(encerr) + } + + hashLeaves = append(hashLeaves, encodedRootMetadata) } + } - hashLeaves = append(hashLeaves, encodedRootMetadata) + txNonces, txerr := p.TransactionNonces() + if txerr != nil { + return nil, wrapTreeGenErr(txerr) } for i, op := range p.Operations { - txNonces, txerr := p.TransactionNonces() - if txerr != nil { - return nil, wrapTreeGenErr(txerr) - } - txNonce, txerr := safecast.Uint64ToUint32(txNonces[i]) if txerr != nil { return nil, wrapTreeGenErr(txerr) @@ -272,9 +339,15 @@ func (p *Proposal) MerkleTree() (*merkle.Tree, error) { // selector defined in the transactions. encoder := encoders[op.ChainSelector] + // Hash the operation against the metadata of its governing MCM instance. + mcmMetadata, mcmErr := p.mcmMetadataForOp(op) + if mcmErr != nil { + return nil, wrapTreeGenErr(mcmErr) + } + encodedOp, txerr := encoder.HashOperation( txNonce, - p.ChainMetadata[op.ChainSelector], + mcmMetadata, op, ) if txerr != nil { @@ -330,32 +403,54 @@ func (p *Proposal) TransactionCounts() map[types.ChainSelector]uint64 { return txCounts } +// TransactionCountsByInstance returns the number of operations governed by each MCM +// instance, keyed by (chain selector, MCM address). The count for an instance is used to +// derive that instance's postOpCount (StartingOpCount + count) in its root metadata leaf. +// Operations whose governing MCM cannot be resolved are counted under the chain's +// primary MCM; resolution errors surface during Validate/MerkleTree. +func (p *Proposal) TransactionCountsByInstance() map[instanceKey]uint64 { + counts := make(map[instanceKey]uint64) + for _, o := range p.Operations { + md, err := p.mcmMetadataForOp(o) + if err != nil { + counts[instanceKey{chainSelector: o.ChainSelector}]++ + continue + } + counts[instanceKey{chainSelector: o.ChainSelector, mcmAddress: md.MCMAddress}]++ + } + + return counts +} + // TransactionNonces calculates and returns a slice of nonces for each transaction based on their // respective chain selectors and associated metadata. // // It returns a slice of nonces, where each nonce corresponds to a transaction in the same order // as the transactions slice. The nonce is calculated as the local index of the transaction with -// respect to it's chain selector, plus the starting op count for that chain selector. +// respect to its governing MCM instance (chain selector + MCM address), plus the starting op +// count for that instance. For single-MCM chains this is equivalent to per-chain sequencing. func (p *Proposal) TransactionNonces() ([]uint64, error) { - // Map to keep track of local index counts for each ChainSelector - chainIndexMap := make(map[types.ChainSelector]uint64, len(p.ChainMetadata)) + // Map to keep track of local index counts for each (ChainSelector, MCMAddress) instance + instanceIndexMap := make(map[instanceKey]uint64, len(p.ChainMetadata)) txNonces := make([]uint64, len(p.Operations)) for i, op := range p.Operations { - // Get the current local index for this ChainSelector - localIndex := chainIndexMap[op.ChainSelector] - - // Lookup the StartingOpCount for this ChainSelector from cmMap - md, ok := p.ChainMetadata[op.ChainSelector] - if !ok { - return nil, NewChainMetadataNotFoundError(op.ChainSelector) + // Lookup the governing MCM instance metadata for this operation + md, err := p.mcmMetadataForOp(op) + if err != nil { + return nil, err } + key := instanceKey{chainSelector: op.ChainSelector, mcmAddress: md.MCMAddress} + + // Get the current local index for this instance + localIndex := instanceIndexMap[key] + // Add the local index to the StartingOpCount to get the final nonce txNonces[i] = localIndex + md.StartingOpCount - // Increment the local index for the current ChainSelector - chainIndexMap[op.ChainSelector]++ + // Increment the local index for the current instance + instanceIndexMap[key]++ } return txNonces, nil @@ -377,6 +472,27 @@ func (p *Proposal) GetEncoders() (map[types.ChainSelector]sdk.Encoder, error) { return encoders, nil } +// GetInstanceEncoders generates one encoder per MCM instance, keyed by (chain selector, +// MCM address), with each instance's transaction count. These must be used for hashing +// root metadata leaves, since an instance's postOpCount covers only its own operations. +func (p *Proposal) GetInstanceEncoders() (map[instanceKey]sdk.Encoder, error) { + txCounts := p.TransactionCountsByInstance() + encoders := make(map[instanceKey]sdk.Encoder) + for chainSelector, metadata := range p.ChainMetadata { + for _, instance := range metadata.AllMCMs() { + key := instanceKey{chainSelector: chainSelector, mcmAddress: instance.MCMAddress} + encoder, err := newEncoder(chainSelector, txCounts[key], p.OverridePreviousRoot, p.useSimulatedBackend) + if err != nil { + return nil, fmt.Errorf("unable to create encoder: %w", err) + } + + encoders[key] = encoder + } + } + + return encoders, nil +} + // Decode decodes the raw transactions into a list of human-readable operations. func (p *Proposal) Decode(decoders map[types.ChainSelector]sdk.Decoder, contractInterfaces map[string]string) ([]sdk.DecodedOperation, error) { decodedOps := make([]sdk.DecodedOperation, len(p.Operations)) diff --git a/proposal_multimcm_test.go b/proposal_multimcm_test.go new file mode 100644 index 00000000..be23b161 --- /dev/null +++ b/proposal_multimcm_test.go @@ -0,0 +1,453 @@ +package mcms + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/internal/testutils/chaintest" + "github.com/smartcontractkit/mcms/sdk" + evmsdk "github.com/smartcontractkit/mcms/sdk/evm" + "github.com/smartcontractkit/mcms/types" +) + +const ( + testMCMAddressPrimary = "0x0000000000000000000000000000000000000aaa" + testMCMAddressSecond = "0x0000000000000000000000000000000000000bbb" +) + +// multiMCMChainMetadata returns Chain1 metadata with a primary and one additional MCM instance. +func multiMCMChainMetadata() types.ChainMetadata { + return types.ChainMetadata{ + StartingOpCount: 5, + MCMAddress: testMCMAddressPrimary, + AdditionalMCMs: []types.ChainMetadata{ + {StartingOpCount: 2, MCMAddress: testMCMAddressSecond}, + }, + } +} + +func multiMCMOp(chainSelector types.ChainSelector, mcmAddress string) types.Operation { + return types.Operation{ + ChainSelector: chainSelector, + McmAddress: mcmAddress, + Transaction: types.Transaction{ + To: TestAddress, + AdditionalFields: json.RawMessage([]byte(`{"value": 0}`)), + Data: common.Hex2Bytes("0x"), + }, + } +} + +func TestProposal_MultiMCM_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(p *Proposal) + wantErr string + }{ + { + name: "success: v2 proposal with two instances and attributed ops", + setup: func(_ *Proposal) { + // NOP: valid proposal built by default + }, + }, + { + name: "failure: additional MCMs require v2", + setup: func(p *Proposal) { + p.Version = "v1" + p.Operations = []types.Operation{multiMCMOp(chaintest.Chain1Selector, "")} + }, + wantErr: "additional MCM instances require proposal version v2", + }, + { + name: "failure: operation mcmAddress requires v2", + setup: func(p *Proposal) { + p.Version = "v1" + p.ChainMetadata = map[types.ChainSelector]types.ChainMetadata{ + chaintest.Chain1Selector: {StartingOpCount: 5, MCMAddress: testMCMAddressPrimary}, + } + p.Operations = []types.Operation{multiMCMOp(chaintest.Chain1Selector, testMCMAddressPrimary)} + }, + wantErr: "operation mcmAddress requires proposal version v2", + }, + { + name: "failure: unknown operation mcmAddress", + setup: func(p *Proposal) { + p.Operations = []types.Operation{multiMCMOp(chaintest.Chain1Selector, "0xunknown")} + }, + wantErr: "does not match the chain's primary MCM or any additional MCM instance", + }, + { + name: "failure: duplicate instance address", + setup: func(p *Proposal) { + p.ChainMetadata = map[types.ChainSelector]types.ChainMetadata{ + chaintest.Chain1Selector: { + StartingOpCount: 5, + MCMAddress: testMCMAddressPrimary, + AdditionalMCMs: []types.ChainMetadata{ + {StartingOpCount: 2, MCMAddress: testMCMAddressPrimary}, + }, + }, + } + }, + wantErr: "duplicate MCMAddress", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + builder := NewProposalBuilder() + builder.SetVersion("v2"). + SetValidUntil(2552083725). + AddChainMetadata(chaintest.Chain1Selector, multiMCMChainMetadata()). + AddOperation(multiMCMOp(chaintest.Chain1Selector, "")). + AddOperation(multiMCMOp(chaintest.Chain1Selector, testMCMAddressSecond)) + give, err := builder.Build() + require.NoError(t, err) + + tt.setup(give) + + err = give.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestProposal_MultiMCM_TransactionNonces(t *testing.T) { + t.Parallel() + + builder := NewProposalBuilder() + builder.SetVersion("v2"). + SetValidUntil(2552083725). + AddChainMetadata(chaintest.Chain1Selector, multiMCMChainMetadata()). + SetOperations([]types.Operation{ + multiMCMOp(chaintest.Chain1Selector, ""), // primary, nonce 5 + multiMCMOp(chaintest.Chain1Selector, testMCMAddressSecond), // second, nonce 2 + multiMCMOp(chaintest.Chain1Selector, ""), // primary, nonce 6 + multiMCMOp(chaintest.Chain1Selector, testMCMAddressSecond), // second, nonce 3 + }) + proposal, err := builder.Build() + require.NoError(t, err) + + nonces, err := proposal.TransactionNonces() + require.NoError(t, err) + assert.Equal(t, []uint64{5, 2, 6, 3}, nonces) +} + +func TestProposal_MultiMCM_MerkleTree(t *testing.T) { + t.Parallel() + + buildProposal := func(ops []types.Operation) *Proposal { + builder := NewProposalBuilder() + builder.SetVersion("v2"). + SetValidUntil(2552083725). + AddChainMetadata(chaintest.Chain1Selector, multiMCMChainMetadata()). + SetOperations(ops) + p, err := builder.Build() + require.NoError(t, err) + return p + } + + opsPrimaryThenSecond := []types.Operation{ + multiMCMOp(chaintest.Chain1Selector, ""), + multiMCMOp(chaintest.Chain1Selector, testMCMAddressSecond), + } + + t.Run("success: builds tree covering both instances", func(t *testing.T) { + t.Parallel() + tree, err := buildProposal(opsPrimaryThenSecond).MerkleTree() + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, tree.Root) + }) + + t.Run("root is deterministic regardless of additionalMCMs ordering", func(t *testing.T) { + t.Parallel() + p1 := buildProposal(opsPrimaryThenSecond) + + p2 := buildProposal(opsPrimaryThenSecond) + md := p2.ChainMetadata[chaintest.Chain1Selector] + md.AdditionalMCMs = append(md.AdditionalMCMs, types.ChainMetadata{ + StartingOpCount: 9, MCMAddress: "0x0000000000000000000000000000000000000ccc", + }) + p2.ChainMetadata[chaintest.Chain1Selector] = md + + tree1, err := p1.MerkleTree() + require.NoError(t, err) + tree2, err := p2.MerkleTree() + require.NoError(t, err) + assert.NotEqual(t, tree1.Root, tree2.Root, "adding an instance must change the root") + + // Rebuilding p1's tree yields the same root + tree1Again, err := buildProposal(opsPrimaryThenSecond).MerkleTree() + require.NoError(t, err) + assert.Equal(t, tree1.Root, tree1Again.Root) + }) + + t.Run("op attribution changes the root", func(t *testing.T) { + t.Parallel() + treePrimary, err := buildProposal([]types.Operation{ + multiMCMOp(chaintest.Chain1Selector, ""), + multiMCMOp(chaintest.Chain1Selector, ""), + }).MerkleTree() + require.NoError(t, err) + + treeSecond, err := buildProposal([]types.Operation{ + multiMCMOp(chaintest.Chain1Selector, testMCMAddressSecond), + multiMCMOp(chaintest.Chain1Selector, testMCMAddressSecond), + }).MerkleTree() + require.NoError(t, err) + + assert.NotEqual(t, treePrimary.Root, treeSecond.Root) + }) + + t.Run("single-MCM v1 regression: root matches pre-multi-MCM algorithm", func(t *testing.T) { + t.Parallel() + // Same shape as TestProposal_MerkleTree's success case, whose root was + // generated before multi-MCM support existed. + builder := NewProposalBuilder() + builder.SetVersion("v1"). + SetValidUntil(2552083725). + AddChainMetadata(chaintest.Chain1Selector, types.ChainMetadata{StartingOpCount: 5}). + AddChainMetadata(chaintest.Chain2Selector, types.ChainMetadata{StartingOpCount: 10}). + AddOperation(types.Operation{ + ChainSelector: chaintest.Chain1Selector, + Transaction: types.Transaction{ + To: TestAddress, + AdditionalFields: json.RawMessage([]byte(`{"value": 0}`)), + Data: common.Hex2Bytes("0x"), + OperationMetadata: types.OperationMetadata{ + ContractType: "Sample contract", + Tags: []string{"tag1", "tag2"}, + }, + }, + }). + AddOperation(types.Operation{ + ChainSelector: chaintest.Chain2Selector, + Transaction: types.Transaction{ + To: TestAddress, + AdditionalFields: json.RawMessage([]byte(`{"value": 0}`)), + Data: common.Hex2Bytes("0x"), + OperationMetadata: types.OperationMetadata{ + ContractType: "Sample contract", + Tags: []string{"tag1", "tag2"}, + }, + }, + }) + p, err := builder.Build() + require.NoError(t, err) + + tree, err := p.MerkleTree() + require.NoError(t, err) + assert.Equal(t, + common.HexToHash("0x4fdb98431759bbcab33cbd1b4034fea43ef360f11b1de4ca10fc20f8916bda19"), + tree.Root) + }) +} + +func TestProposal_MultiMCM_JSONRoundTrip(t *testing.T) { + t.Parallel() + + proposalJSON := `{ + "version": "v2", + "kind": "Proposal", + "validUntil": 2552083725, + "chainMetadata": { + "3379446385462418246": { + "startingOpCount": 5, + "mcmAddress": "0x0000000000000000000000000000000000000aaa", + "additionalMCMs": [ + {"startingOpCount": 2, "mcmAddress": "0x0000000000000000000000000000000000000bbb"} + ] + } + }, + "operations": [ + { + "chainSelector": 3379446385462418246, + "mcmAddress": "0x0000000000000000000000000000000000000bbb", + "transaction": { + "to": "0xsomeaddress", + "data": "EjM=", + "additionalFields": {"value": 0} + } + } + ] + }` + + proposal, err := NewProposal(jsonReader(proposalJSON)) + require.NoError(t, err) + + md := proposal.ChainMetadata[chaintest.Chain1Selector] + require.Len(t, md.AdditionalMCMs, 1) + assert.Equal(t, testMCMAddressSecond, md.AdditionalMCMs[0].MCMAddress) + assert.Equal(t, testMCMAddressSecond, proposal.Operations[0].McmAddress) +} + +func TestTimelockProposal_MultiMCM_Convert(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + chainMetadata := map[types.ChainSelector]types.ChainMetadata{ + chaintest.Chain1Selector: multiMCMChainMetadata(), + } + timelockAddresses := map[types.ChainSelector]string{ + chaintest.Chain1Selector: "0xtimelock", + } + tx := func(data string) types.Transaction { + return types.Transaction{ + To: "0x123", + AdditionalFields: json.RawMessage([]byte(`{"value": 0}`)), + Data: common.Hex2Bytes(data), + } + } + + proposal := TimelockProposal{ + BaseProposal: BaseProposal{ + Version: "v2", + Kind: types.KindTimelockProposal, + ValidUntil: 2552083725, + ChainMetadata: chainMetadata, + }, + Action: types.TimelockActionSchedule, + Delay: types.MustParseDuration("1h"), + TimelockAddresses: timelockAddresses, + Operations: []types.BatchOperation{ + {ChainSelector: chaintest.Chain1Selector, Transactions: []types.Transaction{tx("0x1")}}, + {ChainSelector: chaintest.Chain1Selector, McmAddress: testMCMAddressSecond, Transactions: []types.Transaction{tx("0x2")}}, + {ChainSelector: chaintest.Chain1Selector, Transactions: []types.Transaction{tx("0x3")}}, + }, + } + + converters := map[types.ChainSelector]sdk.TimelockConverter{ + chaintest.Chain1Selector: &evmsdk.TimelockConverter{}, + } + + mcmsProposal, predecessors, err := proposal.Convert(ctx, converters) + require.NoError(t, err) + + // One converted op per batch op for the EVM schedule action + require.Len(t, mcmsProposal.Operations, 3) + require.Len(t, predecessors, 3) + + // Converted ops preserve the batch's MCM attribution + assert.Empty(t, mcmsProposal.Operations[0].McmAddress) + assert.Equal(t, testMCMAddressSecond, mcmsProposal.Operations[1].McmAddress) + assert.Empty(t, mcmsProposal.Operations[2].McmAddress) + + // Predecessors chain per instance: the second instance's first op has a zero + // predecessor even though the primary instance already scheduled an op on this chain. + assert.Equal(t, ZeroHash, predecessors[0]) + assert.Equal(t, ZeroHash, predecessors[1], "second instance's first op must not chain to the primary instance") + assert.NotEqual(t, ZeroHash, predecessors[2], "primary instance's second op chains to its first") + + // The converted MCMS proposal must validate and sequence nonces per instance + require.NoError(t, mcmsProposal.Validate()) + nonces, err := mcmsProposal.TransactionNonces() + require.NoError(t, err) + assert.Equal(t, []uint64{5, 2, 6}, nonces) +} + +func TestTimelockProposal_TimelockAddressForOp(t *testing.T) { + t.Parallel() + + proposal := TimelockProposal{ + BaseProposal: BaseProposal{ + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + chaintest.Chain1Selector: multiMCMChainMetadata(), + }, + }, + TimelockAddresses: map[types.ChainSelector]string{ + chaintest.Chain1Selector: "0xtimelock", + }, + } + + // Primary op (no attribution): chain timelock address + assert.Equal(t, "0xtimelock", proposal.TimelockAddressForOp(types.BatchOperation{ + ChainSelector: chaintest.Chain1Selector, + })) + + // Explicit primary attribution: still the chain timelock address + assert.Equal(t, "0xtimelock", proposal.TimelockAddressForOp(types.BatchOperation{ + ChainSelector: chaintest.Chain1Selector, + McmAddress: testMCMAddressPrimary, + })) + + // Additional instance: the instance itself is the timelock (Canton model) + assert.Equal(t, testMCMAddressSecond, proposal.TimelockAddressForOp(types.BatchOperation{ + ChainSelector: chaintest.Chain1Selector, + McmAddress: testMCMAddressSecond, + })) +} + +func TestTimelockProposal_Merge_MultiMCM(t *testing.T) { + t.Parallel() + + newProposal := func(md types.ChainMetadata) *TimelockProposal { + return &TimelockProposal{ + BaseProposal: BaseProposal{ + Version: "v2", + Kind: types.KindTimelockProposal, + ValidUntil: 2552083725, + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + chaintest.Chain1Selector: md, + }, + }, + Action: types.TimelockActionSchedule, + TimelockAddresses: map[types.ChainSelector]string{ + chaintest.Chain1Selector: "0xtimelock", + }, + } + } + + t.Run("union when primaries cross-reference additionals", func(t *testing.T) { + t.Parallel() + a := newProposal(types.ChainMetadata{ + StartingOpCount: 5, + MCMAddress: testMCMAddressPrimary, + AdditionalMCMs: []types.ChainMetadata{{StartingOpCount: 2, MCMAddress: testMCMAddressSecond}}, + }) + b := newProposal(types.ChainMetadata{ + StartingOpCount: 3, + MCMAddress: testMCMAddressSecond, + AdditionalMCMs: []types.ChainMetadata{{StartingOpCount: 7, MCMAddress: testMCMAddressPrimary}}, + }) + + merged, err := a.Merge(context.Background(), b) + require.NoError(t, err) + + md := merged.ChainMetadata[chaintest.Chain1Selector] + // Deterministic primary: lexicographically smallest address + assert.Equal(t, testMCMAddressPrimary, md.MCMAddress) + assert.Equal(t, uint64(5), md.StartingOpCount) // min(5, 7) for the primary instance + require.Len(t, md.AdditionalMCMs, 1) + assert.Equal(t, testMCMAddressSecond, md.AdditionalMCMs[0].MCMAddress) + assert.Equal(t, uint64(2), md.AdditionalMCMs[0].StartingOpCount) // min(2, 3) + }) + + t.Run("error on truly disjoint instance sets", func(t *testing.T) { + t.Parallel() + a := newProposal(types.ChainMetadata{MCMAddress: testMCMAddressPrimary}) + b := newProposal(types.ChainMetadata{MCMAddress: testMCMAddressSecond}) + + _, err := a.Merge(context.Background(), b) + require.ErrorContains(t, err, "cannot merge ChainMetadata with different MCMAddress") + }) +} + +// jsonReader is a small helper to keep test JSON readable. +func jsonReader(s string) *strings.Reader { + return strings.NewReader(s) +} diff --git a/sdk/aptos/executor.go b/sdk/aptos/executor.go index a54da989..ee423de7 100644 --- a/sdk/aptos/executor.go +++ b/sdk/aptos/executor.go @@ -236,6 +236,33 @@ func (e Executor) SetRoot( root [32]byte, validUntil uint32, sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, e.TxCount, proof, root, validUntil, sortedSignatures) +} + +// SetRootForInstance implements sdk.InstanceExecutor: it sets the root on a specific MCM +// instance, deriving postOpCount from the instance's own operation count so the on-chain +// root metadata matches the per-instance metadata leaf in the Merkle proof. +func (e Executor) SetRootForInstance( + ctx context.Context, + metadata types.ChainMetadata, + instanceOpCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, instanceOpCount, proof, root, validUntil, sortedSignatures) +} + +func (e Executor) setRoot( + ctx context.Context, + metadata types.ChainMetadata, + txCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, ) (types.TransactionResult, error) { mcmsAddress, err := hexToAddress(metadata.MCMAddress) if err != nil { @@ -272,7 +299,7 @@ func (e Executor) SetRoot( chainIDBig, mcmsAddress, metadata.StartingOpCount, - metadata.StartingOpCount+e.TxCount, + metadata.StartingOpCount+txCount, e.OverridePreviousRoot, proofBytes, signatures, @@ -287,7 +314,7 @@ func (e Executor) SetRoot( chainIDBig, mcmsAddress, metadata.StartingOpCount, - metadata.StartingOpCount+e.TxCount, + metadata.StartingOpCount+txCount, e.OverridePreviousRoot, proofBytes, signatures, diff --git a/sdk/canton/executor.go b/sdk/canton/executor.go index 6ddc8ed6..8fdbea51 100644 --- a/sdk/canton/executor.go +++ b/sdk/canton/executor.go @@ -211,6 +211,33 @@ func (e Executor) SetRoot( root [32]byte, validUntil uint32, sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, e.TxCount, proof, root, validUntil, sortedSignatures) +} + +// SetRootForInstance implements sdk.InstanceExecutor: it sets the root on a specific MCM +// instance, deriving postOpCount from the instance's own operation count so the on-ledger +// root metadata matches the per-instance metadata leaf in the Merkle proof. +func (e Executor) SetRootForInstance( + ctx context.Context, + metadata types.ChainMetadata, + instanceOpCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, instanceOpCount, proof, root, validUntil, sortedSignatures) +} + +func (e Executor) setRoot( + ctx context.Context, + metadata types.ChainMetadata, + txCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, ) (types.TransactionResult, error) { // Resolve MCMAddress (InstanceAddress hex) to current contract ID before submitting mcmsContractID, err := ResolveMCMSContractID(ctx, e.StateServiceClient(), e.mcmsParties, metadata.MCMAddress) @@ -258,7 +285,7 @@ func (e Executor) SetRoot( if err != nil { return types.TransactionResult{}, fmt.Errorf("preOpCount out of range: %w", err) } - postOpCount, convErr := safecast.Uint64ToInt64(metadata.StartingOpCount + e.TxCount) + postOpCount, convErr := safecast.Uint64ToInt64(metadata.StartingOpCount + txCount) if convErr != nil { return types.TransactionResult{}, fmt.Errorf("postOpCount out of range: %w", convErr) } diff --git a/sdk/evm/encoder.go b/sdk/evm/encoder.go index 6910d5dc..0ed4899e 100644 --- a/sdk/evm/encoder.go +++ b/sdk/evm/encoder.go @@ -129,6 +129,16 @@ func (e *Encoder) ToGethOperation( // ToGethRootMetadata converts the MCMS ChainMetadata into the format expected by the EVM // ManyChainMultiSig contract. func (e *Encoder) ToGethRootMetadata(ctx context.Context, metadata types.ChainMetadata) (bindings.ManyChainMultiSigRootMetadata, error) { + return e.ToGethRootMetadataWithTxCount(ctx, metadata, e.TxCount) +} + +// ToGethRootMetadataWithTxCount is ToGethRootMetadata with an explicit transaction count +// for postOpCount derivation. Used for per-instance roots when a chain hosts multiple +// MCM instances (v2 proposals), where each instance's postOpCount covers only its own +// operations. +func (e *Encoder) ToGethRootMetadataWithTxCount( + ctx context.Context, metadata types.ChainMetadata, txCount uint64, +) (bindings.ManyChainMultiSigRootMetadata, error) { evmChainID, err := getEVMChainID(ctx, e.ChainSelector, e.IsSim) if err != nil { return bindings.ManyChainMultiSigRootMetadata{}, err @@ -138,7 +148,7 @@ func (e *Encoder) ToGethRootMetadata(ctx context.Context, metadata types.ChainMe ChainId: new(big.Int).SetUint64(evmChainID), MultiSig: common.HexToAddress(metadata.MCMAddress), PreOpCount: new(big.Int).SetUint64(metadata.StartingOpCount), - PostOpCount: new(big.Int).SetUint64(metadata.StartingOpCount + e.TxCount), + PostOpCount: new(big.Int).SetUint64(metadata.StartingOpCount + txCount), OverridePreviousRoot: e.OverridePreviousRoot, }, nil } diff --git a/sdk/evm/executor.go b/sdk/evm/executor.go index 8d39c4e7..387c160f 100644 --- a/sdk/evm/executor.go +++ b/sdk/evm/executor.go @@ -95,7 +95,38 @@ func (e *Executor) SetRoot( return types.TransactionResult{}, errors.New("failed to create sdk.Executor - encoder (sdk.Encoder) is nil") } - bindMeta, err := e.ToGethRootMetadata(ctx, metadata) + return e.setRoot(ctx, metadata, e.TxCount, proof, root, validUntil, sortedSignatures) +} + +// SetRootForInstance implements sdk.InstanceExecutor: it sets the root on a specific MCM +// instance, deriving postOpCount from the instance's own operation count so the on-chain +// root metadata matches the per-instance metadata leaf in the Merkle proof. +func (e *Executor) SetRootForInstance( + ctx context.Context, + metadata types.ChainMetadata, + instanceOpCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, instanceOpCount, proof, root, validUntil, sortedSignatures) +} + +func (e *Executor) setRoot( + ctx context.Context, + metadata types.ChainMetadata, + txCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + if e.Encoder == nil { + return types.TransactionResult{}, errors.New("failed to create sdk.Executor - encoder (sdk.Encoder) is nil") + } + + bindMeta, err := e.ToGethRootMetadataWithTxCount(ctx, metadata, txCount) if err != nil { return types.TransactionResult{}, err } diff --git a/sdk/executor.go b/sdk/executor.go index c6ec1cd1..d415d167 100644 --- a/sdk/executor.go +++ b/sdk/executor.go @@ -34,3 +34,23 @@ type Executor interface { sortedSignatures []types.Signature, ) (types.TransactionResult, error) } + +// InstanceExecutor is an optional extension of Executor for chain families that support +// multiple MCM instances per chain selector (v2 proposals). The library type-asserts +// executors to this interface when setting the root on a non-primary instance, so that +// the root metadata's postOpCount is derived from the instance's own operation count +// (not the executor's chain-wide transaction count), matching the per-instance metadata +// leaf in the Merkle proof. +type InstanceExecutor interface { + // SetRootForInstance behaves like SetRoot, but derives postOpCount as + // metadata.StartingOpCount + instanceOpCount. + SetRootForInstance( + ctx context.Context, + metadata types.ChainMetadata, + instanceOpCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, + ) (types.TransactionResult, error) +} diff --git a/sdk/solana/executor.go b/sdk/solana/executor.go index 6beb7855..c03ac2ee 100644 --- a/sdk/solana/executor.go +++ b/sdk/solana/executor.go @@ -133,6 +133,33 @@ func (e *Executor) SetRoot( root [32]byte, validUntil uint32, sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, e.TxCount, proof, root, validUntil, sortedSignatures) +} + +// SetRootForInstance implements sdk.InstanceExecutor: it sets the root on a specific MCM +// instance, deriving postOpCount from the instance's own operation count so the on-chain +// root metadata matches the per-instance metadata leaf in the Merkle proof. +func (e *Executor) SetRootForInstance( + ctx context.Context, + metadata types.ChainMetadata, + instanceOpCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, instanceOpCount, proof, root, validUntil, sortedSignatures) +} + +func (e *Executor) setRoot( + ctx context.Context, + metadata types.ChainMetadata, + txCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, ) (types.TransactionResult, error) { sameRoot, err := e.equalCurrentRoot(ctx, metadata.MCMAddress, root) if err != nil { @@ -185,7 +212,7 @@ func (e *Executor) SetRoot( pdaSeed, root, validUntil, - e.solanaMetadata(metadata, configPDA), + e.solanaMetadata(metadata, txCount, configPDA), solanaProof(proof), rootSignaturesPDA, rootMetadataPDA, @@ -278,12 +305,12 @@ func (e *Executor) retryPreloadSignatures( } // solanaMetadata returns the root metadata input for the MCM program -func (e *Executor) solanaMetadata(metadata types.ChainMetadata, configPDA [32]byte) mcm.RootMetadataInput { +func (e *Executor) solanaMetadata(metadata types.ChainMetadata, txCount uint64, configPDA [32]byte) mcm.RootMetadataInput { return mcm.RootMetadataInput{ ChainId: uint64(e.ChainSelector), Multisig: solana.PublicKey(configPDA), PreOpCount: metadata.StartingOpCount, - PostOpCount: metadata.StartingOpCount + e.TxCount, + PostOpCount: metadata.StartingOpCount + txCount, OverridePreviousRoot: e.OverridePreviousRoot, } } diff --git a/sdk/sui/executor.go b/sdk/sui/executor.go index b8de6963..5f8e5c43 100644 --- a/sdk/sui/executor.go +++ b/sdk/sui/executor.go @@ -246,6 +246,33 @@ func (e Executor) SetRoot( root [32]byte, validUntil uint32, sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, e.TxCount, proof, root, validUntil, sortedSignatures) +} + +// SetRootForInstance implements sdk.InstanceExecutor: it sets the root on a specific MCM +// instance, deriving postOpCount from the instance's own operation count so the on-chain +// root metadata matches the per-instance metadata leaf in the Merkle proof. +func (e Executor) SetRootForInstance( + ctx context.Context, + metadata types.ChainMetadata, + instanceOpCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, instanceOpCount, proof, root, validUntil, sortedSignatures) +} + +func (e Executor) setRoot( + ctx context.Context, + metadata types.ChainMetadata, + txCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, ) (types.TransactionResult, error) { var additionalFieldsMetadata AdditionalFieldsMetadata if len(metadata.AdditionalFields) > 0 { @@ -288,7 +315,7 @@ func (e Executor) SetRoot( // Use the actual MCMS package address e.mcmsPackageID, metadata.StartingOpCount, - metadata.StartingOpCount+e.TxCount, + metadata.StartingOpCount+txCount, e.OverridePreviousRoot, proofBytes, signatures, diff --git a/sdk/ton/encoder.go b/sdk/ton/encoder.go index a38c534e..3008aff1 100644 --- a/sdk/ton/encoder.go +++ b/sdk/ton/encoder.go @@ -35,6 +35,13 @@ type RootMetadataEncoder[T any] interface { ToRootMetadata(metadata types.ChainMetadata) (T, error) } +// RootMetadataTxCountEncoder is an optional extension of RootMetadataEncoder for +// per-instance roots (v2 proposals), deriving postOpCount from an explicit transaction +// count instead of the encoder's chain-wide TxCount. +type RootMetadataTxCountEncoder[T any] interface { + ToRootMetadataWithTxCount(metadata types.ChainMetadata, txCount uint64) (T, error) +} + // TODO: bubble up to sdk, use in evm as well // Defines encoding from sdk types.ChainMetadata + types.Operation to chain type Operation T type OperationEncoder[T any] interface { @@ -162,6 +169,14 @@ func (e *Encoder) ToOperation(opCount uint32, metadata types.ChainMetadata, op t } func (e *Encoder) ToRootMetadata(metadata types.ChainMetadata) (mcms.RootMetadata, error) { + return e.ToRootMetadataWithTxCount(metadata, e.TxCount) +} + +// ToRootMetadataWithTxCount is ToRootMetadata with an explicit transaction count for +// postOpCount derivation. Used for per-instance roots when a chain hosts multiple MCM +// instances (v2 proposals), where each instance's postOpCount covers only its own +// operations. +func (e *Encoder) ToRootMetadataWithTxCount(metadata types.ChainMetadata, txCount uint64) (mcms.RootMetadata, error) { chainID, err := chainsel.TonChainIdFromSelector(uint64(e.ChainSelector)) if err != nil { return mcms.RootMetadata{}, &sdkerrors.InvalidChainIDError{ReceivedChainID: e.ChainSelector} @@ -177,7 +192,7 @@ func (e *Encoder) ToRootMetadata(metadata types.ChainMetadata) (mcms.RootMetadat ChainID: new(big.Int).SetInt64(int64(chainID)), MultiSig: mcmsAddr, PreOpCount: metadata.StartingOpCount, - PostOpCount: metadata.StartingOpCount + e.TxCount, + PostOpCount: metadata.StartingOpCount + txCount, OverridePreviousRoot: e.OverridePreviousRoot, }, nil } diff --git a/sdk/ton/executor.go b/sdk/ton/executor.go index bceecd8e..9e86f159 100644 --- a/sdk/ton/executor.go +++ b/sdk/ton/executor.go @@ -143,6 +143,36 @@ func (e *executor) SetRoot( root [32]byte, validUntil uint32, sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, nil, proof, root, validUntil, sortedSignatures) +} + +// SetRootForInstance implements sdk.InstanceExecutor: it sets the root on a specific MCM +// instance, deriving postOpCount from the instance's own operation count so the on-chain +// root metadata matches the per-instance metadata leaf in the Merkle proof. +func (e *executor) SetRootForInstance( + ctx context.Context, + metadata types.ChainMetadata, + instanceOpCount uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + return e.setRoot(ctx, metadata, &instanceOpCount, proof, root, validUntil, sortedSignatures) +} + +// setRoot is the shared SetRoot implementation. A nil txCount defers to the encoder's +// chain-wide transaction count (single-instance behavior); a non-nil count is used +// explicitly (per-instance roots). +func (e *executor) setRoot( + ctx context.Context, + metadata types.ChainMetadata, + txCount *uint64, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, ) (types.TransactionResult, error) { var z types.TransactionResult // zero value @@ -153,12 +183,20 @@ func (e *executor) SetRoot( } // Encode root metadata - rme, ok := e.Encoder.(RootMetadataEncoder[mcms.RootMetadata]) - if !ok { - return z, errors.New("failed to assert RootMetadataEncoder") + var rm mcms.RootMetadata + if txCount == nil { + rme, ok := e.Encoder.(RootMetadataEncoder[mcms.RootMetadata]) + if !ok { + return z, errors.New("failed to assert RootMetadataEncoder") + } + rm, err = rme.ToRootMetadata(metadata) + } else { + rme, ok := e.Encoder.(RootMetadataTxCountEncoder[mcms.RootMetadata]) + if !ok { + return z, errors.New("failed to assert RootMetadataTxCountEncoder") + } + rm, err = rme.ToRootMetadataWithTxCount(metadata, *txCount) } - - rm, err := rme.ToRootMetadata(metadata) if err != nil { return z, fmt.Errorf("failed to convert to root metadata: %w", err) } diff --git a/signable.go b/signable.go index 9c793664..b469c8b0 100644 --- a/signable.go +++ b/signable.go @@ -112,8 +112,13 @@ func (s *Signable) Simulate(ctx context.Context) error { return fmt.Errorf("simulator not found for chain %d", op.ChainSelector) } + metadata, err := s.proposal.mcmMetadataForOp(op) + if err != nil { + return err + } + // TODO: should we fail on the first error or aggregate all simulation errors? - err := simulator.SimulateOperation(ctx, s.proposal.ChainMetadata[op.ChainSelector], op) + err = simulator.SimulateOperation(ctx, metadata, op) if err != nil { return err } @@ -148,8 +153,15 @@ func (s *Signable) GetConfigs(ctx context.Context) (map[types.ChainSelector]*typ // CheckQuorum checks if the quorum for the proposal on the given chain has been reached. This will // fetch the current configuration for the chain and check if the recovered signers from the -// proposal's signatures can set the root. +// proposal's signatures can set the root. For chains with multiple MCM instances, quorum is +// checked against the primary MCM; use CheckQuorumForMCM to target a specific instance. func (s *Signable) CheckQuorum(ctx context.Context, chain types.ChainSelector) (bool, error) { + return s.CheckQuorumForMCM(ctx, chain, "") +} + +// CheckQuorumForMCM checks if the quorum has been reached for the MCM instance identified +// by mcmAddress on the given chain. An empty mcmAddress targets the primary MCM. +func (s *Signable) CheckQuorumForMCM(ctx context.Context, chain types.ChainSelector, mcmAddress string) (bool, error) { if s.inspectors == nil { return false, ErrInspectorsNotProvided } @@ -159,12 +171,19 @@ func (s *Signable) CheckQuorum(ctx context.Context, chain types.ChainSelector) ( return false, errors.New("inspector not found for chain " + strconv.FormatUint(uint64(chain), 10)) } + metadata, ok := s.proposal.ChainMetadata[chain].GetMCM(mcmAddress) + if !ok { + return false, fmt.Errorf( + "chain %d: mcmAddress %q does not match the chain's primary MCM or any additional MCM instance", + chain, mcmAddress) + } + recoveredSigners, err := s.proposal.RecoverSigningAddressesStrict() //nolint:contextcheck,nolintlint //OPT-400 if err != nil { return false, err } - configuration, err := inspector.GetConfig(ctx, s.proposal.ChainMetadata[chain].MCMAddress) + configuration, err := inspector.GetConfig(ctx, metadata.MCMAddress) if err != nil { return false, err } @@ -192,16 +211,18 @@ func (s *Signable) CheckQuorum(ctx context.Context, chain types.ChainSelector) ( } // ValidateSignatures checks if the quorum for the proposal has been reached on the MCM contracts -// across all chains in the proposal. +// across all chains (and all MCM instances) in the proposal. func (s *Signable) ValidateSignatures(ctx context.Context) (bool, error) { - for chain := range s.proposal.ChainMetadata { - checkQuorum, err := s.CheckQuorum(ctx, chain) - if err != nil { - return false, err - } + for chain, metadata := range s.proposal.ChainMetadata { + for _, instance := range metadata.AllMCMs() { + checkQuorum, err := s.CheckQuorumForMCM(ctx, chain, instance.MCMAddress) + if err != nil { + return false, err + } - if !checkQuorum { - return false, NewQuorumNotReachedError(chain) + if !checkQuorum { + return false, NewQuorumNotReachedError(chain) + } } } diff --git a/timelock_executable.go b/timelock_executable.go index 339e95ee..f11a8238 100644 --- a/timelock_executable.go +++ b/timelock_executable.go @@ -86,7 +86,7 @@ func (t *TimelockExecutable) IsOperationReady(ctx context.Context, idx int) erro op := t.proposal.Operations[idx] cs := op.ChainSelector - timelock := t.proposal.TimelockAddresses[cs] + timelock := t.proposal.TimelockAddressForOp(op) operationID, err := t.GetOpID(ctx, idx, op, cs) if err != nil { @@ -123,7 +123,7 @@ func (t *TimelockExecutable) IsOperationPending(ctx context.Context, idx int) er op := t.proposal.Operations[idx] cs := op.ChainSelector - timelock := t.proposal.TimelockAddresses[cs] + timelock := t.proposal.TimelockAddressForOp(op) operationID, err := t.GetOpID(ctx, idx, op, cs) if err != nil { @@ -160,7 +160,7 @@ func (t *TimelockExecutable) IsOperationDone(ctx context.Context, idx int) error op := t.proposal.Operations[idx] cs := op.ChainSelector - timelock := t.proposal.TimelockAddresses[cs] + timelock := t.proposal.TimelockAddressForOp(op) operationID, err := t.GetOpID(ctx, idx, op, cs) if err != nil { @@ -219,7 +219,7 @@ func (t *TimelockExecutable) Execute(ctx context.Context, index int, opts ...Opt // Get target contract execAddress := execOpts.callProxy if len(execAddress) == 0 { - execAddress = t.proposal.TimelockAddresses[op.ChainSelector] + execAddress = t.proposal.TimelockAddressForOp(op) } return t.executors[op.ChainSelector].Execute( diff --git a/timelock_proposal.go b/timelock_proposal.go index 4c130a55..7e362bc2 100644 --- a/timelock_proposal.go +++ b/timelock_proposal.go @@ -95,10 +95,21 @@ func (m *TimelockProposal) Validate() error { return NewInvalidProposalKindError(m.Kind, types.KindTimelockProposal) } - // Validate all chains in transactions have an entry in chain metadata + // Validate multi-MCM invariants and version gating + if err := m.validateMultiMCM(); err != nil { + return err + } + + // Validate all chains in transactions have an entry in chain metadata, and that any + // batch-level MCM address resolves to a known instance for _, op := range m.Operations { - if _, ok := m.ChainMetadata[op.ChainSelector]; !ok { - return NewChainMetadataNotFoundError(op.ChainSelector) + if op.McmAddress != "" && m.Version != "v2" { + return fmt.Errorf( + "chain %d: operation mcmAddress requires proposal version v2, got %q", + op.ChainSelector, m.Version) + } + if _, err := m.mcmMetadataForBatchOp(op); err != nil { + return err } for _, tx := range op.Transactions { @@ -112,6 +123,44 @@ func (m *TimelockProposal) Validate() error { return timeLockProposalValidateBasic(*m) } +// mcmMetadataForBatchOp resolves the governing MCM instance metadata for a batch +// operation: the batch's McmAddress if set, otherwise the chain's primary MCM. +func (m *TimelockProposal) mcmMetadataForBatchOp(bop types.BatchOperation) (types.ChainMetadata, error) { + metadata, ok := m.ChainMetadata[bop.ChainSelector] + if !ok { + return types.ChainMetadata{}, NewChainMetadataNotFoundError(bop.ChainSelector) + } + + mcmMetadata, ok := metadata.GetMCM(bop.McmAddress) + if !ok { + return types.ChainMetadata{}, fmt.Errorf( + "chain %d: operation mcmAddress %q does not match the chain's primary MCM or any additional MCM instance", + bop.ChainSelector, bop.McmAddress) + } + + return mcmMetadata, nil +} + +// TimelockAddressForOp resolves the timelock address governing a batch operation. When +// the batch targets a non-primary MCM instance, the instance itself is the timelock +// (the Canton model, where each MCMS contract has a built-in timelock). Otherwise the +// chain's TimelockAddresses entry is used. +func (m *TimelockProposal) TimelockAddressForOp(bop types.BatchOperation) string { + if bop.McmAddress == "" { + return m.TimelockAddresses[bop.ChainSelector] + } + + metadata, ok := m.ChainMetadata[bop.ChainSelector] + if !ok { + return m.TimelockAddresses[bop.ChainSelector] + } + if bop.McmAddress == metadata.MCMAddress { + return m.TimelockAddresses[bop.ChainSelector] + } + + return bop.McmAddress +} + func replaceChainMetadataWithAddresses(p *TimelockProposal, addresses map[types.ChainSelector]types.ChainMetadata) error { for chain := range p.ChainMetadata { newMeta, ok := addresses[chain] @@ -179,11 +228,14 @@ func (m *TimelockProposal) Convert( // 2) Initialize the global predecessors slice predecessors := make([]common.Hash, len(m.Operations)) - // 3) Keep track of the last operation ID per chain - lastOpID := make(map[types.ChainSelector]common.Hash) + // 3) Keep track of the last operation ID per MCM instance (chain + MCM address). + // For single-MCM chains this is equivalent to per-chain chaining. + lastOpID := make(map[instanceKey]common.Hash) // Initialize them to ZeroHash - for sel := range m.ChainMetadata { - lastOpID[sel] = ZeroHash + for sel, metadata := range m.ChainMetadata { + for _, instance := range metadata.AllMCMs() { + lastOpID[instanceKey{chainSelector: sel, mcmAddress: instance.MCMAddress}] = ZeroHash + } } // 4) Rebuild chainMetadata in baseProposal @@ -208,24 +260,27 @@ func (m *TimelockProposal) Convert( return Proposal{}, nil, fmt.Errorf("unable to find converter for chain selector %d", chainSelector) } - chainMetadata, ok := m.ChainMetadata[chainSelector] - if !ok { - return Proposal{}, nil, fmt.Errorf("missing chain metadata for chainSelector %d", chainSelector) + // Resolve the governing MCM instance for this batch operation + mcmMetadata, err := m.mcmMetadataForBatchOp(bop) + if err != nil { + return Proposal{}, nil, err } - // The predecessor for this op is the lastOpID for its chain - predecessor := lastOpID[chainSelector] + key := instanceKey{chainSelector: chainSelector, mcmAddress: mcmMetadata.MCMAddress} + + // The predecessor for this op is the lastOpID for its MCM instance + predecessor := lastOpID[key] predecessors[i] = predecessor - timelockAddr := m.TimelockAddresses[chainSelector] + timelockAddr := m.TimelockAddressForOp(bop) // Convert the batch operation convertedOps, operationID, err := converter.ConvertBatchToChainOperations( ctx, - chainMetadata, + mcmMetadata, bop, timelockAddr, - chainMetadata.MCMAddress, + mcmMetadata.MCMAddress, m.Delay, m.Action, predecessor, @@ -235,11 +290,17 @@ func (m *TimelockProposal) Convert( return Proposal{}, nil, err } + // Preserve the governing MCM instance on the converted operations so the + // resulting MCMS proposal sequences nonces per instance. + for j := range convertedOps { + convertedOps[j].McmAddress = bop.McmAddress + } + // Append the converted operation to the MCMS only proposal result.Operations = append(result.Operations, convertedOps...) - // Update lastOpID for that chain - lastOpID[chainSelector] = operationID + // Update lastOpID for that instance + lastOpID[key] = operationID } // 7) Return the MCMS-only proposal + the single slice of predecessors @@ -313,13 +374,21 @@ func (m *TimelockProposal) buildTimelockConverters(_ context.Context) (map[types func (m *TimelockProposal) calcOperationIDs(ctx context.Context) ([]common.Hash, []common.Hash, error) { operationIDs := make([]common.Hash, len(m.Operations)) predecessors := make([]common.Hash, len(m.Operations)) - lastOpID := make(map[types.ChainSelector]common.Hash) - for sel := range m.ChainMetadata { - lastOpID[sel] = ZeroHash + lastOpID := make(map[instanceKey]common.Hash) + for sel, metadata := range m.ChainMetadata { + for _, instance := range metadata.AllMCMs() { + lastOpID[instanceKey{chainSelector: sel, mcmAddress: instance.MCMAddress}] = ZeroHash + } } for i, batchOp := range m.Operations { - predecessors[i] = lastOpID[batchOp.ChainSelector] + mcmMetadata, err := m.mcmMetadataForBatchOp(batchOp) + if err != nil { + return nil, nil, err + } + + key := instanceKey{chainSelector: batchOp.ChainSelector, mcmAddress: mcmMetadata.MCMAddress} + predecessors[i] = lastOpID[key] calculateOperationID, err := operationIDFn(ctx, batchOp.ChainSelector) if err != nil { @@ -331,7 +400,7 @@ func (m *TimelockProposal) calcOperationIDs(ctx context.Context) ([]common.Hash, return nil, nil, fmt.Errorf("failed to calculate operation ID for chain selector %d: %w", batchOp.ChainSelector, err) } - lastOpID[batchOp.ChainSelector] = newOperationID + lastOpID[key] = newOperationID operationIDs[i] = newOperationID } @@ -392,20 +461,29 @@ func (m *TimelockProposal) GetOpCount( opt(&options) } + // Resolve the target MCM instance (primary unless overridden). + mcmMetadata, ok := metadata.GetMCM(options.mcmAddress) + if !ok { + return 0, fmt.Errorf( + "chain %d: mcmAddress %q does not match the chain's primary MCM or any additional MCM instance", + chainSelector, options.mcmAddress) + } + inspector := options.inspector if inspector == nil { var err error - inspector, err = chainwrappers.BuildInspector(chains, chainSelector, m.Action, metadata) + inspector, err = chainwrappers.BuildInspector(chains, chainSelector, m.Action, mcmMetadata) if err != nil { return 0, err } } - return inspector.GetOpCount(ctx, metadata.MCMAddress) + return inspector.GetOpCount(ctx, mcmMetadata.MCMAddress) } type getOpCountOptions struct { - inspector sdk.Inspector + inspector sdk.Inspector + mcmAddress string } type GetOpCountOption func(*getOpCountOptions) @@ -417,6 +495,13 @@ func WithInspector(inspector sdk.Inspector) GetOpCountOption { } } +// WithMCMAddress targets a specific MCM instance on the chain instead of the primary MCM. +func WithMCMAddress(mcmAddress string) GetOpCountOption { + return func(o *getOpCountOptions) { + o.mcmAddress = mcmAddress + } +} + // timeLockProposalValidateBasic basic validation for an MCMS proposal func timeLockProposalValidateBasic(timelockProposal TimelockProposal) error { // Get the current Unix timestamp as an int64 diff --git a/types/chain.go b/types/chain.go index 7e55e2c5..0b43210c 100644 --- a/types/chain.go +++ b/types/chain.go @@ -10,6 +10,61 @@ type ChainMetadata struct { StartingOpCount uint64 `json:"startingOpCount"` MCMAddress string `json:"mcmAddress"` AdditionalFields json.RawMessage `json:"additionalFields,omitempty" validate:"omitempty"` + // AdditionalMCMs holds metadata for extra MCM instances on the same chain, when the + // chain is governed by more than one MCM contract (e.g. Canton's mcms-ccip and + // mcms-ccv). Entries must have unique MCMAddress values distinct from the primary + // MCMAddress, and must not themselves carry AdditionalMCMs. Requires proposal + // version v2. + AdditionalMCMs []ChainMetadata `json:"additionalMCMs,omitempty" validate:"omitempty,dive"` +} + +// AllMCMs returns the primary MCM metadata followed by all AdditionalMCMs entries. +func (m ChainMetadata) AllMCMs() []ChainMetadata { + all := make([]ChainMetadata, 0, len(m.AdditionalMCMs)+1) + all = append(all, ChainMetadata{ + StartingOpCount: m.StartingOpCount, + MCMAddress: m.MCMAddress, + AdditionalFields: m.AdditionalFields, + }) + return append(all, m.AdditionalMCMs...) +} + +// GetMCM returns the metadata for the MCM instance with the given address. An empty +// address resolves to the primary MCM. The second return value reports whether the +// address matched the primary or an AdditionalMCMs entry. +func (m ChainMetadata) GetMCM(mcmAddress string) (ChainMetadata, bool) { + if mcmAddress == "" || mcmAddress == m.MCMAddress { + return ChainMetadata{ + StartingOpCount: m.StartingOpCount, + MCMAddress: m.MCMAddress, + AdditionalFields: m.AdditionalFields, + }, true + } + for _, additional := range m.AdditionalMCMs { + if additional.MCMAddress == mcmAddress { + return additional, true + } + } + return ChainMetadata{}, false +} + +// ValidateMultiMCM checks the multi-MCM invariants: unique addresses across primary and +// AdditionalMCMs, and no nested AdditionalMCMs. +func (m ChainMetadata) ValidateMultiMCM() error { + seen := map[string]struct{}{m.MCMAddress: {}} + for _, additional := range m.AdditionalMCMs { + if additional.MCMAddress == "" { + return fmt.Errorf("additional MCM entries must have a non-empty MCMAddress") + } + if _, exists := seen[additional.MCMAddress]; exists { + return fmt.Errorf("duplicate MCMAddress %q in chain metadata", additional.MCMAddress) + } + seen[additional.MCMAddress] = struct{}{} + if len(additional.AdditionalMCMs) > 0 { + return fmt.Errorf("additional MCM entries must not themselves have AdditionalMCMs") + } + } + return nil } func (m *ChainMetadata) Merge(other ChainMetadata) (ChainMetadata, error) { @@ -55,9 +110,45 @@ func (m *ChainMetadata) Merge(other ChainMetadata) (ChainMetadata, error) { } } + mergedAdditionalMCMs, err := mergeAdditionalMCMs(m.AdditionalMCMs, other.AdditionalMCMs) + if err != nil { + return ChainMetadata{}, err + } + return ChainMetadata{ StartingOpCount: min(m.StartingOpCount, other.StartingOpCount), MCMAddress: m.MCMAddress, AdditionalFields: mergedAdditionalFields, + AdditionalMCMs: mergedAdditionalMCMs, }, nil } + +// mergeAdditionalMCMs unions two AdditionalMCMs lists by MCMAddress. Entries with the +// same MCMAddress are merged with the usual per-instance rules. +func mergeAdditionalMCMs(a, b []ChainMetadata) ([]ChainMetadata, error) { + if len(a) == 0 && len(b) == 0 { + return nil, nil + } + + merged := make([]ChainMetadata, 0, len(a)+len(b)) + indexByAddress := make(map[string]int, len(a)+len(b)) + for _, entry := range a { + indexByAddress[entry.MCMAddress] = len(merged) + merged = append(merged, entry) + } + for _, entry := range b { + if idx, exists := indexByAddress[entry.MCMAddress]; exists { + m, err := merged[idx].Merge(entry) + if err != nil { + return nil, err + } + m.AdditionalMCMs = nil // additional MCM entries never nest + merged[idx] = m + continue + } + indexByAddress[entry.MCMAddress] = len(merged) + merged = append(merged, entry) + } + + return merged, nil +} diff --git a/types/chain_test.go b/types/chain_test.go new file mode 100644 index 00000000..6f3f6070 --- /dev/null +++ b/types/chain_test.go @@ -0,0 +1,228 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChainMetadata_AllMCMs(t *testing.T) { + t.Parallel() + + primary := ChainMetadata{StartingOpCount: 5, MCMAddress: "0xaaa"} + additional := ChainMetadata{StartingOpCount: 2, MCMAddress: "0xbbb"} + md := ChainMetadata{ + StartingOpCount: primary.StartingOpCount, + MCMAddress: primary.MCMAddress, + AdditionalMCMs: []ChainMetadata{additional}, + } + + all := md.AllMCMs() + require.Len(t, all, 2) + assert.Equal(t, primary.MCMAddress, all[0].MCMAddress) + assert.Equal(t, additional.MCMAddress, all[1].MCMAddress) + // The primary view must not carry the nested list + assert.Empty(t, all[0].AdditionalMCMs) + + // Single-MCM chain: exactly one entry + single := ChainMetadata{MCMAddress: "0xaaa"} + require.Len(t, single.AllMCMs(), 1) +} + +func TestChainMetadata_GetMCM(t *testing.T) { + t.Parallel() + + md := ChainMetadata{ + StartingOpCount: 5, + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{ + {StartingOpCount: 2, MCMAddress: "0xbbb"}, + }, + } + + tests := []struct { + name string + give string + wantAddr string + wantCount uint64 + wantOK bool + }{ + {name: "empty resolves to primary", give: "", wantAddr: "0xaaa", wantCount: 5, wantOK: true}, + {name: "explicit primary", give: "0xaaa", wantAddr: "0xaaa", wantCount: 5, wantOK: true}, + {name: "additional instance", give: "0xbbb", wantAddr: "0xbbb", wantCount: 2, wantOK: true}, + {name: "unknown address", give: "0xccc", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := md.GetMCM(tt.give) + assert.Equal(t, tt.wantOK, ok) + if tt.wantOK { + assert.Equal(t, tt.wantAddr, got.MCMAddress) + assert.Equal(t, tt.wantCount, got.StartingOpCount) + } + }) + } +} + +func TestChainMetadata_ValidateMultiMCM(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + give ChainMetadata + wantErr string + }{ + { + name: "valid: single MCM", + give: ChainMetadata{MCMAddress: "0xaaa"}, + }, + { + name: "valid: primary plus additional", + give: ChainMetadata{ + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{{MCMAddress: "0xbbb"}}, + }, + }, + { + name: "failure: empty additional address", + give: ChainMetadata{ + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{{MCMAddress: ""}}, + }, + wantErr: "additional MCM entries must have a non-empty MCMAddress", + }, + { + name: "failure: duplicate of primary", + give: ChainMetadata{ + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{{MCMAddress: "0xaaa"}}, + }, + wantErr: `duplicate MCMAddress "0xaaa" in chain metadata`, + }, + { + name: "failure: duplicate additionals", + give: ChainMetadata{ + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{ + {MCMAddress: "0xbbb"}, + {MCMAddress: "0xbbb"}, + }, + }, + wantErr: `duplicate MCMAddress "0xbbb" in chain metadata`, + }, + { + name: "failure: nested additionals", + give: ChainMetadata{ + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{ + {MCMAddress: "0xbbb", AdditionalMCMs: []ChainMetadata{{MCMAddress: "0xccc"}}}, + }, + }, + wantErr: "additional MCM entries must not themselves have AdditionalMCMs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.give.ValidateMultiMCM() + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, tt.wantErr) + } + }) + } +} + +func TestChainMetadata_Merge_AdditionalMCMs(t *testing.T) { + t.Parallel() + + t.Run("union of disjoint additionals", func(t *testing.T) { + t.Parallel() + a := ChainMetadata{ + StartingOpCount: 5, + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{{StartingOpCount: 1, MCMAddress: "0xbbb"}}, + } + b := ChainMetadata{ + StartingOpCount: 7, + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{{StartingOpCount: 3, MCMAddress: "0xccc"}}, + } + + merged, err := a.Merge(b) + require.NoError(t, err) + assert.Equal(t, uint64(5), merged.StartingOpCount) // min of primaries + assert.Equal(t, "0xaaa", merged.MCMAddress) + require.Len(t, merged.AdditionalMCMs, 2) + assert.Equal(t, "0xbbb", merged.AdditionalMCMs[0].MCMAddress) + assert.Equal(t, uint64(1), merged.AdditionalMCMs[0].StartingOpCount) + assert.Equal(t, "0xccc", merged.AdditionalMCMs[1].MCMAddress) + assert.Equal(t, uint64(3), merged.AdditionalMCMs[1].StartingOpCount) + }) + + t.Run("same additional address merges per-instance rules", func(t *testing.T) { + t.Parallel() + a := ChainMetadata{ + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{{StartingOpCount: 1, MCMAddress: "0xbbb"}}, + } + b := ChainMetadata{ + MCMAddress: "0xaaa", + AdditionalMCMs: []ChainMetadata{{StartingOpCount: 4, MCMAddress: "0xbbb"}}, + } + + merged, err := a.Merge(b) + require.NoError(t, err) + require.Len(t, merged.AdditionalMCMs, 1) + assert.Equal(t, uint64(1), merged.AdditionalMCMs[0].StartingOpCount) // min + }) + + t.Run("different primaries still rejected", func(t *testing.T) { + t.Parallel() + a := ChainMetadata{MCMAddress: "0xaaa"} + b := ChainMetadata{MCMAddress: "0xbbb"} + + _, err := a.Merge(b) + require.ErrorContains(t, err, "cannot merge ChainMetadata with different MCMAddress") + }) +} + +func TestChainMetadata_JSONRoundTrip(t *testing.T) { + t.Parallel() + + md := ChainMetadata{ + StartingOpCount: 5, + MCMAddress: "0xaaa", + AdditionalFields: json.RawMessage(`{"chainId": "1"}`), + AdditionalMCMs: []ChainMetadata{ + { + StartingOpCount: 2, + MCMAddress: "0xbbb", + AdditionalFields: json.RawMessage(`{"chainId": "1"}`), + }, + }, + } + + data, err := json.Marshal(md) + require.NoError(t, err) + + var got ChainMetadata + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, md.MCMAddress, got.MCMAddress) + assert.Equal(t, md.StartingOpCount, got.StartingOpCount) + require.Len(t, got.AdditionalMCMs, 1) + assert.Equal(t, "0xbbb", got.AdditionalMCMs[0].MCMAddress) + assert.Equal(t, uint64(2), got.AdditionalMCMs[0].StartingOpCount) + + // Single-MCM metadata must not emit the additionalMCMs key + single := ChainMetadata{StartingOpCount: 1, MCMAddress: "0xaaa"} + singleData, err := json.Marshal(single) + require.NoError(t, err) + assert.NotContains(t, string(singleData), "additionalMCMs") +} diff --git a/types/operation.go b/types/operation.go index e4ebb5c1..e89a7769 100644 --- a/types/operation.go +++ b/types/operation.go @@ -25,11 +25,19 @@ type Transaction struct { // Operation represents an operation with a single transaction to be executed type Operation struct { ChainSelector ChainSelector `json:"chainSelector" validate:"required"` - Transaction Transaction `json:"transaction" validate:"required"` + // McmAddress optionally identifies which MCM instance on the chain governs this + // operation. Empty means the chain's primary MCM (the MCMAddress on the chain's + // ChainMetadata entry). Non-empty values must match the primary MCM or one of its + // AdditionalMCMs entries. Requires proposal version v2. + McmAddress string `json:"mcmAddress,omitempty"` + Transaction Transaction `json:"transaction" validate:"required"` } // BatchOperation represents an operation with a batch of transactions to be executed. type BatchOperation struct { ChainSelector ChainSelector `json:"chainSelector" validate:"required"` - Transactions []Transaction `json:"transactions" validate:"required,min=1,dive"` + // McmAddress optionally identifies which MCM instance on the chain governs this + // batch. See Operation.McmAddress. + McmAddress string `json:"mcmAddress,omitempty"` + Transactions []Transaction `json:"transactions" validate:"required,min=1,dive"` }