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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
7 changes: 7 additions & 0 deletions mcms/changesets/grant-role/all/wire.go
Original file line number Diff line number Diff line change
@@ -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"
)
154 changes: 154 additions & 0 deletions mcms/changesets/grant-role/changeset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package grantrole

import (
"errors"
"fmt"
"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 := make([]string, 0, len(byFamily))
for family := range byFamily {
families = append(families, family)
}
slices.Sort(families)
Comment on lines +41 to +45

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could use map.Slice with map.Keys


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
}
190 changes: 190 additions & 0 deletions mcms/changesets/grant-role/changeset_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
Loading