Skip to content

Commit 3d6a235

Browse files
authored
feat: grant role timelock - changesets and EVM sequence (#104)
Adds the refactored version of the grant role changeset, using the mcms lib on the operations and all the helpers and structure established by the new refactored changesets.
1 parent d52089d commit 3d6a235

16 files changed

Lines changed: 1559 additions & 32 deletions

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ require (
1616
github.com/samber/lo v1.53.0
1717
github.com/segmentio/ksuid v1.0.4
1818
github.com/smartcontractkit/ccip-owner-contracts v0.1.0
19-
github.com/smartcontractkit/chain-selectors v1.0.102
19+
github.com/smartcontractkit/chain-selectors v1.0.103
2020
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc
2121
github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc
2222
github.com/smartcontractkit/chainlink-deployments-framework v0.114.1
2323
github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828
2424
github.com/smartcontractkit/chainlink-protos/job-distributor v0.19.0
25-
github.com/smartcontractkit/mcms v0.48.1-0.20260616002102-085d81f76b05
25+
github.com/smartcontractkit/mcms v0.49.0
2626
github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9
2727
github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945
2828
github.com/spf13/cast v1.10.0

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -847,8 +847,8 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w
847847
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
848848
github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9LsA7vTMPv+0n7ClhSFnZFAk=
849849
github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY=
850-
github.com/smartcontractkit/chain-selectors v1.0.102 h1:qYP4+72HfvogCHR5ymwRFee36WH77514ZBj299SVCBA=
851-
github.com/smartcontractkit/chain-selectors v1.0.102/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
850+
github.com/smartcontractkit/chain-selectors v1.0.103 h1:PpvIinn1TIDT7nh/P5KLQunRk0Kp1IR6moP2IGvlP58=
851+
github.com/smartcontractkit/chain-selectors v1.0.103/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
852852
github.com/smartcontractkit/chainlink-aptos v0.0.0-20260430175646-295a7f9a1500 h1:045jrHCLI+MpeAyByJkyHbEjq0+aTPt04C7+sbsNNtw=
853853
github.com/smartcontractkit/chainlink-aptos v0.0.0-20260430175646-295a7f9a1500/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
854854
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
897897
github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA=
898898
github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d h1:PvXor5Fjer7FIONSqYXbpd1LkA14hWrlAyxXzOrC9t8=
899899
github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d/go.mod h1:PLdNK6GlqfxIWXzziPkU7dCAVlVFeYkyyW7AQY0R+4Q=
900-
github.com/smartcontractkit/mcms v0.48.1-0.20260616002102-085d81f76b05 h1:PVRKr9ra3ma9I+e1hWNqWnOwnYAzUMzZwPIzRDhAih4=
901-
github.com/smartcontractkit/mcms v0.48.1-0.20260616002102-085d81f76b05/go.mod h1:O5OnKQjuY/4VIOVBTRfBECBuWBM/eKvDF5UDDae8Eyc=
900+
github.com/smartcontractkit/mcms v0.49.0 h1:4Bav/bNsIc6pNlPhiNqYpvMyxDF9OpRgrWVtCa3BW+A=
901+
github.com/smartcontractkit/mcms v0.49.0/go.mod h1:tzyPA51qtN5us/2DS3kBIY7OVWZXSVKPOrOcYcKuvZI=
902902
github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9 h1:MOEuXYogv+RStASb8dWsyescu/xkigSi/Sv45NEjV7A=
903903
github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9/go.mod h1:iwy4yWFuK+1JeoIRTaSOA9pl+8Kf//26zezxEXrAQEQ=
904904
github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 h1:zxcODLrFytOKmAd8ty8S/XK6WcIEJEgRBaL7sY/7l4Y=
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Package all blank-imports built-in MCMS grant-role families and readers.
2+
package all
3+
4+
import (
5+
_ "github.com/smartcontractkit/cld-changesets/mcms/evm/grant-role"
6+
_ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers"
7+
)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package grantrole
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"maps"
7+
"slices"
8+
9+
"github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils"
10+
cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore"
11+
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
12+
13+
"github.com/smartcontractkit/cld-changesets/internal/maputil"
14+
)
15+
16+
var _ cldf.ChangeSetV2[Input] = Changeset{}
17+
18+
// Changeset grants RBACTimelock roles across configured chains.
19+
type Changeset struct{}
20+
21+
func (Changeset) VerifyPreconditions(env cldf.Environment, input Input) error {
22+
if env.DataStore == nil {
23+
return errors.New("datastore is required for grant-role")
24+
}
25+
if input.MCMS != nil {
26+
if err := input.MCMS.Validate(); err != nil {
27+
return fmt.Errorf("invalid MCMS timelock proposal input: %w", err)
28+
}
29+
}
30+
if len(input.Cfg.GrantsByChain) == 0 {
31+
return errors.New("no role grants provided")
32+
}
33+
if err := validateGrants(input.Cfg.GrantsByChain); err != nil {
34+
return err
35+
}
36+
37+
byFamily, err := groupByFamily(input)
38+
if err != nil {
39+
return err
40+
}
41+
42+
families := slices.Collect(maps.Keys(byFamily))
43+
slices.Sort(families)
44+
45+
for _, family := range families {
46+
if err := Registry.VerifyForFamily(family, env, byFamily[family]); err != nil {
47+
return err
48+
}
49+
}
50+
51+
return nil
52+
}
53+
54+
func (Changeset) Apply(env cldf.Environment, input Input) (cldf.ChangesetOutput, error) {
55+
deps := Deps{
56+
BlockChains: env.BlockChains,
57+
DataStore: env.DataStore,
58+
}
59+
60+
var agg sequenceutils.OnChainOutput
61+
for _, chainSelector := range maputil.SortedMapKeys(input.Cfg.GrantsByChain) {
62+
grants := input.Cfg.GrantsByChain[chainSelector]
63+
64+
seq, seqErr := Registry.SequenceForChainSelector(chainSelector)
65+
if seqErr != nil {
66+
return buildOutput(env, input.MCMS, agg, fmt.Errorf("chain selector %d: %w", chainSelector, seqErr))
67+
}
68+
69+
var mergeErr error
70+
agg, mergeErr = sequenceutils.ExecuteOnChainSequenceAndMerge(
71+
env.OperationsBundle,
72+
deps,
73+
seq,
74+
SeqInput{
75+
ChainSelector: chainSelector,
76+
Grants: grants,
77+
MCMS: input.MCMS,
78+
GasBoostConfig: input.Cfg.GasBoostConfig,
79+
},
80+
agg,
81+
)
82+
if mergeErr != nil {
83+
return buildOutput(env, input.MCMS, agg, mergeErr)
84+
}
85+
}
86+
87+
return buildOutput(env, input.MCMS, agg, nil)
88+
}
89+
90+
func buildOutput(
91+
env cldf.Environment,
92+
mcmsInput *cldf.MCMSTimelockProposalInput,
93+
agg sequenceutils.OnChainOutput,
94+
err error,
95+
) (cldf.ChangesetOutput, error) {
96+
ds := cldfdatastore.NewMemoryDataStore()
97+
if metaErr := ds.WriteMetadata(agg.Metadata); metaErr != nil {
98+
return cldf.ChangesetOutput{DataStore: ds},
99+
fmt.Errorf("write metadata to datastore: %w", metaErr)
100+
}
101+
102+
partialOutput := cldf.ChangesetOutput{DataStore: ds}
103+
if err != nil {
104+
return partialOutput, err
105+
}
106+
107+
builder := cldf.NewOutputBuilder(env, ds)
108+
if mcmsInput != nil {
109+
builder = builder.WithTimelockProposal(*mcmsInput, agg.BatchOps)
110+
}
111+
112+
out, buildErr := builder.Build()
113+
if buildErr != nil {
114+
return out, fmt.Errorf("build changeset output: %w", buildErr)
115+
}
116+
117+
if mcmsInput != nil && len(out.MCMSTimelockProposals) > 0 {
118+
env.Logger.Infow("GrantRole proposal created", "proposalCount", len(out.MCMSTimelockProposals))
119+
}
120+
121+
return out, nil
122+
}
123+
124+
func validateGrants(grantsByChain map[uint64][]RoleGrant) error {
125+
for chainSelector, grants := range grantsByChain {
126+
if len(grants) == 0 {
127+
return fmt.Errorf("chain %d: no role grants provided", chainSelector)
128+
}
129+
seen := make(map[string]struct{})
130+
for grantIdx, grant := range grants {
131+
if !grant.Role.Valid() {
132+
return fmt.Errorf("chain %d grants[%d]: unsupported timelock role %s", chainSelector, grantIdx, grant.Role.String())
133+
}
134+
if len(grant.Addresses) == 0 {
135+
return fmt.Errorf("chain %d grants[%d]: no addresses provided", chainSelector, grantIdx)
136+
}
137+
for addrIdx, addr := range grant.Addresses {
138+
if addr == "" {
139+
return fmt.Errorf("chain %d grants[%d].addresses[%d]: address must not be empty", chainSelector, grantIdx, addrIdx)
140+
}
141+
key := grant.Role.String() + ":" + addr
142+
if _, ok := seen[key]; ok {
143+
return fmt.Errorf("chain %d grants[%d].addresses[%d]: duplicate grant for role %s and address %s",
144+
chainSelector, grantIdx, addrIdx, grant.Role.String(), addr)
145+
}
146+
seen[key] = struct{}{}
147+
}
148+
}
149+
}
150+
151+
return nil
152+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package grantrole
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"testing"
8+
9+
chainselectors "github.com/smartcontractkit/chain-selectors"
10+
cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain"
11+
"github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils"
12+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
13+
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
14+
"github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr"
15+
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
16+
mcmssdk "github.com/smartcontractkit/mcms/sdk"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func testEnvironment(t *testing.T, ds datastore.DataStore) cldf.Environment {
21+
t.Helper()
22+
23+
return *cldf.NewEnvironment(
24+
"test",
25+
logger.Test(t),
26+
nil,
27+
ds,
28+
nil,
29+
nil,
30+
func() context.Context { return t.Context() },
31+
ocr.OCRSecrets{},
32+
cldf_chain.NewBlockChains(nil),
33+
)
34+
}
35+
36+
func TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) {
37+
t.Parallel()
38+
39+
input := Input{
40+
Cfg: Config{
41+
GrantsByChain: map[uint64][]RoleGrant{
42+
chainselectors.TEST_90000001.Selector: {{
43+
Role: mcmssdk.TimelockRoleProposer,
44+
Addresses: []string{"0x0000000000000000000000000000000000000001"},
45+
}},
46+
},
47+
},
48+
}
49+
50+
err := Changeset{}.VerifyPreconditions(testEnvironment(t, nil), input)
51+
require.EqualError(t, err, "datastore is required for grant-role")
52+
}
53+
54+
func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) {
55+
t.Parallel()
56+
57+
validAddress := "0x0000000000000000000000000000000000000001"
58+
tests := []struct {
59+
name string
60+
input Input
61+
wantErr string
62+
}{
63+
{
64+
name: "no grants",
65+
input: Input{},
66+
wantErr: "no role grants provided",
67+
},
68+
{
69+
name: "empty grants for chain",
70+
input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{
71+
chainselectors.TEST_90000001.Selector: {},
72+
}}},
73+
wantErr: fmt.Sprintf("chain %d: no role grants provided", chainselectors.TEST_90000001.Selector),
74+
},
75+
{
76+
name: "unsupported role",
77+
input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{
78+
chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRole(99), Addresses: []string{validAddress}}},
79+
}}},
80+
wantErr: fmt.Sprintf("chain %d grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector),
81+
},
82+
{
83+
name: "no addresses",
84+
input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{
85+
chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRoleProposer}},
86+
}}},
87+
wantErr: fmt.Sprintf("chain %d grants[0]: no addresses provided", chainselectors.TEST_90000001.Selector),
88+
},
89+
{
90+
name: "empty address",
91+
input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{
92+
chainselectors.TEST_90000001.Selector: {{
93+
Role: mcmssdk.TimelockRoleProposer,
94+
Addresses: []string{""},
95+
}},
96+
}}},
97+
wantErr: fmt.Sprintf("chain %d grants[0].addresses[0]: address must not be empty", chainselectors.TEST_90000001.Selector),
98+
},
99+
{
100+
name: "duplicate grant",
101+
input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{
102+
chainselectors.TEST_90000001.Selector: {{
103+
Role: mcmssdk.TimelockRoleProposer,
104+
Addresses: []string{validAddress, validAddress},
105+
}},
106+
}}},
107+
wantErr: fmt.Sprintf("chain %d grants[0].addresses[1]: duplicate grant for role Proposer and address 0x0000000000000000000000000000000000000001", chainselectors.TEST_90000001.Selector),
108+
},
109+
{
110+
name: "invalid MCMS input",
111+
input: Input{
112+
MCMS: &cldf.MCMSTimelockProposalInput{},
113+
Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{
114+
chainselectors.TEST_90000001.Selector: {{
115+
Role: mcmssdk.TimelockRoleProposer,
116+
Addresses: []string{validAddress},
117+
}},
118+
}},
119+
},
120+
wantErr: `invalid MCMS timelock proposal input: invalid MCMS timelock proposal input: invalid timelock action ""`,
121+
},
122+
}
123+
124+
env := testEnvironment(t, datastore.NewMemoryDataStore().Seal())
125+
for _, tt := range tests {
126+
t.Run(tt.name, func(t *testing.T) {
127+
t.Parallel()
128+
129+
err := Changeset{}.VerifyPreconditions(env, tt.input)
130+
require.EqualError(t, err, tt.wantErr)
131+
})
132+
}
133+
}
134+
135+
func TestChangeset_VerifyPreconditions_unsupportedFamily(t *testing.T) {
136+
t.Parallel()
137+
138+
err := Changeset{}.VerifyPreconditions(
139+
testEnvironment(t, datastore.NewMemoryDataStore().Seal()),
140+
Input{
141+
Cfg: Config{
142+
GrantsByChain: map[uint64][]RoleGrant{
143+
chainselectors.APTOS_MAINNET.Selector: {{
144+
Role: mcmssdk.TimelockRoleProposer,
145+
Addresses: []string{"0x0000000000000000000000000000000000000001"},
146+
}},
147+
},
148+
},
149+
},
150+
)
151+
require.EqualError(t, err, `mcms grant-role: no sequence registered for family "aptos" (none registered)`)
152+
}
153+
154+
func TestChangeset_Apply_unsupportedFamily(t *testing.T) {
155+
t.Parallel()
156+
157+
_, err := Changeset{}.Apply(cldf.Environment{}, Input{
158+
Cfg: Config{
159+
GrantsByChain: map[uint64][]RoleGrant{
160+
chainselectors.APTOS_MAINNET.Selector: {{
161+
Role: mcmssdk.TimelockRoleProposer,
162+
Addresses: []string{"0x0000000000000000000000000000000000000001"},
163+
}},
164+
},
165+
},
166+
})
167+
require.EqualError(t, err, fmt.Sprintf(`chain selector %d: mcms grant-role: no sequence registered for family "aptos" (none registered)`, chainselectors.APTOS_MAINNET.Selector))
168+
}
169+
170+
func TestBuildOutput(t *testing.T) {
171+
t.Parallel()
172+
173+
env := testEnvironment(t, datastore.NewMemoryDataStore().Seal())
174+
175+
t.Run("success without MCMS", func(t *testing.T) {
176+
t.Parallel()
177+
178+
out, err := buildOutput(env, nil, sequenceutils.OnChainOutput{}, nil)
179+
require.NoError(t, err)
180+
require.NotNil(t, out.DataStore)
181+
})
182+
183+
t.Run("returns partial output on sequence error", func(t *testing.T) {
184+
t.Parallel()
185+
186+
out, err := buildOutput(env, nil, sequenceutils.OnChainOutput{}, errors.New("sequence failed"))
187+
require.EqualError(t, err, "sequence failed")
188+
require.NotNil(t, out.DataStore)
189+
})
190+
}

0 commit comments

Comments
 (0)