Skip to content

Commit 18f655e

Browse files
feat(mcms): add transfer-to-mcms changeset [CLD-2762]
Introduce a ChangeSetV2 flow to transfer ownable contracts to the MCMS timelock, with per-family registry, EVM sequence, datastore validation, and an end-to-end test.
1 parent 2530e9e commit 18f655e

16 files changed

Lines changed: 1474 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Package all blank-imports built-in MCMS transfer-to-mcms families and readers.
2+
package all
3+
4+
import (
5+
_ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers"
6+
_ "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-mcms"
7+
)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package transfertomcms
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"slices"
7+
8+
chainselectors "github.com/smartcontractkit/chain-selectors"
9+
"github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils"
10+
cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore"
11+
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
12+
13+
"github.com/smartcontractkit/cld-changesets/internal/maputil"
14+
)
15+
16+
var _ cldf.ChangeSetV2[Input] = Changeset{}
17+
18+
// Changeset transfers ownable contract ownership to the MCMS timelock.
19+
// transferOwnership is sent on-chain by the deployer; acceptOwnership is
20+
// returned as batch operations in an MCMS timelock proposal. MCMS refs are
21+
// resolved from the datastore only.
22+
type Changeset struct{}
23+
24+
func (Changeset) VerifyPreconditions(env cldf.Environment, input Input) error {
25+
if env.DataStore == nil {
26+
return errors.New("datastore is required for transfer to MCMS")
27+
}
28+
if input.MCMS == nil {
29+
return errors.New("MCMS timelock proposal input is required")
30+
}
31+
if err := input.MCMS.Validate(); err != nil {
32+
return fmt.Errorf("invalid MCMS timelock proposal input: %w", err)
33+
}
34+
if len(input.Cfg.ContractsByChain) == 0 {
35+
return errors.New("no contracts provided")
36+
}
37+
38+
byFamily, err := groupByFamily(input)
39+
if err != nil {
40+
return err
41+
}
42+
43+
families := make([]string, 0, len(byFamily))
44+
for family := range byFamily {
45+
families = append(families, family)
46+
}
47+
slices.Sort(families)
48+
49+
for _, family := range families {
50+
if err := VerifyForFamily(family, env, byFamily[family]); err != nil {
51+
return err
52+
}
53+
}
54+
55+
return nil
56+
}
57+
58+
func (Changeset) Apply(env cldf.Environment, input Input) (cldf.ChangesetOutput, error) {
59+
if input.MCMS == nil {
60+
return cldf.ChangesetOutput{}, errors.New("MCMS timelock proposal input is required")
61+
}
62+
63+
deps := Deps{
64+
BlockChains: env.BlockChains,
65+
DataStore: env.DataStore,
66+
}
67+
68+
var agg sequenceutils.OnChainOutput
69+
70+
for _, chainSelector := range maputil.SortedMapKeys(input.Cfg.ContractsByChain) {
71+
contracts := input.Cfg.ContractsByChain[chainSelector]
72+
73+
seq, seqErr := SequenceForChainSelector(chainSelector)
74+
if seqErr != nil {
75+
return buildOutput(env, input.MCMS, agg, fmt.Errorf("chain selector %d: %w", chainSelector, seqErr))
76+
}
77+
78+
var mergeErr error
79+
agg, mergeErr = sequenceutils.ExecuteOnChainSequenceAndMerge(
80+
env.OperationsBundle,
81+
deps,
82+
seq,
83+
ChainInput{
84+
ChainSelector: chainSelector,
85+
Contracts: contracts,
86+
OnlyAcceptOwnership: input.Cfg.OnlyAcceptOwnership,
87+
MCMS: input.MCMS,
88+
},
89+
agg,
90+
)
91+
if mergeErr != nil {
92+
return buildOutput(env, input.MCMS, agg, mergeErr)
93+
}
94+
}
95+
96+
return buildOutput(env, input.MCMS, agg, nil)
97+
}
98+
99+
func buildOutput(
100+
env cldf.Environment,
101+
mcmsInput *cldf.MCMSTimelockProposalInput,
102+
agg sequenceutils.OnChainOutput,
103+
err error,
104+
) (cldf.ChangesetOutput, error) {
105+
ds := cldfdatastore.NewMemoryDataStore()
106+
if metaErr := ds.WriteMetadata(agg.Metadata); metaErr != nil {
107+
return cldf.ChangesetOutput{DataStore: ds},
108+
fmt.Errorf("write metadata to datastore: %w", metaErr)
109+
}
110+
111+
partialOutput := cldf.ChangesetOutput{DataStore: ds}
112+
if err != nil {
113+
return partialOutput, err
114+
}
115+
116+
builder := cldf.NewOutputBuilder(env, ds).
117+
WithTimelockProposal(*mcmsInput, agg.BatchOps)
118+
119+
out, buildErr := builder.Build()
120+
if buildErr != nil {
121+
return out, fmt.Errorf("build changeset output: %w", buildErr)
122+
}
123+
124+
if len(out.MCMSTimelockProposals) > 0 {
125+
env.Logger.Infow("Transfer to MCMS proposal created", "proposalCount", len(out.MCMSTimelockProposals))
126+
}
127+
128+
return out, nil
129+
}
130+
131+
func groupByFamily(input Input) (map[string][]ChainInput, error) {
132+
byFamily := make(map[string][]ChainInput)
133+
for chainSelector, contracts := range input.Cfg.ContractsByChain {
134+
if len(contracts) == 0 {
135+
return nil, fmt.Errorf("chain %d: no contracts provided", chainSelector)
136+
}
137+
family, err := chainselectors.GetSelectorFamily(chainSelector)
138+
if err != nil {
139+
return nil, fmt.Errorf("chain selector %d: %w", chainSelector, err)
140+
}
141+
byFamily[family] = append(byFamily[family], ChainInput{
142+
ChainSelector: chainSelector,
143+
Contracts: contracts,
144+
OnlyAcceptOwnership: input.Cfg.OnlyAcceptOwnership,
145+
MCMS: input.MCMS,
146+
})
147+
}
148+
149+
return byFamily, nil
150+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package transfertomcms_test
2+
3+
import (
4+
"crypto/ecdsa"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
"github.com/ethereum/go-ethereum/common"
10+
chainselectors "github.com/smartcontractkit/chain-selectors"
11+
cldfevm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm"
12+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
13+
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
14+
linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link"
15+
cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils"
16+
cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers"
17+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment"
18+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime"
19+
"github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token"
20+
mcmstypes "github.com/smartcontractkit/mcms/types"
21+
"github.com/stretchr/testify/require"
22+
23+
"github.com/smartcontractkit/cld-changesets/internal/semvers"
24+
"github.com/smartcontractkit/cld-changesets/mcms/changesets/deploy"
25+
transfertomcms "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-mcms"
26+
linkchangesets "github.com/smartcontractkit/cld-changesets/tokens/link/changesets"
27+
28+
_ "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-mcms/all"
29+
_ "github.com/smartcontractkit/cld-changesets/mcms/evm/deploy"
30+
)
31+
32+
func TestTransferToMCMS_DataStore(t *testing.T) {
33+
t.Parallel()
34+
35+
selector := chainselectors.TEST_90000001.Selector
36+
rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
37+
environment.WithEVMSimulated(t, []uint64{selector}),
38+
))
39+
require.NoError(t, err)
40+
41+
chain := rt.Environment().BlockChains.EVMChains()[selector]
42+
43+
err = rt.Exec(
44+
runtime.ChangesetTask(linkchangesets.DeployLinkTokenChangeset{}, linkchangesets.DeployLinkTokenInput{
45+
EVM: map[uint64]linkchangesets.EVMLinkConfig{selector: {}},
46+
}),
47+
runtime.ChangesetTask(deploy.Changeset{}, deploy.Input{
48+
ConfigByChain: map[uint64]cldfproposalutils.MCMSWithTimelockConfig{
49+
selector: cldftesthelpers.SingleGroupTimelockConfig(t),
50+
},
51+
}),
52+
)
53+
require.NoError(t, err)
54+
55+
reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilyEVM)
56+
require.True(t, ok)
57+
timelockRef, err := reader.GetTimelockRef(rt.Environment(), selector, cldf.MCMSTimelockProposalInput{})
58+
require.NoError(t, err)
59+
timelockAddr := common.HexToAddress(timelockRef.Address)
60+
61+
linkToken, err := loadLinkTokenFromDataStore(chain, rt.State().DataStore)
62+
require.NoError(t, err)
63+
64+
err = rt.Exec(
65+
runtime.ChangesetTask(transfertomcms.Changeset{}, transfertomcms.Input{
66+
Cfg: transfertomcms.Config{
67+
ContractsByChain: map[uint64][]common.Address{
68+
selector: {linkToken.Address()},
69+
},
70+
},
71+
MCMS: &cldf.MCMSTimelockProposalInput{
72+
TimelockAction: mcmstypes.TimelockActionBypass,
73+
ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp
74+
Description: "Transfer ownership to timelock",
75+
TimelockDelay: mcmstypes.NewDuration(0),
76+
},
77+
}),
78+
runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}),
79+
)
80+
require.NoError(t, err)
81+
require.Len(t, rt.State().Proposals, 1)
82+
require.True(t, rt.State().Proposals[0].IsExecuted)
83+
84+
owner, err := linkToken.Owner(nil)
85+
require.NoError(t, err)
86+
require.Equal(t, timelockAddr, owner)
87+
}
88+
89+
func loadLinkTokenFromDataStore(chain cldfevm.Chain, ds datastore.DataStore) (*link_token.LinkToken, error) {
90+
linkTokenTV := cldf.NewTypeAndVersion(linkcontracts.LinkToken, semvers.V1_0_0)
91+
92+
refs, err := ds.Addresses().Fetch()
93+
if err != nil {
94+
return nil, err
95+
}
96+
97+
for _, ref := range refs {
98+
if ref.ChainSelector != chain.Selector {
99+
continue
100+
}
101+
102+
if ref.Type == datastore.ContractType(linkTokenTV.Type.String()) && ref.Version != nil && ref.Version.String() == linkTokenTV.Version.String() {
103+
return link_token.NewLinkToken(common.HexToAddress(ref.Address), chain.Client)
104+
}
105+
}
106+
107+
return nil, fmt.Errorf("link token not found on chain %s", chain.Name())
108+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Package transfertomcms provides the TransferToMCMS changeset and a registry
2+
// for per-chain-family implementations.
3+
//
4+
// # Usage
5+
//
6+
// Import the changeset and blank-import each chain family's transfer-to-mcms
7+
// sequence (plus MCMS readers when building timelock proposals):
8+
//
9+
// import (
10+
// "github.com/ethereum/go-ethereum/common"
11+
//
12+
// transfertomcms "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-mcms"
13+
// _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers"
14+
// _ "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-mcms"
15+
// )
16+
//
17+
// rt.Exec(runtime.ChangesetTask(transfertomcms.Changeset{}, transfertomcms.Input{
18+
// Cfg: transfertomcms.Config{
19+
// ContractsByChain: map[uint64][]common.Address{
20+
// chainSelector: {common.HexToAddress("0x...")},
21+
// },
22+
// },
23+
// MCMS: mcmsInput,
24+
// }))
25+
//
26+
// For pipelines that need every built-in family, blank-import [all] instead:
27+
//
28+
// _ "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-mcms/all"
29+
//
30+
// MCMS timelock refs are resolved from the environment datastore only.
31+
package transfertomcms

0 commit comments

Comments
 (0)