Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions mcms/changesets/transfer-to-timelock/all/wire.go
Original file line number Diff line number Diff line change
@@ -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"
)
150 changes: 150 additions & 0 deletions mcms/changesets/transfer-to-timelock/changeset.go
Original file line number Diff line number Diff line change
@@ -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{}
Comment thread
graham-chainlink marked this conversation as resolved.

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
}
141 changes: 141 additions & 0 deletions mcms/changesets/transfer-to-timelock/changeset_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
31 changes: 31 additions & 0 deletions mcms/changesets/transfer-to-timelock/doc.go
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading