Skip to content

Commit 0d52620

Browse files
feat(mcms): add transfer-to-timelock changeset [CLD-2762] (#99)
## Summary Adds a new `ChangeSetV2` transfer-to-timelock changeset ([CLD-2762](https://smartcontract-it.atlassian.net/browse/CLD-2762)) that moves ownable contract ownership to the MCMS timelock. The deployer sends `transferOwnership` on-chain; `acceptOwnership` is batched into an MCMS timelock proposal. - **`mcms/changesets/transfer-to-timelock/`** — family registry, changeset orchestration, `all` wire package, and E2E test - **`mcms/evm/transfer-to-timelock/`** — EVM sequence (operations-gen), validation (datastore contract lookup, ownership preconditions, `OnlyAcceptOwnership`), and unit tests MCMS timelock refs are resolved from the datastore via the MCMS reader registry, matching the pattern used by deploy and set-config.
1 parent 2530e9e commit 0d52620

17 files changed

Lines changed: 1735 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-timelock 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-timelock"
7+
)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package transfertotimelock
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 timelock")
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 timelock 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: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package transfertotimelock_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/ethereum/go-ethereum/common"
9+
chainselectors "github.com/smartcontractkit/chain-selectors"
10+
cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain"
11+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
12+
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
13+
"github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr"
14+
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
15+
mcmstypes "github.com/smartcontractkit/mcms/types"
16+
"github.com/stretchr/testify/require"
17+
18+
transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock"
19+
)
20+
21+
func testEnvironment(t *testing.T, ds datastore.DataStore) cldf.Environment {
22+
t.Helper()
23+
24+
return *cldf.NewEnvironment(
25+
"test",
26+
logger.Test(t),
27+
nil,
28+
ds,
29+
nil,
30+
nil,
31+
func() context.Context { return t.Context() },
32+
ocr.OCRSecrets{},
33+
cldf_chain.NewBlockChains(nil),
34+
)
35+
}
36+
37+
func testMCMSInput() *cldf.MCMSTimelockProposalInput {
38+
return &cldf.MCMSTimelockProposalInput{
39+
TimelockAction: mcmstypes.TimelockActionSchedule,
40+
ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp
41+
TimelockDelay: mcmstypes.NewDuration(time.Second),
42+
Description: "transfer-to-timelock test",
43+
}
44+
}
45+
46+
func TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) {
47+
t.Parallel()
48+
49+
env := testEnvironment(t, nil)
50+
input := transfertotimelock.Input{
51+
MCMS: testMCMSInput(),
52+
Cfg: transfertotimelock.Config{
53+
ContractsByChain: map[uint64][]common.Address{
54+
chainselectors.TEST_90000001.Selector: {common.HexToAddress("0x1")},
55+
},
56+
},
57+
}
58+
59+
err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input)
60+
require.ErrorContains(t, err, "datastore is required")
61+
}
62+
63+
func TestChangeset_VerifyPreconditions_NoMCMSInput(t *testing.T) {
64+
t.Parallel()
65+
66+
env := testEnvironment(t, datastore.NewMemoryDataStore().Seal())
67+
input := transfertotimelock.Input{
68+
Cfg: transfertotimelock.Config{
69+
ContractsByChain: map[uint64][]common.Address{
70+
chainselectors.TEST_90000001.Selector: {common.HexToAddress("0x1")},
71+
},
72+
},
73+
}
74+
75+
err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input)
76+
require.ErrorContains(t, err, "MCMS timelock proposal input is required")
77+
}
78+
79+
func TestChangeset_VerifyPreconditions_NoContracts(t *testing.T) {
80+
t.Parallel()
81+
82+
env := testEnvironment(t, datastore.NewMemoryDataStore().Seal())
83+
input := transfertotimelock.Input{
84+
MCMS: testMCMSInput(),
85+
}
86+
87+
err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input)
88+
require.ErrorContains(t, err, "no contracts provided")
89+
}
90+
91+
func TestChangeset_VerifyPreconditions_EmptyContractsForChain(t *testing.T) {
92+
t.Parallel()
93+
94+
selector := chainselectors.TEST_90000001.Selector
95+
env := testEnvironment(t, datastore.NewMemoryDataStore().Seal())
96+
input := transfertotimelock.Input{
97+
MCMS: testMCMSInput(),
98+
Cfg: transfertotimelock.Config{
99+
ContractsByChain: map[uint64][]common.Address{
100+
selector: {},
101+
},
102+
},
103+
}
104+
105+
err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input)
106+
require.ErrorContains(t, err, "no contracts provided")
107+
}
108+
109+
func TestChangeset_VerifyPreconditions_UnsupportedChainFamily(t *testing.T) {
110+
t.Parallel()
111+
112+
selector := chainselectors.APTOS_MAINNET.Selector
113+
env := testEnvironment(t, datastore.NewMemoryDataStore().Seal())
114+
input := transfertotimelock.Input{
115+
MCMS: testMCMSInput(),
116+
Cfg: transfertotimelock.Config{
117+
ContractsByChain: map[uint64][]common.Address{
118+
selector: {common.HexToAddress("0x1")},
119+
},
120+
},
121+
}
122+
123+
err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input)
124+
require.ErrorContains(t, err, "no sequence registered for family")
125+
}
126+
127+
func TestChangeset_Apply_NoMCMSInput(t *testing.T) {
128+
t.Parallel()
129+
130+
env := testEnvironment(t, datastore.NewMemoryDataStore().Seal())
131+
input := transfertotimelock.Input{
132+
Cfg: transfertotimelock.Config{
133+
ContractsByChain: map[uint64][]common.Address{
134+
chainselectors.TEST_90000001.Selector: {common.HexToAddress("0x1")},
135+
},
136+
},
137+
}
138+
139+
_, err := transfertotimelock.Changeset{}.Apply(env, input)
140+
require.ErrorContains(t, err, "MCMS timelock proposal input is required")
141+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Package transfertotimelock provides the transfer-to-timelock 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-timelock
7+
// sequence (plus MCMS readers when building timelock proposals):
8+
//
9+
// import (
10+
// "github.com/ethereum/go-ethereum/common"
11+
//
12+
// transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock"
13+
// _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers"
14+
// _ "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-timelock"
15+
// )
16+
//
17+
// rt.Exec(runtime.ChangesetTask(transfertotimelock.Changeset{}, transfertotimelock.Input{
18+
// Cfg: transfertotimelock.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-timelock/all"
29+
//
30+
// MCMS timelock refs are resolved from the environment datastore only.
31+
package transfertotimelock

0 commit comments

Comments
 (0)