diff --git a/deployment/common/changeset/set_config_mcms.go b/deployment/common/changeset/set_config_mcms.go new file mode 100644 index 00000000000..3ba5d2db4b6 --- /dev/null +++ b/deployment/common/changeset/set_config_mcms.go @@ -0,0 +1,209 @@ +package changeset + +import ( + "context" + "errors" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/config" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/mcms" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/timelock" + chain_selectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" +) + +type ConfigPerRole struct { + Proposer config.Config + Canceller config.Config + Bypasser config.Config +} +type TimelockConfig struct { + MinDelay time.Duration // delay for timelock worker to execute the transfers. +} + +type MCMSConfig struct { + ConfigsPerChain map[uint64]ConfigPerRole + ProposalConfig *TimelockConfig +} + +var _ deployment.ChangeSet[MCMSConfig] = SetConfigMCMS + +// Validate checks that the MCMSConfig is valid +func (cfg MCMSConfig) Validate(e deployment.Environment, selectors []uint64) error { + if len(cfg.ConfigsPerChain) == 0 { + return errors.New("no chain configs provided") + } + // configs should have at least one chain + state, err := MaybeLoadMCMSWithTimelockState(e, selectors) + if err != nil { + return err + } + for chainSelector, c := range cfg.ConfigsPerChain { + family, err := chain_selectors.GetSelectorFamily(chainSelector) + if err != nil { + return err + } + if family != chain_selectors.FamilyEVM { + return fmt.Errorf("chain selector: %d is not an ethereum chain", chainSelector) + } + _, ok := e.Chains[chainSelector] + if !ok { + return fmt.Errorf("chain selector: %d not found in environment", chainSelector) + } + _, ok = state[chainSelector] + if !ok { + return fmt.Errorf("chain selector: %d not found for MCMS state", chainSelector) + } + if err := c.Proposer.Validate(); err != nil { + return err + } + if err := c.Canceller.Validate(); err != nil { + return err + } + if err := c.Bypasser.Validate(); err != nil { + return err + } + } + return nil +} + +// setConfigOrTxData executes set config tx or gets the tx data for the MCMS proposal +func setConfigOrTxData(ctx context.Context, lggr logger.Logger, chain deployment.Chain, cfg config.Config, contract *gethwrappers.ManyChainMultiSig, useMCMS bool) (*types.Transaction, error) { + groupQuorums, groupParents, signerAddresses, signerGroups := cfg.ExtractSetConfigInputs() + opts := deployment.SimTransactOpts() + if !useMCMS { + opts = chain.DeployerKey + } + opts.Context = ctx + tx, err := contract.SetConfig(opts, signerAddresses, signerGroups, groupQuorums, groupParents, false) + if err != nil { + return nil, err + } + if !useMCMS { + _, err = deployment.ConfirmIfNoError(chain, tx, err) + if err != nil { + return nil, err + } + lggr.Infow("SetConfigMCMS tx confirmed", "txHash", tx.Hash().Hex()) + } + return tx, nil +} + +type setConfigTxs struct { + proposerTx *types.Transaction + cancellerTx *types.Transaction + bypasserTx *types.Transaction +} + +// setConfigPerRole sets the configuration for each of the MCMS contract roles on the mcmsState. +func setConfigPerRole(ctx context.Context, lggr logger.Logger, chain deployment.Chain, cfg ConfigPerRole, mcmsState *MCMSWithTimelockState, useMCMS bool) (setConfigTxs, error) { + // Proposer set config + proposerTx, err := setConfigOrTxData(ctx, lggr, chain, cfg.Proposer, mcmsState.ProposerMcm, useMCMS) + if err != nil { + return setConfigTxs{}, err + } + // Canceller set config + cancellerTx, err := setConfigOrTxData(ctx, lggr, chain, cfg.Canceller, mcmsState.CancellerMcm, useMCMS) + if err != nil { + return setConfigTxs{}, err + } + // Bypasser set config + bypasserTx, err := setConfigOrTxData(ctx, lggr, chain, cfg.Bypasser, mcmsState.BypasserMcm, useMCMS) + if err != nil { + return setConfigTxs{}, err + } + + return setConfigTxs{ + proposerTx: proposerTx, + cancellerTx: cancellerTx, + bypasserTx: bypasserTx, + }, nil +} + +func addTxsToProposalBatch(setConfigTxsChain setConfigTxs, chainSelector uint64, state MCMSWithTimelockState) timelock.BatchChainOperation { + result := timelock.BatchChainOperation{ + ChainIdentifier: mcms.ChainIdentifier(chainSelector), + Batch: []mcms.Operation{}, + } + result.Batch = append(result.Batch, mcms.Operation{ + To: state.ProposerMcm.Address(), + Data: setConfigTxsChain.proposerTx.Data(), + Value: big.NewInt(0), + ContractType: string(commontypes.ProposerManyChainMultisig), + }) + result.Batch = append(result.Batch, mcms.Operation{ + To: state.CancellerMcm.Address(), + Data: setConfigTxsChain.cancellerTx.Data(), + Value: big.NewInt(0), + ContractType: string(commontypes.CancellerManyChainMultisig), + }) + result.Batch = append(result.Batch, mcms.Operation{ + To: state.BypasserMcm.Address(), + Data: setConfigTxsChain.bypasserTx.Data(), + Value: big.NewInt(0), + ContractType: string(commontypes.BypasserManyChainMultisig), + }) + return result +} + +// SetConfigMCMS sets the configuration of the MCMS contract on the chain identified by the chainSelector. +func SetConfigMCMS(e deployment.Environment, cfg MCMSConfig) (deployment.ChangesetOutput, error) { + selectors := []uint64{} + lggr := e.Logger + ctx := e.GetContext() + for chainSelector := range cfg.ConfigsPerChain { + selectors = append(selectors, chainSelector) + } + useMCMS := cfg.ProposalConfig != nil + err := cfg.Validate(e, selectors) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + batches := []timelock.BatchChainOperation{} + timelocksPerChain := map[uint64]common.Address{} + proposerMcmsPerChain := map[uint64]*gethwrappers.ManyChainMultiSig{} + + mcmsStatePerChain, err := MaybeLoadMCMSWithTimelockState(e, selectors) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + for chainSelector, c := range cfg.ConfigsPerChain { + chain := e.Chains[chainSelector] + state := mcmsStatePerChain[chainSelector] + timelocksPerChain[chainSelector] = state.Timelock.Address() + proposerMcmsPerChain[chainSelector] = state.ProposerMcm + setConfigTxsChain, err := setConfigPerRole(ctx, lggr, chain, c, state, useMCMS) + if err != nil { + return deployment.ChangesetOutput{}, err + } + if useMCMS { + batch := addTxsToProposalBatch(setConfigTxsChain, chainSelector, *state) + batches = append(batches, batch) + } + + } + + if useMCMS { + // Create MCMS with timelock proposal + proposal, err := proposalutils.BuildProposalFromBatches(timelocksPerChain, proposerMcmsPerChain, batches, "Set config proposal", cfg.ProposalConfig.MinDelay) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to build proposal from batch: %w", err) + } + lggr.Infow("SetConfigMCMS proposal created", "proposal", proposal) + return deployment.ChangesetOutput{Proposals: []timelock.MCMSWithTimelockProposal{*proposal}}, nil + } + + return deployment.ChangesetOutput{}, nil +} diff --git a/deployment/common/changeset/set_config_mcms_test.go b/deployment/common/changeset/set_config_mcms_test.go new file mode 100644 index 00000000000..7220bdd755a --- /dev/null +++ b/deployment/common/changeset/set_config_mcms_test.go @@ -0,0 +1,313 @@ +package changeset_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/config" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + "github.com/smartcontractkit/chainlink/v2/core/logger" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink/deployment" + + "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/deployment/environment/memory" +) + +// setupSetConfigTestEnv deploys all required contracts for the setConfig MCMS contract call. +func setupSetConfigTestEnv(t *testing.T) deployment.Environment { + + lggr := logger.TestLogger(t) + cfg := memory.MemoryEnvironmentConfig{ + Nodes: 1, + Chains: 2, + } + env := memory.NewMemoryEnvironment(t, lggr, zapcore.DebugLevel, cfg) + chainSelector := env.AllChainSelectors()[0] + + config := proposalutils.SingleGroupTimelockConfig(t) + // Deploy MCMS and Timelock + env, err := commonchangeset.ApplyChangesets(t, env, nil, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(commonchangeset.DeployLinkToken), + Config: []uint64{chainSelector}, + }, + { + Changeset: commonchangeset.WrapChangeSet(commonchangeset.DeployMCMSWithTimelock), + Config: map[uint64]types.MCMSWithTimelockConfig{ + chainSelector: config, + }, + }, + }) + require.NoError(t, err) + return env +} + +// TestSetConfigMCMSVariants tests the SetConfigMCMS changeset variants. +func TestSetConfigMCMSVariants(t *testing.T) { + + // Add the timelock as a signer to check state changes + for _, tc := range []struct { + name string + changeSets func(mcmsState *commonchangeset.MCMSWithTimelockState, chainSel uint64, cfgProp, cfgCancel, cfgBypass config.Config) []commonchangeset.ChangesetApplication + }{ + { + name: "MCMS disabled", + changeSets: func(mcmsState *commonchangeset.MCMSWithTimelockState, chainSel uint64, cfgProp, cfgCancel, cfgBypass config.Config) []commonchangeset.ChangesetApplication { + + return []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(commonchangeset.SetConfigMCMS), + Config: commonchangeset.MCMSConfig{ + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chainSel: { + Proposer: cfgProp, + Canceller: cfgCancel, + Bypasser: cfgBypass, + }, + }, + }, + }, + } + }, + }, + { + name: "MCMS enabled", + changeSets: func(mcmsState *commonchangeset.MCMSWithTimelockState, chainSel uint64, cfgProp, cfgCancel, cfgBypass config.Config) []commonchangeset.ChangesetApplication { + return []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(commonchangeset.TransferToMCMSWithTimelock), + Config: commonchangeset.TransferToMCMSWithTimelockConfig{ + ContractsByChain: map[uint64][]common.Address{ + chainSel: {mcmsState.ProposerMcm.Address(), mcmsState.BypasserMcm.Address(), mcmsState.CancellerMcm.Address()}, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(commonchangeset.SetConfigMCMS), + Config: commonchangeset.MCMSConfig{ + ProposalConfig: &commonchangeset.TimelockConfig{ + MinDelay: 0, + }, + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chainSel: { + Proposer: cfgProp, + Canceller: cfgCancel, + Bypasser: cfgBypass, + }, + }, + }, + }, + } + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := tests.Context(t) + + env := setupSetConfigTestEnv(t) + chainSelector := env.AllChainSelectors()[0] + chain := env.Chains[chainSelector] + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + require.Len(t, addrs, 6) + + mcmsState, err := commonchangeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + timelockAddress := mcmsState.Timelock.Address() + cfgProposer := proposalutils.SingleGroupMCMS(t) + cfgProposer.Signers = append(cfgProposer.Signers, timelockAddress) + cfgProposer.Quorum = 2 // quorum should change to 2 out of 2 signers + timelockMap := map[uint64]*proposalutils.TimelockExecutionContracts{ + chainSelector: { + Timelock: mcmsState.Timelock, + CallProxy: mcmsState.CallProxy, + }, + } + cfgCanceller := proposalutils.SingleGroupMCMS(t) + cfgBypasser := proposalutils.SingleGroupMCMS(t) + cfgBypasser.Signers = append(cfgBypasser.Signers, timelockAddress) + cfgBypasser.Signers = append(cfgBypasser.Signers, mcmsState.ProposerMcm.Address()) + cfgBypasser.Quorum = 3 // quorum should change to 3 out of 3 signers + + // Set config on all 3 MCMS contracts + changesetsToApply := tc.changeSets(mcmsState, chainSelector, cfgProposer, cfgCanceller, cfgBypasser) + _, err = commonchangeset.ApplyChangesets(t, env, timelockMap, changesetsToApply) + require.NoError(t, err) + + // Check new State + expected := cfgProposer.ToRawConfig() + opts := &bind.CallOpts{Context: ctx} + newConf, err := mcmsState.ProposerMcm.GetConfig(opts) + require.NoError(t, err) + require.Equal(t, expected, newConf) + + expected = cfgBypasser.ToRawConfig() + newConf, err = mcmsState.BypasserMcm.GetConfig(opts) + require.NoError(t, err) + require.Equal(t, expected, newConf) + + expected = cfgCanceller.ToRawConfig() + newConf, err = mcmsState.CancellerMcm.GetConfig(opts) + require.NoError(t, err) + require.Equal(t, expected, newConf) + }) + } +} + +func TestValidate(t *testing.T) { + env := setupSetConfigTestEnv(t) + + chainSelector := env.AllChainSelectors()[0] + chain := env.Chains[chainSelector] + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + require.Len(t, addrs, 6) + mcmsState, err := commonchangeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + cfg := proposalutils.SingleGroupMCMS(t) + timelockAddress := mcmsState.Timelock.Address() + // Add the timelock as a signer to check state changes + cfg.Signers = append(cfg.Signers, timelockAddress) + cfg.Quorum = 2 // quorum + + cfgInvalid := proposalutils.SingleGroupMCMS(t) + cfgInvalid.Quorum = 0 + require.NoError(t, err) + tests := []struct { + name string + cfg commonchangeset.MCMSConfig + errorMsg string + }{ + { + name: "valid config", + cfg: commonchangeset.MCMSConfig{ + ProposalConfig: &commonchangeset.TimelockConfig{ + MinDelay: 0, + }, + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chainSelector: { + Proposer: cfg, + Canceller: cfg, + Bypasser: cfg, + }, + }, + }, + }, + { + name: "valid non mcms config", + cfg: commonchangeset.MCMSConfig{ + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chainSelector: { + Proposer: cfg, + Canceller: cfg, + Bypasser: cfg, + }, + }, + }, + }, + { + name: "no chain configurations", + cfg: commonchangeset.MCMSConfig{ + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{}, + }, + errorMsg: "no chain configs provided", + }, + { + name: "non evm chain", + cfg: commonchangeset.MCMSConfig{ + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chain_selectors.APTOS_MAINNET.Selector: { + Proposer: cfg, + Canceller: cfg, + Bypasser: cfg, + }, + }, + }, + errorMsg: "chain selector: 4741433654826277614 is not an ethereum chain", + }, + { + name: "chain selector not found in environment", + cfg: commonchangeset.MCMSConfig{ + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + 123: { + Proposer: cfg, + Canceller: cfg, + Bypasser: cfg, + }, + }, + }, + errorMsg: "unknown chain selector 123", + }, + { + name: "invalid proposer config", + cfg: commonchangeset.MCMSConfig{ + ProposalConfig: &commonchangeset.TimelockConfig{ + MinDelay: 0, + }, + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chainSelector: { + Proposer: cfgInvalid, + Canceller: cfg, + Bypasser: cfg, + }, + }, + }, + errorMsg: "invalid MCMS config: Quorum must be greater than 0", + }, + { + name: "invalid canceller config", + cfg: commonchangeset.MCMSConfig{ + ProposalConfig: &commonchangeset.TimelockConfig{ + MinDelay: 0, + }, + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chainSelector: { + Proposer: cfg, + Canceller: cfgInvalid, + Bypasser: cfg, + }, + }, + }, + errorMsg: "invalid MCMS config: Quorum must be greater than 0", + }, + { + name: "invalid bypasser config", + cfg: commonchangeset.MCMSConfig{ + ProposalConfig: &commonchangeset.TimelockConfig{ + MinDelay: 0, + }, + ConfigsPerChain: map[uint64]commonchangeset.ConfigPerRole{ + chainSelector: { + Proposer: cfg, + Canceller: cfg, + Bypasser: cfgInvalid, + }, + }, + }, + errorMsg: "invalid MCMS config: Quorum must be greater than 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selectors := []uint64{chainSelector} + + err := tt.cfg.Validate(env, selectors) + if tt.errorMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +}