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
125 changes: 101 additions & 24 deletions deployment/common/changeset/deploy_mcms_with_timelock.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,21 @@ import (
"github.com/smartcontractkit/chainlink/deployment/common/types"
)

// DefaultTimelockQualifier is the qualifier used for the first (default) timelock on a chain when the user omits qualifier.
// Existing prod_mainnet refs should be migrated to this qualifier so FindQualifierWithFullMCMSSet and lookups work.
const DefaultTimelockQualifier = "default"

// timelockQualifierFromConfig returns the qualifier to use for deploy/storage. Empty or nil from config becomes DefaultTimelockQualifier.
func timelockQualifierFromConfig(q *string) string {
if q != nil && *q != "" {
return *q
}
return DefaultTimelockQualifier
}

// migrateAddressBookWithQualifiers migrates an address book to a data store,
// applying custom qualifiers from MCMS configs when available
// applying custom qualifiers from MCMS configs when available.
// First deploy on a chain uses DefaultTimelockQualifier ("default") when qualifier is omitted.
func migrateAddressBookWithQualifiers(ab cldf.AddressBook, cfgByChain map[uint64]types.MCMSWithTimelockConfigV2) (datastore.MutableDataStore, error) {
addrs, err := ab.Addresses()
if err != nil {
Expand All @@ -39,10 +52,9 @@ func migrateAddressBookWithQualifiers(ab cldf.AddressBook, cfgByChain map[uint64
ds := datastore.NewMemoryDataStore()

for chainSelector, chainAddresses := range addrs {
// Get the qualifier for this chain from the config
qualifier := ""
if cfg, exists := cfgByChain[chainSelector]; exists && cfg.Qualifier != nil && *cfg.Qualifier != "" {
qualifier = *cfg.Qualifier
qualifier := DefaultTimelockQualifier
if cfg, exists := cfgByChain[chainSelector]; exists {
qualifier = timelockQualifierFromConfig(cfg.Qualifier)
}

for addr, typever := range chainAddresses {
Expand All @@ -53,8 +65,7 @@ func migrateAddressBookWithQualifiers(ab cldf.AddressBook, cfgByChain map[uint64
Version: &typever.Version,
}

// If we have a custom qualifier for this chain, use it for MCMS contracts
if qualifier != "" && isMCMSContract(string(typever.Type)) {
if isMCMSContract(string(typever.Type)) {
ref.Qualifier = qualifier
}

Expand Down Expand Up @@ -85,8 +96,17 @@ func isMCMSContract(contractType string) bool {
return slices.Contains(mcmsTypes, contractType)
}

// DeployTimelockInput is the input for deploy_timelock when using SharedMCMSPerChain (e.g. prod_mainnet, staging_testnet).
// When SharedMCMSPerChain is true, for any chain that already has a full MCMS set in the datastore (Bypasser, Canceller, Proposer, CallProxy, RBACTimelock),
// only a new RBACTimelock (and its CallProxy) is deployed with the new qualifier; the existing MCMS contracts are reused.
type DeployTimelockInput struct {
Chains map[uint64]types.MCMSWithTimelockConfigV2 `json:"chains"`
SharedMCMSPerChain bool `json:"sharedMCMSPerChain"`
}

var (
_ cldf.ChangeSet[map[uint64]types.MCMSWithTimelockConfigV2] = DeployMCMSWithTimelockV2
_ cldf.ChangeSet[DeployTimelockInput] = DeployMCMSWithTimelockV2FromInput

// GrantRoleInTimeLock grants proposer, canceller, bypasser, executor, admin roles to the timelock contract with corresponding addresses if the
// roles are not already set with the same addresses.
Expand All @@ -96,16 +116,63 @@ var (
GrantRoleInTimeLock = cldf.CreateChangeSet(grantRoleLogic, grantRolePreconditions)
)

// DeployMCMSWithTimelockV2FromInput runs deploy_timelock with optional SharedMCMSPerChain behavior.
// When SharedMCMSPerChain is true and a chain already has a full MCMS set in the datastore, only a new RBACTimelock (and CallProxy) is deployed for the new qualifier.
func DeployMCMSWithTimelockV2FromInput(env cldf.Environment, input DeployTimelockInput) (cldf.ChangesetOutput, error) {
return deployMCMSWithTimelockV2Core(env, input.Chains, input.SharedMCMSPerChain)
}

// DeployMCMSWithTimelockV2 deploys and initializes the MCM and Timelock contracts
func DeployMCMSWithTimelockV2(
env cldf.Environment, cfgByChain map[uint64]types.MCMSWithTimelockConfigV2,
) (cldf.ChangesetOutput, error) {
return deployMCMSWithTimelockV2Core(env, cfgByChain, false)
}

func deployMCMSWithTimelockV2Core(
env cldf.Environment, cfgByChain map[uint64]types.MCMSWithTimelockConfigV2, sharedMCMSPerChain bool,
) (cldf.ChangesetOutput, error) {
// Fail if any (chain, qualifier) already has addresses in the datastore so we never overwrite or duplicate.
if env.DataStore != nil {
for chainSel, cfg := range cfgByChain {
qualifier := timelockQualifierFromConfig(cfg.Qualifier)
existing := env.DataStore.Addresses().Filter(
datastore.AddressRefByChainSelector(chainSel),
datastore.AddressRefByQualifier(qualifier),
)
if len(existing) > 0 {
return cldf.ChangesetOutput{}, fmt.Errorf(
"chain %d qualifier %q already has %d address ref(s) in the datastore; cannot deploy again with the same qualifier on this chain",
chainSel, qualifier, len(existing),
)
}
}
}

// Require proposer, bypasser, and canceller for chains that will do a full deploy.
// When SharedMCMSPerChain is true and the chain already has a full set, we only deploy timelock+callProxy and skip MCMS validation.
for chainSel, cfg := range cfgByChain {
skipValidation := false
if sharedMCMSPerChain && env.DataStore != nil {
if _, found := state.FindQualifierWithFullMCMSSet(env.DataStore.Addresses(), chainSel); found {
skipValidation = true
}
}
if !skipValidation {
if err := evminternal.ValidateMCMSWithTimelockConfigV2(cfg); err != nil {
return cldf.ChangesetOutput{}, fmt.Errorf("chain %d: %w", chainSel, err)
}
}
}

newAddresses := cldf.NewMemoryAddressBook()

eg := xerrgroup.Group{}
mu := sync.Mutex{}
allReports := make([]operations.Report[any, any], 0)
for chainSel, cfg := range cfgByChain {
chainSel := chainSel
cfg := cfg
eg.Go(func() error {
family, err := chain_selectors.GetSelectorFamily(chainSel)
if err != nil {
Expand All @@ -114,28 +181,38 @@ func DeployMCMSWithTimelockV2(

switch family {
case chain_selectors.FamilyEVM:
// Extract qualifier from config for this chain
qualifier := ""
if cfg.Qualifier != nil {
qualifier = *cfg.Qualifier
}
qualifier := timelockQualifierFromConfig(cfg.Qualifier)

// load mcms state with qualifier awareness
// we load the state one by one to avoid early return from MaybeLoadMCMSWithTimelockStateWithQualifier
// due to one of the chain not found
var chainstate *state.MCMSWithTimelockState
s, err := state.MaybeLoadMCMSWithTimelockStateWithQualifier(env, []uint64{chainSel}, qualifier)
if err != nil {
// if the state is not found for chain, we assume it's a fresh deployment
// this includes "no addresses found" which is expected for new qualifiers
if !strings.Contains(err.Error(), cldf.ErrChainNotFound.Error()) &&
!strings.Contains(err.Error(), "no addresses found") {
return err
if sharedMCMSPerChain && env.DataStore != nil {
if sharedQ, found := state.FindQualifierWithFullMCMSSet(env.DataStore.Addresses(), chainSel); found {
// Reuse existing MCMS + CallProxy; only deploy new Timelock + CallProxy for this qualifier.
fullState, err := state.GetMCMSWithTimelockState(env.DataStore.Addresses(), env.BlockChains.EVMChains()[chainSel], sharedQ)
if err != nil {
return fmt.Errorf("chain %d: load shared MCMS state: %w", chainSel, err)
}
chainstate = &state.MCMSWithTimelockState{
BypasserMcm: fullState.BypasserMcm,
ProposerMcm: fullState.ProposerMcm,
CancellerMcm: fullState.CancellerMcm,
Timelock: nil,
CallProxy: nil,
}
}
}
if s != nil {
chainstate = s[chainSel]
if chainstate == nil {
s, err := state.MaybeLoadMCMSWithTimelockStateWithQualifier(env, []uint64{chainSel}, qualifier)
if err != nil {
if !strings.Contains(err.Error(), cldf.ErrChainNotFound.Error()) &&
!strings.Contains(err.Error(), "no addresses found") {
return err
}
}
if s != nil {
chainstate = s[chainSel]
}
}

reports, err := evminternal.DeployMCMSWithTimelockContractsEVM(env, env.BlockChains.EVMChains()[chainSel], newAddresses, cfg, chainstate)
mu.Lock()
allReports = append(allReports, reports...)
Expand Down
113 changes: 90 additions & 23 deletions deployment/common/changeset/deploy_mcms_with_timelock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,85 @@ import (
"github.com/smartcontractkit/chainlink/deployment/internal/soltestutils"
)

func TestDeployMCMSWithTimelockV2FailsWhenQualifierAlreadyExists(t *testing.T) {
t.Parallel()

selector := chain_selectors.TEST_90000001.Selector
ds := datastore.NewMemoryDataStore()
require.NoError(t, ds.Addresses().Add(datastore.AddressRef{
ChainSelector: selector,
Address: "0x1234567890123456789012345678901234567890",
Type: datastore.ContractType(commontypes.RBACTimelock),
Version: &deployment.Version1_0_0,
Qualifier: "",
}))

env, err := environment.New(t.Context(),
environment.WithEVMSimulated(t, []uint64{selector}),
environment.WithLogger(logger.Test(t)),
environment.WithDatastore(ds.Seal()),
)
require.NoError(t, err)

_, err = commonchangeset.DeployMCMSWithTimelockV2(*env, map[uint64]commontypes.MCMSWithTimelockConfigV2{
selector: proposalutils.SingleGroupTimelockConfigV2(t),
})
require.Error(t, err)
require.Contains(t, err.Error(), "already has")
require.Contains(t, err.Error(), "address ref")
require.Contains(t, err.Error(), "cannot deploy again with the same qualifier")

// Same chain with non-empty qualifier that already exists should also fail
ds2 := datastore.NewMemoryDataStore()
require.NoError(t, ds2.Addresses().Add(datastore.AddressRef{
ChainSelector: selector,
Address: "0xabcdef0000000000000000000000000000000000",
Type: datastore.ContractType(commontypes.RBACTimelock),
Version: &deployment.Version1_0_0,
Qualifier: "vault_2",
}))
env2, err := environment.New(t.Context(),
environment.WithEVMSimulated(t, []uint64{selector}),
environment.WithLogger(logger.Test(t)),
environment.WithDatastore(ds2.Seal()),
)
require.NoError(t, err)
qualifierV2 := "vault_2"
_, err = commonchangeset.DeployMCMSWithTimelockV2(*env2, map[uint64]commontypes.MCMSWithTimelockConfigV2{
selector: {
Proposer: proposalutils.SingleGroupTimelockConfigV2(t).Proposer,
Canceller: proposalutils.SingleGroupTimelockConfigV2(t).Canceller,
Bypasser: proposalutils.SingleGroupTimelockConfigV2(t).Bypasser,
TimelockMinDelay: big.NewInt(0),
Qualifier: &qualifierV2,
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "vault_2")
require.Contains(t, err.Error(), "already has")
}

func TestDeployMCMSWithTimelockV2FailsWhenProposerBypasserCancellerMissing(t *testing.T) {
t.Parallel()

selector := chain_selectors.TEST_90000001.Selector
env, err := environment.New(t.Context(),
environment.WithEVMSimulated(t, []uint64{selector}),
environment.WithLogger(logger.Test(t)),
)
require.NoError(t, err)

// Omit proposer, bypasser, canceller in YAML → unmarshalled as zero value; deploy must fail fast.
_, err = commonchangeset.DeployMCMSWithTimelockV2(*env, map[uint64]commontypes.MCMSWithTimelockConfigV2{
selector: {
TimelockMinDelay: big.NewInt(0),
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "chain ")
require.Regexp(t, `(proposer|bypasser|canceller).*required|required.*(proposer|bypasser|canceller)`, err.Error())
}

func TestGrantRoleInTimeLock(t *testing.T) {
selector := chain_selectors.TEST_90000001.Selector
env, err := environment.New(t.Context(),
Expand Down Expand Up @@ -93,6 +172,11 @@ func TestGrantRoleInTimeLock(t *testing.T) {
chain := evmChains[selector]
chain.DeployerKey = evmChains[selector].Users[0]

// Clear DataStore so the duplicate-(chain,qualifier) check does not run; this test is
// re-deploying only the proposer (state is loaded from AddressBook). In production,
// deploy with the same (chain, qualifier) would fail if refs already exist.
updatedEnv.DataStore = nil

// now deploy MCMS again so that only the proposer is new
updatedEnv, err = commonchangeset.Apply(t, updatedEnv, configuredChangeset)
require.NoError(t, err)
Expand Down Expand Up @@ -128,39 +212,22 @@ func TestDeployMCMSWithTimelockV2WithFewExistingContracts(t *testing.T) {
selector2 := chain_selectors.TEST_90000002.Selector
selectors := []uint64{selector1, selector2}

// Build a datastore with some dummy address for callproxy, canceller and bypasser
// to simulate the case where they already exist and so the changeset will not try to deploy
// them again
ds := datastore.NewMemoryDataStore()

callProxyAddress := utils.RandomAddress()
mcmsAddress := utils.RandomAddress()
mcmsType := cldf.NewTypeAndVersion(commontypes.ManyChainMultisig, deployment.Version1_0_0)
// we use same address for bypasser and canceller
mcmsType.AddLabel(commontypes.BypasserRole.String())
mcmsType.AddLabel(commontypes.CancellerRole.String())

// Add CallProxy for first chain only
require.NoError(t, ds.AddressRefStore.Add(datastore.AddressRef{
ChainSelector: selector1,
Address: callProxyAddress.String(),
Type: datastore.ContractType(commontypes.CallProxy),
Version: &deployment.Version1_0_0,
}))

// Add MCMS contract with both bypasser and canceller labels for first chain only
require.NoError(t, ds.AddressRefStore.Add(datastore.AddressRef{
ChainSelector: selector1,
Address: mcmsAddress.String(),
Type: datastore.ContractType(mcmsType.Type),
Version: &mcmsType.Version,
Labels: datastore.NewLabelSet(mcmsType.Labels.List()...),
}))
// Use empty datastore so the duplicate-(chain,qualifier) check passes. Put "few existing"
// refs in the address book so the deploy sees them and does not redeploy CallProxy/MCMS for chain1.
ab := cldf.NewMemoryAddressBook()
require.NoError(t, ab.Save(selector1, callProxyAddress.String(), cldf.NewTypeAndVersion(commontypes.CallProxy, deployment.Version1_0_0)))
require.NoError(t, ab.Save(selector1, mcmsAddress.String(), mcmsType))

rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
environment.WithEVMSimulated(t, selectors),
environment.WithLogger(logger.Test(t)),
environment.WithDatastore(ds.Seal()),
environment.WithAddressBook(ab),
))
require.NoError(t, err)

Expand Down
41 changes: 41 additions & 0 deletions deployment/common/changeset/evm/mcms/mcms.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ type MCMSWithTimelockEVMDeploy struct {
CallProxy *cldf.ContractDeploy[*bindings.CallProxy]
}

// ValidateMCMSWithTimelockConfigV2 checks that proposer, bypasser, and canceller
// configs are valid (can be used by ExtractSetConfigInputs). Returns an error
// describing which role is missing or invalid so deploy fails fast with a clear message.
func ValidateMCMSWithTimelockConfigV2(config commontypes.MCMSWithTimelockConfigV2) error {
for role, cfg := range map[string]mcmsTypes.Config{
"proposer": config.Proposer,
"bypasser": config.Bypasser,
"canceller": config.Canceller,
} {
if _, _, _, _, err := sdk.ExtractSetConfigInputs(&cfg); err != nil {
return fmt.Errorf("%s config is required and must be valid (quorum and at least one signer): %w", role, err)
}
}
return nil
}

// TODO: Remove this function once the tests are implemented for the new sequence.
func DeployMCMSWithConfigEVM(
contractType cldf.ContractType,
Expand Down Expand Up @@ -184,6 +200,15 @@ func DeployMCMSWithTimelockContractsEVM(
lggr.Infow("Bypasser MCMS deployed", "chain", chain.String(), "address", bypasser.Address().String())
} else {
lggr.Infow("Bypasser MCMS already deployed", "chain", chain.String(), "address", bypasser.Address().String())
// Persist existing MCMS to address book so migration writes it under this run's qualifier (shared-MCMS / timelock-only path).
typeAndVersion := cldf.NewTypeAndVersion(commontypes.BypasserManyChainMultisig, deployment.Version1_0_0)
for _, option := range opts {
option(&typeAndVersion)
}
if err := ab.Save(chain.Selector, bypasser.Address().Hex(), typeAndVersion); err != nil {
lggr.Errorw("Failed to save existing bypasser MCMS to address book", "chain", chain.String(), "err", err)
return execReports, err
}
}

if canceller == nil {
Expand Down Expand Up @@ -223,6 +248,14 @@ func DeployMCMSWithTimelockContractsEVM(
lggr.Infow("Canceller MCMS deployed", "chain", chain.String(), "address", canceller.Address().String())
} else {
lggr.Infow("Canceller MCMS already deployed", "chain", chain.String(), "address", canceller.Address().String())
typeAndVersion := cldf.NewTypeAndVersion(commontypes.CancellerManyChainMultisig, deployment.Version1_0_0)
for _, option := range opts {
option(&typeAndVersion)
}
if err := ab.Save(chain.Selector, canceller.Address().Hex(), typeAndVersion); err != nil {
lggr.Errorw("Failed to save existing canceller MCMS to address book", "chain", chain.String(), "err", err)
return execReports, err
}
}

if proposer == nil {
Expand Down Expand Up @@ -262,6 +295,14 @@ func DeployMCMSWithTimelockContractsEVM(
lggr.Infow("Proposer MCMS deployed", "chain", chain.String(), "address", proposer.Address().String())
} else {
lggr.Infow("Proposer MCMS already deployed", "chain", chain.String(), "address", proposer.Address().String())
typeAndVersion := cldf.NewTypeAndVersion(commontypes.ProposerManyChainMultisig, deployment.Version1_0_0)
for _, option := range opts {
option(&typeAndVersion)
}
if err := ab.Save(chain.Selector, proposer.Address().Hex(), typeAndVersion); err != nil {
lggr.Errorw("Failed to save existing proposer MCMS to address book", "chain", chain.String(), "err", err)
return execReports, err
}
}

if timelock == nil {
Expand Down
Loading
Loading