Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions deployment/vault/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<environment>/durable_pipelines/inputs/<name>.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:
Expand Down Expand Up @@ -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.
33 changes: 33 additions & 0 deletions deployment/vault/changeset/renounce_timelock_deployer.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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,
})
}
27 changes: 27 additions & 0 deletions deployment/vault/changeset/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
39 changes: 38 additions & 1 deletion deployment/vault/changeset/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Loading