diff --git a/deployment/changesets/update_mcms_config.go b/deployment/changesets/update_mcms_config.go new file mode 100644 index 000000000..706f8d735 --- /dev/null +++ b/deployment/changesets/update_mcms_config.go @@ -0,0 +1,142 @@ +package changesets + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + ccipsequences "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/go-daml/pkg/types" + + "github.com/smartcontractkit/chainlink-canton/contracts/v2" + mcmsCore "github.com/smartcontractkit/chainlink-canton/contracts/v2/bindings/generated/mcms/core" + mcmsops "github.com/smartcontractkit/chainlink-canton/deployment/operations/mcms" + dsutils "github.com/smartcontractkit/chainlink-canton/deployment/utils/datastore" + opcontract "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" +) + +// UpdateMCMSConfig updates signer, quorum and parent configuration for one or more +// roles on an existing Canton MCMS instance, resolved from the datastore by qualifier. +// +// The SetConfig choice is controlled by the MCMS owner party, so this changeset only +// supports direct execution by a participant that can ActAs that party (devnet, test +// environments, or before owner-party authorization is locked down). Proposal-driven +// updates via MCMS self-dispatch (ScheduleBatch → ExecuteScheduledBatch) are not +// supported yet. +type UpdateMCMSConfigParams struct { + // Qualifier selects the MCMS instance in the datastore. Defaults to the CLL qualifier. + Qualifier string `json:"qualifier,omitempty" yaml:"qualifier,omitempty"` + RoleConfigs []MCMSRoleConfigParams `json:"roleConfigs" yaml:"roleConfigs"` +} + +type UpdateMCMSConfigConfig struct { + Params UpdateMCMSConfigParams `json:"params" yaml:"params"` +} + +type UpdateMCMSConfig struct{} + +var _ cldf.ChangeSetV2[CantonCSDeps[UpdateMCMSConfigConfig]] = UpdateMCMSConfig{} + +func (u UpdateMCMSConfig) VerifyPreconditions(e cldf.Environment, config CantonCSDeps[UpdateMCMSConfigConfig]) error { + params := config.Config.Params + if len(params.RoleConfigs) == 0 { + return fmt.Errorf("roleConfigs is required") + } + for _, roleConfig := range params.RoleConfigs { + if _, err := buildNormalizedConfig(roleConfig.Config); err != nil { + return fmt.Errorf("build MCMS config for role %s: %w", roleConfig.Role, err) + } + } + + chain, ok := e.BlockChains.CantonChains()[config.ChainSelector] + if !ok { + return fmt.Errorf("canton chain %v not found", config.ChainSelector) + } + if config.Participant < 0 || config.Participant >= len(chain.Participants) { + return fmt.Errorf("participant index %d out of range for canton chain %d with %d participants", config.Participant, config.ChainSelector, len(chain.Participants)) + } + + qualifier := qualifierOrDefault(params.Qualifier) + if _, err := dsutils.ProposerMCMSAddressRef(e.DataStore, config.ChainSelector, qualifier); err != nil { + return fmt.Errorf("MCMS instance for qualifier %q must be deployed first: %w", qualifier, err) + } + rawInstanceAddress, err := dsutils.MCMSRawInstanceAddress(e.DataStore, config.ChainSelector, qualifier) + if err != nil { + return fmt.Errorf("resolve MCMS raw instance address for qualifier %q: %w", qualifier, err) + } + + participant := chain.Participants[config.Participant] + if opcontract.ProposalDrivenForCaller(participant, rawInstanceAddress.Owner()) { + return fmt.Errorf( + "participant %s cannot ActAs MCMS owner party %s required by the SetConfig choice; proposal-driven config updates are not supported yet", + participant.PartyID, rawInstanceAddress.Owner(), + ) + } + + return nil +} + +func (u UpdateMCMSConfig) Apply(e cldf.Environment, config CantonCSDeps[UpdateMCMSConfigConfig]) (cldf.ChangesetOutput, error) { + chain := e.BlockChains.CantonChains()[config.ChainSelector] + + rawInstanceAddress, err := dsutils.MCMSRawInstanceAddress(e.DataStore, config.ChainSelector, qualifierOrDefault(config.Config.Params.Qualifier)) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("resolve MCMS raw instance address: %w", err) + } + + _, err = operations.ExecuteSequence(e.OperationsBundle, updateMCMSConfigSequence, chain, updateMCMSConfigInput{ + RawInstanceAddress: rawInstanceAddress, + ParticipantIndex: config.Participant, + RoleConfigs: config.Config.Params.RoleConfigs, + }) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to execute UpdateMCMSConfig sequence: %w", err) + } + + // SetConfig archives and recreates the MCMS contract at the same instance address, + // so no new address refs are produced. + return cldf.ChangesetOutput{ + DataStore: datastore.NewMemoryDataStore(), + Reports: []operations.Report[any, any]{}, + }, nil +} + +type updateMCMSConfigInput struct { + RawInstanceAddress contracts.RawInstanceAddress `json:"rawInstanceAddress"` + ParticipantIndex int `json:"participantIndex"` + RoleConfigs []MCMSRoleConfigParams `json:"roleConfigs"` +} + +var updateMCMSConfigSequence = operations.NewSequence( + "canton/mcms/update_config", + semver.MustParse("0.1.0"), + "Updates signer configuration on an existing Canton MCMS contract", + func(b operations.Bundle, deps canton.Chain, input updateMCMSConfigInput) (ccipsequences.OnChainOutput, error) { + for i, roleConfig := range input.RoleConfigs { + groupConfig, err := buildNormalizedConfig(roleConfig.Config) + if err != nil { + return ccipsequences.OnChainOutput{}, fmt.Errorf("build MCMS config for role %s: %w", roleConfig.Role, err) + } + + _, err = operations.ExecuteOperation(b, mcmsops.SetConfig, deps, opcontract.ChoiceInput[mcmsCore.SetConfig]{ + InstanceAddress: input.RawInstanceAddress.InstanceAddress(), + ParticipantIndex: input.ParticipantIndex, + Args: mcmsCore.SetConfig{ + TargetRole: roleConfig.Role, + NewSigners: groupConfig.Signers, + NewGroupQuorums: groupConfig.GroupQuorums, + NewGroupParents: groupConfig.GroupParents, + ClearRoot: types.BOOL(roleConfig.Config.ClearRoot), + }, + }) + if err != nil { + return ccipsequences.OnChainOutput{}, fmt.Errorf("update MCMS role %s at index %d: %w", roleConfig.Role, i, err) + } + } + + return ccipsequences.OnChainOutput{}, nil + }, +) diff --git a/deployment/changesets/update_mcms_config_test.go b/deployment/changesets/update_mcms_config_test.go new file mode 100644 index 000000000..d018bb33d --- /dev/null +++ b/deployment/changesets/update_mcms_config_test.go @@ -0,0 +1,270 @@ +package changesets + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chainsel "github.com/smartcontractkit/chain-selectors" + ccipdeploymentutils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/go-daml/pkg/types" + + "github.com/smartcontractkit/chainlink-canton/contracts/v2" + mcmsApi "github.com/smartcontractkit/chainlink-canton/contracts/v2/bindings/generated/mcms/api" + mcmsCore "github.com/smartcontractkit/chainlink-canton/contracts/v2/bindings/generated/mcms/core" + dsutils "github.com/smartcontractkit/chainlink-canton/deployment/utils/datastore" + opcontract "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" +) + +const ( + mcmsUnitOwnerParty = "participant::owner" + mcmsUnitOtherParty = "participant::other" + mcmsUnitSignerPrefix = "00000000000000000000000000000000000000" +) + +func mcmsUnitTestSigners(prefix string) []mcmsApi.SignerInfo { + signers := make([]mcmsApi.SignerInfo, 3) + for i := range signers { + signers[i] = mcmsApi.SignerInfo{ + SignerAddress: types.TEXT(fmt.Sprintf("%s%c%c", mcmsUnitSignerPrefix, prefix[0], 'a'+byte(i))), + SignerIndex: types.INT64(i), + SignerGroup: 0, + } + } + + return signers +} + +func mcmsUnitTestEnv(t *testing.T, participantParty string, seedMCMSRef bool) cldf.Environment { + t.Helper() + + ds := datastore.NewMemoryDataStore() + if seedMCMSRef { + raw, err := contracts.RawInstanceAddressFromString("mcms-upd-unit@" + mcmsUnitOwnerParty) + require.NoError(t, err) + ref := newMCMSRoleAddressRef( + chainsel.CANTON_LOCALNET.Selector, raw, + datastore.ContractType(ccipdeploymentutils.ProposerManyChainMultisig), "CLLCCIP", + ) + require.NoError(t, ds.AddressRefStore.Add(ref)) + } + + return cldf.Environment{ + BlockChains: chain.NewBlockChainsFromSlice([]chain.BlockChain{&canton.Chain{ + ChainMetadata: canton.ChainMetadata{Selector: chainsel.CANTON_LOCALNET.Selector}, + Participants: []canton.Participant{{PartyID: participantParty}}, + }}), + DataStore: ds.Seal(), + } +} + +func TestUpdateMCMSConfig_VerifyPreconditions(t *testing.T) { + t.Parallel() + + validConfig := MCMSConfigParams{ + Signers: mcmsUnitTestSigners("aa"), + GroupQuorums: []types.INT64{2}, + GroupParents: []types.INT64{0}, + } + + tests := []struct { + name string + env cldf.Environment + roleConfigs []MCMSRoleConfigParams + errorContains string + }{ + { + name: "happy path", + env: mcmsUnitTestEnv(t, mcmsUnitOwnerParty, true), + roleConfigs: []MCMSRoleConfigParams{{ + Role: mcmsApi.RoleProposer, + Config: validConfig, + }}, + }, + { + name: "empty role configs", + env: mcmsUnitTestEnv(t, mcmsUnitOwnerParty, true), + roleConfigs: nil, + errorContains: "roleConfigs is required", + }, + { + name: "oversized group config", + env: mcmsUnitTestEnv(t, mcmsUnitOwnerParty, true), + roleConfigs: []MCMSRoleConfigParams{{ + Role: mcmsApi.RoleProposer, + Config: MCMSConfigParams{ + Signers: mcmsUnitTestSigners("aa"), + GroupQuorums: make([]types.INT64, mcmsGroupCount+1), + GroupParents: []types.INT64{0}, + }, + }}, + errorContains: "build MCMS config for role Proposer", + }, + { + name: "missing MCMS ref", + env: mcmsUnitTestEnv(t, mcmsUnitOwnerParty, false), + roleConfigs: []MCMSRoleConfigParams{{ + Role: mcmsApi.RoleProposer, + Config: validConfig, + }}, + errorContains: "must be deployed first", + }, + { + name: "proposal-driven participant rejected", + env: mcmsUnitTestEnv(t, mcmsUnitOtherParty, true), + roleConfigs: []MCMSRoleConfigParams{{ + Role: mcmsApi.RoleProposer, + Config: validConfig, + }}, + errorContains: "cannot ActAs MCMS owner party", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := UpdateMCMSConfig{}.VerifyPreconditions(tt.env, CantonCSDeps[UpdateMCMSConfigConfig]{ + ChainSelector: chainsel.CANTON_LOCALNET.Selector, + Participant: 0, + Config: UpdateMCMSConfigConfig{ + Params: UpdateMCMSConfigParams{RoleConfigs: tt.roleConfigs}, + }, + }) + if tt.errorContains == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.errorContains) + }) + } +} + +// TestUpdateMCMSConfig_DirectExecution deploys an MCMS via the DeployAndConfigureMCMS +// changeset, then exercises UpdateMCMSConfig with a new signer set and verifies the +// proposer role state on-chain. +func TestUpdateMCMSConfig_DirectExecution(t *testing.T) { + t.Parallel() + + cantonChain, _, env := setupCantonEnv(t) + participant := cantonChain.Participants[0] + party := participant.PartyID + + uploadDARs(t, participant, contracts.MCMSCore) + + initialSigners := mcmsUnitTestSigners("aa") + initialConfig := MCMSConfigParams{ + Signers: initialSigners, + GroupQuorums: []types.INT64{2}, + GroupParents: []types.INT64{0}, + } + + deployOut, err := DeployAndConfigureMCMS{}.Apply(*env, CantonCSDeps[DeployAndConfigureMCMSConfig]{ + ChainSelector: chainsel.CANTON_LOCALNET.Selector, + Participant: 0, + Config: DeployAndConfigureMCMSConfig{ + Params: DeployAndConfigureMCMSParams{ + OwnerParty: party, + ChainID: 1, + InitialConfig: initialConfig, + RoleConfigs: []MCMSRoleConfigParams{ + {Role: mcmsApi.RoleProposer, Config: initialConfig}, + {Role: mcmsApi.RoleCanceller, Config: initialConfig}, + {Role: mcmsApi.RoleBypasser, Config: initialConfig}, + }, + }, + }, + }) + require.NoError(t, err, "deploy and configure MCMS") + + updatedEnv := *env + updatedEnv.DataStore = deployOut.DataStore.Seal() + + rawInstanceAddress, err := dsutils.MCMSRawInstanceAddress(updatedEnv.DataStore, chainsel.CANTON_LOCALNET.Selector, "CLLCCIP") + require.NoError(t, err, "resolve deployed MCMS raw instance address") + + newSigners := mcmsUnitTestSigners("bb") + deps := CantonCSDeps[UpdateMCMSConfigConfig]{ + ChainSelector: chainsel.CANTON_LOCALNET.Selector, + Participant: 0, + Config: UpdateMCMSConfigConfig{ + Params: UpdateMCMSConfigParams{ + RoleConfigs: []MCMSRoleConfigParams{{ + Role: mcmsApi.RoleProposer, + Config: MCMSConfigParams{ + Signers: newSigners, + GroupQuorums: []types.INT64{3}, + GroupParents: []types.INT64{0}, + ClearRoot: true, + }, + }}, + }, + }, + } + + require.NoError(t, UpdateMCMSConfig{}.VerifyPreconditions(updatedEnv, deps)) + + out, err := UpdateMCMSConfig{}.Apply(updatedEnv, deps) + require.NoError(t, err, "update MCMS config") + assert.Empty(t, out.MCMSTimelockProposals, "direct execution should not produce proposals") + + requireRoleSigners(t, participant, party, rawInstanceAddress, "proposer", signerAddresses(newSigners)) + requireRoleSigners(t, participant, party, rawInstanceAddress, "bypasser", signerAddresses(initialSigners)) +} + +func signerAddresses(signers []mcmsApi.SignerInfo) []string { + addrs := make([]string, len(signers)) + for i, s := range signers { + addrs[i] = string(s.SignerAddress) + } + + return addrs +} + +func requireRoleSigners( + t *testing.T, + participant canton.Participant, + party string, + rawInstanceAddress contracts.RawInstanceAddress, + roleField string, + want []string, +) { + t.Helper() + + active, err := opcontract.FindActiveContractByInstanceAddress( + t.Context(), participant.LedgerServices.State, []string{party}, + mcmsCore.MCMS{}.GetTemplateID(), rawInstanceAddress.InstanceAddress(), + ) + require.NoError(t, err, "find updated MCMS contract") + + var got []string + for _, field := range active.GetCreatedEvent().GetCreateArguments().GetFields() { + if field.GetLabel() != roleField { + continue + } + for _, roleField := range field.GetValue().GetRecord().GetFields() { + if roleField.GetLabel() != "config" { + continue + } + for _, configField := range roleField.GetValue().GetRecord().GetFields() { + if configField.GetLabel() != "signers" { + continue + } + for _, signer := range configField.GetValue().GetList().GetElements() { + for _, signerField := range signer.GetRecord().GetFields() { + if signerField.GetLabel() == "signerAddress" { + got = append(got, signerField.GetValue().GetText()) + } + } + } + } + } + } + require.Equal(t, want, got, "%s signers after SetConfig", roleField) +}