diff --git a/mcms/changesets/transfer-to-timelock/all/wire.go b/mcms/changesets/transfer-to-timelock/all/wire.go new file mode 100644 index 0000000..9761451 --- /dev/null +++ b/mcms/changesets/transfer-to-timelock/all/wire.go @@ -0,0 +1,7 @@ +// Package all blank-imports built-in MCMS transfer-to-timelock families and readers. +package all + +import ( + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-timelock" +) diff --git a/mcms/changesets/transfer-to-timelock/changeset.go b/mcms/changesets/transfer-to-timelock/changeset.go new file mode 100644 index 0000000..475c44d --- /dev/null +++ b/mcms/changesets/transfer-to-timelock/changeset.go @@ -0,0 +1,150 @@ +package transfertotimelock + +import ( + "errors" + "fmt" + "slices" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/cld-changesets/internal/maputil" +) + +var _ cldf.ChangeSetV2[Input] = Changeset{} + +// Changeset transfers ownable contract ownership to the MCMS timelock. +// transferOwnership is sent on-chain by the deployer; acceptOwnership is +// returned as batch operations in an MCMS timelock proposal. MCMS refs are +// resolved from the datastore only. +type Changeset struct{} + +func (Changeset) VerifyPreconditions(env cldf.Environment, input Input) error { + if env.DataStore == nil { + return errors.New("datastore is required for transfer to timelock") + } + if input.MCMS == nil { + return errors.New("MCMS timelock proposal input is required") + } + if err := input.MCMS.Validate(); err != nil { + return fmt.Errorf("invalid MCMS timelock proposal input: %w", err) + } + if len(input.Cfg.ContractsByChain) == 0 { + return errors.New("no contracts provided") + } + + byFamily, err := groupByFamily(input) + if err != nil { + return err + } + + families := make([]string, 0, len(byFamily)) + for family := range byFamily { + families = append(families, family) + } + slices.Sort(families) + + for _, family := range families { + if err := VerifyForFamily(family, env, byFamily[family]); err != nil { + return err + } + } + + return nil +} + +func (Changeset) Apply(env cldf.Environment, input Input) (cldf.ChangesetOutput, error) { + if input.MCMS == nil { + return cldf.ChangesetOutput{}, errors.New("MCMS timelock proposal input is required") + } + + deps := Deps{ + BlockChains: env.BlockChains, + DataStore: env.DataStore, + } + + var agg sequenceutils.OnChainOutput + + for _, chainSelector := range maputil.SortedMapKeys(input.Cfg.ContractsByChain) { + contracts := input.Cfg.ContractsByChain[chainSelector] + + seq, seqErr := SequenceForChainSelector(chainSelector) + if seqErr != nil { + return buildOutput(env, input.MCMS, agg, fmt.Errorf("chain selector %d: %w", chainSelector, seqErr)) + } + + var mergeErr error + agg, mergeErr = sequenceutils.ExecuteOnChainSequenceAndMerge( + env.OperationsBundle, + deps, + seq, + ChainInput{ + ChainSelector: chainSelector, + Contracts: contracts, + OnlyAcceptOwnership: input.Cfg.OnlyAcceptOwnership, + MCMS: input.MCMS, + }, + agg, + ) + if mergeErr != nil { + return buildOutput(env, input.MCMS, agg, mergeErr) + } + } + + return buildOutput(env, input.MCMS, agg, nil) +} + +func buildOutput( + env cldf.Environment, + mcmsInput *cldf.MCMSTimelockProposalInput, + agg sequenceutils.OnChainOutput, + err error, +) (cldf.ChangesetOutput, error) { + ds := cldfdatastore.NewMemoryDataStore() + if metaErr := ds.WriteMetadata(agg.Metadata); metaErr != nil { + return cldf.ChangesetOutput{DataStore: ds}, + fmt.Errorf("write metadata to datastore: %w", metaErr) + } + + partialOutput := cldf.ChangesetOutput{DataStore: ds} + if err != nil { + return partialOutput, err + } + + builder := cldf.NewOutputBuilder(env, ds). + WithTimelockProposal(*mcmsInput, agg.BatchOps) + + out, buildErr := builder.Build() + if buildErr != nil { + return out, fmt.Errorf("build changeset output: %w", buildErr) + } + + if len(out.MCMSTimelockProposals) > 0 { + env.Logger.Infow("Transfer to timelock proposal created", "proposalCount", len(out.MCMSTimelockProposals)) + } + + return out, nil +} + +func groupByFamily(input Input) (map[string][]ChainInput, error) { + byFamily := make(map[string][]ChainInput) + for chainSelector, contracts := range input.Cfg.ContractsByChain { + if len(contracts) == 0 { + return nil, fmt.Errorf("chain %d: no contracts provided", chainSelector) + } + family, err := chainselectors.GetSelectorFamily(chainSelector) + if err != nil { + return nil, fmt.Errorf("chain selector %d: %w", chainSelector, err) + } + byFamily[family] = append(byFamily[family], ChainInput{ + ChainSelector: chainSelector, + Contracts: contracts, + OnlyAcceptOwnership: input.Cfg.OnlyAcceptOwnership, + MCMS: input.MCMS, + }) + } + + return byFamily, nil +} diff --git a/mcms/changesets/transfer-to-timelock/changeset_test.go b/mcms/changesets/transfer-to-timelock/changeset_test.go new file mode 100644 index 0000000..6be4347 --- /dev/null +++ b/mcms/changesets/transfer-to-timelock/changeset_test.go @@ -0,0 +1,141 @@ +package transfertotimelock_test + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" +) + +func testEnvironment(t *testing.T, ds datastore.DataStore) cldf.Environment { + t.Helper() + + return *cldf.NewEnvironment( + "test", + logger.Test(t), + nil, + ds, + nil, + nil, + func() context.Context { return t.Context() }, + ocr.OCRSecrets{}, + cldf_chain.NewBlockChains(nil), + ) +} + +func testMCMSInput() *cldf.MCMSTimelockProposalInput { + return &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(time.Second), + Description: "transfer-to-timelock test", + } +} + +func TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) { + t.Parallel() + + env := testEnvironment(t, nil) + input := transfertotimelock.Input{ + MCMS: testMCMSInput(), + Cfg: transfertotimelock.Config{ + ContractsByChain: map[uint64][]common.Address{ + chainselectors.TEST_90000001.Selector: {common.HexToAddress("0x1")}, + }, + }, + } + + err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input) + require.ErrorContains(t, err, "datastore is required") +} + +func TestChangeset_VerifyPreconditions_NoMCMSInput(t *testing.T) { + t.Parallel() + + env := testEnvironment(t, datastore.NewMemoryDataStore().Seal()) + input := transfertotimelock.Input{ + Cfg: transfertotimelock.Config{ + ContractsByChain: map[uint64][]common.Address{ + chainselectors.TEST_90000001.Selector: {common.HexToAddress("0x1")}, + }, + }, + } + + err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input) + require.ErrorContains(t, err, "MCMS timelock proposal input is required") +} + +func TestChangeset_VerifyPreconditions_NoContracts(t *testing.T) { + t.Parallel() + + env := testEnvironment(t, datastore.NewMemoryDataStore().Seal()) + input := transfertotimelock.Input{ + MCMS: testMCMSInput(), + } + + err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input) + require.ErrorContains(t, err, "no contracts provided") +} + +func TestChangeset_VerifyPreconditions_EmptyContractsForChain(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + env := testEnvironment(t, datastore.NewMemoryDataStore().Seal()) + input := transfertotimelock.Input{ + MCMS: testMCMSInput(), + Cfg: transfertotimelock.Config{ + ContractsByChain: map[uint64][]common.Address{ + selector: {}, + }, + }, + } + + err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input) + require.ErrorContains(t, err, "no contracts provided") +} + +func TestChangeset_VerifyPreconditions_UnsupportedChainFamily(t *testing.T) { + t.Parallel() + + selector := chainselectors.APTOS_MAINNET.Selector + env := testEnvironment(t, datastore.NewMemoryDataStore().Seal()) + input := transfertotimelock.Input{ + MCMS: testMCMSInput(), + Cfg: transfertotimelock.Config{ + ContractsByChain: map[uint64][]common.Address{ + selector: {common.HexToAddress("0x1")}, + }, + }, + } + + err := transfertotimelock.Changeset{}.VerifyPreconditions(env, input) + require.ErrorContains(t, err, "no sequence registered for family") +} + +func TestChangeset_Apply_NoMCMSInput(t *testing.T) { + t.Parallel() + + env := testEnvironment(t, datastore.NewMemoryDataStore().Seal()) + input := transfertotimelock.Input{ + Cfg: transfertotimelock.Config{ + ContractsByChain: map[uint64][]common.Address{ + chainselectors.TEST_90000001.Selector: {common.HexToAddress("0x1")}, + }, + }, + } + + _, err := transfertotimelock.Changeset{}.Apply(env, input) + require.ErrorContains(t, err, "MCMS timelock proposal input is required") +} diff --git a/mcms/changesets/transfer-to-timelock/doc.go b/mcms/changesets/transfer-to-timelock/doc.go new file mode 100644 index 0000000..9d50e78 --- /dev/null +++ b/mcms/changesets/transfer-to-timelock/doc.go @@ -0,0 +1,31 @@ +// Package transfertotimelock provides the transfer-to-timelock changeset and a registry +// for per-chain-family implementations. +// +// # Usage +// +// Import the changeset and blank-import each chain family's transfer-to-timelock +// sequence (plus MCMS readers when building timelock proposals): +// +// import ( +// "github.com/ethereum/go-ethereum/common" +// +// transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" +// _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" +// _ "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-timelock" +// ) +// +// rt.Exec(runtime.ChangesetTask(transfertotimelock.Changeset{}, transfertotimelock.Input{ +// Cfg: transfertotimelock.Config{ +// ContractsByChain: map[uint64][]common.Address{ +// chainSelector: {common.HexToAddress("0x...")}, +// }, +// }, +// MCMS: mcmsInput, +// })) +// +// For pipelines that need every built-in family, blank-import [all] instead: +// +// _ "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock/all" +// +// MCMS timelock refs are resolved from the environment datastore only. +package transfertotimelock diff --git a/mcms/changesets/transfer-to-timelock/registry.go b/mcms/changesets/transfer-to-timelock/registry.go new file mode 100644 index 0000000..63b175c --- /dev/null +++ b/mcms/changesets/transfer-to-timelock/registry.go @@ -0,0 +1,119 @@ +package transfertotimelock + +import ( + "fmt" + "slices" + "strings" + "sync" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +// Registration describes one chain family's transfer-to-timelock implementation. +type Registration struct { + Family string + // Sequence executes the per-chain transfer-to-timelock operations sequence. + Sequence *Sequence + // Verify performs family-specific validation across all chains in the input. + // It is called during VerifyPreconditions. Optional — nil means no extra checks. + Verify func(env cldf.Environment, chains []ChainInput) error +} + +var ( + sequenceRegistryMu sync.RWMutex + sequenceRegistry = make(map[string]Registration) +) + +// Register adds a family transfer-to-timelock sequence. Panics on invalid input or duplicate registration. +func Register(reg Registration) { + if reg.Family == "" { + panic("mcms transfer-to-timelock: family is required") + } + if reg.Sequence == nil { + panic(fmt.Sprintf("mcms transfer-to-timelock: sequence is required for family %q", reg.Family)) + } + + sequenceRegistryMu.Lock() + defer sequenceRegistryMu.Unlock() + + if _, exists := sequenceRegistry[reg.Family]; exists { + panic(fmt.Sprintf("mcms transfer-to-timelock: family %q already registered", reg.Family)) + } + + sequenceRegistry[reg.Family] = reg +} + +// RegisteredFamilies returns the sorted list of families with a registered sequence. +func RegisteredFamilies() []string { + sequenceRegistryMu.RLock() + defer sequenceRegistryMu.RUnlock() + + families := make([]string, 0, len(sequenceRegistry)) + for family := range sequenceRegistry { + families = append(families, family) + } + slices.Sort(families) + + return families +} + +// SequenceForChainSelector returns the registered sequence for a chain selector. +func SequenceForChainSelector(chainSelector uint64) (*Sequence, error) { + family, err := chainselectors.GetSelectorFamily(chainSelector) + if err != nil { + return nil, err + } + + return SequenceForFamily(family) +} + +// SequenceForFamily returns the registered sequence for a chain family. +func SequenceForFamily(family string) (*Sequence, error) { + reg, err := registrationForFamily(family) + if err != nil { + return nil, err + } + + return reg.Sequence, nil +} + +// VerifyForFamily runs the registered family verify hook, if any. +func VerifyForFamily(family string, env cldf.Environment, chains []ChainInput) error { + reg, err := registrationForFamily(family) + if err != nil { + return err + } + if reg.Verify == nil { + return nil + } + if err := reg.Verify(env, chains); err != nil { + return fmt.Errorf("family %s: %w", family, err) + } + + return nil +} + +func registrationForFamily(family string) (Registration, error) { + sequenceRegistryMu.RLock() + reg, ok := sequenceRegistry[family] + sequenceRegistryMu.RUnlock() + + if ok { + return reg, nil + } + + registered := RegisteredFamilies() + if len(registered) == 0 { + return Registration{}, fmt.Errorf( + "mcms transfer-to-timelock: no sequence registered for family %q (none registered — blank-import mcms//transfer-to-timelock or mcms/changesets/transfer-to-timelock/all)", + family, + ) + } + + return Registration{}, fmt.Errorf( + "mcms transfer-to-timelock: no sequence registered for family %q (registered: %s)", + family, + strings.Join(registered, ", "), + ) +} diff --git a/mcms/changesets/transfer-to-timelock/registry_test.go b/mcms/changesets/transfer-to-timelock/registry_test.go new file mode 100644 index 0000000..f658f4d --- /dev/null +++ b/mcms/changesets/transfer-to-timelock/registry_test.go @@ -0,0 +1,218 @@ +package transfertotimelock + +import ( + "errors" + "fmt" + "sync/atomic" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +var testFamilyCounter atomic.Uint64 + +func testSequence(id string) *Sequence { + return operations.NewSequence( + id, + semver.MustParse("1.0.0"), + "test sequence", + func(_ operations.Bundle, _ Deps, _ ChainInput) (sequenceutils.OnChainOutput, error) { + return sequenceutils.OnChainOutput{}, nil + }, + ) +} + +func uniqueTestFamily(prefix string) string { + return fmt.Sprintf("test-transfer-to-timelock-%s-%d", prefix, testFamilyCounter.Add(1)) +} + +func TestSequenceForChainSelector(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + chainSelector uint64 + }{ + {name: "invalid selector", chainSelector: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := SequenceForChainSelector(tt.chainSelector) + require.Error(t, err) + }) + } +} + +func TestRegister_andLookup(t *testing.T) { + t.Parallel() + + family := uniqueTestFamily("family-a") + + Register(Registration{ + Family: family, + Sequence: testSequence("test-seq-a"), + Verify: func(_ cldf.Environment, chains []ChainInput) error { + if len(chains) == 0 { + return errors.New("no chains") + } + + return nil + }, + }) + + require.Contains(t, RegisteredFamilies(), family) + + seq, err := SequenceForFamily(family) + require.NoError(t, err) + require.NotNil(t, seq) + + tests := []struct { + name string + chains []ChainInput + wantErr bool + }{ + { + name: "verify success", + chains: []ChainInput{{ChainSelector: 1}}, + }, + { + name: "verify hook error", + chains: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := VerifyForFamily(family, cldf.Environment{}, tt.chains) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestRegister_validationPanics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reg Registration + }{ + { + name: "empty family", + reg: Registration{Family: "", Sequence: testSequence("empty-family")}, + }, + { + name: "nil sequence", + reg: Registration{Family: uniqueTestFamily("family-b"), Sequence: nil}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Panics(t, func() { Register(tt.reg) }) + }) + } +} + +func TestRegister_duplicatePanics(t *testing.T) { + t.Parallel() + + family := uniqueTestFamily("family-c") + Register(Registration{Family: family, Sequence: testSequence("test-seq-c")}) + + require.Panics(t, func() { + Register(Registration{Family: family, Sequence: testSequence("test-seq-c-dup")}) + }) +} + +func TestSequenceForFamily_errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + family string + }{ + {name: "missing family", family: uniqueTestFamily("missing")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := SequenceForFamily(tt.family) + require.ErrorContains(t, err, fmt.Sprintf(`no sequence registered for family %q`, tt.family)) + }) + } +} + +func TestVerifyForFamily(t *testing.T) { + t.Parallel() + + nilHookFamily := uniqueTestFamily("family-d") + Register(Registration{Family: nilHookFamily, Sequence: testSequence("test-seq-d")}) + + wrapFamily := uniqueTestFamily("family-e") + Register(Registration{ + Family: wrapFamily, + Sequence: testSequence("test-seq-e"), + Verify: func(_ cldf.Environment, _ []ChainInput) error { + return errors.New("boom") + }, + }) + + tests := []struct { + name string + family string + chains []ChainInput + wantErr string + }{ + { + name: "missing family", + family: uniqueTestFamily("missing-verify"), + wantErr: "no sequence registered for family", + }, + { + name: "nil verify hook", + family: nilHookFamily, + }, + { + name: "wrapped verify error", + family: wrapFamily, + chains: []ChainInput{{ChainSelector: 1}}, + wantErr: "boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := VerifyForFamily(tt.family, cldf.Environment{}, tt.chains) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tt.wantErr) + if tt.family == wrapFamily { + require.ErrorContains(t, err, fmt.Sprintf("family %s:", wrapFamily)) + } + }) + } +} diff --git a/mcms/changesets/transfer-to-timelock/types.go b/mcms/changesets/transfer-to-timelock/types.go new file mode 100644 index 0000000..eb67180 --- /dev/null +++ b/mcms/changesets/transfer-to-timelock/types.go @@ -0,0 +1,46 @@ +package transfertotimelock + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// Config selects ownable contracts to transfer to the MCMS timelock per chain. +type Config struct { + ContractsByChain map[uint64][]common.Address `json:"contractsByChain"` + // OnlyAcceptOwnership skips the on-chain transferOwnership step and only + // builds accept-ownership operations for the MCMS proposal. + OnlyAcceptOwnership bool `json:"onlyAcceptOwnership,omitempty"` +} + +// Input is the changeset configuration with MCMS timelock proposal settings. +type Input = sequenceutils.WithMCMS[Config] + +// ChainInput is the per-chain request passed to a family sequence. +type ChainInput struct { + ChainSelector uint64 `json:"chainSelector"` + Contracts []common.Address `json:"contracts"` + OnlyAcceptOwnership bool `json:"onlyAcceptOwnership,omitempty"` + MCMS *cldf.MCMSTimelockProposalInput `json:"mcms,omitempty"` +} + +// Deps is the read-only dependency bundle available to every family sequence. +type Deps struct { + BlockChains chain.BlockChains + DataStore cldfdatastore.DataStore +} + +// Sequence is the required operations sequence type for all family implementations. +type Sequence = operations.Sequence[ChainInput, sequenceutils.OnChainOutput, Deps] + +// EnvFromDeps reconstructs the environment fields sequences need for datastore resolution. +func EnvFromDeps(deps Deps) cldf.Environment { + return cldf.Environment{ + BlockChains: deps.BlockChains, + DataStore: deps.DataStore, + } +} diff --git a/mcms/evm/transfer-to-timelock/addresses.go b/mcms/evm/transfer-to-timelock/addresses.go new file mode 100644 index 0000000..18269f0 --- /dev/null +++ b/mcms/evm/transfer-to-timelock/addresses.go @@ -0,0 +1,20 @@ +package evmtransfertotimelock + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +func parseEVMAddress(addr string, label string) (common.Address, error) { + if !common.IsHexAddress(addr) { + return common.Address{}, fmt.Errorf("invalid %s address %q", label, addr) + } + + parsed := common.HexToAddress(addr) + if parsed == (common.Address{}) { + return common.Address{}, fmt.Errorf("%s address must not be zero", label) + } + + return parsed, nil +} diff --git a/mcms/evm/transfer-to-timelock/addresses_test.go b/mcms/evm/transfer-to-timelock/addresses_test.go new file mode 100644 index 0000000..3ac001d --- /dev/null +++ b/mcms/evm/transfer-to-timelock/addresses_test.go @@ -0,0 +1,55 @@ +package evmtransfertotimelock + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestParseEVMAddress(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + addr string + label string + want common.Address + wantErr string + }{ + { + name: "valid address", + addr: "0x0000000000000000000000000000000000000abc", + label: "timelock", + want: common.HexToAddress("0xabc"), + }, + { + name: "invalid hex", + addr: "not-an-address", + label: "timelock", + wantErr: `invalid timelock address "not-an-address"`, + }, + { + name: "zero address", + addr: "0x0000000000000000000000000000000000000000", + label: "mcms", + wantErr: "mcms address must not be zero", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseEVMAddress(tt.addr, tt.label) + if tt.wantErr == "" { + require.NoError(t, err) + require.Equal(t, tt.want, got) + + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/mcms/evm/transfer-to-timelock/changeset_test.go b/mcms/evm/transfer-to-timelock/changeset_test.go new file mode 100644 index 0000000..71fae62 --- /dev/null +++ b/mcms/evm/transfer-to-timelock/changeset_test.go @@ -0,0 +1,181 @@ +package evmtransfertotimelock_test + +import ( + "crypto/ecdsa" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfevm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + "github.com/smartcontractkit/cld-changesets/mcms/changesets/deploy" + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" + linkchangesets "github.com/smartcontractkit/cld-changesets/tokens/link/changesets" + + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/deploy" + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-timelock" +) + +func TestChangeset_TransferOwnershipToTimelock(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt, _, timelockAddr, linkToken := newTransferToTimelockTestEnv(t, selector) + + err := rt.Exec( + runtime.ChangesetTask(transfertotimelock.Changeset{}, transfertotimelock.Input{ + Cfg: transfertotimelock.Config{ + ContractsByChain: map[uint64][]common.Address{ + selector: {linkToken.Address()}, + }, + }, + MCMS: &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + Description: "Transfer ownership to timelock", + TimelockDelay: mcmstypes.NewDuration(0), + }, + }), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + ) + require.NoError(t, err) + require.Len(t, rt.State().Proposals, 1) + require.True(t, rt.State().Proposals[0].IsExecuted) + + owner, err := linkToken.Owner(nil) + require.NoError(t, err) + require.Equal(t, timelockAddr, owner) +} + +func TestChangeset_OnlyAcceptOwnership(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt, chain, timelockAddr, linkToken := newTransferToTimelockTestEnv(t, selector) + + tx, err := linkToken.TransferOwnership(chain.DeployerKey, timelockAddr) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + owner, err := linkToken.Owner(nil) + require.NoError(t, err) + require.Equal(t, chain.DeployerKey.From, owner, "deployer remains owner until acceptOwnership") + + err = rt.Exec( + runtime.ChangesetTask(transfertotimelock.Changeset{}, transfertotimelock.Input{ + Cfg: transfertotimelock.Config{ + OnlyAcceptOwnership: true, + ContractsByChain: map[uint64][]common.Address{ + selector: {linkToken.Address()}, + }, + }, + MCMS: &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + Description: "Accept ownership on timelock", + TimelockDelay: mcmstypes.NewDuration(0), + }, + }), + ) + require.NoError(t, err) + + var proposal mcms.TimelockProposal + var foundProposal bool + for _, out := range rt.State().Outputs { + if len(out.MCMSTimelockProposals) > 0 { + proposal = out.MCMSTimelockProposals[0] + foundProposal = true + } + } + require.True(t, foundProposal, "expected one MCMS timelock proposal") + require.Len(t, proposal.Operations, 1) + require.Len(t, proposal.Operations[0].Transactions, 1) + require.Equal(t, linkToken.Address().Hex(), proposal.Operations[0].Transactions[0].To) + require.Equal(t, []byte{0x79, 0xba, 0x50, 0x97}, proposal.Operations[0].Transactions[0].Data) + + err = rt.Exec(runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner})) + require.NoError(t, err) + require.Len(t, rt.State().Proposals, 1) + require.True(t, rt.State().Proposals[0].IsExecuted) + + owner, err = linkToken.Owner(nil) + require.NoError(t, err) + require.Equal(t, timelockAddr, owner) +} + +func newTransferToTimelockTestEnv( + t *testing.T, + selector uint64, +) (*runtime.Runtime, cldfevm.Chain, common.Address, *link_token.LinkToken) { + t.Helper() + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + )) + require.NoError(t, err) + + chain := rt.Environment().BlockChains.EVMChains()[selector] + + err = rt.Exec( + runtime.ChangesetTask(linkchangesets.DeployLinkTokenChangeset{}, linkchangesets.DeployLinkTokenInput{ + EVM: map[uint64]linkchangesets.EVMLinkConfig{selector: {}}, + }), + runtime.ChangesetTask(deploy.Changeset{}, deploy.Input{ + ConfigByChain: map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cldftesthelpers.SingleGroupTimelockConfig(t), + }, + }), + ) + require.NoError(t, err) + + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilyEVM) + require.True(t, ok) + timelockRef, err := reader.GetTimelockRef(rt.Environment(), selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + timelockAddr := common.HexToAddress(timelockRef.Address) + + linkToken := loadLinkTokenFromDataStore(t, chain, rt.State().DataStore) + + return rt, chain, timelockAddr, linkToken +} + +func loadLinkTokenFromDataStore(t *testing.T, chain cldfevm.Chain, ds datastore.DataStore) *link_token.LinkToken { + t.Helper() + + linkTokenTV := cldf.NewTypeAndVersion(linkcontracts.LinkToken, semvers.V1_0_0) + + refs, err := ds.Addresses().Fetch() + require.NoError(t, err) + + for _, ref := range refs { + if ref.ChainSelector != chain.Selector { + continue + } + + if ref.Type == datastore.ContractType(linkTokenTV.Type.String()) && ref.Version != nil && ref.Version.String() == linkTokenTV.Version.String() { + linkToken, err := link_token.NewLinkToken(common.HexToAddress(ref.Address), chain.Client) + require.NoError(t, err) + + return linkToken + } + } + + require.Failf(t, "link token not found", "chain %s", chain.Name()) + + return nil +} diff --git a/mcms/evm/transfer-to-timelock/generate.go b/mcms/evm/transfer-to-timelock/generate.go new file mode 100644 index 0000000..ea81e3b --- /dev/null +++ b/mcms/evm/transfer-to-timelock/generate.go @@ -0,0 +1,5 @@ +package evmtransfertotimelock + +// Regenerate ownable contract operations from operations_gen_config.yaml. +// +//go:generate go run github.com/smartcontractkit/chainlink-deployments-framework/tools/operations-gen@v0.2.0 -config operations_gen_config.yaml diff --git a/mcms/evm/transfer-to-timelock/operations_gen_config.yaml b/mcms/evm/transfer-to-timelock/operations_gen_config.yaml new file mode 100644 index 0000000..e2cb8dc --- /dev/null +++ b/mcms/evm/transfer-to-timelock/operations_gen_config.yaml @@ -0,0 +1,19 @@ +version: "1.0.0" +chain_family: evm + +output: + base_path: "." + +contracts: + - contract_name: BurnMintERC677 + version: "1.0.0" + package_name: burn_mint_erc677 + gobindings_package: "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc677" + omit_deploy: true + functions: + - name: owner + access: public + - name: transferOwnership + access: owner + - name: acceptOwnership + access: private diff --git a/mcms/evm/transfer-to-timelock/register.go b/mcms/evm/transfer-to-timelock/register.go new file mode 100644 index 0000000..cb2baff --- /dev/null +++ b/mcms/evm/transfer-to-timelock/register.go @@ -0,0 +1,41 @@ +package evmtransfertotimelock + +import ( + "fmt" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" +) + +// init auto-registers the EVM family when this package is imported. +// A blank import is sufficient to enable EVM chain support in [transfertotimelock.Changeset]. +func init() { + transfertotimelock.Register(Registration()) +} + +// Registration returns the EVM chain-family transfer-to-timelock registration. +// Importing this package registers EVM automatically via init; use +// Registration() only in tests that call [transfertotimelock.Register] manually +// without importing this package (for example to control registration order). +func Registration() transfertotimelock.Registration { + return transfertotimelock.Registration{ + Family: chainselectors.FamilyEVM, + Sequence: seqTransferToTimelock, + Verify: verifyEVMChains, + } +} + +func verifyEVMChains(env cldf.Environment, chains []transfertotimelock.ChainInput) error { + for _, in := range chains { + if err := validateMCMS(env, in); err != nil { + return err + } + if err := validateContracts(env, in); err != nil { + return fmt.Errorf("chain %d: %w", in.ChainSelector, err) + } + } + + return nil +} diff --git a/mcms/evm/transfer-to-timelock/sequence.go b/mcms/evm/transfer-to-timelock/sequence.go new file mode 100644 index 0000000..93a225a --- /dev/null +++ b/mcms/evm/transfer-to-timelock/sequence.go @@ -0,0 +1,150 @@ +package evmtransfertotimelock + +import ( + "errors" + "fmt" + "strconv" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + opscontract "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations2/contract" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + gobindings "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc677" + mcmstypes "github.com/smartcontractkit/mcms/types" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" + ownableops "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-timelock/v1_0_0/operations/burn_mint_erc677" +) + +var seqTransferToTimelock = operations.NewSequence( + "seq-evm-transfer-to-timelock", + semver.MustParse("1.0.0"), + "Transfers ownable contract ownership to the MCMS timelock", + runEVMTransferToTimelock, +) + +func runEVMTransferToTimelock( + b operations.Bundle, + deps transfertotimelock.Deps, + in transfertotimelock.ChainInput, +) (sequenceutils.OnChainOutput, error) { + chain, ok := deps.BlockChains.EVMChains()[in.ChainSelector] + if !ok { + return sequenceutils.OnChainOutput{}, fmt.Errorf("EVM chain %d not found in environment", in.ChainSelector) + } + + env := transfertotimelock.EnvFromDeps(deps) + if in.MCMS == nil { + return sequenceutils.OnChainOutput{}, errors.New("MCMS timelock proposal input is required") + } + + timelock, err := timelockAddress(env, in) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + var transactions []mcmstypes.Transaction + for _, contract := range in.Contracts { + txs, err := transferContractToTimelock(b, chain, timelock, contract, in) + if err != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("contract %s: %w", contract.Hex(), err) + } + transactions = append(transactions, txs...) + } + + if len(transactions) == 0 { + return sequenceutils.OnChainOutput{}, nil + } + + return sequenceutils.OnChainOutput{ + BatchOps: []mcmstypes.BatchOperation{{ + ChainSelector: mcmstypes.ChainSelector(in.ChainSelector), + Transactions: transactions, + }}, + }, nil +} + +func transferContractToTimelock( + b operations.Bundle, + chain cldf_evm.Chain, + timelock common.Address, + contract common.Address, + in transfertotimelock.ChainInput, +) ([]mcmstypes.Transaction, error) { + binding, err := bindOwnableContract(contract, chain.Client) + if err != nil { + return nil, err + } + + owner, err := contractOwner(binding) + if err != nil { + return nil, err + } + + if owner == timelock { + b.Logger.Infof("contract %s already owned by timelock", contract.Hex()) + return nil, nil + } + + if !in.OnlyAcceptOwnership { + _, err = operations.ExecuteOperation( + b, + ownableops.NewWriteTransferOwnership(binding), + chain, + opscontract.FunctionInput[common.Address]{Args: timelock}, + contractIdempotencyKey[opscontract.FunctionInput[common.Address]](chain, contract), + ) + if err != nil { + return nil, fmt.Errorf("transfer ownership: %w", err) + } + + owner, err = contractOwner(binding) + if err != nil { + return nil, err + } + if owner == timelock { + b.Logger.Infof("contract %s already owned by timelock after transfer", contract.Hex()) + return nil, nil + } + } + + acceptReport, err := operations.ExecuteOperation( + b, + ownableops.NewWriteAcceptOwnership(binding), + chain, + opscontract.FunctionInput[struct{}]{}, + contractIdempotencyKey[opscontract.FunctionInput[struct{}]](chain, contract), + ) + if err != nil { + return nil, fmt.Errorf("accept ownership: %w", err) + } + + return []mcmstypes.Transaction{acceptReport.Output.Tx}, nil +} + +func bindOwnableContract(addr common.Address, client bind.ContractBackend) (gobindings.BurnMintERC677Interface, error) { + // BurnMintERC677 bindings provide a generic two-step ownable ABI surface + // (owner, transferOwnership, acceptOwnership). Validation only calls Owner(). + c, err := gobindings.NewBurnMintERC677(addr, client) + if err != nil { + return nil, fmt.Errorf("create ownable contract binding: %w", err) + } + + return c, nil +} + +func contractOwner(c gobindings.BurnMintERC677Interface) (common.Address, error) { + owner, err := c.Owner(nil) + if err != nil { + return common.Address{}, fmt.Errorf("get owner of contract %s: %w", c.Address().Hex(), err) + } + + return owner, nil +} + +func contractIdempotencyKey[IN any](chain cldf_evm.Chain, contract common.Address) operations.ExecuteOption[IN, cldf_evm.Chain] { + return operations.WithIdempotencyKey[IN, cldf_evm.Chain](strconv.FormatUint(chain.Selector, 10) + ":" + contract.Hex()) +} diff --git a/mcms/evm/transfer-to-timelock/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677.go b/mcms/evm/transfer-to-timelock/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677.go new file mode 100644 index 0000000..ddc8bd8 --- /dev/null +++ b/mcms/evm/transfer-to-timelock/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677.go @@ -0,0 +1,75 @@ +// Code generated by operations-gen. DO NOT EDIT. + +package burn_mint_erc677 + +import ( + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations2/contract" + cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cld_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + gobindings "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc677" +) + +var ContractType cldf_deployment.ContractType = "BurnMintERC677" +var Version = semver.MustParse("1.0.0") +var TypeAndVersion = cldf_deployment.NewTypeAndVersion(ContractType, *Version) + +func NewReadOwner(c gobindings.BurnMintERC677Interface) *cld_ops.Operation[contract.FunctionInput[struct{}], common.Address, cldf_evm.Chain] { + return contract.NewRead(contract.ReadParams[struct{}, common.Address, gobindings.BurnMintERC677Interface]{ + Name: "burn-mint-erc677:owner", + Version: Version, + Description: "Calls owner on the contract", + ContractType: ContractType, + Contract: c, + CallContract: func(c gobindings.BurnMintERC677Interface, opts *bind.CallOpts, args struct{}) (common.Address, error) { + return c.Owner(opts) + }, + }) +} + +func NewWriteTransferOwnership(c gobindings.BurnMintERC677Interface) *cld_ops.Operation[contract.FunctionInput[common.Address], contract.WriteOutput, cldf_evm.Chain] { + return contract.NewWrite(contract.WriteParams[common.Address, gobindings.BurnMintERC677Interface]{ + Name: "burn-mint-erc677:transfer-ownership", + Version: Version, + Description: "Calls transferOwnership on the contract", + ContractType: ContractType, + ContractABI: gobindings.BurnMintERC677MetaData.ABI, + Contract: c, + IsAllowedCaller: func(c gobindings.BurnMintERC677Interface, opts *bind.CallOpts, caller common.Address, args common.Address) (bool, error) { + return contract.OnlyOwner(c, opts, caller, args) + }, + CallContract: func( + c gobindings.BurnMintERC677Interface, + opts *bind.TransactOpts, + args common.Address, + ) (*types.Transaction, error) { + return c.TransferOwnership(opts, args) + }, + }) +} + +func NewWriteAcceptOwnership(c gobindings.BurnMintERC677Interface) *cld_ops.Operation[contract.FunctionInput[struct{}], contract.WriteOutput, cldf_evm.Chain] { + return contract.NewWrite(contract.WriteParams[struct{}, gobindings.BurnMintERC677Interface]{ + Name: "burn-mint-erc677:accept-ownership", + Version: Version, + Description: "Calls acceptOwnership on the contract", + ContractType: ContractType, + ContractABI: gobindings.BurnMintERC677MetaData.ABI, + Contract: c, + IsAllowedCaller: func(c gobindings.BurnMintERC677Interface, opts *bind.CallOpts, caller common.Address, args struct{}) (bool, error) { + return false, nil + }, + CallContract: func( + c gobindings.BurnMintERC677Interface, + opts *bind.TransactOpts, + args struct{}, + ) (*types.Transaction, error) { + return c.AcceptOwnership(opts) + }, + }) +} diff --git a/mcms/evm/transfer-to-timelock/validate.go b/mcms/evm/transfer-to-timelock/validate.go new file mode 100644 index 0000000..348a4c9 --- /dev/null +++ b/mcms/evm/transfer-to-timelock/validate.go @@ -0,0 +1,153 @@ +package evmtransfertotimelock + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" +) + +func validateMCMS(env cldf.Environment, in transfertotimelock.ChainInput) error { + if in.MCMS == nil { + return errors.New("MCMS timelock proposal input is required") + } + + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilyEVM) + if !ok { + return fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilyEVM) + } + timelockRef, err := reader.GetTimelockRef(env, in.ChainSelector, *in.MCMS) + if err != nil { + return fmt.Errorf("timelock not present on chain %d: %w", in.ChainSelector, err) + } + if _, err = parseEVMAddress(timelockRef.Address, "timelock"); err != nil { + return fmt.Errorf("invalid timelock ref on chain %d: %w", in.ChainSelector, err) + } + + mcmsRef, err := reader.GetMCMSRef(env, in.ChainSelector, *in.MCMS) + if err != nil { + return fmt.Errorf("mcms not present on chain %d: %w", in.ChainSelector, err) + } + if _, err = parseEVMAddress(mcmsRef.Address, "mcms"); err != nil { + return fmt.Errorf("invalid mcms ref on chain %d: %w", in.ChainSelector, err) + } + + return nil +} + +func validateContracts(env cldf.Environment, in transfertotimelock.ChainInput) error { + chain, ok := env.BlockChains.EVMChains()[in.ChainSelector] + if !ok { + return fmt.Errorf("EVM chain %d not found in environment", in.ChainSelector) + } + if chain.DeployerKey == nil { + return fmt.Errorf("missing deployer key for chain %d", in.ChainSelector) + } + + seen := make(map[common.Address]struct{}, len(in.Contracts)) + for _, contract := range in.Contracts { + if (contract == common.Address{}) { + return errors.New("contract address must not be zero") + } + if _, dup := seen[contract]; dup { + return fmt.Errorf("duplicate contract address %s", contract.Hex()) + } + seen[contract] = struct{}{} + } + + timelock, err := timelockAddress(env, in) + if err != nil { + return err + } + + for _, contract := range in.Contracts { + if err := contractInDatastore(env, in.ChainSelector, contract); err != nil { + return fmt.Errorf("contract %s: %w", contract.Hex(), err) + } + + binding, err := bindOwnableContract(contract, chain.Client) + if err != nil { + return fmt.Errorf("contract %s: %w", contract.Hex(), err) + } + + owner, err := contractOwner(binding) + if err != nil { + return fmt.Errorf("contract %s: %w", contract.Hex(), err) + } + if err := validateContractOwner(contract, owner, chain.DeployerKey.From, timelock, in.OnlyAcceptOwnership); err != nil { + return err + } + } + + return nil +} + +func timelockAddress(env cldf.Environment, in transfertotimelock.ChainInput) (common.Address, error) { + if in.MCMS == nil { + return common.Address{}, errors.New("MCMS timelock proposal input is required") + } + + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilyEVM) + if !ok { + return common.Address{}, fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilyEVM) + } + timelockRef, err := reader.GetTimelockRef(env, in.ChainSelector, *in.MCMS) + if err != nil { + return common.Address{}, fmt.Errorf("resolve timelock for chain %d: %w", in.ChainSelector, err) + } + + return parseEVMAddress(timelockRef.Address, "timelock") +} + +func contractInDatastore(env cldf.Environment, chainSelector uint64, contract common.Address) error { + if env.DataStore == nil { + return errors.New("datastore is required") + } + + refs := env.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(chainSelector)) + for _, ref := range refs { + if common.HexToAddress(ref.Address) == contract { + return nil + } + } + + return errors.New("not found in datastore") +} + +// validateContractOwner enforces ownership preconditions for transfer-to-timelock. +// When onlyAccept is true, the timelock may already own the contract or the +// deployer may still be owner after an on-chain transferOwnership (pending accept). +func validateContractOwner( + contract common.Address, + owner common.Address, + deployer common.Address, + timelock common.Address, + onlyAccept bool, +) error { + if owner == timelock { + return nil + } + + if onlyAccept { + if owner != deployer { + return fmt.Errorf( + "contract %s: only accept ownership requires current owner to be deployer or timelock, got %s", + contract.Hex(), + owner.Hex(), + ) + } + + return nil + } + + if owner != deployer { + return fmt.Errorf("contract %s is not owned by the deployer key", contract.Hex()) + } + + return nil +} diff --git a/mcms/evm/transfer-to-timelock/validate_test.go b/mcms/evm/transfer-to-timelock/validate_test.go new file mode 100644 index 0000000..8e78488 --- /dev/null +++ b/mcms/evm/transfer-to-timelock/validate_test.go @@ -0,0 +1,324 @@ +package evmtransfertotimelock + +import ( + "context" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfevm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmstypes "github.com/smartcontractkit/mcms/types" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" + + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" +) + +const ( + testTimelockAddr = "0x0000000000000000000000000000000000000100" + testMCMSAddr = "0x0000000000000000000000000000000000000200" +) + +func TestValidateMCMS(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + version := semver.MustParse("1.0.0") + mcmsInput := cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(time.Second), + } + + tests := []struct { + name string + refs []validateRefSpec + mcms *cldf.MCMSTimelockProposalInput + wantErr string + }{ + { + name: "missing timelock ref", + mcms: &mcmsInput, + wantErr: "timelock not present", + }, + { + name: "missing mcms ref", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr}, + }, + mcms: &mcmsInput, + wantErr: "mcms not present", + }, + { + name: "success", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr}, + {mcmscontracts.ProposerManyChainMultisig, testMCMSAddr}, + }, + mcms: &mcmsInput, + }, + { + name: "invalid timelock address", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, "not-an-address"}, + {mcmscontracts.ProposerManyChainMultisig, testMCMSAddr}, + }, + mcms: &mcmsInput, + wantErr: "invalid timelock ref", + }, + { + name: "invalid mcms address", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr}, + {mcmscontracts.ProposerManyChainMultisig, "0x0000000000000000000000000000000000000000"}, + }, + mcms: &mcmsInput, + wantErr: "invalid mcms ref", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ds := datastore.NewMemoryDataStore() + for _, ref := range tt.refs { + addValidateRef(t, ds, selector, ref.contractType, ref.address, version, "") + } + + err := validateMCMS( + validateTestEnv(ds.Seal()), + transfertotimelock.ChainInput{ + ChainSelector: selector, + MCMS: tt.mcms, + }, + ) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestTimelockAddress_nilMCMS(t *testing.T) { + t.Parallel() + + _, err := timelockAddress( + validateTestEnv(datastore.NewMemoryDataStore().Seal()), + transfertotimelock.ChainInput{ChainSelector: chainselectors.TEST_90000001.Selector}, + ) + require.ErrorContains(t, err, "MCMS timelock proposal input is required") +} + +func TestTimelockAddress_invalidAddress(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + version := semver.MustParse("1.0.0") + mcmsInput := cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(time.Second), + } + + ds := datastore.NewMemoryDataStore() + addValidateRef(t, ds, selector, mcmscontracts.RBACTimelock, "not-an-address", version, "") + + _, err := timelockAddress( + validateTestEnv(ds.Seal()), + transfertotimelock.ChainInput{ + ChainSelector: selector, + MCMS: &mcmsInput, + }, + ) + require.ErrorContains(t, err, `invalid timelock address "not-an-address"`) +} + +func TestValidateContracts_nilDeployerKey(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + env := cldf.Environment{ + Logger: logger.Nop(), + BlockChains: cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ + selector: cldfevm.Chain{Selector: selector}, + }), + } + + err := validateContracts(env, transfertotimelock.ChainInput{ + ChainSelector: selector, + MCMS: &cldf.MCMSTimelockProposalInput{}, + }) + require.ErrorContains(t, err, "missing deployer key") +} + +func TestValidateContracts_duplicateContract(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + contract := common.HexToAddress("0x0000000000000000000000000000000000000abc") + env := cldf.Environment{ + Logger: logger.Nop(), + BlockChains: cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ + selector: cldfevm.Chain{ + Selector: selector, + DeployerKey: &bind.TransactOpts{From: common.HexToAddress("0x1")}, + }, + }), + } + + err := validateContracts(env, transfertotimelock.ChainInput{ + ChainSelector: selector, + Contracts: []common.Address{contract, contract}, + MCMS: &cldf.MCMSTimelockProposalInput{}, + }) + require.ErrorContains(t, err, "duplicate contract address") +} + +func TestContractInDatastore(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + version := semver.MustParse("1.0.0") + contract := common.HexToAddress("0x0000000000000000000000000000000000000abc") + + ds := datastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: contract.Hex(), + ChainSelector: selector, + Type: datastore.ContractType("LinkToken"), + Version: version, + })) + + env := validateTestEnv(ds.Seal()) + + err := contractInDatastore(env, selector, contract) + require.NoError(t, err) + + err = contractInDatastore(env, selector, common.HexToAddress("0xdef")) + require.ErrorContains(t, err, "not found in datastore") + + err = contractInDatastore(cldf.Environment{}, selector, contract) + require.ErrorContains(t, err, "datastore is required") +} + +func TestContractInDatastore_shortHex(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + version := semver.MustParse("1.0.0") + contract := common.HexToAddress("0x0000000000000000000000000000000000000abc") + + ds := datastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: "0xabc", + ChainSelector: selector, + Type: datastore.ContractType("LinkToken"), + Version: version, + })) + + err := contractInDatastore(validateTestEnv(ds.Seal()), selector, contract) + require.NoError(t, err) +} + +func TestValidateContractOwner(t *testing.T) { + t.Parallel() + + contract := common.HexToAddress("0x0000000000000000000000000000000000000abc") + deployer := common.HexToAddress("0x0000000000000000000000000000000000000001") + timelock := common.HexToAddress("0x0000000000000000000000000000000000000002") + other := common.HexToAddress("0x0000000000000000000000000000000000000003") + + tests := []struct { + name string + owner common.Address + onlyAccept bool + wantErr string + }{ + { + name: "timelock already owns", + owner: timelock, + }, + { + name: "only accept with deployer owner", + owner: deployer, + onlyAccept: true, + }, + { + name: "only accept rejects third party", + owner: other, + onlyAccept: true, + wantErr: "only accept ownership requires current owner to be deployer or timelock", + }, + { + name: "full transfer requires deployer owner", + owner: deployer, + wantErr: "", + }, + { + name: "full transfer rejects third party", + owner: other, + wantErr: "not owned by the deployer key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateContractOwner(contract, tt.owner, deployer, timelock, tt.onlyAccept) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +type validateRefSpec struct { + contractType cldf.ContractType + address string +} + +func addValidateRef( + t *testing.T, + ds *datastore.MemoryDataStore, + selector uint64, + contractType cldf.ContractType, + address string, + version *semver.Version, + qualifier string, +) { + t.Helper() + + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: address, + ChainSelector: selector, + Type: datastore.ContractType(contractType), + Version: version, + Qualifier: qualifier, + })) +} + +func validateTestEnv(ds datastore.DataStore) cldf.Environment { + return cldf.Environment{ + Logger: logger.Nop(), + DataStore: ds, + GetContext: context.Background, + } +}