From 40f7ea7ba51579a66e46ff2006b0cd414a119f80 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 26 Jun 2026 18:48:42 -0600 Subject: [PATCH 1/2] fix: use generic string type instead of addresses to keep inputs chain agnostic. --- mcms/changesets/grant-role/changeset.go | 9 +++--- mcms/changesets/grant-role/changeset_test.go | 21 ++++++------- mcms/changesets/grant-role/registry_test.go | 9 +++--- mcms/changesets/grant-role/types.go | 3 +- mcms/evm/grant-role/sequence.go | 19 ++++++----- mcms/evm/grant-role/sequence_test.go | 26 +++++++-------- mcms/evm/grant-role/validate.go | 25 +++++++++++++-- mcms/evm/grant-role/validate_test.go | 33 ++++++++++++-------- 8 files changed, 86 insertions(+), 59 deletions(-) diff --git a/mcms/changesets/grant-role/changeset.go b/mcms/changesets/grant-role/changeset.go index 390ce55..e2646e9 100644 --- a/mcms/changesets/grant-role/changeset.go +++ b/mcms/changesets/grant-role/changeset.go @@ -5,7 +5,6 @@ import ( "fmt" "slices" - "github.com/ethereum/go-ethereum/common" "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -138,13 +137,13 @@ func validateGrants(grantsByChain map[uint64][]RoleGrant) error { return fmt.Errorf("chain %d grants[%d]: no addresses provided", chainSelector, grantIdx) } for addrIdx, addr := range grant.Addresses { - if addr == (common.Address{}) { - return fmt.Errorf("chain %d grants[%d].addresses[%d]: address must not be zero", chainSelector, grantIdx, addrIdx) + if addr == "" { + return fmt.Errorf("chain %d grants[%d].addresses[%d]: address must not be empty", chainSelector, grantIdx, addrIdx) } - key := grant.Role.String() + ":" + addr.Hex() + key := grant.Role.String() + ":" + addr if _, ok := seen[key]; ok { return fmt.Errorf("chain %d grants[%d].addresses[%d]: duplicate grant for role %s and address %s", - chainSelector, grantIdx, addrIdx, grant.Role.String(), addr.Hex()) + chainSelector, grantIdx, addrIdx, grant.Role.String(), addr) } seen[key] = struct{}{} } diff --git a/mcms/changesets/grant-role/changeset_test.go b/mcms/changesets/grant-role/changeset_test.go index a7b2a12..e8ebc4e 100644 --- a/mcms/changesets/grant-role/changeset_test.go +++ b/mcms/changesets/grant-role/changeset_test.go @@ -6,7 +6,6 @@ import ( "fmt" "testing" - "github.com/ethereum/go-ethereum/common" chainselectors "github.com/smartcontractkit/chain-selectors" cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" @@ -42,7 +41,7 @@ func TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{common.HexToAddress("0x1")}, + Addresses: []string{"0x0000000000000000000000000000000000000001"}, }}, }, }, @@ -55,7 +54,7 @@ func TestChangeset_VerifyPreconditions_NoDatastore(t *testing.T) { func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { t.Parallel() - validAddress := common.HexToAddress("0x1") + validAddress := "0x0000000000000000000000000000000000000001" tests := []struct { name string input Input @@ -76,7 +75,7 @@ func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { { name: "unsupported role", input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ - chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRole(99), Addresses: []common.Address{validAddress}}}, + chainselectors.TEST_90000001.Selector: {{Role: mcmssdk.TimelockRole(99), Addresses: []string{validAddress}}}, }}}, wantErr: fmt.Sprintf("chain %d grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector), }, @@ -88,21 +87,21 @@ func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { wantErr: fmt.Sprintf("chain %d grants[0]: no addresses provided", chainselectors.TEST_90000001.Selector), }, { - name: "zero address", + name: "empty address", input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{{}}, + Addresses: []string{""}, }}, }}}, - wantErr: fmt.Sprintf("chain %d grants[0].addresses[0]: address must not be zero", chainselectors.TEST_90000001.Selector), + wantErr: fmt.Sprintf("chain %d grants[0].addresses[0]: address must not be empty", chainselectors.TEST_90000001.Selector), }, { name: "duplicate grant", input: Input{Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{validAddress, validAddress}, + Addresses: []string{validAddress, validAddress}, }}, }}}, wantErr: fmt.Sprintf("chain %d grants[0].addresses[1]: duplicate grant for role Proposer and address 0x0000000000000000000000000000000000000001", chainselectors.TEST_90000001.Selector), @@ -114,7 +113,7 @@ func TestChangeset_VerifyPreconditions_InvalidInput(t *testing.T) { Cfg: Config{GrantsByChain: map[uint64][]RoleGrant{ chainselectors.TEST_90000001.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{validAddress}, + Addresses: []string{validAddress}, }}, }}, }, @@ -143,7 +142,7 @@ func TestChangeset_VerifyPreconditions_unsupportedFamily(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ chainselectors.APTOS_MAINNET.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{common.HexToAddress("0x1")}, + Addresses: []string{"0x0000000000000000000000000000000000000001"}, }}, }, }, @@ -160,7 +159,7 @@ func TestChangeset_Apply_unsupportedFamily(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ chainselectors.APTOS_MAINNET.Selector: {{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{common.HexToAddress("0x1")}, + Addresses: []string{"0x0000000000000000000000000000000000000001"}, }}, }, }, diff --git a/mcms/changesets/grant-role/registry_test.go b/mcms/changesets/grant-role/registry_test.go index 48f5733..96a9b7d 100644 --- a/mcms/changesets/grant-role/registry_test.go +++ b/mcms/changesets/grant-role/registry_test.go @@ -4,7 +4,6 @@ import ( "testing" "time" - "github.com/ethereum/go-ethereum/common" chainselectors "github.com/smartcontractkit/chain-selectors" cldfchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" @@ -25,7 +24,7 @@ func TestGroupByFamily(t *testing.T) { ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp TimelockDelay: mcmstypes.NewDuration(time.Second), } - grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + grantee := "0x00000000000000000000000000000000000000aa" byFamily, err := groupByFamily(Input{ MCMS: mcmsInput, @@ -33,7 +32,7 @@ func TestGroupByFamily(t *testing.T) { GrantsByChain: map[uint64][]RoleGrant{ selector: {{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }, GasBoostConfig: gasBoost, @@ -44,14 +43,14 @@ func TestGroupByFamily(t *testing.T) { require.Equal(t, selector, byFamily[chainselectors.FamilyEVM][0].ChainSelector) require.Equal(t, mcmsInput, byFamily[chainselectors.FamilyEVM][0].MCMS) require.Equal(t, gasBoost, byFamily[chainselectors.FamilyEVM][0].GasBoostConfig) - require.Equal(t, []common.Address{grantee}, byFamily[chainselectors.FamilyEVM][0].Grants[0].Addresses) + require.Equal(t, []string{grantee}, byFamily[chainselectors.FamilyEVM][0].Grants[0].Addresses) _, err = groupByFamily(Input{ Cfg: Config{ GrantsByChain: map[uint64][]RoleGrant{ 0: {{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }, }, diff --git a/mcms/changesets/grant-role/types.go b/mcms/changesets/grant-role/types.go index 0f3ae01..fb40ce2 100644 --- a/mcms/changesets/grant-role/types.go +++ b/mcms/changesets/grant-role/types.go @@ -1,7 +1,6 @@ package grantrole import ( - "github.com/ethereum/go-ethereum/common" "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" @@ -14,7 +13,7 @@ import ( // RoleGrant grants one timelock role to multiple accounts. type RoleGrant struct { Role mcmssdk.TimelockRole `json:"role"` - Addresses []common.Address `json:"addresses"` + Addresses []string `json:"addresses"` } // Config selects timelock role grants by chain selector. diff --git a/mcms/evm/grant-role/sequence.go b/mcms/evm/grant-role/sequence.go index 90bf3c7..e18b9f7 100644 --- a/mcms/evm/grant-role/sequence.go +++ b/mcms/evm/grant-role/sequence.go @@ -44,7 +44,7 @@ func runEVMGrantRole( useMCMS := in.MCMS != nil var writes []opscontract.WriteOutput - var addresses []common.Address + var addresses []string for _, grant := range in.Grants { addresses, err = addressesNeedingGrant(b, chain, timelock, grant) @@ -53,10 +53,15 @@ func runEVMGrantRole( } for _, address := range addresses { + grantee, parseErr := parseEVMAddress(address) + if parseErr != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("parse grantee address %q: %w", address, parseErr) + } + target := GrantRoleTarget{ Timelock: timelock, Role: grant.Role, - Address: address, + Address: grantee, } if err = validateGrantRoleTarget(target); err != nil { return sequenceutils.OnChainOutput{}, err @@ -73,7 +78,7 @@ func runEVMGrantRole( }, retryGrantRoleWithGasBoost(in.GasBoostConfig), operations.WithIdempotencyKey[OpEVMGrantRoleInput, cldf_evm.Chain]( - strconv.FormatUint(chain.Selector, 10)+":"+timelock.Hex()+":"+grant.Role.String()+":"+address.Hex(), + strconv.FormatUint(chain.Selector, 10)+":"+timelock.Hex()+":"+grant.Role.String()+":"+address, ), ) if err != nil { @@ -90,7 +95,7 @@ func runEVMGrantRole( "role", grant.Role.String(), "chainSelector", chain.Selector, "timelock", timelock.Hex(), - "address", address.Hex(), + "address", grantee.Hex(), "txHash", report.Output.ExecInfo.Hash, ) } @@ -118,7 +123,7 @@ func addressesNeedingGrant( chain cldf_evm.Chain, timelock common.Address, grant grantrole.RoleGrant, -) ([]common.Address, error) { +) ([]string, error) { addressesWithRole, err := addressesForRole(b, chain, timelock, grant.Role) if err != nil { return nil, err @@ -127,9 +132,9 @@ func addressesNeedingGrant( return grant.Addresses, nil } - out := make([]common.Address, 0, len(grant.Addresses)) + out := make([]string, 0, len(grant.Addresses)) for _, address := range grant.Addresses { - if slices.Contains(addressesWithRole, address.Hex()) { + if slices.Contains(addressesWithRole, common.HexToAddress(address).Hex()) { continue } out = append(out, address) diff --git a/mcms/evm/grant-role/sequence_test.go b/mcms/evm/grant-role/sequence_test.go index 445e83e..7cecd71 100644 --- a/mcms/evm/grant-role/sequence_test.go +++ b/mcms/evm/grant-role/sequence_test.go @@ -47,7 +47,7 @@ func TestRunEVMGrantRole(t *testing.T) { rt := newEVMGrantRoleRuntime(t, selector) refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) chain := rt.Environment().BlockChains.EVMChains()[selector] - grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa").Hex() var mcmsInput *cldf.MCMSTimelockProposalInput if tt.useMCMS { @@ -68,7 +68,7 @@ func TestRunEVMGrantRole(t *testing.T) { ChainSelector: selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, MCMS: mcmsInput, }, @@ -88,7 +88,7 @@ func TestRunEVMGrantRole(t *testing.T) { executors, err := mcmsevm.NewTimelockInspector(chain.Client).GetExecutors(t.Context(), refs.Timelock.Hex()) require.NoError(t, err) - require.Contains(t, executors, grantee.Hex()) + require.Contains(t, executors, grantee) }) } } @@ -100,7 +100,7 @@ func TestRunEVMGrantRole_idempotent(t *testing.T) { rt := newEVMGrantRoleRuntime(t, selector) refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) chain := rt.Environment().BlockChains.EVMChains()[selector] - grantee := common.HexToAddress("0x00000000000000000000000000000000000000bb") + grantee := common.HexToAddress("0x00000000000000000000000000000000000000bb").Hex() deps := grantrole.Deps{ BlockChains: rt.Environment().BlockChains, DataStore: rt.Environment().DataStore, @@ -109,7 +109,7 @@ func TestRunEVMGrantRole_idempotent(t *testing.T) { ChainSelector: selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleProposer, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, } @@ -122,7 +122,7 @@ func TestRunEVMGrantRole_idempotent(t *testing.T) { proposers, err := mcmsevm.NewTimelockInspector(chain.Client).GetProposers(t.Context(), refs.Timelock.Hex()) require.NoError(t, err) - require.Contains(t, proposers, grantee.Hex()) + require.Contains(t, proposers, grantee) } func TestAddressesNeedingGrant(t *testing.T) { @@ -138,30 +138,30 @@ func TestAddressesNeedingGrant(t *testing.T) { DataStore: rt.Environment().DataStore, } - grantee := common.HexToAddress("0x00000000000000000000000000000000000000dd") - pending := common.HexToAddress("0x00000000000000000000000000000000000000ee") + grantee := common.HexToAddress("0x00000000000000000000000000000000000000dd").Hex() + pending := common.HexToAddress("0x00000000000000000000000000000000000000ee").Hex() _, err := runEVMGrantRole(bundle, deps, grantrole.SeqInput{ ChainSelector: selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleCanceller, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }) require.NoError(t, err) needed, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ Role: mcmssdk.TimelockRoleCanceller, - Addresses: []common.Address{grantee, pending}, + Addresses: []string{grantee, pending}, }) require.NoError(t, err) - require.Equal(t, []common.Address{pending}, needed) + require.Equal(t, []string{pending}, needed) adminNeeded, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ Role: mcmssdk.TimelockRoleAdmin, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }) require.NoError(t, err) - require.Equal(t, []common.Address{grantee}, adminNeeded) + require.Equal(t, []string{grantee}, adminNeeded) _, err = addressesForRole(bundle, chain, refs.Timelock, mcmssdk.TimelockRole(99)) require.EqualError(t, err, "unsupported timelock role Unknown") diff --git a/mcms/evm/grant-role/validate.go b/mcms/evm/grant-role/validate.go index 76967d4..034a823 100644 --- a/mcms/evm/grant-role/validate.go +++ b/mcms/evm/grant-role/validate.go @@ -23,6 +23,9 @@ func validateEVMChains(env cldf.Environment, chains []grantrole.SeqInput) error if err := validateRoles(in); err != nil { return fmt.Errorf("chain %d: %w", in.ChainSelector, err) } + if err := validateGrantAddresses(in); err != nil { + return fmt.Errorf("chain %d: %w", in.ChainSelector, err) + } } return nil @@ -74,14 +77,30 @@ func validateRoles(in grantrole.SeqInput) error { return nil } -func parseTimelockAddress(raw string) (common.Address, error) { +func validateGrantAddresses(in grantrole.SeqInput) error { + for i, grant := range in.Grants { + for j, addr := range grant.Addresses { + if _, err := parseEVMAddress(addr); err != nil { + return fmt.Errorf("grants[%d].addresses[%d]: %w", i, j, err) + } + } + } + + return nil +} + +func parseEVMAddress(raw string) (common.Address, error) { if !common.IsHexAddress(raw) { - return common.Address{}, fmt.Errorf("timelock address %q is not a valid EVM address", raw) + return common.Address{}, fmt.Errorf("address %q is not a valid EVM address", raw) } addr := common.HexToAddress(raw) if addr == (common.Address{}) { - return common.Address{}, errors.New("timelock address must not be zero") + return common.Address{}, errors.New("address must not be zero") } return addr, nil } + +func parseTimelockAddress(raw string) (common.Address, error) { + return parseEVMAddress(raw) +} diff --git a/mcms/evm/grant-role/validate_test.go b/mcms/evm/grant-role/validate_test.go index 6dc3557..ff59f4d 100644 --- a/mcms/evm/grant-role/validate_test.go +++ b/mcms/evm/grant-role/validate_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" chainselectors "github.com/smartcontractkit/chain-selectors" @@ -108,7 +107,7 @@ func TestValidateMCMSRefs(t *testing.T) { }, mcms: &mcmsInput, wantErr: fmt.Sprintf( - `invalid timelock ref on chain %d: timelock address "not-an-address" is not a valid EVM address`, + `invalid timelock ref on chain %d: address "not-an-address" is not a valid EVM address`, chainselectors.TEST_90000001.Selector, ), }, @@ -144,7 +143,7 @@ func TestValidateEVMChains(t *testing.T) { t.Parallel() version := semver.MustParse("1.0.0") - grantee := common.HexToAddress("0x00000000000000000000000000000000000000aa") + grantee := "0x00000000000000000000000000000000000000aa" tests := []struct { name string @@ -159,7 +158,7 @@ func TestValidateEVMChains(t *testing.T) { ChainSelector: chainselectors.TEST_90000001.Selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }}, wantErr: fmt.Sprintf("EVM chain %d not found in environment", chainselectors.TEST_90000001.Selector), @@ -173,7 +172,7 @@ func TestValidateEVMChains(t *testing.T) { ChainSelector: chainselectors.TEST_90000001.Selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRole(99), - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }}, wantErr: fmt.Sprintf("chain %d: grants[0]: unsupported timelock role Unknown", chainselectors.TEST_90000001.Selector), @@ -187,7 +186,7 @@ func TestValidateEVMChains(t *testing.T) { ChainSelector: chainselectors.TEST_90000001.Selector, Grants: []grantrole.RoleGrant{{ Role: mcmssdk.TimelockRoleExecutor, - Addresses: []common.Address{grantee}, + Addresses: []string{grantee}, }}, }}, }, @@ -208,18 +207,26 @@ func TestValidateEVMChains(t *testing.T) { } } -func TestParseTimelockAddress(t *testing.T) { +func TestParseEVMAddress(t *testing.T) { t.Parallel() - addr, err := parseTimelockAddress(testTimelockAddr) + addr, err := parseEVMAddress(testTimelockAddr) require.NoError(t, err) require.Equal(t, testTimelockAddr, addr.Hex()) - _, err = parseTimelockAddress("not-an-address") - require.EqualError(t, err, `timelock address "not-an-address" is not a valid EVM address`) + _, err = parseEVMAddress("not-an-address") + require.EqualError(t, err, `address "not-an-address" is not a valid EVM address`) + + _, err = parseEVMAddress("0x0000000000000000000000000000000000000000") + require.EqualError(t, err, "address must not be zero") +} + +func TestParseTimelockAddress(t *testing.T) { + t.Parallel() - _, err = parseTimelockAddress("0x0000000000000000000000000000000000000000") - require.EqualError(t, err, "timelock address must not be zero") + addr, err := parseTimelockAddress(testTimelockAddr) + require.NoError(t, err) + require.Equal(t, testTimelockAddr, addr.Hex()) } func TestTimelockAddress(t *testing.T) { @@ -250,7 +257,7 @@ func TestTimelockAddress(t *testing.T) { validateTestEnv(ds.Seal()), grantrole.SeqInput{ChainSelector: chainselectors.TEST_90000001.Selector}, ) - require.EqualError(t, err, `timelock address "not-an-address" is not a valid EVM address`) + require.EqualError(t, err, `address "not-an-address" is not a valid EVM address`) }) t.Run("success", func(t *testing.T) { From 196404f49a57dc7664835df4a2e3e74f8a94de34 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 26 Jun 2026 20:22:30 -0600 Subject: [PATCH 2/2] feat: add solana grant role implementation sequence and operation --- go.mod | 3 + go.sum | 2 - mcms/changesets/grant-role/all/wire.go | 2 + mcms/changesets/grant-role/roles_helpers.go | 70 +++++ .../grant-role/roles_helpers_test.go | 68 +++++ mcms/evm/grant-role/sequence.go | 59 +--- mcms/evm/grant-role/sequence_test.go | 37 ++- mcms/solana/grant-role/helpers_test.go | 184 +++++++++++++ mcms/solana/grant-role/operation.go | 134 +++++++++ mcms/solana/grant-role/operation_test.go | 176 ++++++++++++ mcms/solana/grant-role/register.go | 41 +++ mcms/solana/grant-role/register_test.go | 102 +++++++ mcms/solana/grant-role/sequence.go | 184 +++++++++++++ mcms/solana/grant-role/sequence_test.go | 257 ++++++++++++++++++ mcms/solana/grant-role/validate.go | 120 ++++++++ mcms/solana/grant-role/validate_test.go | 201 ++++++++++++++ 16 files changed, 1572 insertions(+), 68 deletions(-) create mode 100644 mcms/changesets/grant-role/roles_helpers.go create mode 100644 mcms/changesets/grant-role/roles_helpers_test.go create mode 100644 mcms/solana/grant-role/helpers_test.go create mode 100644 mcms/solana/grant-role/operation.go create mode 100644 mcms/solana/grant-role/operation_test.go create mode 100644 mcms/solana/grant-role/register.go create mode 100644 mcms/solana/grant-role/register_test.go create mode 100644 mcms/solana/grant-role/sequence.go create mode 100644 mcms/solana/grant-role/sequence_test.go create mode 100644 mcms/solana/grant-role/validate.go create mode 100644 mcms/solana/grant-role/validate_test.go diff --git a/go.mod b/go.mod index 6da2aa1..e7eb6af 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.26.2 replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014120029-d73d15cc23f7 +// TODO: remove once mcms lib is released +replace github.com/smartcontractkit/mcms => /Users/pablo/mcms + require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/aptos-labs/aptos-go-sdk v1.13.0 diff --git a/go.sum b/go.sum index c65d2ac..dc7710b 100644 --- a/go.sum +++ b/go.sum @@ -897,8 +897,6 @@ 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.49.0 h1:4Bav/bNsIc6pNlPhiNqYpvMyxDF9OpRgrWVtCa3BW+A= -github.com/smartcontractkit/mcms v0.49.0/go.mod h1:tzyPA51qtN5us/2DS3kBIY7OVWZXSVKPOrOcYcKuvZI= github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9 h1:MOEuXYogv+RStASb8dWsyescu/xkigSi/Sv45NEjV7A= github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9/go.mod h1:iwy4yWFuK+1JeoIRTaSOA9pl+8Kf//26zezxEXrAQEQ= github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 h1:zxcODLrFytOKmAd8ty8S/XK6WcIEJEgRBaL7sY/7l4Y= diff --git a/mcms/changesets/grant-role/all/wire.go b/mcms/changesets/grant-role/all/wire.go index ed23509..e272944 100644 --- a/mcms/changesets/grant-role/all/wire.go +++ b/mcms/changesets/grant-role/all/wire.go @@ -4,4 +4,6 @@ package all import ( _ "github.com/smartcontractkit/cld-changesets/mcms/evm/grant-role" _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/grant-role" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" ) diff --git a/mcms/changesets/grant-role/roles_helpers.go b/mcms/changesets/grant-role/roles_helpers.go new file mode 100644 index 0000000..6d48129 --- /dev/null +++ b/mcms/changesets/grant-role/roles_helpers.go @@ -0,0 +1,70 @@ +package grantrole + +import ( + "context" + "fmt" + "slices" + + mcmssdk "github.com/smartcontractkit/mcms/sdk" +) + +// AddressesForRole returns accounts that currently hold role on the timelock. +// Admin returns nil without querying the chain. +func AddressesForRole( + ctx context.Context, + inspector mcmssdk.TimelockInspector, + timelockAddress string, + role mcmssdk.TimelockRole, +) ([]string, error) { + switch role { + case mcmssdk.TimelockRoleProposer: + return inspector.GetProposers(ctx, timelockAddress) + case mcmssdk.TimelockRoleCanceller: + return inspector.GetCancellers(ctx, timelockAddress) + case mcmssdk.TimelockRoleBypasser: + return inspector.GetBypassers(ctx, timelockAddress) + case mcmssdk.TimelockRoleExecutor: + return inspector.GetExecutors(ctx, timelockAddress) + case mcmssdk.TimelockRoleAdmin: + return nil, nil + default: + return nil, fmt.Errorf("unsupported timelock role %s", role.String()) + } +} + +// AddressesNeedingGrant returns grant addresses that do not yet hold the role. +// normalize canonicalizes addresses for comparison; pass nil to compare raw strings. +func AddressesNeedingGrant( + ctx context.Context, + inspector mcmssdk.TimelockInspector, + timelockAddress string, + grant RoleGrant, + normalize func(string) string, +) ([]string, error) { + addressesWithRole, err := AddressesForRole(ctx, inspector, timelockAddress, grant.Role) + if err != nil { + return nil, err + } + if len(addressesWithRole) == 0 { + return grant.Addresses, nil + } + + if normalize == nil { + normalize = func(address string) string { return address } + } + + normalizedExisting := make([]string, len(addressesWithRole)) + for i, address := range addressesWithRole { + normalizedExisting[i] = normalize(address) + } + + out := make([]string, 0, len(grant.Addresses)) + for _, address := range grant.Addresses { + if slices.Contains(normalizedExisting, normalize(address)) { + continue + } + out = append(out, address) + } + + return out, nil +} diff --git a/mcms/changesets/grant-role/roles_helpers_test.go b/mcms/changesets/grant-role/roles_helpers_test.go new file mode 100644 index 0000000..dbbacf0 --- /dev/null +++ b/mcms/changesets/grant-role/roles_helpers_test.go @@ -0,0 +1,68 @@ +package grantrole + +import ( + "testing" + + "github.com/stretchr/testify/require" + + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmssdkmocks "github.com/smartcontractkit/mcms/sdk/mocks" +) + +func TestAddressesForRole_unsupported(t *testing.T) { + t.Parallel() + + inspector := mcmssdkmocks.NewTimelockInspector(t) + + _, err := AddressesForRole(t.Context(), inspector, "timelock", mcmssdk.TimelockRole(99)) + require.EqualError(t, err, "unsupported timelock role Unknown") +} + +func TestAddressesForRole_admin(t *testing.T) { + t.Parallel() + + inspector := mcmssdkmocks.NewTimelockInspector(t) + + got, err := AddressesForRole(t.Context(), inspector, "timelock", mcmssdk.TimelockRoleAdmin) + require.NoError(t, err) + require.Nil(t, got) +} + +func TestAddressesNeedingGrant(t *testing.T) { + t.Parallel() + + ctx := t.Context() + timelock := "timelock" + + inspector := mcmssdkmocks.NewTimelockInspector(t) + inspector.EXPECT(). + GetCancellers(ctx, timelock). + Return([]string{"alice", "bob"}, nil) + + got, err := AddressesNeedingGrant( + ctx, + inspector, + timelock, + RoleGrant{ + Role: mcmssdk.TimelockRoleCanceller, + Addresses: []string{"alice", "carol"}, + }, + nil, + ) + require.NoError(t, err) + require.Equal(t, []string{"carol"}, got) + + adminInspector := mcmssdkmocks.NewTimelockInspector(t) + all, err := AddressesNeedingGrant( + ctx, + adminInspector, + timelock, + RoleGrant{ + Role: mcmssdk.TimelockRoleAdmin, + Addresses: []string{"alice"}, + }, + nil, + ) + require.NoError(t, err) + require.Equal(t, []string{"alice"}, all) +} diff --git a/mcms/evm/grant-role/sequence.go b/mcms/evm/grant-role/sequence.go index e18b9f7..e5d69ba 100644 --- a/mcms/evm/grant-role/sequence.go +++ b/mcms/evm/grant-role/sequence.go @@ -2,7 +2,6 @@ package evmgrantrole import ( "fmt" - "slices" "strconv" "github.com/Masterminds/semver/v3" @@ -13,7 +12,6 @@ import ( "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" @@ -47,7 +45,13 @@ func runEVMGrantRole( var addresses []string for _, grant := range in.Grants { - addresses, err = addressesNeedingGrant(b, chain, timelock, grant) + addresses, err = grantrole.AddressesNeedingGrant( + b.GetContext(), + mcmsevm.NewTimelockInspector(chain.Client), + timelock.Hex(), + grant, + func(address string) string { return common.HexToAddress(address).Hex() }, + ) if err != nil { return sequenceutils.OnChainOutput{}, err } @@ -117,55 +121,6 @@ func runEVMGrantRole( 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 { diff --git a/mcms/evm/grant-role/sequence_test.go b/mcms/evm/grant-role/sequence_test.go index 7cecd71..dfd8a08 100644 --- a/mcms/evm/grant-role/sequence_test.go +++ b/mcms/evm/grant-role/sequence_test.go @@ -100,7 +100,7 @@ func TestRunEVMGrantRole_idempotent(t *testing.T) { rt := newEVMGrantRoleRuntime(t, selector) refs := grantRoleRefsFromEnv(t, rt.Environment(), selector) chain := rt.Environment().BlockChains.EVMChains()[selector] - grantee := common.HexToAddress("0x00000000000000000000000000000000000000bb").Hex() + grantee := "0x00000000000000000000000000000000000000bb" deps := grantrole.Deps{ BlockChains: rt.Environment().BlockChains, DataStore: rt.Environment().DataStore, @@ -138,8 +138,8 @@ func TestAddressesNeedingGrant(t *testing.T) { DataStore: rt.Environment().DataStore, } - grantee := common.HexToAddress("0x00000000000000000000000000000000000000dd").Hex() - pending := common.HexToAddress("0x00000000000000000000000000000000000000ee").Hex() + grantee := "0x00000000000000000000000000000000000000dd" + pending := "0x00000000000000000000000000000000000000ee" _, err := runEVMGrantRole(bundle, deps, grantrole.SeqInput{ ChainSelector: selector, Grants: []grantrole.RoleGrant{{ @@ -149,22 +149,31 @@ func TestAddressesNeedingGrant(t *testing.T) { }) require.NoError(t, err) - needed, err := addressesNeedingGrant(bundle, chain, refs.Timelock, grantrole.RoleGrant{ - Role: mcmssdk.TimelockRoleCanceller, - Addresses: []string{grantee, pending}, - }) + needed, err := grantrole.AddressesNeedingGrant( + t.Context(), + mcmsevm.NewTimelockInspector(chain.Client), + refs.Timelock.Hex(), + grantrole.RoleGrant{ + Role: mcmssdk.TimelockRoleCanceller, + Addresses: []string{grantee, pending}, + }, + func(address string) string { return common.HexToAddress(address).Hex() }, + ) 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}, - }) + adminNeeded, err := grantrole.AddressesNeedingGrant( + t.Context(), + mcmsevm.NewTimelockInspector(chain.Client), + refs.Timelock.Hex(), + grantrole.RoleGrant{ + Role: mcmssdk.TimelockRoleAdmin, + Addresses: []string{grantee}, + }, + func(address string) string { return common.HexToAddress(address).Hex() }, + ) 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 { diff --git a/mcms/solana/grant-role/helpers_test.go b/mcms/solana/grant-role/helpers_test.go new file mode 100644 index 0000000..0757f70 --- /dev/null +++ b/mcms/solana/grant-role/helpers_test.go @@ -0,0 +1,184 @@ +package solgrantrole + +import ( + "fmt" + "testing" + + "github.com/Masterminds/semver/v3" + solanago "github.com/gagliardetto/solana-go" + "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" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" + + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" +) + +func TestAccessControllerContractType(t *testing.T) { + t.Parallel() + + tests := []struct { + role mcmssdk.TimelockRole + want cldf.ContractType + wantErr string + }{ + {role: mcmssdk.TimelockRoleProposer, want: mcmscontracts.ProposerAccessControllerAccount}, + {role: mcmssdk.TimelockRoleExecutor, want: mcmscontracts.ExecutorAccessControllerAccount}, + {role: mcmssdk.TimelockRoleCanceller, want: mcmscontracts.CancellerAccessControllerAccount}, + {role: mcmssdk.TimelockRoleBypasser, want: mcmscontracts.BypasserAccessControllerAccount}, + {role: mcmssdk.TimelockRoleAdmin, wantErr: "admin role not supported on solana"}, + {role: mcmssdk.TimelockRole(99), wantErr: "unsupported timelock role Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.role.String(), func(t *testing.T) { + t.Parallel() + + got, err := accessControllerContractType(tt.role) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestProgramRef(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + programID := solanago.NewWallet().PublicKey().String() + + ds := datastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: programID, + ChainSelector: selector, + Type: datastore.ContractType(mcmscontracts.AccessControllerProgram), + Version: semver.MustParse("1.0.0"), + })) + env := validateTestEnv(ds.Seal(), selector) + + got, err := programRef(env, selector, mcmscontracts.AccessControllerProgram) + require.NoError(t, err) + require.Equal(t, programID, got) + + _, err = programRef(cldf.Environment{}, selector, mcmscontracts.AccessControllerProgram) + require.EqualError(t, err, fmt.Sprintf("datastore not available for chain %d", selector)) + + _, err = programRef(env, selector, mcmscontracts.ManyChainMultisigProgram) + require.EqualError(t, err, fmt.Sprintf( + "resolve ManyChainMultiSigProgram for chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: ManyChainMultiSigProgram}, found 0", + selector, selector, + )) +} + +func TestAccessControllerProgramAndAccount(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + programID := solanago.NewWallet().PublicKey() + accountID := solanago.NewWallet().PublicKey() + + ds := datastore.NewMemoryDataStore() + version := semver.MustParse("1.0.0") + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: programID.String(), + ChainSelector: selector, + Type: datastore.ContractType(mcmscontracts.AccessControllerProgram), + Version: version, + })) + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: accountID.String(), + ChainSelector: selector, + Type: datastore.ContractType(mcmscontracts.ExecutorAccessControllerAccount), + Version: version, + })) + env := validateTestEnv(ds.Seal(), selector) + + gotProgram, err := accessControllerProgram(env, selector) + require.NoError(t, err) + require.Equal(t, programID, gotProgram) + + gotAccount, err := accessControllerAccount(env, selector, mcmssdk.TimelockRoleExecutor) + require.NoError(t, err) + require.Equal(t, accountID, gotAccount) +} + +func TestTimelockContractAddress(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + timelockProgram := solanago.NewWallet().PublicKey() + timelockAddr := mcmssolana.ContractAddress(timelockProgram, testPDASeed(4)) + + ds := datastore.NewMemoryDataStore() + addHelperRef(t, ds, selector, mcmscontracts.RBACTimelock, timelockAddr) + env := validateTestEnv(ds.Seal(), selector) + + got, err := timelockContractAddress(env, grantrole.SeqInput{ChainSelector: selector}) + require.NoError(t, err) + require.Equal(t, timelockAddr, got) + + _, err = timelockContractAddress(validateTestEnv(datastore.NewMemoryDataStore().Seal(), selector), grantrole.SeqInput{ChainSelector: 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", + selector, selector, + )) +} + +func TestTimelockSignerPDA(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + timelockProgram := solanago.NewWallet().PublicKey() + timelockSeed := testPDASeed(4) + timelockAddr := mcmssolana.ContractAddress(timelockProgram, timelockSeed) + + ds := datastore.NewMemoryDataStore() + addHelperRef(t, ds, selector, mcmscontracts.RBACTimelock, timelockAddr) + env := validateTestEnv(ds.Seal(), selector) + + mcmsInput := cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: 1, + TimelockDelay: mcmstypes.NewDuration(0), + } + got, err := timelockSignerPDA(env, grantrole.SeqInput{ChainSelector: selector, MCMS: &mcmsInput}) + require.NoError(t, err) + + parsedProgram, parsedSeed, err := mcmssolana.ParseContractAddress(timelockAddr) + require.NoError(t, err) + var legacySeed legacysolana.PDASeed + copy(legacySeed[:], parsedSeed[:]) + require.Equal(t, familysolana.GetTimelockSignerPDA(parsedProgram, legacySeed), got) +} + +func addHelperRef(t *testing.T, ds *datastore.MemoryDataStore, selector uint64, contractType cldf.ContractType, address string) { + t.Helper() + + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: address, + ChainSelector: selector, + Type: datastore.ContractType(contractType), + Version: semver.MustParse("1.0.0"), + })) +} + +func testPDASeed(v byte) mcmssolana.PDASeed { + var seed mcmssolana.PDASeed + seed[31] = v + + return seed +} diff --git a/mcms/solana/grant-role/operation.go b/mcms/solana/grant-role/operation.go new file mode 100644 index 0000000..7d63f97 --- /dev/null +++ b/mcms/solana/grant-role/operation.go @@ -0,0 +1,134 @@ +package solgrantrole + +import ( + "errors" + "fmt" + + "github.com/Masterminds/semver/v3" + solanago "github.com/gagliardetto/solana-go" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +// GrantRoleTarget identifies one timelock role grant on Solana. +type GrantRoleTarget struct { + Timelock string `json:"timelock"` + Role mcmssdk.TimelockRole `json:"role"` + Address string `json:"address"` +} + +// OpSolanaGrantRoleInput is the input for granting one timelock role on Solana. +type OpSolanaGrantRoleInput struct { + Target GrantRoleTarget `json:"target"` + NoSend bool `json:"noSend"` + AuthorityAccount solanago.PublicKey `json:"authorityAccount"` +} + +// OpSolanaGrantRoleOutput is the output of a Solana grant-role operation. +type OpSolanaGrantRoleOutput struct { + Confirmed bool `json:"confirmed"` + BatchOperation mcmstypes.BatchOperation `json:"batchOperation"` + Signature string `json:"signature,omitempty"` +} + +// OpSolanaGrantRole grants a timelock role via the MCMS SDK timelock configurer. +var OpSolanaGrantRole = operations.NewOperation( + "solana-grant-role", + semver.MustParse("1.0.0"), + "Grants a timelock role to one Solana address via the MCMS SDK timelock configurer", + func(b operations.Bundle, deps cldfsol.Chain, in OpSolanaGrantRoleInput) (OpSolanaGrantRoleOutput, error) { + if err := validateGrantRoleTarget(in.Target); err != nil { + return OpSolanaGrantRoleOutput{}, err + } + if deps.DeployerKey == nil { + return OpSolanaGrantRoleOutput{}, fmt.Errorf("missing deployer key for chain %d", deps.Selector) + } + if deps.Client == nil { + return OpSolanaGrantRoleOutput{}, fmt.Errorf("missing rpc client for chain %d", deps.Selector) + } + + configurer := newGrantRoleTimelockConfigurer(deps, in) + + res, err := configurer.GrantRole(b.GetContext(), in.Target.Timelock, in.Target.Role, in.Target.Address) + if err != nil { + return OpSolanaGrantRoleOutput{}, fmt.Errorf("grant role %s to %s on %s: %w", + in.Target.Role.String(), in.Target.Address, in.Target.Timelock, err) + } + + if in.NoSend { + tx, ok := res.RawData.(mcmstypes.Transaction) + if !ok { + return OpSolanaGrantRoleOutput{}, fmt.Errorf("unexpected raw data type %T from GrantRole", res.RawData) + } + + return OpSolanaGrantRoleOutput{ + BatchOperation: mcmstypes.BatchOperation{ + ChainSelector: mcmstypes.ChainSelector(deps.Selector), + Transactions: []mcmstypes.Transaction{tx}, + }, + }, nil + } + + b.Logger.Infow("GrantRole confirmed", + "chainSelector", deps.Selector, + "timelock", in.Target.Timelock, + "role", in.Target.Role.String(), + "address", in.Target.Address, + "signature", res.Hash, + ) + + return OpSolanaGrantRoleOutput{Confirmed: true, Signature: res.Hash}, nil + }, +) + +func newGrantRoleTimelockConfigurer(deps cldfsol.Chain, in OpSolanaGrantRoleInput) *mcmssolana.TimelockConfigurer { + client := deps.Client + auth := *deps.DeployerKey + + if in.NoSend { + if !in.AuthorityAccount.IsZero() { + return mcmssolana.NewTimelockConfigurer( + client, + auth, + mcmssolana.WithDoNotSendTimelockInstructionsOnChain(), + mcmssolana.WithTimelockAuthorityAccount(in.AuthorityAccount), + ) + } + + return mcmssolana.NewTimelockConfigurer( + client, + auth, + mcmssolana.WithDoNotSendTimelockInstructionsOnChain(), + ) + } + + if !in.AuthorityAccount.IsZero() { + return mcmssolana.NewTimelockConfigurer( + client, + auth, + mcmssolana.WithTimelockAuthorityAccount(in.AuthorityAccount), + ) + } + + return mcmssolana.NewTimelockConfigurer(client, auth) +} + +func validateGrantRoleTarget(target GrantRoleTarget) error { + if target.Timelock == "" { + return errors.New("timelock address must not be empty") + } + if !target.Role.Valid() { + return errors.New("role is unsupported") + } + if target.Role == mcmssdk.TimelockRoleAdmin { + return errors.New("admin role not supported on solana") + } + if target.Address == "" { + return errors.New("address must not be empty") + } + + return nil +} diff --git a/mcms/solana/grant-role/operation_test.go b/mcms/solana/grant-role/operation_test.go new file mode 100644 index 0000000..6fecb35 --- /dev/null +++ b/mcms/solana/grant-role/operation_test.go @@ -0,0 +1,176 @@ +package solgrantrole + +import ( + "crypto/ecdsa" + "fmt" + "testing" + "time" + + solanago "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/operations/optest" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" +) + +func TestOpSolanaGrantRole(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + wallet := solanago.NewWallet() + deployerKey := wallet.PrivateKey + grantee := solanago.NewWallet().PublicKey().String() + + t.Run("missing deployer key", func(t *testing.T) { + t.Parallel() + + _, err := operations.ExecuteOperation( + optest.NewBundle(t), + OpSolanaGrantRole, + cldfsol.Chain{Selector: selector}, + OpSolanaGrantRoleInput{ + Target: GrantRoleTarget{ + Timelock: "timelock", + Role: mcmssdk.TimelockRoleExecutor, + Address: grantee, + }, + }, + ) + require.EqualError(t, err, fmt.Sprintf("missing deployer key for chain %d", selector)) + }) + + t.Run("missing rpc client", func(t *testing.T) { + t.Parallel() + + _, err := operations.ExecuteOperation( + optest.NewBundle(t), + OpSolanaGrantRole, + cldfsol.Chain{Selector: selector, DeployerKey: &deployerKey}, + OpSolanaGrantRoleInput{ + Target: GrantRoleTarget{ + Timelock: "not-a-valid-timelock", + Role: mcmssdk.TimelockRoleExecutor, + Address: grantee, + }, + }, + ) + require.EqualError(t, err, fmt.Sprintf("missing rpc client for chain %d", selector)) + }) + + t.Run("admin role rejected", func(t *testing.T) { + t.Parallel() + + _, err := operations.ExecuteOperation( + optest.NewBundle(t), + OpSolanaGrantRole, + cldfsol.Chain{Selector: selector, DeployerKey: &deployerKey}, + OpSolanaGrantRoleInput{ + Target: GrantRoleTarget{ + Timelock: "timelock", + Role: mcmssdk.TimelockRoleAdmin, + Address: grantee, + }, + }, + ) + require.EqualError(t, err, "admin role not supported on solana") + }) +} + +func TestValidateGrantRoleTarget(t *testing.T) { + t.Parallel() + + require.NoError(t, validateGrantRoleTarget(GrantRoleTarget{ + Timelock: "timelock", + Role: mcmssdk.TimelockRoleExecutor, + Address: solanago.NewWallet().PublicKey().String(), + })) + + require.EqualError(t, validateGrantRoleTarget(GrantRoleTarget{ + Role: mcmssdk.TimelockRoleExecutor, + Address: "addr", + }), "timelock address must not be empty") +} + +//nolint:paralleltest // global mcm.SetProgramID state; serialized via soltestutils.PreloadMCMS lock +func TestSolanaGrantRole(t *testing.T) { + t.Run("sequence", testRunSolanaGrantRole) + t.Run("operation", testOpSolanaGrantRole) +} + +func testOpSolanaGrantRole(t *testing.T) { + tests := []struct { + name string + noSend bool + }{ + {name: "direct send", noSend: false}, + {name: "MCMS proposal", noSend: true}, + } + + for _, tt := range tests { //nolint:paralleltest // global mcm.SetProgramID state + t.Run(tt.name, func(t *testing.T) { + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + rt := newSolanaGrantRoleRuntime(t, selector) + chain := rt.Environment().BlockChains.SolanaChains()[selector] + env := rt.Environment() + timelock := timelockRefAddress(t, env, selector) + fundSolanaGrantRolePDAs(t, rt, selector, chain) + + 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), + } + + opInput := OpSolanaGrantRoleInput{ + Target: GrantRoleTarget{ + Timelock: timelock, + Role: mcmssdk.TimelockRoleExecutor, + Address: solanago.NewWallet().PublicKey().String(), + }, + NoSend: tt.noSend, + } + if tt.noSend { + transferSolanaMCMSToTimelock(t, rt, selector) + fundSolanaGrantRolePDAs(t, rt, selector, chain) + var err error + opInput.AuthorityAccount, err = timelockSignerPDA(env, grantrole.SeqInput{ + ChainSelector: selector, + MCMS: mcmsInput, + }) + require.NoError(t, err) + } + + report, err := operations.ExecuteOperation( + rt.Environment().OperationsBundle, + OpSolanaGrantRole, + chain, + opInput, + ) + require.NoError(t, err) + require.Equal(t, !tt.noSend, report.Output.Confirmed) + + if tt.noSend { + require.Equal(t, mcmstypes.ChainSelector(selector), report.Output.BatchOperation.ChainSelector) + require.NotEmpty(t, report.Output.BatchOperation.Transactions) + require.NoError(t, rt.Exec( + newTimelockProposalTask([]mcmstypes.BatchOperation{report.Output.BatchOperation}, "solana grant role operation test"), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + } + + executors, err := mcmssolana.NewTimelockInspector(chain.Client).GetExecutors(t.Context(), timelock) + require.NoError(t, err) + require.Contains(t, executors, opInput.Target.Address) + }) + } +} diff --git a/mcms/solana/grant-role/register.go b/mcms/solana/grant-role/register.go new file mode 100644 index 0000000..064bffb --- /dev/null +++ b/mcms/solana/grant-role/register.go @@ -0,0 +1,41 @@ +package solgrantrole + +import ( + "fmt" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + 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.FamilySolana, + Sequence: seqGrantRole, + Verify: verifySolanaChains, + } +} + +func verifySolanaChains(env cldf.Environment, chains []grantrole.SeqInput) error { + for _, in := range chains { + if _, ok := env.BlockChains.SolanaChains()[in.ChainSelector]; !ok { + return fmt.Errorf("solana chain %d not found in environment", in.ChainSelector) + } + if err := validateMCMSIfPresent(env, in); err != nil { + return err + } + if err := validateRoles(in); err != nil { + return fmt.Errorf("chain %d: %w", in.ChainSelector, err) + } + if err := validateGrantAddresses(env, in); err != nil { + return fmt.Errorf("chain %d: %w", in.ChainSelector, err) + } + } + + return nil +} diff --git a/mcms/solana/grant-role/register_test.go b/mcms/solana/grant-role/register_test.go new file mode 100644 index 0000000..74672d5 --- /dev/null +++ b/mcms/solana/grant-role/register_test.go @@ -0,0 +1,102 @@ +package solgrantrole + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" +) + +func TestRegistration(t *testing.T) { + t.Parallel() + + reg := Registration() + require.Equal(t, chainselectors.FamilySolana, reg.Family) + require.Equal(t, seqGrantRole, reg.Sequence) + require.NotNil(t, reg.Verify) +} + +func TestInitRegistersSolana(t *testing.T) { + t.Parallel() + + seq, err := grantrole.Registry.SequenceForFamily(chainselectors.FamilySolana) + require.NoError(t, err) + require.Equal(t, seqGrantRole, seq) + + got, err := grantrole.Registry.SequenceForChainSelector( + chainselectors.TEST_22222222222222222222222222222222222222222222.Selector, + ) + require.NoError(t, err) + require.Equal(t, seqGrantRole, got) +} + +func TestVerifySolanaChains(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + reg := Registration() + + tests := []struct { + name string + env cldf.Environment + chains []grantrole.SeqInput + wantErr string + }{ + { + name: "chain missing from environment", + env: cldf.Environment{}, + wantErr: fmt.Sprintf("solana chain %d not found in environment", selector), + }, + { + name: "invalid grant role", + env: cldf.Environment{ + BlockChains: chain.NewBlockChains(map[uint64]chain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }), + }, + chains: []grantrole.SeqInput{{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleAdmin, + Addresses: []string{"11111111111111111111111111111112"}, + }}, + }}, + wantErr: fmt.Sprintf("chain %d: grants[0]: admin role not supported on solana", selector), + }, + { + name: "chain present", + env: cldf.Environment{ + BlockChains: chain.NewBlockChains(map[uint64]chain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + chains := tt.chains + if chains == nil { + chains = []grantrole.SeqInput{{ChainSelector: selector}} + } + + err := reg.Verify(tt.env, chains) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.EqualError(t, err, tt.wantErr) + }) + } +} diff --git a/mcms/solana/grant-role/sequence.go b/mcms/solana/grant-role/sequence.go new file mode 100644 index 0000000..ed7bf8f --- /dev/null +++ b/mcms/solana/grant-role/sequence.go @@ -0,0 +1,184 @@ +package solgrantrole + +import ( + "fmt" + "strconv" + + solanago "github.com/gagliardetto/solana-go" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + "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/operations" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" +) + +var seqGrantRole = operations.NewSequence( + "seq-solana-grant-role", + &semvers.V1_0_0, + "Grants RBACTimelock roles on Solana chains", + runSolanaGrantRole, +) + +func runSolanaGrantRole( + b operations.Bundle, + deps grantrole.Deps, + in grantrole.SeqInput, +) (sequenceutils.OnChainOutput, error) { + chain, ok := deps.BlockChains.SolanaChains()[in.ChainSelector] + if !ok { + return sequenceutils.OnChainOutput{}, fmt.Errorf("solana chain %d not found in environment", in.ChainSelector) + } + + env := grantrole.EnvFromDeps(deps) + timelockAddress, err := timelockContractAddress(env, in) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + useMCMS := in.MCMS != nil + var authorityAccount solanago.PublicKey + if useMCMS { + authorityAccount, err = timelockSignerPDA(env, in) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + } + + var batchOps []mcmstypes.BatchOperation + if useMCMS { + batchOps = make([]mcmstypes.BatchOperation, 0) + } + + var addresses []string + for _, grant := range in.Grants { + addresses, err = grantrole.AddressesNeedingGrant( + b.GetContext(), + mcmssolanasdk.NewTimelockInspector(chain.Client), + timelockAddress, + grant, + nil, + ) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + for _, address := range addresses { + opInput := OpSolanaGrantRoleInput{ + Target: GrantRoleTarget{ + Timelock: timelockAddress, + Role: grant.Role, + Address: address, + }, + NoSend: useMCMS, + } + if useMCMS { + opInput.AuthorityAccount = authorityAccount + } + + var report operations.Report[OpSolanaGrantRoleInput, OpSolanaGrantRoleOutput] + report, err = operations.ExecuteOperation( + b, + OpSolanaGrantRole, + chain, + opInput, + operations.WithIdempotencyKey[OpSolanaGrantRoleInput, cldfsol.Chain]( + strconv.FormatUint(chain.Selector, 10)+":"+grant.Role.String()+":"+address, + ), + ) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + if useMCMS { + batchOps = append(batchOps, report.Output.BatchOperation) + } + } + } + + return sequenceutils.OnChainOutput{BatchOps: batchOps}, nil +} + +func timelockContractAddress(env cldf.Environment, in grantrole.SeqInput) (string, error) { + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilySolana) + if !ok { + return "", fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilySolana) + } + + input := cldf.MCMSTimelockProposalInput{} + if in.MCMS != nil { + input = *in.MCMS + } + + ref, err := reader.GetTimelockRef(env, in.ChainSelector, input) + if err != nil { + return "", fmt.Errorf("resolve timelock for chain %d: %w", in.ChainSelector, err) + } + + return ref.Address, nil +} + +func timelockSignerPDA(env cldf.Environment, in grantrole.SeqInput) (solanago.PublicKey, error) { + timelockAddress, err := timelockContractAddress(env, in) + if err != nil { + return solanago.PublicKey{}, err + } + + timelockProgram, timelockSeed, err := mcmssolanasdk.ParseContractAddress(timelockAddress) + if err != nil { + return solanago.PublicKey{}, fmt.Errorf("parse timelock ref address for chain %d: %w", in.ChainSelector, err) + } + + var seed legacysolana.PDASeed + copy(seed[:], timelockSeed[:]) + + return familysolana.GetTimelockSignerPDA(timelockProgram, seed), nil +} + +func accessControllerProgram(env cldf.Environment, chainSelector uint64) (solanago.PublicKey, error) { + raw, err := programRef(env, chainSelector, mcmscontracts.AccessControllerProgram) + if err != nil { + return solanago.PublicKey{}, err + } + + return solanago.PublicKeyFromBase58(raw) +} + +func accessControllerAccount(env cldf.Environment, chainSelector uint64, role mcmssdk.TimelockRole) (solanago.PublicKey, error) { + contractType, err := accessControllerContractType(role) + if err != nil { + return solanago.PublicKey{}, err + } + + raw, err := accessControllerRef(env, chainSelector, contractType) + if err != nil { + return solanago.PublicKey{}, err + } + + return solanago.PublicKeyFromBase58(raw) +} + +func programRef(env cldf.Environment, chainSelector uint64, contractType cldf.ContractType) (string, error) { + if env.DataStore == nil { + return "", fmt.Errorf("datastore not available for chain %d", chainSelector) + } + + ref, err := datastore.FindUniqueRef(env.DataStore.Addresses(), datastore.AddressRef{ + ChainSelector: chainSelector, + Type: datastore.ContractType(contractType), + }) + if err != nil { + return "", fmt.Errorf("resolve %s for chain %d: %w", contractType, chainSelector, err) + } + + return ref.Address, nil +} diff --git a/mcms/solana/grant-role/sequence_test.go b/mcms/solana/grant-role/sequence_test.go new file mode 100644 index 0000000..ae10ad0 --- /dev/null +++ b/mcms/solana/grant-role/sequence_test.go @@ -0,0 +1,257 @@ +package solgrantrole + +import ( + "crypto/ecdsa" + "fmt" + "testing" + "time" + + solanago "github.com/gagliardetto/solana-go" + "github.com/segmentio/ksuid" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "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/operations/optest" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + legacymcms "github.com/smartcontractkit/cld-changesets/legacy/mcms/changesets" + solstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + solchangesets "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/changesets" + soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils" + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" + solreaders "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" +) + +//nolint:paralleltest // global mcm.SetProgramID state; serialized via soltestutils.PreloadMCMS lock +func testRunSolanaGrantRole(t *testing.T) { + tests := []struct { + name string + useMCMS bool + }{ + {name: "direct send", useMCMS: false}, + {name: "MCMS proposal", useMCMS: true}, + } + + for _, tt := range tests { //nolint:paralleltest // global mcm.SetProgramID state + t.Run(tt.name, func(t *testing.T) { + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + rt := newSolanaGrantRoleRuntime(t, selector) + chain := rt.Environment().BlockChains.SolanaChains()[selector] + timelock := timelockRefAddress(t, rt.Environment(), selector) + fundSolanaGrantRolePDAs(t, rt, selector, chain) + + var mcmsInput *cldf.MCMSTimelockProposalInput + if tt.useMCMS { + transferSolanaMCMSToTimelock(t, rt, selector) + fundSolanaGrantRolePDAs(t, rt, selector, chain) + 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 := solanago.NewWallet().PublicKey().String() + out, err := runSolanaGrantRole( + 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.NotEmpty(t, out.BatchOps[0].Transactions) + require.NoError(t, rt.Exec( + newTimelockProposalTask(out.BatchOps, "solana grant role sequence test"), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + } else { + require.Empty(t, out.BatchOps) + } + + executors, err := mcmssolana.NewTimelockInspector(chain.Client).GetExecutors(t.Context(), timelock) + require.NoError(t, err) + require.Contains(t, executors, grantee) + }) + } +} + +func TestRunSolanaGrantRole_idempotent(t *testing.T) { + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + rt := newSolanaGrantRoleRuntime(t, selector) + chain := rt.Environment().BlockChains.SolanaChains()[selector] + timelock := timelockRefAddress(t, rt.Environment(), selector) + fundSolanaGrantRolePDAs(t, rt, selector, chain) + + grantee := solanago.NewWallet().PublicKey().String() + 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 := runSolanaGrantRole(rt.Environment().OperationsBundle, deps, input) + require.NoError(t, err) + + out, err := runSolanaGrantRole(rt.Environment().OperationsBundle, deps, input) + require.NoError(t, err) + require.Empty(t, out.BatchOps) + + proposers, err := mcmssolana.NewTimelockInspector(chain.Client).GetProposers(t.Context(), timelock) + require.NoError(t, err) + require.Contains(t, proposers, grantee) +} + +func TestRunSolanaGrantRole_errors(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + grantee := solanago.NewWallet().PublicKey().String() + + _, err := runSolanaGrantRole( + optest.NewBundle(t), + grantrole.Deps{BlockChains: chain.NewBlockChains(nil)}, + grantrole.SeqInput{ChainSelector: selector}, + ) + require.EqualError(t, err, fmt.Sprintf("solana chain %d not found in environment", selector)) + + deps := grantrole.Deps{ + BlockChains: chain.NewBlockChains(map[uint64]chain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }), + DataStore: datastore.NewMemoryDataStore().Seal(), + } + _, err = runSolanaGrantRole( + optest.NewBundle(t), + deps, + grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []string{grantee}, + }}, + }, + ) + 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", + selector, selector, + )) +} + +func newSolanaGrantRoleRuntime(t *testing.T, selector uint64) *runtime.Runtime { + t.Helper() + + programsPath, programIDs, ab := soltestutils.PreloadMCMS(t, selector) + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithSolanaContainer(t, []uint64{selector}, programsPath, programIDs), + environment.WithAddressBook(ab), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + require.Contains(t, rt.Environment().BlockChains.SolanaChains(), selector) + + err = rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cldftesthelpers.SingleGroupTimelockConfig(t), + }), + ) + require.NoError(t, err) + + return rt +} + +func timelockRefAddress(t *testing.T, env cldf.Environment, selector uint64) string { + t.Helper() + + reader := solreaders.Reader{} + ref, err := reader.GetTimelockRef(env, selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + + return ref.Address +} + +func fundSolanaGrantRolePDAs(t *testing.T, rt *runtime.Runtime, selector uint64, chain cldfsol.Chain) { + t.Helper() + + addrs, err := rt.State().AddressBook.AddressesForChain(selector) + require.NoError(t, err) + mcmsState, err := solstate.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + soltestutils.FundSignerPDAs(t, chain, mcmsState) +} + +func transferSolanaMCMSToTimelock(t *testing.T, rt *runtime.Runtime, selector uint64) { + t.Helper() + + err := rt.Exec( + runtime.ChangesetTask(solchangesets.TransferMCMSToTimelockSolana{}, solchangesets.TransferMCMSToTimelockSolanaConfig{ + Chains: []uint64{selector}, + MCMSCfg: cldfproposalutils.TimelockConfig{MinDelay: time.Second}, + }), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + ) + require.NoError(t, err) +} + +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.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(time.Second), + Description: t.description, + }, t.batchOps). + Build() + if err != nil { + return err + } + + return state.MergeChangesetOutput(t.id, out) +} diff --git a/mcms/solana/grant-role/validate.go b/mcms/solana/grant-role/validate.go new file mode 100644 index 0000000..efdaa52 --- /dev/null +++ b/mcms/solana/grant-role/validate.go @@ -0,0 +1,120 @@ +package solgrantrole + +import ( + "errors" + "fmt" + + solanago "github.com/gagliardetto/solana-go" + chainselectors "github.com/smartcontractkit/chain-selectors" + "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" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + + grantrole "github.com/smartcontractkit/cld-changesets/mcms/changesets/grant-role" +) + +func validateMCMSIfPresent(e cldf.Environment, in grantrole.SeqInput) error { + if in.MCMS == nil { + return nil + } + + input := *in.MCMS + chainSelector := in.ChainSelector + + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilySolana) + if !ok { + return fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilySolana) + } + + if _, err := reader.GetTimelockRef(e, chainSelector, input); err != nil { + return fmt.Errorf("validate timelock ref for chain %d: %w", chainSelector, err) + } + if _, err := reader.GetMCMSRef(e, chainSelector, input); err != nil { + return fmt.Errorf("validate mcms ref for chain %d: %w", 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()) + } + if grant.Role == mcmssdk.TimelockRoleAdmin { + return fmt.Errorf("grants[%d]: admin role not supported on solana", i) + } + } + + return nil +} + +func validateGrantAddresses(env cldf.Environment, in grantrole.SeqInput) error { + for i, grant := range in.Grants { + contractType, err := accessControllerContractType(grant.Role) + if err != nil { + return fmt.Errorf("grants[%d]: %w", i, err) + } + if _, err := accessControllerRef(env, in.ChainSelector, contractType); err != nil { + return fmt.Errorf("grants[%d]: %w", i, err) + } + + for j, addr := range grant.Addresses { + if _, err := parseSolanaAddress(addr); err != nil { + return fmt.Errorf("grants[%d].addresses[%d]: %w", i, j, err) + } + } + } + + return nil +} + +func parseSolanaAddress(raw string) (solanago.PublicKey, error) { + pubkey, err := solanago.PublicKeyFromBase58(raw) + if err != nil { + return solanago.PublicKey{}, fmt.Errorf("address %q is not a valid solana address: %w", raw, err) + } + if pubkey == (solanago.PublicKey{}) { + return solanago.PublicKey{}, errors.New("address must not be zero") + } + + return pubkey, nil +} + +func accessControllerContractType(role mcmssdk.TimelockRole) (cldf.ContractType, error) { + switch role { + case mcmssdk.TimelockRoleProposer: + return mcmscontracts.ProposerAccessControllerAccount, nil + case mcmssdk.TimelockRoleExecutor: + return mcmscontracts.ExecutorAccessControllerAccount, nil + case mcmssdk.TimelockRoleCanceller: + return mcmscontracts.CancellerAccessControllerAccount, nil + case mcmssdk.TimelockRoleBypasser: + return mcmscontracts.BypasserAccessControllerAccount, nil + case mcmssdk.TimelockRoleAdmin: + return "", errors.New("admin role not supported on solana") + default: + return "", fmt.Errorf("unsupported timelock role %s", role.String()) + } +} + +func accessControllerRef( + env cldf.Environment, + chainSelector uint64, + contractType cldf.ContractType, +) (string, error) { + if env.DataStore == nil { + return "", fmt.Errorf("datastore not available for chain %d", chainSelector) + } + + ref, err := datastore.FindUniqueRef(env.DataStore.Addresses(), datastore.AddressRef{ + ChainSelector: chainSelector, + Type: datastore.ContractType(contractType), + }) + if err != nil { + return "", fmt.Errorf("error fetching address in datastore for %s in chain %d: %w", contractType, chainSelector, err) + } + + return ref.Address, nil +} diff --git a/mcms/solana/grant-role/validate_test.go b/mcms/solana/grant-role/validate_test.go new file mode 100644 index 0000000..175cf4d --- /dev/null +++ b/mcms/solana/grant-role/validate_test.go @@ -0,0 +1,201 @@ +package solgrantrole + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "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/solana/readers" +) + +type validateRefSpec struct { + contractType cldf.ContractType + address string +} + +func TestValidateMCMSIfPresent(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + 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"}, + { + name: "missing timelock ref", + mcms: &mcmsInput, + wantErr: fmt.Sprintf( + "validate timelock ref for chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: RBACTimelock}, found 0", + selector, selector, + ), + }, + { + name: "missing mcms ref", + refs: []validateRefSpec{{mcmscontracts.RBACTimelock, "timelock"}}, + mcms: &mcmsInput, + wantErr: fmt.Sprintf( + "validate mcms ref for chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: ProposerManyChainMultiSig}, found 0", + selector, selector, + ), + }, + { + name: "success", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, "timelock"}, + {mcmscontracts.ProposerManyChainMultisig, "proposer"}, + }, + mcms: &mcmsInput, + }, + } + + 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, selector, ref.contractType, ref.address, version, "") + } + + err := validateMCMSIfPresent( + validateTestEnv(ds.Seal(), selector), + grantrole.SeqInput{ChainSelector: selector, MCMS: tt.mcms}, + ) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.EqualError(t, err, tt.wantErr) + }) + } +} + +func TestValidateRoles(t *testing.T) { + t.Parallel() + + err := validateRoles(grantrole.SeqInput{ + Grants: []grantrole.RoleGrant{{Role: mcmssdk.TimelockRole(99), Addresses: []string{"11111111111111111111111111111111"}}}, + }) + require.EqualError(t, err, "grants[0]: unsupported timelock role Unknown") + + err = validateRoles(grantrole.SeqInput{ + Grants: []grantrole.RoleGrant{{Role: mcmssdk.TimelockRoleAdmin, Addresses: []string{"11111111111111111111111111111111"}}}, + }) + require.EqualError(t, err, "grants[0]: admin role not supported on solana") +} + +func TestParseSolanaAddress(t *testing.T) { + t.Parallel() + + valid := "11111111111111111111111111111112" + addr, err := parseSolanaAddress(valid) + require.NoError(t, err) + require.Equal(t, valid, addr.String()) + + _, err = parseSolanaAddress("not-a-pubkey") + require.EqualError(t, err, `address "not-a-pubkey" is not a valid solana address: decode: invalid base58 digit ('-')`) +} + +func TestValidateGrantAddresses(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + version := semver.MustParse("1.0.0") + grantee := "11111111111111111111111111111112" + + t.Run("missing access controller ref", func(t *testing.T) { + t.Parallel() + + err := validateGrantAddresses( + validateTestEnv(datastore.NewMemoryDataStore().Seal(), selector), + grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []string{grantee}, + }}, + }, + ) + require.EqualError(t, err, fmt.Sprintf( + "grants[0]: error fetching address in datastore for ExecutorAccessControllerAccount in chain %d: no address ref matched query: expected exactly 1 ref matching query {ChainSelector: %d, Type: ExecutorAccessControllerAccount}, found 0", + selector, selector, + )) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + ds := datastore.NewMemoryDataStore() + addValidateRef(t, ds, selector, mcmscontracts.ExecutorAccessControllerAccount, "executor-ac", version, "") + + err := validateGrantAddresses( + validateTestEnv(ds.Seal(), selector), + grantrole.SeqInput{ + ChainSelector: selector, + Grants: []grantrole.RoleGrant{{ + Role: mcmssdk.TimelockRoleExecutor, + Addresses: []string{grantee}, + }}, + }, + ) + require.NoError(t, err) + }) +} + +func addValidateRef( + t *testing.T, + ds *datastore.MemoryDataStore, + selector uint64, + contractType cldf.ContractType, + address string, + version *semver.Version, + qualifier string, +) { + t.Helper() + + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: address, + ChainSelector: selector, + Type: datastore.ContractType(contractType), + Version: version, + Qualifier: qualifier, + })) +} + +func validateTestEnv(ds datastore.DataStore, selector uint64) cldf.Environment { + return cldf.Environment{ + Logger: logger.Nop(), + DataStore: ds, + GetContext: context.Background, + BlockChains: chain.NewBlockChains(map[uint64]chain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }), + } +}