From 74f20a61a15f798af745c894454805dee944c071 Mon Sep 17 00:00:00 2001 From: Utsav Roy Date: Wed, 25 Feb 2026 00:08:36 +0400 Subject: [PATCH] vault: MCMS ownership transfer to Timelock and deployer renounce - Add TransferMCMSOwnershipToTimelock changeset: transfers ownership of Bypasser, Canceller, Proposer ManyChainMultiSig to RBAC Timelock (excludes CallProxy), builds acceptOwnership MCMS proposal. Supports migration (existing chains) and new chains after deploy_timelock. - Add RenounceTimelockDeployerChains changeset: renounces deployer/KMS ADMIN role on RBAC Timelock per chain so only Timelock is admin. - Extend vault types with TransferMCMSOwnershipToTimelockConfig and RenounceTimelockDeployerChainsConfig; add validation for both. - Update README: document durable pipeline flow (new vs migration), YAML payload templates, how to run (CI + local), and that set_mcms_config in prod_mainnet uses proposal path post-migration. Co-authored-by: Cursor --- deployment/vault/README.md | 102 ++++++++++++++++++ .../changeset/renounce_timelock_deployer.go | 33 ++++++ .../transfer_mcms_ownership_to_timelock.go | 70 ++++++++++++ deployment/vault/changeset/types/types.go | 27 +++++ deployment/vault/changeset/validation.go | 39 ++++++- 5 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 deployment/vault/changeset/renounce_timelock_deployer.go create mode 100644 deployment/vault/changeset/transfer_mcms_ownership_to_timelock.go diff --git a/deployment/vault/README.md b/deployment/vault/README.md index cf35beac225..53b1dc1ce4b 100644 --- a/deployment/vault/README.md +++ b/deployment/vault/README.md @@ -10,6 +10,100 @@ Vault provides [changesets](https://github.com/smartcontractkit/chainlink/tree/d - **MCMS Integration**: Full integration with MCMS for secure multi-sig governance - **Cross-Chain Support**: Execute operations across multiple EVM chains simultaneously +## Durable pipeline (prod_mainnet) – RBAC Timelock and MCMS ownership + +Contracts are deployed by KMS via CI/CD. After deployment and config, ownership of the ManyChainMultiSig contracts (Bypasser, Canceller, Proposer; **CallProxy is left as-is**) must be transferred to the RBAC Timelock, and the deployer must renounce its ADMIN role on the Timelock so that only the Timelock owns the MCMS contracts and is the sole admin of itself. + +### Correct flow for **new** chain selectors + +1. **deploy_timelock** – Deploy RBAC Timelock and MCMS contracts (Bypasser, Canceller, Proposer, CallProxy) for the new chain(s). +2. **set_mcms_config** – Set MCMS config (signers, thresholds, etc.) for the new chain(s). Leave CallProxy config as-is. +3. **transfer_mcms_ownership_to_timelock** – KMS transfers ownership of Bypasser/Canceller/Proposer MCMS to the RBAC Timelock and produces an MCMS proposal for `acceptOwnership`. +4. **Execute the MCMS proposal** – Execute the accept-ownership proposal through the normal MCMS flow (e.g. sign and execute). +5. **renounce_timelock_deployer** – KMS renounces its ADMIN role on the RBAC Timelock. After this, only the Timelock owns the MCMS contracts and is the sole admin of itself. + +### Migration for **existing** chain selectors (already deployed, no redeploy) + +For chain selectors already in the datastore (contracts already deployed, config already set), do **not** redeploy. Run only the ownership handover: + +1. **transfer_mcms_ownership_to_timelock** – With payload listing the existing `chainSelectors`. This performs the transfer and produces the accept-ownership proposal. +2. **Execute the MCMS proposal** – As above. +3. **renounce_timelock_deployer** – With the same `chainSelectors`. KMS renounces ADMIN on the Timelock for those chains. + +Pipeline payloads are resolved via the vault resolvers in `chainlink-deployments` (e.g. `environment`, `chainSelectors`, and for transfer optionally `timelockIdentifier`, `onlyAcceptOwnership`). In prod_mainnet, **set_mcms_config** is configured to produce an MCMS proposal (rather than sending directly), so it works both before and after ownership migration; run the pipeline, then sign and execute the proposal as with other MCMS proposals. + +### YAML templates (payload only) + +Use these under `payload:` for the corresponding changeset in your pipeline input file (see [How to run](#how-to-run) below). + +**transfer_mcms_ownership_to_timelock** + +```yaml +environment: prod_mainnet +chainSelectors: + - 5009297550715157269 # e.g. Ethereum mainnet; add all target chain selectors +# Optional: +# timelockIdentifier: "" # Omit or use "default" for default timelock qualifier +# onlyAcceptOwnership: false # Set true to only build accept-ownership proposal (skip transfer) +``` + +**renounce_timelock_deployer** + +```yaml +environment: prod_mainnet +chainSelectors: + - 5009297550715157269 # Same list as used for transfer_mcms_ownership_to_timelock +``` + +### How to run + +Input files live in **chainlink-deployments**: `domains/vault//durable_pipelines/inputs/.yaml`. + +1. **Create the input YAML** in `domains/vault/prod_mainnet/durable_pipelines/inputs/`. Example for migration (transfer then renounce): + + **Example: `transfer_mcms_ownership.yaml`** + ```yaml + environment: prod_mainnet + domain: vault + changesets: + - transfer_mcms_ownership_to_timelock: + payload: + environment: prod_mainnet + chainSelectors: + - 5009297550715157269 + # add other chain selectors as needed + ``` + + **Example: `renounce_timelock_deployer.yaml`** (run after executing the accept-ownership proposal) + ```yaml + environment: prod_mainnet + domain: vault + changesets: + - renounce_timelock_deployer: + payload: + environment: prod_mainnet + chainSelectors: + - 5009297550715157269 + # same chain selectors as transfer + ``` + +2. **Open a PR** against `main` in chainlink-deployments with the new/updated YAML. + +3. **Trigger execution**: Comment **`/run-pipelines`** on the PR. CI will run the pipeline and persist artifacts. + +4. **If you ran `transfer_mcms_ownership_to_timelock`**: CI will open a **proposal PR** with the accept-ownership MCMS proposal. Then: + - Check out the proposal PR branch, sign the proposal with Ledger (see [Signing proposals](https://docs.cld.cldev.sh/guides/mcms/signing-proposals/)), push your signature. + - Add the **`execute-proposal`** label on that PR to execute on-chain. + - After the proposal is executed, run the **`renounce_timelock_deployer`** pipeline (new input file or new PR) with the same `chainSelectors`. + +5. **Local run (optional)** from chainlink-deployments repo root: + ```bash + go run . durable-pipeline run --environment prod_mainnet --input-file transfer_mcms_ownership.yaml --changeset transfer_mcms_ownership_to_timelock + go run . durable-pipeline run --environment prod_mainnet --input-file renounce_timelock_deployer.yaml --changeset renounce_timelock_deployer + ``` + +--- + ## Changesets Vault currently provides the following changesets, with more planned for future releases: @@ -99,3 +193,11 @@ config := types.SetWhitelistConfig{ } output, err := SetWhitelistChangeset.Apply(env, config) ``` + +### 4. Transfer MCMS ownership to Timelock (pipeline: `transfer_mcms_ownership_to_timelock`) + +Transfers ownership of Bypasser, Canceller, and Proposer ManyChainMultiSig contracts to the RBAC Timelock (CallProxy excluded), and builds an MCMS proposal for `acceptOwnership`. Used for migration of existing chains and for new chains after `deploy_timelock` + `set_mcms_config`. See [Durable pipeline (prod_mainnet)](#durable-pipeline-prod_mainnet--rbac-timelock-and-mcms-ownership) for the full flow. + +### 5. Renounce Timelock deployer (pipeline: `renounce_timelock_deployer`) + +Renounces the deployer/KMS ADMIN role on the RBAC Timelock for the given chain selectors. Run after executing the accept-ownership proposal produced by `transfer_mcms_ownership_to_timelock`. See [Durable pipeline (prod_mainnet)](#durable-pipeline-prod_mainnet--rbac-timelock-and-mcms-ownership) for the full flow. diff --git a/deployment/vault/changeset/renounce_timelock_deployer.go b/deployment/vault/changeset/renounce_timelock_deployer.go new file mode 100644 index 00000000000..e05d982b0a0 --- /dev/null +++ b/deployment/vault/changeset/renounce_timelock_deployer.go @@ -0,0 +1,33 @@ +package changeset + +import ( + "fmt" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +// RenounceTimelockDeployerChainsChangeset renounces the deployer/KMS key's ADMIN role +// on the RBAC Timelock for each given chain so that only the Timelock is admin of itself. +// Run after the transfer_mcms_ownership_to_timelock proposal has been executed. +var RenounceTimelockDeployerChainsChangeset cldf.ChangeSetV2[types.RenounceTimelockDeployerChainsConfig] = renounceTimelockDeployerChainsChangeset{} + +type renounceTimelockDeployerChainsChangeset struct{} + +func (r renounceTimelockDeployerChainsChangeset) VerifyPreconditions(e cldf.Environment, cfg types.RenounceTimelockDeployerChainsConfig) error { + return ValidateRenounceTimelockDeployerChainsConfig(e, cfg) +} + +func (r renounceTimelockDeployerChainsChangeset) Apply(e cldf.Environment, cfg types.RenounceTimelockDeployerChainsConfig) (cldf.ChangesetOutput, error) { + for _, chainSelector := range cfg.ChainSelectors { + output, err := commonchangeset.RenounceTimelockDeployer(e, commonchangeset.RenounceTimelockDeployerConfig{ + ChainSel: chainSelector, + }) + if err != nil { + return output, fmt.Errorf("chain %d: %w", chainSelector, err) + } + } + return cldf.ChangesetOutput{}, nil +} diff --git a/deployment/vault/changeset/transfer_mcms_ownership_to_timelock.go b/deployment/vault/changeset/transfer_mcms_ownership_to_timelock.go new file mode 100644 index 00000000000..0320a91a90b --- /dev/null +++ b/deployment/vault/changeset/transfer_mcms_ownership_to_timelock.go @@ -0,0 +1,70 @@ +package changeset + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + commonstate "github.com/smartcontractkit/chainlink/deployment/common/changeset/state" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +// TransferMCMSOwnershipToTimelockChangeset transfers ownership of Bypasser, Canceller, and +// Proposer ManyChainMultiSig contracts to the RBAC Timelock (excluding CallProxy). +// It performs the transfer from KMS/deployer and builds an MCMS proposal for acceptOwnership. +// Use for: (1) migration of existing deployed contracts, (2) after deploy_timelock + set_mcms_config for new chains. +var TransferMCMSOwnershipToTimelockChangeset cldf.ChangeSetV2[types.TransferMCMSOwnershipToTimelockConfig] = transferMCMSOwnershipToTimelockChangeset{} + +type transferMCMSOwnershipToTimelockChangeset struct{} + +func (t transferMCMSOwnershipToTimelockChangeset) VerifyPreconditions(e cldf.Environment, cfg types.TransferMCMSOwnershipToTimelockConfig) error { + return ValidateTransferMCMSOwnershipToTimelockConfig(e, cfg) +} + +func (t transferMCMSOwnershipToTimelockChangeset) Apply(e cldf.Environment, cfg types.TransferMCMSOwnershipToTimelockConfig) (cldf.ChangesetOutput, error) { + qualifier := cfg.TimelockIdentifier + if qualifier == "" { + qualifier = commonchangeset.DefaultTimelockQualifier + } + + contractsByChain := make(map[uint64][]common.Address) + for _, chainSelector := range cfg.ChainSelectors { + addresses, err := commonstate.AddressesForChain(e, chainSelector, qualifier) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("chain %d: load addresses: %w", chainSelector, err) + } + + var addrs []common.Address + for addr, tv := range addresses { + switch tv.Type { + case commontypes.BypasserManyChainMultisig, commontypes.CancellerManyChainMultisig, commontypes.ProposerManyChainMultisig: + addrs = append(addrs, common.HexToAddress(addr)) + } + } + if len(addrs) == 0 { + return cldf.ChangesetOutput{}, fmt.Errorf("chain %d: no Bypasser/Canceller/Proposer MCMS addresses found in state", chainSelector) + } + contractsByChain[chainSelector] = addrs + } + + mcmsConfig := proposalutils.TimelockConfig{MinDelay: 0} + if cfg.MCMSConfig != nil { + mcmsConfig = *cfg.MCMSConfig + } + if mcmsConfig.TimelockQualifierPerChain == nil { + mcmsConfig.TimelockQualifierPerChain = make(map[uint64]string) + } + for _, chainSel := range cfg.ChainSelectors { + mcmsConfig.TimelockQualifierPerChain[chainSel] = qualifier + } + + return commonchangeset.TransferToMCMSWithTimelockV2(e, commonchangeset.TransferToMCMSWithTimelockConfig{ + ContractsByChain: contractsByChain, + MCMSConfig: mcmsConfig, + OnlyAcceptOwnership: cfg.OnlyAcceptOwnership, + }) +} diff --git a/deployment/vault/changeset/types/types.go b/deployment/vault/changeset/types/types.go index 31f61431c30..3a8dc899ff6 100644 --- a/deployment/vault/changeset/types/types.go +++ b/deployment/vault/changeset/types/types.go @@ -72,3 +72,30 @@ type BatchNativeTransferState struct { // ValidationErrors contains any validation errors found ValidationErrors []TransferValidationError `json:"validation_errors"` } + +// TransferMCMSOwnershipToTimelockConfig configures transferring ownership of +// Bypasser, Canceller, and Proposer ManyChainMultiSig contracts to the RBAC Timelock. +// Used for both migration (existing deployments) and for new deployments after deploy_timelock + set_mcms_config. +// Excludes CallProxy as per operational requirement. +type TransferMCMSOwnershipToTimelockConfig struct { + // ChainSelectors is the list of chain selectors to process + ChainSelectors []uint64 `json:"chain_selectors"` + + // TimelockIdentifier is the qualifier for the timelock (e.g. "default"). Use "" for legacy. + TimelockIdentifier string `json:"timelock_identifier"` + + // MCMSConfig contains timelock and MCMS configuration for building the accept-ownership proposal + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config"` + + // OnlyAcceptOwnership if true skips the transfer step and only builds the MCMS proposal for acceptOwnership. + // Use false for migration and new deployments (KMS transfers then proposal for accept). + OnlyAcceptOwnership bool `json:"only_accept_ownership"` +} + +// RenounceTimelockDeployerChainsConfig configures renouncing the deployer/KMS key's +// ADMIN role on the RBAC Timelock for each chain so that only the Timelock is admin of itself. +// Run after the transfer_mcms_ownership_to_timelock proposal has been executed. +type RenounceTimelockDeployerChainsConfig struct { + // ChainSelectors is the list of chain selectors on which to renounce + ChainSelectors []uint64 `json:"chain_selectors"` +} diff --git a/deployment/vault/changeset/validation.go b/deployment/vault/changeset/validation.go index ee9c1595b31..aed23113080 100644 --- a/deployment/vault/changeset/validation.go +++ b/deployment/vault/changeset/validation.go @@ -11,7 +11,7 @@ import ( cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" - "github.com/smartcontractkit/chainlink/deployment/common/changeset" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" evmstate "github.com/smartcontractkit/chainlink/deployment/common/changeset/state" "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" @@ -221,3 +221,40 @@ func ValidateSetWhitelistConfig(e cldf.Environment, cfg types.SetWhitelistConfig return nil } + +func ValidateTransferMCMSOwnershipToTimelockConfig(e cldf.Environment, cfg types.TransferMCMSOwnershipToTimelockConfig) error { + if len(cfg.ChainSelectors) == 0 { + return errors.New("chain_selectors must not be empty") + } + if cfg.MCMSConfig == nil { + return errors.New("MCMSConfig is required for transfer_mcms_ownership_to_timelock") + } + qualifier := cfg.TimelockIdentifier + if qualifier == "" { + qualifier = commonchangeset.DefaultTimelockQualifier + } + for _, chainSelector := range cfg.ChainSelectors { + if err := validateChainSelector(chainSelector, e); err != nil { + return fmt.Errorf("invalid chain selector %d: %w", chainSelector, err) + } + if _, err := GetContractAddressWithQualifier(e.DataStore, chainSelector, commontypes.RBACTimelock, qualifier); err != nil { + return fmt.Errorf("chain %d: timelock not found: %w", chainSelector, err) + } + if _, err := GetContractAddressWithQualifier(e.DataStore, chainSelector, commontypes.ProposerManyChainMultisig, qualifier); err != nil { + return fmt.Errorf("chain %d: proposer MCMS not found: %w", chainSelector, err) + } + } + return nil +} + +func ValidateRenounceTimelockDeployerChainsConfig(e cldf.Environment, cfg types.RenounceTimelockDeployerChainsConfig) error { + if len(cfg.ChainSelectors) == 0 { + return errors.New("chain_selectors must not be empty") + } + for _, chainSelector := range cfg.ChainSelectors { + if err := validateChainSelector(chainSelector, e); err != nil { + return fmt.Errorf("invalid chain selector %d: %w", chainSelector, err) + } + } + return nil +}