Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
golang 1.25.7
golang 1.26.2
golangci-lint 2.11.4
mockery 3.6.3
task 3.48.0
168 changes: 88 additions & 80 deletions go.mod

Large diffs are not rendered by default.

794 changes: 542 additions & 252 deletions go.sum

Large diffs are not rendered by default.

29 changes: 1 addition & 28 deletions link/changesets/deploy_link_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime"

cldchangesetscommon "github.com/smartcontractkit/cld-changesets/pkg/common"
evmstate "github.com/smartcontractkit/cld-changesets/pkg/family/evm"
evmstate "github.com/smartcontractkit/cld-changesets/pkg/family/evm/legacy"
)

func TestDeployLinkToken(t *testing.T) {
Expand Down Expand Up @@ -138,19 +138,6 @@ func TestDeployLinkTokenRejectsExistingStateBeforeDeploy(t *testing.T) {
},
wantErr: "LinkToken contract already exists",
},
{
name: "link token exists in datastore with nil version",
env: cldf.Environment{
BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{
cldf_evm.Chain{Selector: evmSelector},
}),
DataStore: datastoreWithNilVersion(t, evmSelector, evmAddress, linkcontracts.LinkToken, "migrated"),
},
run: func(env cldf.Environment) (cldf.ChangesetOutput, error) {
return DeployLinkToken(env, []uint64{evmSelector})
},
wantErr: "LinkToken contract already exists",
},
{
name: "static link token exists in datastore",
env: cldf.Environment{
Expand Down Expand Up @@ -239,20 +226,6 @@ func datastoreWith(t *testing.T, selector uint64, address string, tv cldf.TypeAn
return ds.Seal()
}

func datastoreWithNilVersion(t *testing.T, selector uint64, address string, contractType cldf.ContractType, qualifier string) datastore.DataStore {
t.Helper()

ds := datastore.NewMemoryDataStore()
require.NoError(t, ds.Addresses().Add(datastore.AddressRef{
ChainSelector: selector,
Address: address,
Type: datastore.ContractType(contractType.String()),
Qualifier: qualifier,
}))

return ds.Seal()
}

func typeAndVersionWithLabels(tv cldf.TypeAndVersion, labels ...string) cldf.TypeAndVersion {
for _, label := range labels {
tv.Labels.Add(label)
Expand Down
310 changes: 310 additions & 0 deletions mcms/changesets/legacy/deploy_mcms_with_timelock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
package legacy

import (
"context"
"fmt"
"slices"
"strings"
"sync"

"github.com/ethereum/go-ethereum/accounts/abi/bind"
mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms"
xerrgroup "golang.org/x/sync/errgroup"

evmchangesets "github.com/smartcontractkit/cld-changesets/pkg/family/evm/changesets"
evmstate "github.com/smartcontractkit/cld-changesets/pkg/family/evm/legacy"

opsevm "github.com/smartcontractkit/cld-changesets/pkg/family/evm/operations"
solchangesets "github.com/smartcontractkit/cld-changesets/pkg/family/solana/changesets/legacy"

cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils"

"github.com/ethereum/go-ethereum/common"
"github.com/gagliardetto/solana-go"
"github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers"
chainselectors "github.com/smartcontractkit/chain-selectors"

"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"

"github.com/smartcontractkit/chainlink-deployments-framework/operations"
)

// migrateAddressBookWithQualifiers migrates an address book to a data store,
// applying custom qualifiers from MCMS configs when available
func migrateAddressBookWithQualifiers(ab cldf.AddressBook, cfgByChain map[uint64]cldfproposalutils.MCMSWithTimelockConfig) (datastore.MutableDataStore, error) {
addrs, err := ab.Addresses()
if err != nil {
return nil, err
}

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
}

for addr, typever := range chainAddresses {
ref := datastore.AddressRef{
ChainSelector: chainSelector,
Address: addr,
Type: datastore.ContractType(typever.Type),
Version: &typever.Version,
}

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

// If the address book has labels, we need to add them to the addressRef
if !typever.Labels.IsEmpty() {
ref.Labels = datastore.NewLabelSet(typever.Labels.List()...)
}

if err = ds.Addresses().Add(ref); err != nil {
return nil, fmt.Errorf("failed to add address %s: %w", addr, err)
}
}
}

return ds, nil
}

// isMCMSContract checks if a contract type is part of the MCMS system
func isMCMSContract(contractType string) bool {
mcmsTypes := []string{
string(mcmscontracts.RBACTimelock),
string(mcmscontracts.ManyChainMultisig),
string(mcmscontracts.ProposerManyChainMultisig),
string(mcmscontracts.BypasserManyChainMultisig),
string(mcmscontracts.CancellerManyChainMultisig),
string(mcmscontracts.CallProxy),
}

return slices.Contains(mcmsTypes, contractType)
}

var (
_ cldf.ChangeSet[map[uint64]cldfproposalutils.MCMSWithTimelockConfig] = DeployMCMSWithTimelockV2

// 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.
// It creates a proposal if deployer key is not admin of the timelock contract.
// otherwise it executes the transactions directly.
// If neither timelock, nor the deployer key is the admin of the timelock contract, it returns an error.
GrantRoleInTimeLock = cldf.CreateChangeSet(grantRoleLogic, grantRolePreconditions)
)

// DeployMCMSWithTimelockV2 deploys and initializes the MCM and Timelock contracts
func DeployMCMSWithTimelockV2(
env cldf.Environment, cfgByChain map[uint64]cldfproposalutils.MCMSWithTimelockConfig,
) (cldf.ChangesetOutput, error) {
newAddresses := cldf.NewMemoryAddressBook()

eg := xerrgroup.Group{}
mu := sync.Mutex{}
allReports := make([]operations.Report[any, any], 0)
for chainSel, cfg := range cfgByChain {
eg.Go(func() error {
family, err := chainselectors.GetSelectorFamily(chainSel)
if err != nil {
return err
}

switch family {
case chainselectors.FamilyEVM:
// Extract qualifier from config for this chain
qualifier := ""
if cfg.Qualifier != nil {
qualifier = *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 *evmstate.MCMSWithTimelockState
s, err := evmstate.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 s != nil {
chainstate = s[chainSel]
}
reports, err := evmchangesets.DeployMCMSWithTimelockContractsEVM(env, env.BlockChains.EVMChains()[chainSel], newAddresses, cfg, chainstate)
mu.Lock()
allReports = append(allReports, reports...)
mu.Unlock()

return err

case chainselectors.FamilySolana:
// this is not used in CLD as we need to dynamically resolve the artifacts to deploy these contracts
// we did not want to add the artifact resolution logic here, so we instead deploy using ccip/changeset/solana/cs_deploy_chain.go
// for in memory tests, programs and state are pre-loaded, so we use this function via testhelpers.TransferOwnershipSolana
_, err := solchangesets.DeployMCMSWithTimelockProgramsSolana(env, env.BlockChains.SolanaChains()[chainSel], newAddresses, cfg)
return err

default:
return fmt.Errorf("unsupported chain family: %s", family)
}
})
}
err := eg.Wait()
if err != nil {
return cldf.ChangesetOutput{Reports: allReports, AddressBook: newAddresses}, err
}
ds, err := migrateAddressBookWithQualifiers(newAddresses, cfgByChain)
if err != nil {
return cldf.ChangesetOutput{Reports: allReports, AddressBook: newAddresses}, fmt.Errorf("failed to migrate address book to data store: %w", err)
}

return cldf.ChangesetOutput{Reports: allReports, AddressBook: newAddresses, DataStore: ds}, nil
}

type GrantRoleInput struct {
ExistingProposerByChain map[uint64]common.Address // if needed in the future, need to add bypasser and canceller here
MCMS *cldfproposalutils.TimelockConfig
GasBoostConfigPerChain map[uint64]cldfproposalutils.GasBoostConfig
}

func grantRolePreconditions(e cldf.Environment, cfg GrantRoleInput) error {
mcmsState, err := loadMCMSStatePerChainWithQualifier(e, cfg)
if err != nil {
return err
}
for selector, proposer := range cfg.ExistingProposerByChain {
if proposer == (common.Address{}) {
return fmt.Errorf("proposer address not found for chain %d", selector)
}
chain, ok := e.BlockChains.EVMChains()[selector]
if !ok {
return fmt.Errorf("chain not found for chain %d", selector)
}
timelockContracts, ok := mcmsState[selector]
if !ok {
return fmt.Errorf("timelock state not found for chain %d", selector)
}
if timelockContracts.Timelock == nil {
return fmt.Errorf("timelock contract not found for chain %s", chain.String())
}
if timelockContracts.ProposerMcm == nil {
return fmt.Errorf("proposerMcm contract not found for chain %s", chain.String())
}
if timelockContracts.CancellerMcm == nil {
return fmt.Errorf("cancellerMcm contract not found for chain %s", chain.String())
}
if timelockContracts.BypasserMcm == nil {
return fmt.Errorf("bypasserMcm contract not found for chain %s", chain.String())
}
if timelockContracts.CallProxy == nil {
return fmt.Errorf("callProxy contract not found for chain %s", chain.String())
}
}

return nil
}

// loads MCMS state for each chain using per-chain qualifiers from cfg.MCMS.TimelockQualifierPerChain when available
func loadMCMSStatePerChainWithQualifier(e cldf.Environment, cfg GrantRoleInput) (map[uint64]*evmstate.MCMSWithTimelockState, error) {
result := make(map[uint64]*evmstate.MCMSWithTimelockState)
for selector := range cfg.ExistingProposerByChain {
qualifier := ""
if cfg.MCMS != nil && cfg.MCMS.TimelockQualifierPerChain != nil {
qualifier = cfg.MCMS.TimelockQualifierPerChain[selector]
}
chainState, err := evmstate.MaybeLoadMCMSWithTimelockStateWithQualifier(e, []uint64{selector}, qualifier)
if err != nil {
return nil, err
}
result[selector] = chainState[selector]
}

return result, nil
}

func grantRoleLogic(e cldf.Environment, cfg GrantRoleInput) (cldf.ChangesetOutput, error) {
mcmsState, err := loadMCMSStatePerChainWithQualifier(e, cfg)
if err != nil {
return cldf.ChangesetOutput{}, err
}
mcmsStateForProposal := make(map[uint64]evmstate.MCMSWithTimelockState)
for k, v := range mcmsState {
if v != nil {
// Replace the proposer MCM in state with the existing proposer.
// This is to ensure that we are using an MCM contract that already has the proposer role.
existingProposerMcm, err := gethwrappers.NewManyChainMultiSig(
cfg.ExistingProposerByChain[k],
e.BlockChains.EVMChains()[k].Client,
)
if err != nil {
return cldf.ChangesetOutput{}, fmt.Errorf("failed to create ManyChainMultiSig for existing proposer %s on chain %d: %w",
cfg.ExistingProposerByChain[k].Hex(), k, err)
}
mcmsStateForProposal[k] = evmstate.MCMSWithTimelockState{
CancellerMcm: v.CancellerMcm,
BypasserMcm: v.BypasserMcm,
ProposerMcm: existingProposerMcm,
Timelock: v.Timelock,
CallProxy: v.CallProxy,
}
}
}

out := cldf.ChangesetOutput{}
gasBoostConfigs := opsevm.GasBoostConfigsForChainMap(cfg.ExistingProposerByChain, cfg.GasBoostConfigPerChain)
for chain := range cfg.ExistingProposerByChain {
stateForChain := mcmsState[chain]
evmChains := e.BlockChains.EVMChains()
seqReport, err := evmchangesets.GrantRolesForTimelock(
e, evmChains[chain], &cldfproposalutils.MCMSWithTimelockContracts{
CancellerMcm: stateForChain.CancellerMcm,
BypasserMcm: stateForChain.BypasserMcm,
ProposerMcm: stateForChain.ProposerMcm,
Timelock: stateForChain.Timelock,
CallProxy: stateForChain.CallProxy,
}, false, gasBoostConfigs[chain])
out, err = opsevm.AddEVMCallSequenceToCSOutput(e, out, seqReport, err, mcmsStateForProposal, cfg.MCMS, fmt.Sprintf("GrantRolesForTimelock on %s", evmChains[chain]))
if err != nil {
return out, fmt.Errorf("failed to grant roles for timelock on chain %d: %w", chain, err)
}
}

return out, nil
}

func ValidateOwnership(ctx context.Context, mcms bool, deployerKey, timelock common.Address, contract evmstate.Ownable) error {
owner, err := contract.Owner(&bind.CallOpts{Context: ctx})
if err != nil {
return fmt.Errorf("failed to get owner: %w", err)
}
if mcms && owner != timelock {
return fmt.Errorf("%s not owned by timelock, Owner: %s", contract.Address(), owner.Hex())
} else if !mcms && owner != deployerKey {
return fmt.Errorf("%s not owned by deployer key, Owner: %s", contract.Address(), owner.Hex())
}

return nil
}

func ValidateOwnershipSolanaCommon(mcms bool, deployerKey solana.PublicKey, timelockSignerPDA solana.PublicKey, programOwner solana.PublicKey) error {
if !mcms {
if deployerKey.String() != programOwner.String() {
return fmt.Errorf("deployer key %s does not match owner %s", deployerKey.String(), programOwner.String())
}
} else {
if timelockSignerPDA.String() != programOwner.String() {
return fmt.Errorf("timelock signer PDA %s does not match owner %s", timelockSignerPDA.String(), programOwner.String())
}
}

return nil
}
Loading
Loading