From 19d82f905cd30ac4a455217620d8f5b636d0311f Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 26 Jun 2026 09:12:45 -0600 Subject: [PATCH 1/4] feat: add EVM grant role changeset --- go.mod | 4 +- go.sum | 8 +- mcms/changesets/grant-role/all/wire.go | 7 + mcms/changesets/grant-role/changeset.go | 155 +++++++++ mcms/changesets/grant-role/changeset_test.go | 191 +++++++++++ mcms/changesets/grant-role/registry.go | 33 ++ mcms/changesets/grant-role/registry_test.go | 74 +++++ mcms/changesets/grant-role/types.go | 53 ++++ mcms/evm/grant-role/operation.go | 138 +++++++- mcms/evm/grant-role/operation_test.go | 32 +- mcms/evm/grant-role/register.go | 19 ++ mcms/evm/grant-role/register_test.go | 30 ++ mcms/evm/grant-role/sequence.go | 178 +++++++++++ mcms/evm/grant-role/sequence_test.go | 237 ++++++++++++++ mcms/evm/grant-role/validate.go | 87 +++++ mcms/evm/grant-role/validate_test.go | 315 +++++++++++++++++++ 16 files changed, 1529 insertions(+), 32 deletions(-) create mode 100644 mcms/changesets/grant-role/all/wire.go create mode 100644 mcms/changesets/grant-role/changeset.go create mode 100644 mcms/changesets/grant-role/changeset_test.go create mode 100644 mcms/changesets/grant-role/registry.go create mode 100644 mcms/changesets/grant-role/registry_test.go create mode 100644 mcms/changesets/grant-role/types.go create mode 100644 mcms/evm/grant-role/register.go create mode 100644 mcms/evm/grant-role/register_test.go create mode 100644 mcms/evm/grant-role/sequence.go create mode 100644 mcms/evm/grant-role/sequence_test.go create mode 100644 mcms/evm/grant-role/validate.go create mode 100644 mcms/evm/grant-role/validate_test.go diff --git a/go.mod b/go.mod index dd2ccbb..6da2aa1 100644 --- a/go.mod +++ b/go.mod @@ -16,13 +16,13 @@ require ( github.com/samber/lo v1.53.0 github.com/segmentio/ksuid v1.0.4 github.com/smartcontractkit/ccip-owner-contracts v0.1.0 - github.com/smartcontractkit/chain-selectors v1.0.102 + github.com/smartcontractkit/chain-selectors v1.0.103 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc github.com/smartcontractkit/chainlink-deployments-framework v0.114.1 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828 github.com/smartcontractkit/chainlink-protos/job-distributor v0.19.0 - github.com/smartcontractkit/mcms v0.48.1-0.20260616002102-085d81f76b05 + github.com/smartcontractkit/mcms v0.49.0 github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9 github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 github.com/spf13/cast v1.10.0 diff --git a/go.sum b/go.sum index 5c9c274..c65d2ac 100644 --- a/go.sum +++ b/go.sum @@ -847,8 +847,8 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9LsA7vTMPv+0n7ClhSFnZFAk= github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY= -github.com/smartcontractkit/chain-selectors v1.0.102 h1:qYP4+72HfvogCHR5ymwRFee36WH77514ZBj299SVCBA= -github.com/smartcontractkit/chain-selectors v1.0.102/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= +github.com/smartcontractkit/chain-selectors v1.0.103 h1:PpvIinn1TIDT7nh/P5KLQunRk0Kp1IR6moP2IGvlP58= +github.com/smartcontractkit/chain-selectors v1.0.103/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260430175646-295a7f9a1500 h1:045jrHCLI+MpeAyByJkyHbEjq0+aTPt04C7+sbsNNtw= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260430175646-295a7f9a1500/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig= github.com/smartcontractkit/chainlink-canton v0.0.0-20260615233851-4e78e7c23a58 h1:QT9lFZBf3bFsp7oJWLTQuUXW4FU5QXyJx2a2qZ40G6Q= @@ -897,8 +897,8 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d h1:PvXor5Fjer7FIONSqYXbpd1LkA14hWrlAyxXzOrC9t8= github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d/go.mod h1:PLdNK6GlqfxIWXzziPkU7dCAVlVFeYkyyW7AQY0R+4Q= -github.com/smartcontractkit/mcms v0.48.1-0.20260616002102-085d81f76b05 h1:PVRKr9ra3ma9I+e1hWNqWnOwnYAzUMzZwPIzRDhAih4= -github.com/smartcontractkit/mcms v0.48.1-0.20260616002102-085d81f76b05/go.mod h1:O5OnKQjuY/4VIOVBTRfBECBuWBM/eKvDF5UDDae8Eyc= +github.com/smartcontractkit/mcms v0.49.0 h1:4Bav/bNsIc6pNlPhiNqYpvMyxDF9OpRgrWVtCa3BW+A= +github.com/smartcontractkit/mcms v0.49.0/go.mod h1:tzyPA51qtN5us/2DS3kBIY7OVWZXSVKPOrOcYcKuvZI= github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9 h1:MOEuXYogv+RStASb8dWsyescu/xkigSi/Sv45NEjV7A= github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9/go.mod h1:iwy4yWFuK+1JeoIRTaSOA9pl+8Kf//26zezxEXrAQEQ= github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 h1:zxcODLrFytOKmAd8ty8S/XK6WcIEJEgRBaL7sY/7l4Y= diff --git a/mcms/changesets/grant-role/all/wire.go b/mcms/changesets/grant-role/all/wire.go new file mode 100644 index 0000000..ed23509 --- /dev/null +++ b/mcms/changesets/grant-role/all/wire.go @@ -0,0 +1,7 @@ +// Package all blank-imports built-in MCMS grant-role families and readers. +package all + +import ( + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/grant-role" + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" +) diff --git a/mcms/changesets/grant-role/changeset.go b/mcms/changesets/grant-role/changeset.go new file mode 100644 index 0000000..390ce55 --- /dev/null +++ b/mcms/changesets/grant-role/changeset.go @@ -0,0 +1,155 @@ +package grantrole + +import ( + "errors" + "fmt" + "slices" + + "github.com/ethereum/go-ethereum/common" + "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 grants RBACTimelock roles across configured chains. +type Changeset struct{} + +func (Changeset) VerifyPreconditions(env cldf.Environment, input Input) error { + if env.DataStore == nil { + return errors.New("datastore is required for grant-role") + } + if input.MCMS != nil { + if err := input.MCMS.Validate(); err != nil { + return fmt.Errorf("invalid MCMS timelock proposal input: %w", err) + } + } + if len(input.Cfg.GrantsByChain) == 0 { + return errors.New("no role grants provided") + } + if err := validateGrants(input.Cfg.GrantsByChain); err != nil { + return err + } + + 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 := Registry.VerifyForFamily(family, env, byFamily[family]); err != nil { + return err + } + } + + return nil +} + +func (Changeset) Apply(env cldf.Environment, input Input) (cldf.ChangesetOutput, error) { + deps := Deps{ + BlockChains: env.BlockChains, + DataStore: env.DataStore, + } + + var agg sequenceutils.OnChainOutput + for _, chainSelector := range maputil.SortedMapKeys(input.Cfg.GrantsByChain) { + grants := input.Cfg.GrantsByChain[chainSelector] + + seq, seqErr := Registry.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, + SeqInput{ + ChainSelector: chainSelector, + Grants: grants, + MCMS: input.MCMS, + GasBoostConfig: input.Cfg.GasBoostConfig, + }, + 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) + if mcmsInput != nil { + builder = builder.WithTimelockProposal(*mcmsInput, agg.BatchOps) + } + + out, buildErr := builder.Build() + if buildErr != nil { + return out, fmt.Errorf("build changeset output: %w", buildErr) + } + + if mcmsInput != nil && len(out.MCMSTimelockProposals) > 0 { + env.Logger.Infow("GrantRole proposal created", "proposalCount", len(out.MCMSTimelockProposals)) + } + + return out, nil +} + +func validateGrants(grantsByChain map[uint64][]RoleGrant) error { + for chainSelector, grants := range grantsByChain { + if len(grants) == 0 { + return fmt.Errorf("chain %d: no role grants provided", chainSelector) + } + seen := make(map[string]struct{}) + for grantIdx, grant := range grants { + if !grant.Role.Valid() { + return fmt.Errorf("chain %d grants[%d]: unsupported timelock role %s", chainSelector, grantIdx, grant.Role.String()) + } + if len(grant.Addresses) == 0 { + return fmt.Errorf("chain %d grants[%d]: no addresses provided", chainSelector, grantIdx) + } + for addrIdx, addr := range grant.Addresses { + if addr == (common.Address{}) { + return fmt.Errorf("chain %d grants[%d].addresses[%d]: address must not be zero", chainSelector, grantIdx, addrIdx) + } + key := grant.Role.String() + ":" + addr.Hex() + if _, ok := seen[key]; ok { + return fmt.Errorf("chain %d grants[%d].addresses[%d]: duplicate grant for role %s and address %s", + chainSelector, grantIdx, addrIdx, grant.Role.String(), addr.Hex()) + } + seen[key] = struct{}{} + } + } + } + + return nil +} diff --git a/mcms/changesets/grant-role/changeset_test.go b/mcms/changesets/grant-role/changeset_test.go new file mode 100644 index 0000000..a7b2a12 --- /dev/null +++ b/mcms/changesets/grant-role/changeset_test.go @@ -0,0 +1,191 @@ +package grantrole + +import ( + "context" + "errors" + "fmt" + "testing" + + "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/changeset/sequenceutils" + "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" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + "github.com/stretchr/testify/require" +) + +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 TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) { + t.Parallel() + + input := Input{ + Cfg: Config{ + GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []common.Address{common.HexToAddress("0x1")}, + }}, + }, + }, + } + + err := Changeset{}.VerifyPreconditions(testEnvironment(t, nil), input) + require.EqualError(t, err, "datastore is required for grant-role") +} + +func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { + t.Parallel() + + validAddress := common.HexToAddress("0x1") + tests := []struct { + name string + input Input + wantErr string + }{ + { + name: "no grants", + input: Input{}, + wantErr: "no role grants provided", + }, + { + name: "empty grants for chain", + input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {}, + }}}, + wantErr: fmt.Sprintf("chain %d: no role grants provided", chainselectors.TEST_90000001.Selector), + }, + { + name: "unsupported role", + input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRole(99), Addresses: []common.Address{validAddress}}}, + }}}, + wantErr: fmt.Sprintf("chain %d grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector), + }, + { + name: "no addresses", + input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRoleProposer}}, + }}}, + wantErr: fmt.Sprintf("chain %d grants[0]: no addresses provided", chainselectors.TEST_90000001.Selector), + }, + { + name: "zero address", + input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []common.Address{{}}, + }}, + }}}, + wantErr: fmt.Sprintf("chain %d grants[0].addresses[0]: address must not be zero", chainselectors.TEST_90000001.Selector), + }, + { + name: "duplicate grant", + input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []common.Address{validAddress, validAddress}, + }}, + }}}, + wantErr: fmt.Sprintf("chain %d grants[0].addresses[1]: duplicate grant for role Proposer and address 0x0000000000000000000000000000000000000001", chainselectors.TEST_90000001.Selector), + }, + { + name: "invalid MCMS input", + input: Input{ + MCMS: &cldf.MCMSTimelockProposalInput{}, + Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []common.Address{validAddress}, + }}, + }}, + }, + wantErr: `invalid MCMS timelock proposal input: invalid MCMS timelock proposal input: invalid timelock action ""`, + }, + } + + env := testEnvironment(t, datastore.NewMemoryDataStore().Seal()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := Changeset{}.VerifyPreconditions(env, tt.input) + require.EqualError(t, err, tt.wantErr) + }) + } +} + +func TestChangeset_VerifyPreconditions_unsupportedFamily(t *testing.T) { + t.Parallel() + + err := Changeset{}.VerifyPreconditions( + testEnvironment(t, datastore.NewMemoryDataStore().Seal()), + Input{ + Cfg: Config{ + GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.APTOS_MAINNET.Selector: {{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []common.Address{common.HexToAddress("0x1")}, + }}, + }, + }, + }, + ) + require.EqualError(t, err, `mcms grant-role: no sequence registered for family "aptos" (none registered)`) +} + +func TestChangeset_Apply_unsupportedFamily(t *testing.T) { + t.Parallel() + + _, err := Changeset{}.Apply(cldf.Environment{}, Input{ + Cfg: Config{ + GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.APTOS_MAINNET.Selector: {{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []common.Address{common.HexToAddress("0x1")}, + }}, + }, + }, + }) + require.EqualError(t, err, fmt.Sprintf(`chain selector %d: mcms grant-role: no sequence registered for family "aptos" (none registered)`, chainselectors.APTOS_MAINNET.Selector)) +} + +func TestBuildOutput(t *testing.T) { + t.Parallel() + + env := testEnvironment(t, datastore.NewMemoryDataStore().Seal()) + + t.Run("success without MCMS", func(t *testing.T) { + t.Parallel() + + out, err := buildOutput(env, nil, sequenceutils.OnChainOutput{}, nil) + require.NoError(t, err) + require.NotNil(t, out.DataStore) + }) + + t.Run("returns partial output on sequence error", func(t *testing.T) { + t.Parallel() + + out, err := buildOutput(env, nil, sequenceutils.OnChainOutput{}, errors.New("sequence failed")) + require.EqualError(t, err, "sequence failed") + require.NotNil(t, out.DataStore) + }) +} diff --git a/mcms/changesets/grant-role/registry.go b/mcms/changesets/grant-role/registry.go new file mode 100644 index 0000000..702cfd2 --- /dev/null +++ b/mcms/changesets/grant-role/registry.go @@ -0,0 +1,33 @@ +package grantrole + +import ( + "fmt" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/cld-changesets/internal/familyregistry" +) + +// Registration describes one chain family's grant-role implementation. +type Registration = familyregistry.Registration[Sequence, SeqInput] + +// Registry holds per-family grant-role sequences. +var Registry = familyregistry.New[Sequence, SeqInput]("mcms grant-role") + +func groupByFamily(input Input) (map[string][]SeqInput, error) { + byFamily := make(map[string][]SeqInput) + for chainSelector, grants := range input.Cfg.GrantsByChain { + family, err := chainselectors.GetSelectorFamily(chainSelector) + if err != nil { + return nil, fmt.Errorf("chain selector %d: %w", chainSelector, err) + } + byFamily[family] = append(byFamily[family], SeqInput{ + ChainSelector: chainSelector, + Grants: grants, + MCMS: input.MCMS, + GasBoostConfig: input.Cfg.GasBoostConfig, + }) + } + + return byFamily, nil +} diff --git a/mcms/changesets/grant-role/registry_test.go b/mcms/changesets/grant-role/registry_test.go new file mode 100644 index 0000000..48f5733 --- /dev/null +++ b/mcms/changesets/grant-role/registry_test.go @@ -0,0 +1,74 @@ +package grantrole + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfchain "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/engine/cld/mcms/proposalutils" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" +) + +func TestGroupByFamily(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + gasBoost := &proposalutils.GasBoostConfig{} + 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), + } + grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + + byFamily, err := groupByFamily(Input{ + MCMS: mcmsInput, + Cfg: Config{ + GrantsByChain: map[uint64][]RoleGrant{ + selector: {{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []common.Address{grantee}, + }}, + }, + GasBoostConfig: gasBoost, + }, + }) + require.NoError(t, err) + require.Len(t, byFamily[chainselectors.FamilyEVM], 1) + require.Equal(t, selector, byFamily[chainselectors.FamilyEVM][0].ChainSelector) + require.Equal(t, mcmsInput, byFamily[chainselectors.FamilyEVM][0].MCMS) + require.Equal(t, gasBoost, byFamily[chainselectors.FamilyEVM][0].GasBoostConfig) + require.Equal(t, []common.Address{grantee}, byFamily[chainselectors.FamilyEVM][0].Grants[0].Addresses) + + _, err = groupByFamily(Input{ + Cfg: Config{ + GrantsByChain: map[uint64][]RoleGrant{ + 0: {{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []common.Address{grantee}, + }}, + }, + }, + }) + require.EqualError(t, err, "chain selector 0: unknown chain selector 0") +} + +func TestEnvFromDeps(t *testing.T) { + t.Parallel() + + blockChains := cldfchain.NewBlockChains(nil) + ds := datastore.NewMemoryDataStore().Seal() + deps := Deps{ + BlockChains: blockChains, + DataStore: ds, + } + env := EnvFromDeps(deps) + require.Equal(t, blockChains, env.BlockChains) + require.Equal(t, ds, env.DataStore) +} diff --git a/mcms/changesets/grant-role/types.go b/mcms/changesets/grant-role/types.go new file mode 100644 index 0000000..0f3ae01 --- /dev/null +++ b/mcms/changesets/grant-role/types.go @@ -0,0 +1,53 @@ +package grantrole + +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/engine/cld/mcms/proposalutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmssdk "github.com/smartcontractkit/mcms/sdk" +) + +// RoleGrant grants one timelock role to multiple accounts. +type RoleGrant struct { + Role mcmssdk.TimelockRole `json:"role"` + Addresses []common.Address `json:"addresses"` +} + +// Config selects timelock role grants by chain selector. +type Config struct { + GrantsByChain map[uint64][]RoleGrant `json:"grantsByChain"` + // GasBoostConfig optionally configures EVM retry gas boosting for direct sends. + GasBoostConfig *proposalutils.GasBoostConfig `json:"gasBoostConfig,omitempty"` +} + +// Input is the grant-role changeset configuration with optional MCMS proposal settings. +type Input = sequenceutils.WithMCMS[Config] + +// SeqInput is the evm input for calling grant role on the timelock contract +type SeqInput struct { + ChainSelector uint64 `json:"chainSelector"` + Grants []RoleGrant `json:"grants"` + MCMS *cldf.MCMSTimelockProposalInput `json:"mcms,omitempty"` + GasBoostConfig *proposalutils.GasBoostConfig `json:"gasBoostConfig,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[SeqInput, 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/grant-role/operation.go b/mcms/evm/grant-role/operation.go index 7728bdc..7604011 100644 --- a/mcms/evm/grant-role/operation.go +++ b/mcms/evm/grant-role/operation.go @@ -1,33 +1,139 @@ package evmgrantrole import ( + "errors" + "fmt" + "math/big" + "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" + opscontract "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations2/contract" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" - "github.com/smartcontractkit/mcms/sdk/evm/bindings" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmsevm "github.com/smartcontractkit/mcms/sdk/evm" + mcmsbindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" - evmops "github.com/smartcontractkit/cld-changesets/legacy/mcms/oputils" + "github.com/smartcontractkit/cld-changesets/mcms/evm/internal/gasboost" ) -// OpGrantRoleInput is the input to OpGrantRole. -type OpGrantRoleInput struct { - Account common.Address `json:"account"` - RoleID [32]byte `json:"roleID"` +type GrantRoleTarget struct { + Timelock common.Address `json:"timelock"` + Role mcmssdk.TimelockRole `json:"role"` + Address common.Address `json:"address"` +} + +type OpEVMGrantRoleInput struct { + Target GrantRoleTarget `json:"target"` + NoSend bool `json:"noSend"` + GasPrice uint64 `json:"gasPrice"` + GasLimit uint64 `json:"gasLimit"` } -// OpGrantRole grants the given role to the given account on the EVM Timelock contract. -// TODO: refactor to use mcms lib -var OpGrantRole = evmops.NewEVMCallOperation( +func (in OpEVMGrantRoleInput) GasBoostValues() (gasLimit, gasPrice uint64) { + return in.GasLimit, in.GasPrice +} + +func (in OpEVMGrantRoleInput) WithGasBoost(gasLimit, gasPrice uint64) OpEVMGrantRoleInput { + in.GasLimit = gasLimit + in.GasPrice = gasPrice + + return in +} + +var OpEVMGrantRole = operations.NewOperation( "evm-timelock-grant-role", semver.MustParse("1.0.0"), - "Grants the specified role to the given account on the EVM Timelock contract", - bindings.RBACTimelockABI, - mcmscontracts.RBACTimelock, - bindings.NewRBACTimelock, - func(timelock *bindings.RBACTimelock, opts *bind.TransactOpts, input OpGrantRoleInput) (*types.Transaction, error) { - return timelock.GrantRole(opts, input.RoleID, input.Account) + "Grants an RBACTimelock role to one EVM address via the MCMS SDK timelock configurer", + func(b operations.Bundle, deps cldf_evm.Chain, in OpEVMGrantRoleInput) (opscontract.WriteOutput, error) { + if !in.NoSend && deps.DeployerKey == nil { + return opscontract.WriteOutput{}, fmt.Errorf("missing deployer key for chain %d", deps.Selector) + } + + var opts *bind.TransactOpts + if in.NoSend { + opts = cldf.SimTransactOpts() + } else { + opts = gasboost.CloneTransactOptsWithGas(deps.DeployerKey, in.GasLimit, in.GasPrice) + } + if opts == nil { + return opscontract.WriteOutput{}, fmt.Errorf("failed to build transact opts for chain %d", deps.Selector) + } + opts.Context = b.GetContext() + + configurer := mcmsevm.NewTimelockConfigurer(deps.Client, opts) + res, err := configurer.GrantRole(b.GetContext(), in.Target.Timelock.Hex(), in.Target.Role, in.Target.Address.Hex()) + if err != nil { + return opscontract.WriteOutput{}, fmt.Errorf("failed to grant role %s to %s on %s: %w", + in.Target.Role.String(), in.Target.Address.Hex(), in.Target.Timelock.Hex(), err) + } + + tx, err := rawTransaction(res.RawData) + if err != nil { + return opscontract.WriteOutput{}, err + } + + out := writeOutputFromGrant(deps.Selector, in.Target.Timelock, tx) + if in.NoSend { + return out, nil + } + + if _, err = cldf.ConfirmIfNoErrorWithABI(deps, tx, mcmsbindings.RBACTimelockABI, nil); err != nil { + return opscontract.WriteOutput{}, fmt.Errorf("failed to confirm grant role tx against %s: %w", in.Target.Timelock.Hex(), err) + } + b.Logger.Infow("GrantRole tx confirmed", "txHash", tx.Hash().Hex(), "timelock", in.Target.Timelock.Hex()) + + out.ExecInfo = &opscontract.ExecInfo{Hash: tx.Hash().Hex()} + + return out, nil }, ) + +func writeOutputFromGrant(chainSelector uint64, timelock common.Address, tx *types.Transaction) opscontract.WriteOutput { + return opscontract.WriteOutput{ + ChainSelector: chainSelector, + Tx: mcmsevm.NewTransaction( + timelock, + tx.Data(), + big.NewInt(0), + string(mcmscontracts.RBACTimelock), + []string{}, + ), + } +} + +func rawTransaction(raw any) (*types.Transaction, error) { + switch tx := raw.(type) { + case *types.Transaction: + return tx, nil + default: + return nil, fmt.Errorf("unexpected raw data type %T from GrantRole", raw) + } +} + +func retryGrantRoleWithGasBoost(cfg *cldfproposalutils.GasBoostConfig) operations.ExecuteOption[OpEVMGrantRoleInput, cldf_evm.Chain] { + if cfg == nil { + return operations.WithRetry[OpEVMGrantRoleInput, cldf_evm.Chain]() + } + + return gasboost.RetryWithGasBoost[OpEVMGrantRoleInput](cfg) +} + +func validateGrantRoleTarget(target GrantRoleTarget) error { + if target.Timelock == (common.Address{}) { + return errors.New("timelock address must not be zero") + } + if !target.Role.Valid() { + return errors.New("role is unsupported") + } + if target.Address == (common.Address{}) { + return errors.New("address must not be zero") + } + + return nil +} diff --git a/mcms/evm/grant-role/operation_test.go b/mcms/evm/grant-role/operation_test.go index a311b6d..e1f0c70 100644 --- a/mcms/evm/grant-role/operation_test.go +++ b/mcms/evm/grant-role/operation_test.go @@ -13,16 +13,28 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" "github.com/smartcontractkit/chainlink-deployments-framework/operations" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmssdk "github.com/smartcontractkit/mcms/sdk" mcmsevmsdk "github.com/smartcontractkit/mcms/sdk/evm" - "github.com/smartcontractkit/cld-changesets/internal/mcmsrole" - evmops "github.com/smartcontractkit/cld-changesets/legacy/mcms/oputils" timelockops "github.com/smartcontractkit/cld-changesets/mcms/evm/deploy/v1_0_0/operations/rbac_timelock" evmgrantrole "github.com/smartcontractkit/cld-changesets/mcms/evm/grant-role" ) +func TestOpEVMGrantRoleInputGasOverridable(t *testing.T) { + t.Parallel() + + in := evmgrantrole.OpEVMGrantRoleInput{GasLimit: 100, GasPrice: 200} + gotLimit, gotPrice := in.GasBoostValues() + require.Equal(t, uint64(100), gotLimit) + require.Equal(t, uint64(200), gotPrice) + + boosted := in.WithGasBoost(500, 600) + require.Equal(t, uint64(500), boosted.GasLimit) + require.Equal(t, uint64(600), boosted.GasPrice) +} + // TestOpGrantRole deploys a timelock with the deployer as admin and grants the -// executor role to a fresh address, asserting it shows up in the inspector. +// executor role to a fresh address via the MCMS SDK timelock configurer. func TestOpGrantRole(t *testing.T) { t.Parallel() @@ -53,16 +65,16 @@ func TestOpGrantRole(t *testing.T) { timelockAddr := common.HexToAddress(tlReport.Output.Address) grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") - _, err = operations.ExecuteOperation(bundle, evmgrantrole.OpGrantRole, chain, - evmops.EVMCallInput[evmgrantrole.OpGrantRoleInput]{ - ChainSelector: sel, - Address: timelockAddr, - CallInput: evmgrantrole.OpGrantRoleInput{ - Account: grantee, - RoleID: [32]byte(mcmsrole.ExecutorRole.ID), + report, err := operations.ExecuteOperation(bundle, evmgrantrole.OpEVMGrantRole, chain, + evmgrantrole.OpEVMGrantRoleInput{ + Target: evmgrantrole.GrantRoleTarget{ + Timelock: timelockAddr, + Role: mcmssdk.TimelockRoleExecutor, + Address: grantee, }, }) require.NoError(t, err) + require.True(t, report.Output.Executed()) executors, err := mcmsevmsdk.NewTimelockInspector(chain.Client).GetExecutors(t.Context(), timelockAddr.Hex()) require.NoError(t, err) diff --git a/mcms/evm/grant-role/register.go b/mcms/evm/grant-role/register.go new file mode 100644 index 0000000..b2f9e16 --- /dev/null +++ b/mcms/evm/grant-role/register.go @@ -0,0 +1,19 @@ +package evmgrantrole + +import ( + chainselectors "github.com/smartcontractkit/chain-selectors" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" +) + +func init() { + grantrole.Registry.Register(Registration()) +} + +func Registration() grantrole.Registration { + return grantrole.Registration{ + Family: chainselectors.FamilyEVM, + Sequence: seqGrantRole, + Verify: validateEVMChains, + } +} diff --git a/mcms/evm/grant-role/register_test.go b/mcms/evm/grant-role/register_test.go new file mode 100644 index 0000000..58bdc19 --- /dev/null +++ b/mcms/evm/grant-role/register_test.go @@ -0,0 +1,30 @@ +package evmgrantrole_test + +import ( + "testing" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/require" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" + evmgrantrole "github.com/smartcontractkit/cld-changesets/mcms/evm/grant-role" +) + +func TestRegistration(t *testing.T) { + t.Parallel() + + reg := evmgrantrole.Registration() + require.Equal(t, chainselectors.FamilyEVM, reg.Family) + require.NotNil(t, reg.Sequence) + require.NotNil(t, reg.Verify) +} + +func TestRegistryHasEVMFamily(t *testing.T) { + t.Parallel() + + require.Contains(t, grantrole.Registry.RegisteredFamilies(), chainselectors.FamilyEVM) + + seq, err := grantrole.Registry.SequenceForFamily(chainselectors.FamilyEVM) + require.NoError(t, err) + require.NotNil(t, seq) +} diff --git a/mcms/evm/grant-role/sequence.go b/mcms/evm/grant-role/sequence.go new file mode 100644 index 0000000..39a3762 --- /dev/null +++ b/mcms/evm/grant-role/sequence.go @@ -0,0 +1,178 @@ +package evmgrantrole + +import ( + "fmt" + "slices" + "strconv" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + chainselectors "github.com/smartcontractkit/chain-selectors" + 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" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmsevm "github.com/smartcontractkit/mcms/sdk/evm" + mcmstypes "github.com/smartcontractkit/mcms/types" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" +) + +var seqGrantRole = operations.NewSequence( + "seq-evm-grant-role", + semver.MustParse("1.0.0"), + "Grants RBACTimelock roles on EVM chains", + runEVMGrantRole, +) + +func runEVMGrantRole( + b operations.Bundle, + deps grantrole.Deps, + in grantrole.SeqInput, +) (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) + } + + timelock, err := timelockAddress(grantrole.EnvFromDeps(deps), in) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + useMCMS := in.MCMS != nil + var writes []opscontract.WriteOutput + + for _, grant := range in.Grants { + addresses, err := addressesNeedingGrant(b, chain, timelock, grant) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + for _, address := range addresses { + target := GrantRoleTarget{ + Timelock: timelock, + Role: grant.Role, + Address: address, + } + if err := validateGrantRoleTarget(target); err != nil { + return sequenceutils.OnChainOutput{}, err + } + + report, err := operations.ExecuteOperation( + b, + OpEVMGrantRole, + chain, + OpEVMGrantRoleInput{ + Target: target, + NoSend: useMCMS, + }, + retryGrantRoleWithGasBoost(in.GasBoostConfig), + operations.WithIdempotencyKey[OpEVMGrantRoleInput, cldf_evm.Chain]( + strconv.FormatUint(chain.Selector, 10)+":"+timelock.Hex()+":"+grant.Role.String()+":"+address.Hex(), + ), + ) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + if useMCMS { + writes = append(writes, report.Output) + continue + } + + if report.Output.Executed() { + b.Logger.Infow("Role granted", + "role", grant.Role.String(), + "chainSelector", chain.Selector, + "timelock", timelock.Hex(), + "address", address.Hex(), + "txHash", report.Output.ExecInfo.Hash, + ) + } + } + } + + if !useMCMS { + return sequenceutils.OnChainOutput{}, nil + } + + batch, err := opscontract.NewBatchOperationFromWrites(writes) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + if len(batch.Transactions) == 0 { + return sequenceutils.OnChainOutput{}, nil + } + + return sequenceutils.OnChainOutput{BatchOps: []mcmstypes.BatchOperation{batch}}, nil +} + +// addressesNeedingGrant returns the set of addresses that don't yet have the provided role. +func addressesNeedingGrant( + b operations.Bundle, + chain cldf_evm.Chain, + timelock common.Address, + grant grantrole.RoleGrant, +) ([]common.Address, error) { + addressesWithRole, err := addressesForRole(b, chain, timelock, grant.Role) + if err != nil { + return nil, err + } + if len(addressesWithRole) == 0 { + return grant.Addresses, nil + } + + out := make([]common.Address, 0, len(grant.Addresses)) + for _, address := range grant.Addresses { + if slices.Contains(addressesWithRole, address.Hex()) { + continue + } + out = append(out, address) + } + + return out, nil +} + +func addressesForRole( + b operations.Bundle, + chain cldf_evm.Chain, + timelock common.Address, + role mcmssdk.TimelockRole, +) ([]string, error) { + inspector := mcmsevm.NewTimelockInspector(chain.Client) + switch role { + case mcmssdk.TimelockRoleProposer: + return inspector.GetProposers(b.GetContext(), timelock.Hex()) + case mcmssdk.TimelockRoleCanceller: + return inspector.GetCancellers(b.GetContext(), timelock.Hex()) + case mcmssdk.TimelockRoleBypasser: + return inspector.GetBypassers(b.GetContext(), timelock.Hex()) + case mcmssdk.TimelockRoleExecutor: + return inspector.GetExecutors(b.GetContext(), timelock.Hex()) + case mcmssdk.TimelockRoleAdmin: + return nil, nil + default: + return nil, fmt.Errorf("unsupported timelock role %s", role.String()) + } +} + +func timelockAddress(env cldf.Environment, in grantrole.SeqInput) (common.Address, error) { + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilyEVM) + if !ok { + return common.Address{}, fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilyEVM) + } + + input := cldf.MCMSTimelockProposalInput{} + if in.MCMS != nil { + input = *in.MCMS + } + ref, err := reader.GetTimelockRef(env, in.ChainSelector, input) + if err != nil { + return common.Address{}, fmt.Errorf("resolve timelock for chain %d: %w", in.ChainSelector, err) + } + + return parseEVMAddress(ref.Address, "timelock") +} diff --git a/mcms/evm/grant-role/sequence_test.go b/mcms/evm/grant-role/sequence_test.go new file mode 100644 index 0000000..445e83e --- /dev/null +++ b/mcms/evm/grant-role/sequence_test.go @@ -0,0 +1,237 @@ +package evmgrantrole + +import ( + "crypto/ecdsa" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/segmentio/ksuid" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + 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-deployments-framework/pkg/logger" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmsevm "github.com/smartcontractkit/mcms/sdk/evm" + mcmstypes "github.com/smartcontractkit/mcms/types" + + legacymcms "github.com/smartcontractkit/cld-changesets/legacy/mcms/changesets" + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" + evmreaders "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" + + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" +) + +func TestRunEVMGrantRole(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + useMCMS bool + }{ + {name: "direct send", useMCMS: false}, + {name: "MCMS proposal", useMCMS: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt := newEVMGrantRoleRuntime(t, selector) + refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) + chain := rt.Environment().BlockChains.EVMChains()[selector] + grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + + var mcmsInput *cldf.MCMSTimelockProposalInput + if tt.useMCMS { + mcmsInput = &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(0), + } + } + + out, err := runEVMGrantRole( + rt.Environment().OperationsBundle, + grantrole.Deps{ + BlockChains: rt.Environment().BlockChains, + DataStore: rt.Environment().DataStore, + }, + grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []common.Address{grantee}, + }}, + MCMS: mcmsInput, + }, + ) + require.NoError(t, err) + + if tt.useMCMS { + require.Len(t, out.BatchOps, 1) + require.Len(t, out.BatchOps[0].Transactions, 1) + require.NoError(t, rt.Exec( + newTimelockProposalTask(out.BatchOps, "grant role sequence test"), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + } else { + require.Empty(t, out.BatchOps) + } + + executors, err := mcmsevm.NewTimelockInspector(chain.Client).GetExecutors(t.Context(), refs.Timelock.Hex()) + require.NoError(t, err) + require.Contains(t, executors, grantee.Hex()) + }) + } +} + +func TestRunEVMGrantRole_idempotent(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt := newEVMGrantRoleRuntime(t, selector) + refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) + chain := rt.Environment().BlockChains.EVMChains()[selector] + grantee := common.HexToAddress("0x00000000000000000000000000000000000000bb") + deps := grantrole.Deps{ + BlockChains: rt.Environment().BlockChains, + DataStore: rt.Environment().DataStore, + } + input := grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []common.Address{grantee}, + }}, + } + + _, err := runEVMGrantRole(rt.Environment().OperationsBundle, deps, input) + require.NoError(t, err) + + out, err := runEVMGrantRole(rt.Environment().OperationsBundle, deps, input) + require.NoError(t, err) + require.Empty(t, out.BatchOps) + + proposers, err := mcmsevm.NewTimelockInspector(chain.Client).GetProposers(t.Context(), refs.Timelock.Hex()) + require.NoError(t, err) + require.Contains(t, proposers, grantee.Hex()) +} + +func TestAddressesNeedingGrant(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt := newEVMGrantRoleRuntime(t, selector) + refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) + bundle := rt.Environment().OperationsBundle + chain := rt.Environment().BlockChains.EVMChains()[selector] + deps := grantrole.Deps{ + BlockChains: rt.Environment().BlockChains, + DataStore: rt.Environment().DataStore, + } + + grantee := common.HexToAddress("0x00000000000000000000000000000000000000dd") + pending := common.HexToAddress("0x00000000000000000000000000000000000000ee") + _, err := runEVMGrantRole(bundle, deps, grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleCanceller, + Addresses: []common.Address{grantee}, + }}, + }) + require.NoError(t, err) + + needed, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ + Role: mcmssdk.TimelockRoleCanceller, + Addresses: []common.Address{grantee, pending}, + }) + require.NoError(t, err) + require.Equal(t, []common.Address{pending}, needed) + + adminNeeded, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ + Role: mcmssdk.TimelockRoleAdmin, + Addresses: []common.Address{grantee}, + }) + require.NoError(t, err) + require.Equal(t, []common.Address{grantee}, adminNeeded) + + _, err = addressesForRole(bundle, chain, refs.Timelock, mcmssdk.TimelockRole(99)) + require.EqualError(t, err, "unsupported timelock role Unknown") +} + +func newEVMGrantRoleRuntime(t *testing.T, selector uint64) *runtime.Runtime { + t.Helper() + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + err = rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cldftesthelpers.SingleGroupTimelockConfig(t), + }), + ) + require.NoError(t, err) + + return rt +} + +type evmGrantRoleRefs struct { + Timelock common.Address +} + +func grantRoleRefsFromEnv(t *testing.T, env cldf.Environment, selector uint64) evmGrantRoleRefs { + t.Helper() + + reader := evmreaders.Reader{} + timelock, err := reader.GetTimelockRef(env, selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + + return evmGrantRoleRefs{ + Timelock: common.HexToAddress(timelock.Address), + } +} + +type timelockProposalTask struct { + id string + batchOps []mcmstypes.BatchOperation + description string +} + +func newTimelockProposalTask(batchOps []mcmstypes.BatchOperation, description string) timelockProposalTask { + return timelockProposalTask{ + id: ksuid.New().String(), + batchOps: batchOps, + description: description, + } +} + +func (t timelockProposalTask) ID() string { + return t.id +} + +func (t timelockProposalTask) Run(e cldf.Environment, state *runtime.State) error { + out, err := cldf.NewOutputBuilder(e, datastore.NewMemoryDataStore()). + WithTimelockProposal(cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(0), + Description: t.description, + }, t.batchOps). + Build() + if err != nil { + return err + } + + return state.MergeChangesetOutput(t.id, out) +} diff --git a/mcms/evm/grant-role/validate.go b/mcms/evm/grant-role/validate.go new file mode 100644 index 0000000..4d2bad2 --- /dev/null +++ b/mcms/evm/grant-role/validate.go @@ -0,0 +1,87 @@ +package evmgrantrole + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" + evmreaders "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" +) + +func validateEVMChains(env cldf.Environment, chains []grantrole.SeqInput) error { + for _, in := range chains { + if _, ok := env.BlockChains.EVMChains()[in.ChainSelector]; !ok { + return fmt.Errorf("EVM chain %d not found in environment", in.ChainSelector) + } + if err := validateMCMSRefs(env, in); err != nil { + return err + } + if err := validateRoles(in); err != nil { + return fmt.Errorf("chain %d: %w", in.ChainSelector, err) + } + } + + return nil +} + +func validateMCMSRefs(env cldf.Environment, in grantrole.SeqInput) error { + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilyEVM) + if !ok { + return fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilyEVM) + } + + input := cldf.MCMSTimelockProposalInput{} + if in.MCMS != nil { + input = *in.MCMS + } + timelockRef, err := reader.GetTimelockRef(env, in.ChainSelector, input) + 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) + } + + if in.MCMS == nil { + return nil + } + if _, err = reader.GetMCMSRef(env, in.ChainSelector, *in.MCMS); err != nil { + return fmt.Errorf("mcms not present on chain %d: %w", in.ChainSelector, err) + } + + evmReader, ok := reader.(evmreaders.CallProxyReader) + if !ok { + return fmt.Errorf("validate call proxy ref for chain %d: reader for family %q does not support call proxy lookup", in.ChainSelector, chainselectors.FamilyEVM) + } + if _, err = evmReader.GetCallProxyRef(env, in.ChainSelector, in.MCMS.Qualifier); err != nil { + return fmt.Errorf("validate call proxy ref for chain %d: %w", in.ChainSelector, err) + } + + return nil +} + +func validateRoles(in grantrole.SeqInput) error { + for i, grant := range in.Grants { + if !grant.Role.Valid() { + return fmt.Errorf("grants[%d]: unsupported timelock role %s", i, grant.Role.String()) + } + } + + return nil +} + +func parseEVMAddress(raw string, name string) (common.Address, error) { + if !common.IsHexAddress(raw) { + return common.Address{}, fmt.Errorf("%s address %q is not a valid EVM address", name, raw) + } + addr := common.HexToAddress(raw) + if addr == (common.Address{}) { + return common.Address{}, errors.New(name + " address must not be zero") + } + + return addr, nil +} diff --git a/mcms/evm/grant-role/validate_test.go b/mcms/evm/grant-role/validate_test.go new file mode 100644 index 0000000..1c041c3 --- /dev/null +++ b/mcms/evm/grant-role/validate_test.go @@ -0,0 +1,315 @@ +package evmgrantrole + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "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" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmstypes "github.com/smartcontractkit/mcms/types" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" + + _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" +) + +const ( + testTimelockAddr = "0x0000000000000000000000000000000000000100" + testMCMSAddr = "0x0000000000000000000000000000000000000200" + testCallProxyAddr = "0x0000000000000000000000000000000000000300" +) + +type validateRefSpec struct { + contractType cldf.ContractType + address string + qualifier string +} + +func TestValidateMCMSRefs(t *testing.T) { + t.Parallel() + + 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: "nil MCMS only requires timelock", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr, ""}, + }, + }, + { + name: "missing timelock ref", + mcms: &mcmsInput, + wantErr: fmt.Sprintf( + "timelock not present on chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: RBACTimelock}, found 0", + chainselectors.TEST_90000001.Selector, chainselectors.TEST_90000001.Selector, + ), + }, + { + name: "missing mcms ref", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr, ""}, + }, + mcms: &mcmsInput, + wantErr: fmt.Sprintf( + "mcms not present on chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: ProposerManyChainMultiSig}, found 0", + chainselectors.TEST_90000001.Selector, chainselectors.TEST_90000001.Selector, + ), + }, + { + name: "missing call proxy ref", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr, ""}, + {mcmscontracts.ProposerManyChainMultisig, testMCMSAddr, ""}, + }, + mcms: &mcmsInput, + wantErr: fmt.Sprintf( + "validate call proxy ref for chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: CallProxy}, found 0", + chainselectors.TEST_90000001.Selector, chainselectors.TEST_90000001.Selector, + ), + }, + { + name: "success", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr, ""}, + {mcmscontracts.ProposerManyChainMultisig, testMCMSAddr, ""}, + {mcmscontracts.CallProxy, testCallProxyAddr, ""}, + }, + mcms: &mcmsInput, + }, + { + name: "invalid timelock address", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, "not-an-address", ""}, + {mcmscontracts.ProposerManyChainMultisig, testMCMSAddr, ""}, + {mcmscontracts.CallProxy, testCallProxyAddr, ""}, + }, + mcms: &mcmsInput, + wantErr: fmt.Sprintf( + `invalid timelock ref on chain %d: timelock address "not-an-address" is not a valid EVM address`, + chainselectors.TEST_90000001.Selector, + ), + }, + } + + 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, chainselectors.TEST_90000001.Selector, ref.contractType, ref.address, version, ref.qualifier) + } + + err := validateMCMSRefs( + validateTestEnv(ds.Seal()), + grantrole.SeqInput{ + ChainSelector: chainselectors.TEST_90000001.Selector, + MCMS: tt.mcms, + }, + ) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.EqualError(t, err, tt.wantErr) + }) + } +} + +func TestValidateEVMChains(t *testing.T) { + t.Parallel() + + version := semver.MustParse("1.0.0") + grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + + tests := []struct { + name string + env cldf.Environment + chains []grantrole.SeqInput + wantErr string + }{ + { + name: "chain not in environment", + env: validateTestEnv(datastore.NewMemoryDataStore().Seal()), + chains: []grantrole.SeqInput{{ + ChainSelector: chainselectors.TEST_90000001.Selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []common.Address{grantee}, + }}, + }}, + wantErr: fmt.Sprintf("EVM chain %d not found in environment", chainselectors.TEST_90000001.Selector), + }, + { + name: "unsupported role", + env: grantRoleValidateEnv(t, chainselectors.TEST_90000001.Selector, version, []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr, ""}, + }), + chains: []grantrole.SeqInput{{ + ChainSelector: chainselectors.TEST_90000001.Selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRole(99), + Addresses: []common.Address{grantee}, + }}, + }}, + wantErr: fmt.Sprintf("chain %d: grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector), + }, + { + name: "success", + env: grantRoleValidateEnv(t, chainselectors.TEST_90000001.Selector, version, []validateRefSpec{ + {mcmscontracts.RBACTimelock, testTimelockAddr, ""}, + }), + chains: []grantrole.SeqInput{{ + ChainSelector: chainselectors.TEST_90000001.Selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []common.Address{grantee}, + }}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateEVMChains(tt.env, tt.chains) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.EqualError(t, err, tt.wantErr) + }) + } +} + +func TestParseEVMAddress(t *testing.T) { + t.Parallel() + + addr, err := parseEVMAddress(testTimelockAddr, "timelock") + require.NoError(t, err) + require.Equal(t, testTimelockAddr, addr.Hex()) + + _, err = parseEVMAddress("not-an-address", "timelock") + require.EqualError(t, err, `timelock address "not-an-address" is not a valid EVM address`) + + _, err = parseEVMAddress("0x0000000000000000000000000000000000000000", "timelock") + require.EqualError(t, err, "timelock address must not be zero") +} + +func TestTimelockAddress(t *testing.T) { + t.Parallel() + + version := semver.MustParse("1.0.0") + + t.Run("missing timelock ref", func(t *testing.T) { + t.Parallel() + + _, err := timelockAddress( + validateTestEnv(datastore.NewMemoryDataStore().Seal()), + grantrole.SeqInput{ChainSelector: chainselectors.TEST_90000001.Selector}, + ) + require.EqualError(t, err, fmt.Sprintf( + "resolve timelock for chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: RBACTimelock}, found 0", + chainselectors.TEST_90000001.Selector, chainselectors.TEST_90000001.Selector, + )) + }) + + t.Run("invalid timelock address", func(t *testing.T) { + t.Parallel() + + ds := datastore.NewMemoryDataStore() + addValidateRef(t, ds, chainselectors.TEST_90000001.Selector, mcmscontracts.RBACTimelock, "not-an-address", version, "") + + _, err := timelockAddress( + validateTestEnv(ds.Seal()), + grantrole.SeqInput{ChainSelector: chainselectors.TEST_90000001.Selector}, + ) + require.EqualError(t, err, `timelock address "not-an-address" is not a valid EVM address`) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + ds := datastore.NewMemoryDataStore() + addValidateRef(t, ds, chainselectors.TEST_90000001.Selector, mcmscontracts.RBACTimelock, testTimelockAddr, version, "") + + addr, err := timelockAddress( + validateTestEnv(ds.Seal()), + grantrole.SeqInput{ChainSelector: chainselectors.TEST_90000001.Selector}, + ) + require.NoError(t, err) + require.Equal(t, testTimelockAddr, addr.Hex()) + }) +} + +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, + } +} + +func grantRoleValidateEnv(t *testing.T, selector uint64, version *semver.Version, refs []validateRefSpec) cldf.Environment { + t.Helper() + + ds := datastore.NewMemoryDataStore() + for _, ref := range refs { + addValidateRef(t, ds, selector, ref.contractType, ref.address, version, ref.qualifier) + } + + return cldf.Environment{ + Logger: logger.Nop(), + BlockChains: cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ + selector: cldfevm.Chain{Selector: selector}, + }), + DataStore: ds.Seal(), + GetContext: context.Background, + } +} From a1a5a658aeb6e4daf4309ac4d15580cc4d817c6d Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 26 Jun 2026 11:07:18 -0600 Subject: [PATCH 2/4] fix: lint errors --- mcms/evm/grant-role/sequence.go | 10 ++++++---- mcms/evm/grant-role/validate.go | 8 ++++---- mcms/evm/grant-role/validate_test.go | 8 ++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/mcms/evm/grant-role/sequence.go b/mcms/evm/grant-role/sequence.go index 39a3762..90bf3c7 100644 --- a/mcms/evm/grant-role/sequence.go +++ b/mcms/evm/grant-role/sequence.go @@ -44,9 +44,10 @@ func runEVMGrantRole( useMCMS := in.MCMS != nil var writes []opscontract.WriteOutput + var addresses []common.Address for _, grant := range in.Grants { - addresses, err := addressesNeedingGrant(b, chain, timelock, grant) + addresses, err = addressesNeedingGrant(b, chain, timelock, grant) if err != nil { return sequenceutils.OnChainOutput{}, err } @@ -57,11 +58,12 @@ func runEVMGrantRole( Role: grant.Role, Address: address, } - if err := validateGrantRoleTarget(target); err != nil { + if err = validateGrantRoleTarget(target); err != nil { return sequenceutils.OnChainOutput{}, err } - report, err := operations.ExecuteOperation( + var report operations.Report[OpEVMGrantRoleInput, opscontract.WriteOutput] + report, err = operations.ExecuteOperation( b, OpEVMGrantRole, chain, @@ -174,5 +176,5 @@ func timelockAddress(env cldf.Environment, in grantrole.SeqInput) (common.Addres return common.Address{}, fmt.Errorf("resolve timelock for chain %d: %w", in.ChainSelector, err) } - return parseEVMAddress(ref.Address, "timelock") + return parseTimelockAddress(ref.Address) } diff --git a/mcms/evm/grant-role/validate.go b/mcms/evm/grant-role/validate.go index 4d2bad2..76967d4 100644 --- a/mcms/evm/grant-role/validate.go +++ b/mcms/evm/grant-role/validate.go @@ -42,7 +42,7 @@ func validateMCMSRefs(env cldf.Environment, in grantrole.SeqInput) error { if err != nil { return fmt.Errorf("timelock not present on chain %d: %w", in.ChainSelector, err) } - if _, err = parseEVMAddress(timelockRef.Address, "timelock"); err != nil { + if _, err = parseTimelockAddress(timelockRef.Address); err != nil { return fmt.Errorf("invalid timelock ref on chain %d: %w", in.ChainSelector, err) } @@ -74,13 +74,13 @@ func validateRoles(in grantrole.SeqInput) error { return nil } -func parseEVMAddress(raw string, name string) (common.Address, error) { +func parseTimelockAddress(raw string) (common.Address, error) { if !common.IsHexAddress(raw) { - return common.Address{}, fmt.Errorf("%s address %q is not a valid EVM address", name, raw) + return common.Address{}, fmt.Errorf("timelock address %q is not a valid EVM address", raw) } addr := common.HexToAddress(raw) if addr == (common.Address{}) { - return common.Address{}, errors.New(name + " address must not be zero") + return common.Address{}, errors.New("timelock address must not be zero") } return addr, nil diff --git a/mcms/evm/grant-role/validate_test.go b/mcms/evm/grant-role/validate_test.go index 1c041c3..6dc3557 100644 --- a/mcms/evm/grant-role/validate_test.go +++ b/mcms/evm/grant-role/validate_test.go @@ -208,17 +208,17 @@ func TestValidateEVMChains(t *testing.T) { } } -func TestParseEVMAddress(t *testing.T) { +func TestParseTimelockAddress(t *testing.T) { t.Parallel() - addr, err := parseEVMAddress(testTimelockAddr, "timelock") + addr, err := parseTimelockAddress(testTimelockAddr) require.NoError(t, err) require.Equal(t, testTimelockAddr, addr.Hex()) - _, err = parseEVMAddress("not-an-address", "timelock") + _, err = parseTimelockAddress("not-an-address") require.EqualError(t, err, `timelock address "not-an-address" is not a valid EVM address`) - _, err = parseEVMAddress("0x0000000000000000000000000000000000000000", "timelock") + _, err = parseTimelockAddress("0x0000000000000000000000000000000000000000") require.EqualError(t, err, "timelock address must not be zero") } From fd8efb42f105047c7611bf847bbe0760720eb11b Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 26 Jun 2026 18:50:46 -0600 Subject: [PATCH 3/4] fix: use generic string type instead of addresses to keep inputs chain agnostic. --- mcms/changesets/grant-role/changeset.go | 9 +++--- mcms/changesets/grant-role/changeset_test.go | 21 ++++++------- mcms/changesets/grant-role/registry_test.go | 9 +++--- mcms/changesets/grant-role/types.go | 3 +- mcms/evm/grant-role/sequence.go | 19 ++++++----- mcms/evm/grant-role/sequence_test.go | 26 +++++++-------- mcms/evm/grant-role/validate.go | 25 +++++++++++++-- mcms/evm/grant-role/validate_test.go | 33 ++++++++++++-------- 8 files changed, 86 insertions(+), 59 deletions(-) diff --git a/mcms/changesets/grant-role/changeset.go b/mcms/changesets/grant-role/changeset.go index 390ce55..e2646e9 100644 --- a/mcms/changesets/grant-role/changeset.go +++ b/mcms/changesets/grant-role/changeset.go @@ -5,7 +5,6 @@ import ( "fmt" "slices" - "github.com/ethereum/go-ethereum/common" "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -138,13 +137,13 @@ func validateGrants(grantsByChain map[uint64][]RoleGrant) error { return fmt.Errorf("chain %d grants[%d]: no addresses provided", chainSelector, grantIdx) } for addrIdx, addr := range grant.Addresses { - if addr == (common.Address{}) { - return fmt.Errorf("chain %d grants[%d].addresses[%d]: address must not be zero", chainSelector, grantIdx, addrIdx) + if addr == "" { + return fmt.Errorf("chain %d grants[%d].addresses[%d]: address must not be empty", chainSelector, grantIdx, addrIdx) } - key := grant.Role.String() + ":" + addr.Hex() + key := grant.Role.String() + ":" + addr if _, ok := seen[key]; ok { return fmt.Errorf("chain %d grants[%d].addresses[%d]: duplicate grant for role %s and address %s", - chainSelector, grantIdx, addrIdx, grant.Role.String(), addr.Hex()) + chainSelector, grantIdx, addrIdx, grant.Role.String(), addr) } seen[key] = struct{}{} } diff --git a/mcms/changesets/grant-role/changeset_test.go b/mcms/changesets/grant-role/changeset_test.go index a7b2a12..e8ebc4e 100644 --- a/mcms/changesets/grant-role/changeset_test.go +++ b/mcms/changesets/grant-role/changeset_test.go @@ -6,7 +6,6 @@ import ( "fmt" "testing" - "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/changeset/sequenceutils" @@ -42,7 +41,7 @@ func TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{common.HexToAddress("0x1")}, + Addresses: []string{"0x0000000000000000000000000000000000000001"}, }}, }, }, @@ -55,7 +54,7 @@ func TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) { func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { t.Parallel() - validAddress := common.HexToAddress("0x1") + validAddress := "0x0000000000000000000000000000000000000001" tests := []struct { name string input Input @@ -76,7 +75,7 @@ func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { { name: "unsupported role", input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ - chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRole(99), Addresses: []common.Address{validAddress}}}, + chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRole(99), Addresses: []string{validAddress}}}, }}}, wantErr: fmt.Sprintf("chain %d grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector), }, @@ -88,21 +87,21 @@ func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { wantErr: fmt.Sprintf("chain %d grants[0]: no addresses provided", chainselectors.TEST_90000001.Selector), }, { - name: "zero address", + name: "empty address", input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{{}}, + Addresses: []string{""}, }}, }}}, - wantErr: fmt.Sprintf("chain %d grants[0].addresses[0]: address must not be zero", chainselectors.TEST_90000001.Selector), + wantErr: fmt.Sprintf("chain %d grants[0].addresses[0]: address must not be empty", chainselectors.TEST_90000001.Selector), }, { name: "duplicate grant", input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{validAddress, validAddress}, + Addresses: []string{validAddress, validAddress}, }}, }}}, wantErr: fmt.Sprintf("chain %d grants[0].addresses[1]: duplicate grant for role Proposer and address 0x0000000000000000000000000000000000000001", chainselectors.TEST_90000001.Selector), @@ -114,7 +113,7 @@ func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{validAddress}, + Addresses: []string{validAddress}, }}, }}, }, @@ -143,7 +142,7 @@ func TestChangeset_VerifyPreconditions_unsupportedFamily(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ chainselectors.APTOS_MAINNET.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{common.HexToAddress("0x1")}, + Addresses: []string{"0x0000000000000000000000000000000000000001"}, }}, }, }, @@ -160,7 +159,7 @@ func TestChangeset_Apply_unsupportedFamily(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ chainselectors.APTOS_MAINNET.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{common.HexToAddress("0x1")}, + Addresses: []string{"0x0000000000000000000000000000000000000001"}, }}, }, }, diff --git a/mcms/changesets/grant-role/registry_test.go b/mcms/changesets/grant-role/registry_test.go index 48f5733..96a9b7d 100644 --- a/mcms/changesets/grant-role/registry_test.go +++ b/mcms/changesets/grant-role/registry_test.go @@ -4,7 +4,6 @@ import ( "testing" "time" - "github.com/ethereum/go-ethereum/common" chainselectors "github.com/smartcontractkit/chain-selectors" cldfchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" @@ -25,7 +24,7 @@ func TestGroupByFamily(t *testing.T) { ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp TimelockDelay: mcmstypes.NewDuration(time.Second), } - grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + grantee := "0x00000000000000000000000000000000000000aa" byFamily, err := groupByFamily(Input{ MCMS: mcmsInput, @@ -33,7 +32,7 @@ func TestGroupByFamily(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ selector: {{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }, GasBoostConfig: gasBoost, @@ -44,14 +43,14 @@ func TestGroupByFamily(t *testing.T) { require.Equal(t, selector, byFamily[chainselectors.FamilyEVM][0].ChainSelector) require.Equal(t, mcmsInput, byFamily[chainselectors.FamilyEVM][0].MCMS) require.Equal(t, gasBoost, byFamily[chainselectors.FamilyEVM][0].GasBoostConfig) - require.Equal(t, []common.Address{grantee}, byFamily[chainselectors.FamilyEVM][0].Grants[0].Addresses) + require.Equal(t, []string{grantee}, byFamily[chainselectors.FamilyEVM][0].Grants[0].Addresses) _, err = groupByFamily(Input{ Cfg: Config{ GrantsByChain: map[uint64][]RoleGrant{ 0: {{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }, }, diff --git a/mcms/changesets/grant-role/types.go b/mcms/changesets/grant-role/types.go index 0f3ae01..fb40ce2 100644 --- a/mcms/changesets/grant-role/types.go +++ b/mcms/changesets/grant-role/types.go @@ -1,7 +1,6 @@ package grantrole 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" @@ -14,7 +13,7 @@ import ( // RoleGrant grants one timelock role to multiple accounts. type RoleGrant struct { Role mcmssdk.TimelockRole `json:"role"` - Addresses []common.Address `json:"addresses"` + Addresses []string `json:"addresses"` } // Config selects timelock role grants by chain selector. diff --git a/mcms/evm/grant-role/sequence.go b/mcms/evm/grant-role/sequence.go index 90bf3c7..e18b9f7 100644 --- a/mcms/evm/grant-role/sequence.go +++ b/mcms/evm/grant-role/sequence.go @@ -44,7 +44,7 @@ func runEVMGrantRole( useMCMS := in.MCMS != nil var writes []opscontract.WriteOutput - var addresses []common.Address + var addresses []string for _, grant := range in.Grants { addresses, err = addressesNeedingGrant(b, chain, timelock, grant) @@ -53,10 +53,15 @@ func runEVMGrantRole( } for _, address := range addresses { + grantee, parseErr := parseEVMAddress(address) + if parseErr != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("parse grantee address %q: %w", address, parseErr) + } + target := GrantRoleTarget{ Timelock: timelock, Role: grant.Role, - Address: address, + Address: grantee, } if err = validateGrantRoleTarget(target); err != nil { return sequenceutils.OnChainOutput{}, err @@ -73,7 +78,7 @@ func runEVMGrantRole( }, retryGrantRoleWithGasBoost(in.GasBoostConfig), operations.WithIdempotencyKey[OpEVMGrantRoleInput, cldf_evm.Chain]( - strconv.FormatUint(chain.Selector, 10)+":"+timelock.Hex()+":"+grant.Role.String()+":"+address.Hex(), + strconv.FormatUint(chain.Selector, 10)+":"+timelock.Hex()+":"+grant.Role.String()+":"+address, ), ) if err != nil { @@ -90,7 +95,7 @@ func runEVMGrantRole( "role", grant.Role.String(), "chainSelector", chain.Selector, "timelock", timelock.Hex(), - "address", address.Hex(), + "address", grantee.Hex(), "txHash", report.Output.ExecInfo.Hash, ) } @@ -118,7 +123,7 @@ func addressesNeedingGrant( chain cldf_evm.Chain, timelock common.Address, grant grantrole.RoleGrant, -) ([]common.Address, error) { +) ([]string, error) { addressesWithRole, err := addressesForRole(b, chain, timelock, grant.Role) if err != nil { return nil, err @@ -127,9 +132,9 @@ func addressesNeedingGrant( return grant.Addresses, nil } - out := make([]common.Address, 0, len(grant.Addresses)) + out := make([]string, 0, len(grant.Addresses)) for _, address := range grant.Addresses { - if slices.Contains(addressesWithRole, address.Hex()) { + if slices.Contains(addressesWithRole, common.HexToAddress(address).Hex()) { continue } out = append(out, address) diff --git a/mcms/evm/grant-role/sequence_test.go b/mcms/evm/grant-role/sequence_test.go index 445e83e..7cecd71 100644 --- a/mcms/evm/grant-role/sequence_test.go +++ b/mcms/evm/grant-role/sequence_test.go @@ -47,7 +47,7 @@ func TestRunEVMGrantRole(t *testing.T) { rt := newEVMGrantRoleRuntime(t, selector) refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) chain := rt.Environment().BlockChains.EVMChains()[selector] - grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa").Hex() var mcmsInput *cldf.MCMSTimelockProposalInput if tt.useMCMS { @@ -68,7 +68,7 @@ func TestRunEVMGrantRole(t *testing.T) { ChainSelector: selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, MCMS: mcmsInput, }, @@ -88,7 +88,7 @@ func TestRunEVMGrantRole(t *testing.T) { executors, err := mcmsevm.NewTimelockInspector(chain.Client).GetExecutors(t.Context(), refs.Timelock.Hex()) require.NoError(t, err) - require.Contains(t, executors, grantee.Hex()) + require.Contains(t, executors, grantee) }) } } @@ -100,7 +100,7 @@ func TestRunEVMGrantRole_idempotent(t *testing.T) { rt := newEVMGrantRoleRuntime(t, selector) refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) chain := rt.Environment().BlockChains.EVMChains()[selector] - grantee := common.HexToAddress("0x00000000000000000000000000000000000000bb") + grantee := common.HexToAddress("0x00000000000000000000000000000000000000bb").Hex() deps := grantrole.Deps{ BlockChains: rt.Environment().BlockChains, DataStore: rt.Environment().DataStore, @@ -109,7 +109,7 @@ func TestRunEVMGrantRole_idempotent(t *testing.T) { ChainSelector: selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, } @@ -122,7 +122,7 @@ func TestRunEVMGrantRole_idempotent(t *testing.T) { proposers, err := mcmsevm.NewTimelockInspector(chain.Client).GetProposers(t.Context(), refs.Timelock.Hex()) require.NoError(t, err) - require.Contains(t, proposers, grantee.Hex()) + require.Contains(t, proposers, grantee) } func TestAddressesNeedingGrant(t *testing.T) { @@ -138,30 +138,30 @@ func TestAddressesNeedingGrant(t *testing.T) { DataStore: rt.Environment().DataStore, } - grantee := common.HexToAddress("0x00000000000000000000000000000000000000dd") - pending := common.HexToAddress("0x00000000000000000000000000000000000000ee") + grantee := common.HexToAddress("0x00000000000000000000000000000000000000dd").Hex() + pending := common.HexToAddress("0x00000000000000000000000000000000000000ee").Hex() _, err := runEVMGrantRole(bundle, deps, grantrole.SeqInput{ ChainSelector: selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleCanceller, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }) require.NoError(t, err) needed, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ Role: mcmssdk.TimelockRoleCanceller, - Addresses: []common.Address{grantee, pending}, + Addresses: []string{grantee, pending}, }) require.NoError(t, err) - require.Equal(t, []common.Address{pending}, needed) + require.Equal(t, []string{pending}, needed) adminNeeded, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ Role: mcmssdk.TimelockRoleAdmin, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }) require.NoError(t, err) - require.Equal(t, []common.Address{grantee}, adminNeeded) + require.Equal(t, []string{grantee}, adminNeeded) _, err = addressesForRole(bundle, chain, refs.Timelock, mcmssdk.TimelockRole(99)) require.EqualError(t, err, "unsupported timelock role Unknown") diff --git a/mcms/evm/grant-role/validate.go b/mcms/evm/grant-role/validate.go index 76967d4..034a823 100644 --- a/mcms/evm/grant-role/validate.go +++ b/mcms/evm/grant-role/validate.go @@ -23,6 +23,9 @@ func validateEVMChains(env cldf.Environment, chains []grantrole.SeqInput) error if err := validateRoles(in); err != nil { return fmt.Errorf("chain %d: %w", in.ChainSelector, err) } + if err := validateGrantAddresses(in); err != nil { + return fmt.Errorf("chain %d: %w", in.ChainSelector, err) + } } return nil @@ -74,14 +77,30 @@ func validateRoles(in grantrole.SeqInput) error { return nil } -func parseTimelockAddress(raw string) (common.Address, error) { +func validateGrantAddresses(in grantrole.SeqInput) error { + for i, grant := range in.Grants { + for j, addr := range grant.Addresses { + if _, err := parseEVMAddress(addr); err != nil { + return fmt.Errorf("grants[%d].addresses[%d]: %w", i, j, err) + } + } + } + + return nil +} + +func parseEVMAddress(raw string) (common.Address, error) { if !common.IsHexAddress(raw) { - return common.Address{}, fmt.Errorf("timelock address %q is not a valid EVM address", raw) + return common.Address{}, fmt.Errorf("address %q is not a valid EVM address", raw) } addr := common.HexToAddress(raw) if addr == (common.Address{}) { - return common.Address{}, errors.New("timelock address must not be zero") + return common.Address{}, errors.New("address must not be zero") } return addr, nil } + +func parseTimelockAddress(raw string) (common.Address, error) { + return parseEVMAddress(raw) +} diff --git a/mcms/evm/grant-role/validate_test.go b/mcms/evm/grant-role/validate_test.go index 6dc3557..ff59f4d 100644 --- a/mcms/evm/grant-role/validate_test.go +++ b/mcms/evm/grant-role/validate_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" chainselectors "github.com/smartcontractkit/chain-selectors" @@ -108,7 +107,7 @@ func TestValidateMCMSRefs(t *testing.T) { }, mcms: &mcmsInput, wantErr: fmt.Sprintf( - `invalid timelock ref on chain %d: timelock address "not-an-address" is not a valid EVM address`, + `invalid timelock ref on chain %d: address "not-an-address" is not a valid EVM address`, chainselectors.TEST_90000001.Selector, ), }, @@ -144,7 +143,7 @@ func TestValidateEVMChains(t *testing.T) { t.Parallel() version := semver.MustParse("1.0.0") - grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + grantee := "0x00000000000000000000000000000000000000aa" tests := []struct { name string @@ -159,7 +158,7 @@ func TestValidateEVMChains(t *testing.T) { ChainSelector: chainselectors.TEST_90000001.Selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }}, wantErr: fmt.Sprintf("EVM chain %d not found in environment", chainselectors.TEST_90000001.Selector), @@ -173,7 +172,7 @@ func TestValidateEVMChains(t *testing.T) { ChainSelector: chainselectors.TEST_90000001.Selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRole(99), - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }}, wantErr: fmt.Sprintf("chain %d: grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector), @@ -187,7 +186,7 @@ func TestValidateEVMChains(t *testing.T) { ChainSelector: chainselectors.TEST_90000001.Selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }}, }, @@ -208,18 +207,26 @@ func TestValidateEVMChains(t *testing.T) { } } -func TestParseTimelockAddress(t *testing.T) { +func TestParseEVMAddress(t *testing.T) { t.Parallel() - addr, err := parseTimelockAddress(testTimelockAddr) + addr, err := parseEVMAddress(testTimelockAddr) require.NoError(t, err) require.Equal(t, testTimelockAddr, addr.Hex()) - _, err = parseTimelockAddress("not-an-address") - require.EqualError(t, err, `timelock address "not-an-address" is not a valid EVM address`) + _, err = parseEVMAddress("not-an-address") + require.EqualError(t, err, `address "not-an-address" is not a valid EVM address`) + + _, err = parseEVMAddress("0x0000000000000000000000000000000000000000") + require.EqualError(t, err, "address must not be zero") +} + +func TestParseTimelockAddress(t *testing.T) { + t.Parallel() - _, err = parseTimelockAddress("0x0000000000000000000000000000000000000000") - require.EqualError(t, err, "timelock address must not be zero") + addr, err := parseTimelockAddress(testTimelockAddr) + require.NoError(t, err) + require.Equal(t, testTimelockAddr, addr.Hex()) } func TestTimelockAddress(t *testing.T) { @@ -250,7 +257,7 @@ func TestTimelockAddress(t *testing.T) { validateTestEnv(ds.Seal()), grantrole.SeqInput{ChainSelector: chainselectors.TEST_90000001.Selector}, ) - require.EqualError(t, err, `timelock address "not-an-address" is not a valid EVM address`) + require.EqualError(t, err, `address "not-an-address" is not a valid EVM address`) }) t.Run("success", func(t *testing.T) { From e59d091f60e8daab9edb7fe6d0ed1f1a1d2b6704 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 29 Jun 2026 07:49:52 -0600 Subject: [PATCH 4/4] fix: address review comments --- mcms/changesets/grant-role/changeset.go | 6 ++---- mcms/changesets/grant-role/registry_test.go | 4 ++-- mcms/changesets/grant-role/types.go | 6 +++--- mcms/evm/grant-role/operation.go | 5 ++--- mcms/evm/grant-role/validate.go | 22 +++++++++++++-------- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/mcms/changesets/grant-role/changeset.go b/mcms/changesets/grant-role/changeset.go index e2646e9..62e0f1a 100644 --- a/mcms/changesets/grant-role/changeset.go +++ b/mcms/changesets/grant-role/changeset.go @@ -3,6 +3,7 @@ package grantrole import ( "errors" "fmt" + "maps" "slices" "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" @@ -38,10 +39,7 @@ func (Changeset) VerifyPreconditions(env cldf.Environment, input Input) error { return err } - families := make([]string, 0, len(byFamily)) - for family := range byFamily { - families = append(families, family) - } + families := slices.Collect(maps.Keys(byFamily)) slices.Sort(families) for _, family := range families { diff --git a/mcms/changesets/grant-role/registry_test.go b/mcms/changesets/grant-role/registry_test.go index 96a9b7d..3ba2e90 100644 --- a/mcms/changesets/grant-role/registry_test.go +++ b/mcms/changesets/grant-role/registry_test.go @@ -6,9 +6,9 @@ import ( chainselectors "github.com/smartcontractkit/chain-selectors" cldfchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + opscontract "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations2/contract" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" - "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" mcmssdk "github.com/smartcontractkit/mcms/sdk" mcmstypes "github.com/smartcontractkit/mcms/types" "github.com/stretchr/testify/require" @@ -18,7 +18,7 @@ func TestGroupByFamily(t *testing.T) { t.Parallel() selector := chainselectors.TEST_90000001.Selector - gasBoost := &proposalutils.GasBoostConfig{} + gasBoost := &opscontract.GasBoostConfig{} mcmsInput := &cldf.MCMSTimelockProposalInput{ TimelockAction: mcmstypes.TimelockActionSchedule, ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp diff --git a/mcms/changesets/grant-role/types.go b/mcms/changesets/grant-role/types.go index fb40ce2..e815000 100644 --- a/mcms/changesets/grant-role/types.go +++ b/mcms/changesets/grant-role/types.go @@ -2,10 +2,10 @@ package grantrole import ( "github.com/smartcontractkit/chainlink-deployments-framework/chain" + opscontract "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations2/contract" "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/engine/cld/mcms/proposalutils" "github.com/smartcontractkit/chainlink-deployments-framework/operations" mcmssdk "github.com/smartcontractkit/mcms/sdk" ) @@ -20,7 +20,7 @@ type RoleGrant struct { type Config struct { GrantsByChain map[uint64][]RoleGrant `json:"grantsByChain"` // GasBoostConfig optionally configures EVM retry gas boosting for direct sends. - GasBoostConfig *proposalutils.GasBoostConfig `json:"gasBoostConfig,omitempty"` + GasBoostConfig *opscontract.GasBoostConfig `json:"gasBoostConfig,omitempty"` } // Input is the grant-role changeset configuration with optional MCMS proposal settings. @@ -31,7 +31,7 @@ type SeqInput struct { ChainSelector uint64 `json:"chainSelector"` Grants []RoleGrant `json:"grants"` MCMS *cldf.MCMSTimelockProposalInput `json:"mcms,omitempty"` - GasBoostConfig *proposalutils.GasBoostConfig `json:"gasBoostConfig,omitempty"` + GasBoostConfig *opscontract.GasBoostConfig `json:"gasBoostConfig,omitempty"` } // Deps is the read-only dependency bundle available to every family sequence. diff --git a/mcms/evm/grant-role/operation.go b/mcms/evm/grant-role/operation.go index 7604011..2645b31 100644 --- a/mcms/evm/grant-role/operation.go +++ b/mcms/evm/grant-role/operation.go @@ -13,7 +13,6 @@ import ( opscontract "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations2/contract" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" - cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" "github.com/smartcontractkit/chainlink-deployments-framework/operations" mcmssdk "github.com/smartcontractkit/mcms/sdk" mcmsevm "github.com/smartcontractkit/mcms/sdk/evm" @@ -116,12 +115,12 @@ func rawTransaction(raw any) (*types.Transaction, error) { } } -func retryGrantRoleWithGasBoost(cfg *cldfproposalutils.GasBoostConfig) operations.ExecuteOption[OpEVMGrantRoleInput, cldf_evm.Chain] { +func retryGrantRoleWithGasBoost(cfg *opscontract.GasBoostConfig) operations.ExecuteOption[OpEVMGrantRoleInput, cldf_evm.Chain] { if cfg == nil { return operations.WithRetry[OpEVMGrantRoleInput, cldf_evm.Chain]() } - return gasboost.RetryWithGasBoost[OpEVMGrantRoleInput](cfg) + return opscontract.RetryWithGasBoost[OpEVMGrantRoleInput](cfg) } func validateGrantRoleTarget(target GrantRoleTarget) error { diff --git a/mcms/evm/grant-role/validate.go b/mcms/evm/grant-role/validate.go index 034a823..a9b788f 100644 --- a/mcms/evm/grant-role/validate.go +++ b/mcms/evm/grant-role/validate.go @@ -37,10 +37,19 @@ func validateMCMSRefs(env cldf.Environment, in grantrole.SeqInput) error { return fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilyEVM) } - input := cldf.MCMSTimelockProposalInput{} - if in.MCMS != nil { - input = *in.MCMS + if in.MCMS == nil { + timelockRef, err := reader.GetTimelockRef(env, in.ChainSelector, cldf.MCMSTimelockProposalInput{}) + if err != nil { + return fmt.Errorf("timelock not present on chain %d: %w", in.ChainSelector, err) + } + if _, err = parseTimelockAddress(timelockRef.Address); err != nil { + return fmt.Errorf("invalid timelock ref on chain %d: %w", in.ChainSelector, err) + } + + return nil } + + input := *in.MCMS timelockRef, err := reader.GetTimelockRef(env, in.ChainSelector, input) if err != nil { return fmt.Errorf("timelock not present on chain %d: %w", in.ChainSelector, err) @@ -49,10 +58,7 @@ func validateMCMSRefs(env cldf.Environment, in grantrole.SeqInput) error { return fmt.Errorf("invalid timelock ref on chain %d: %w", in.ChainSelector, err) } - if in.MCMS == nil { - return nil - } - if _, err = reader.GetMCMSRef(env, in.ChainSelector, *in.MCMS); err != nil { + if _, err = reader.GetMCMSRef(env, in.ChainSelector, input); err != nil { return fmt.Errorf("mcms not present on chain %d: %w", in.ChainSelector, err) } @@ -60,7 +66,7 @@ func validateMCMSRefs(env cldf.Environment, in grantrole.SeqInput) error { if !ok { return fmt.Errorf("validate call proxy ref for chain %d: reader for family %q does not support call proxy lookup", in.ChainSelector, chainselectors.FamilyEVM) } - if _, err = evmReader.GetCallProxyRef(env, in.ChainSelector, in.MCMS.Qualifier); err != nil { + if _, err = evmReader.GetCallProxyRef(env, in.ChainSelector, input.Qualifier); err != nil { return fmt.Errorf("validate call proxy ref for chain %d: %w", in.ChainSelector, err) }