diff --git a/go.mod b/go.mod index 18ef3a1..964dc4a 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/legacy/pkg/family/solana/testutils/artifacts.go b/legacy/pkg/family/solana/testutils/artifacts.go index 55540ec..c8978b0 100644 --- a/legacy/pkg/family/solana/testutils/artifacts.go +++ b/legacy/pkg/family/solana/testutils/artifacts.go @@ -3,34 +3,8 @@ package soltestutils import ( "os" "path/filepath" - "sync" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" ) -var ( - onceCCIP = &sync.Once{} -) - -type downloadFunc func(t *testing.T) string - -// downloadChainlinkCCIPProgramArtifacts downloads CCIP Solana artifacts (includes MCMS programs). -func downloadChainlinkCCIPProgramArtifacts(t *testing.T) string { - t.Helper() - - cachePath := programsCacheDir() - - onceCCIP.Do(func() { - err := solutils.DownloadChainlinkCCIPProgramArtifacts(t.Context(), cachePath, "", nil) - require.NoError(t, err) - }) - - return cachePath -} - // programsCacheDir returns where to store downloaded .so files. Leaf dir is solana_programs // (under UserCacheDir/TempDir, so "cache" is implied; avoids read-only pkg/mod paths). func programsCacheDir() string { diff --git a/legacy/pkg/family/solana/testutils/preload.go b/legacy/pkg/family/solana/testutils/preload.go index 7568fae..17e9437 100644 --- a/legacy/pkg/family/solana/testutils/preload.go +++ b/legacy/pkg/family/solana/testutils/preload.go @@ -1,9 +1,8 @@ package soltestutils import ( - "io" - "os" "path/filepath" + "sync" "testing" "github.com/stretchr/testify/require" @@ -13,17 +12,46 @@ import ( "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" ) -// LoadMCMSPrograms loads the MCMS program artifacts into the given directory. -// -// Returns the path to the temporary test directory and a map of program names to IDs. -func LoadMCMSPrograms(t *testing.T, dir string) (string, map[string]string) { +// programIDMu serializes Solana integration tests that mutate global gobinding +// program IDs via SetProgramID. solana-go bindings use process-wide state, so +// parallel package tests otherwise race and fail with "Program is not deployed". +var programIDMu sync.Mutex + +var ( + mcmsProgramsOnce sync.Once + mcmsProgramsPath string + mcmsProgramIDs map[string]string +) + +// sharedMCMSPrograms downloads MCMS Solana program artifacts once per test process +// and returns the shared cache directory plus program IDs. +func sharedMCMSPrograms(t *testing.T) (string, map[string]string) { t.Helper() - progIDs := loadProgramArtifacts(t, - solutils.MCMSProgramNames, downloadChainlinkCCIPProgramArtifacts, dir, - ) + mcmsProgramsOnce.Do(func() { + mcmsProgramsPath = programsCacheDir() + err := solutils.DownloadChainlinkCCIPProgramArtifacts(t.Context(), mcmsProgramsPath, "", nil) + require.NoError(t, err) + + mcmsProgramIDs = make(map[string]string, len(solutils.MCMSProgramNames)) + for _, name := range solutils.MCMSProgramNames { + id := solutils.GetProgramID(name) + require.NotEmpty(t, id, "program id not found for program name: %s", name) + require.FileExists(t, filepath.Join(mcmsProgramsPath, name+".so")) + mcmsProgramIDs[name] = id + } + }) + + return mcmsProgramsPath, copyProgramIDs(mcmsProgramIDs) +} + +func copyProgramIDs(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for name, id := range src { + dst[name] = id + } - return dir, progIDs + return dst } // PreloadMCMS provides a convenience function to preload the MCMS program artifacts and address @@ -31,53 +59,11 @@ func LoadMCMSPrograms(t *testing.T, dir string) (string, map[string]string) { func PreloadMCMS(t *testing.T, selector uint64) (string, map[string]string, *cldf.AddressBookMap) { t.Helper() - dir := t.TempDir() - - _, programIDs := LoadMCMSPrograms(t, dir) + programIDMu.Lock() + t.Cleanup(programIDMu.Unlock) + programsPath, programIDs := sharedMCMSPrograms(t) ab := PreloadAddressBookWithMCMSPrograms(t, selector) - return dir, programIDs, ab -} - -// loadProgramArtifacts is a helper function that loads program artifacts into a temporary test directory. -// It downloads artifacts using the provided download function and copies the specified programs. -// -// Returns the map of program names to IDs. -func loadProgramArtifacts(t *testing.T, programNames []string, downloadFn downloadFunc, targetDir string) map[string]string { - t.Helper() - - // Download the program artifacts using the provided download function - cachePath := downloadFn(t) - - progIDs := make(map[string]string, len(programNames)) - - // Copy the specific artifacts to the target directory and add the program ID to the map - for _, name := range programNames { - id := solutils.GetProgramID(name) - require.NotEmpty(t, id, "program id not found for program name: %s", name) - - src := filepath.Join(cachePath, name+".so") - dst := filepath.Join(targetDir, name+".so") - - func() { - srcFile, err := os.Open(src) - require.NoError(t, err) - defer srcFile.Close() - - dstFile, err := os.Create(dst) - require.NoError(t, err) - defer dstFile.Close() - - _, err = io.Copy(dstFile, srcFile) - require.NoError(t, err) - }() - - // Add the program ID to the map - progIDs[name] = id - t.Logf("copied solana program %s to %s", name, dst) - } - - // Return the path to the cached artifacts and the map of program IDs - return progIDs + return programsPath, programIDs, ab } 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..62e0f1a --- /dev/null +++ b/mcms/changesets/grant-role/changeset.go @@ -0,0 +1,152 @@ +package grantrole + +import ( + "errors" + "fmt" + "maps" + "slices" + + "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 := slices.Collect(maps.Keys(byFamily)) + 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 == "" { + return fmt.Errorf("chain %d grants[%d].addresses[%d]: address must not be empty", chainSelector, grantIdx, addrIdx) + } + 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) + } + 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..e8ebc4e --- /dev/null +++ b/mcms/changesets/grant-role/changeset_test.go @@ -0,0 +1,190 @@ +package grantrole + +import ( + "context" + "errors" + "fmt" + "testing" + + 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: []string{"0x0000000000000000000000000000000000000001"}, + }}, + }, + }, + } + + 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 := "0x0000000000000000000000000000000000000001" + 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: []string{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: "empty address", + input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ + chainselectors.TEST_90000001.Selector: {{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []string{""}, + }}, + }}}, + 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: []string{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: []string{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: []string{"0x0000000000000000000000000000000000000001"}, + }}, + }, + }, + }, + ) + 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: []string{"0x0000000000000000000000000000000000000001"}, + }}, + }, + }, + }) + 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..3ba2e90 --- /dev/null +++ b/mcms/changesets/grant-role/registry_test.go @@ -0,0 +1,73 @@ +package grantrole + +import ( + "testing" + "time" + + 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" + 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 := &opscontract.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 := "0x00000000000000000000000000000000000000aa" + + byFamily, err := groupByFamily(Input{ + MCMS: mcmsInput, + Cfg: Config{ + GrantsByChain: map[uint64][]RoleGrant{ + selector: {{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []string{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, []string{grantee}, byFamily[chainselectors.FamilyEVM][0].Grants[0].Addresses) + + _, err = groupByFamily(Input{ + Cfg: Config{ + GrantsByChain: map[uint64][]RoleGrant{ + 0: {{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []string{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..e815000 --- /dev/null +++ b/mcms/changesets/grant-role/types.go @@ -0,0 +1,52 @@ +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/operations" + mcmssdk "github.com/smartcontractkit/mcms/sdk" +) + +// RoleGrant grants one timelock role to multiple accounts. +type RoleGrant struct { + Role mcmssdk.TimelockRole `json:"role"` + Addresses []string `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 *opscontract.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 *opscontract.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..2645b31 100644 --- a/mcms/evm/grant-role/operation.go +++ b/mcms/evm/grant-role/operation.go @@ -1,33 +1,138 @@ 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" + "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 *opscontract.GasBoostConfig) operations.ExecuteOption[OpEVMGrantRoleInput, cldf_evm.Chain] { + if cfg == nil { + return operations.WithRetry[OpEVMGrantRoleInput, cldf_evm.Chain]() + } + + return opscontract.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..6d96997 --- /dev/null +++ b/mcms/evm/grant-role/sequence.go @@ -0,0 +1,185 @@ +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 + var addresses []string + + for _, grant := range in.Grants { + addresses, err = addressesNeedingGrant(b, chain, timelock, grant) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + 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: grantee, + } + if err = validateGrantRoleTarget(target); err != nil { + return sequenceutils.OnChainOutput{}, err + } + + var report operations.Report[OpEVMGrantRoleInput, opscontract.WriteOutput] + 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, + ), + ) + 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", grantee.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, +) ([]string, 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([]string, 0, len(grant.Addresses)) + for _, address := range grant.Addresses { + if slices.Contains(addressesWithRole, common.HexToAddress(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) +} diff --git a/mcms/evm/grant-role/sequence_test.go b/mcms/evm/grant-role/sequence_test.go new file mode 100644 index 0000000..7cecd71 --- /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").Hex() + + 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: []string{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) + }) + } +} + +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").Hex() + deps := grantrole.Deps{ + BlockChains: rt.Environment().BlockChains, + DataStore: rt.Environment().DataStore, + } + input := grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleProposer, + Addresses: []string{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) +} + +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").Hex() + pending := common.HexToAddress("0x00000000000000000000000000000000000000ee").Hex() + _, err := runEVMGrantRole(bundle, deps, grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleCanceller, + Addresses: []string{grantee}, + }}, + }) + require.NoError(t, err) + + needed, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ + Role: mcmssdk.TimelockRoleCanceller, + Addresses: []string{grantee, pending}, + }) + require.NoError(t, err) + require.Equal(t, []string{pending}, needed) + + adminNeeded, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ + Role: mcmssdk.TimelockRoleAdmin, + Addresses: []string{grantee}, + }) + require.NoError(t, err) + require.Equal(t, []string{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..dd089fe --- /dev/null +++ b/mcms/evm/grant-role/validate.go @@ -0,0 +1,108 @@ +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) + } + if err := validateGrantAddresses(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) + } + + 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 = parseEVMAddress(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) + } + if _, err = parseEVMAddress(timelockRef.Address); err != nil { + return fmt.Errorf("invalid timelock ref on chain %d: %w", in.ChainSelector, err) + } + + if _, err = reader.GetMCMSRef(env, in.ChainSelector, input); 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, input.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 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("address %q is not a valid EVM address", raw) + } + addr := common.HexToAddress(raw) + if addr == (common.Address{}) { + return common.Address{}, errors.New("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..f408e1e --- /dev/null +++ b/mcms/evm/grant-role/validate_test.go @@ -0,0 +1,324 @@ +package evmgrantrole + +import ( + "fmt" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "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: 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(t, 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 := "0x00000000000000000000000000000000000000aa" + + tests := []struct { + name string + setupEnv func(t *testing.T) cldf.Environment + chains []grantrole.SeqInput + wantErr string + }{ + { + name: "chain not in environment", + setupEnv: func(t *testing.T) cldf.Environment { + t.Helper() + return validateTestEnv(t, datastore.NewMemoryDataStore().Seal()) + }, + chains: []grantrole.SeqInput{{ + ChainSelector: chainselectors.TEST_90000001.Selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []string{grantee}, + }}, + }}, + wantErr: fmt.Sprintf("EVM chain %d not found in environment", chainselectors.TEST_90000001.Selector), + }, + { + name: "unsupported role", + setupEnv: func(t *testing.T) cldf.Environment { + t.Helper() + return 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: []string{grantee}, + }}, + }}, + wantErr: fmt.Sprintf("chain %d: grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector), + }, + { + name: "success", + setupEnv: func(t *testing.T) cldf.Environment { + t.Helper() + return 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: []string{grantee}, + }}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateEVMChains(tt.setupEnv(t), 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) + require.NoError(t, err) + require.Equal(t, testTimelockAddr, addr.Hex()) + + _, 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 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(t, 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(t, ds.Seal()), + grantrole.SeqInput{ChainSelector: chainselectors.TEST_90000001.Selector}, + ) + require.EqualError(t, err, `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(t, 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(t *testing.T, ds datastore.DataStore) cldf.Environment { + t.Helper() + + return cldf.Environment{ + Logger: logger.Nop(), + DataStore: ds, + GetContext: t.Context, + } +} + +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: t.Context, + } +}