diff --git a/e2e/tests/solana/timelock_configuration.go b/e2e/tests/solana/timelock_configuration.go index 1e4ddd9a..bf64d36b 100644 --- a/e2e/tests/solana/timelock_configuration.go +++ b/e2e/tests/solana/timelock_configuration.go @@ -6,10 +6,21 @@ import ( "time" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock" + "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/accesscontroller" + + "github.com/smartcontractkit/mcms/sdk" solanasdk "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/types" +) + +var ( + testPDASeedTimelockGrantRole = [32]byte{'t', 'e', 's', 't', '-', 't', 'i', 'm', 'e', 'g', 'r', 'a', 'n', 't'} + testPDASeedTimelockGrantRoleNoSend = [32]byte{'t', 'e', 's', 't', '-', 't', 'i', 'm', 'e', 'g', 'r', 'n', 's'} ) func (s *TestSuite) TestUpdateDelay() { @@ -37,3 +48,67 @@ func (s *TestSuite) TestUpdateDelay() { s.Require().NoError(err, "Failed to get updated min delay") s.Require().Equal(newDelay, delay, "Delay should match the updated value") } + +func (s *TestSuite) TestGrantRole() { + ctx := s.T().Context() + s.SetupTimelock(testPDASeedTimelockGrantRole, 1*time.Second) + + admin, err := solana.PrivateKeyFromBase58(privateKey) + s.Require().NoError(err) + + target, err := solana.NewRandomPrivateKey() + s.Require().NoError(err) + + timelockAddr := solanasdk.ContractAddress(s.TimelockProgramID, testPDASeedTimelockGrantRole) + role := sdk.TimelockRoleExecutor + accessController := s.Roles[timelock.Executor_Role].AccessController.PublicKey() + + hasAccess, err := accesscontroller.HasAccess(ctx, s.SolanaClient, accessController, target.PublicKey(), rpc.CommitmentConfirmed) + s.Require().NoError(err, "Failed to inspect initial role access") + s.Require().False(hasAccess, "Target should not have role before GrantRole") + + configurer := solanasdk.NewTimelockConfigurer(s.SolanaClient, admin) + result, err := configurer.GrantRole(ctx, timelockAddr, role, target.PublicKey().String()) + s.Require().NoError(err, "Failed to grant role") + s.Require().NotEmpty(result.Hash, "Transaction hash should not be empty") + s.Require().Equal(chainsel.FamilySolana, result.ChainFamily, "Chain family should be Solana") + + hasAccess, err = accesscontroller.HasAccess(ctx, s.SolanaClient, accessController, target.PublicKey(), rpc.CommitmentConfirmed) + s.Require().NoError(err, "Failed to inspect granted role access") + s.Require().True(hasAccess, "Target should have role after GrantRole") +} + +func (s *TestSuite) TestGrantRoleNoSend() { + ctx := s.T().Context() + s.SetupTimelock(testPDASeedTimelockGrantRoleNoSend, 1*time.Second) + + admin, err := solana.PrivateKeyFromBase58(privateKey) + s.Require().NoError(err) + + target, err := solana.NewRandomPrivateKey() + s.Require().NoError(err) + + timelockAddr := solanasdk.ContractAddress(s.TimelockProgramID, testPDASeedTimelockGrantRoleNoSend) + role := sdk.TimelockRoleProposer + accessController := s.Roles[timelock.Proposer_Role].AccessController.PublicKey() + + configurer := solanasdk.NewTimelockConfigurer( + s.SolanaClient, + admin, + solanasdk.WithDoNotSendTimelockInstructionsOnChain(), + ) + result, err := configurer.GrantRole(ctx, timelockAddr, role, target.PublicKey().String()) + s.Require().NoError(err, "Failed to prepare grant role transaction") + s.Require().Empty(result.Hash, "Transaction hash should be empty when not sending") + s.Require().Equal(chainsel.FamilySolana, result.ChainFamily, "Chain family should be Solana") + + tx, ok := result.RawData.(types.Transaction) + s.Require().True(ok, "RawData should contain a Solana MCMS transaction") + s.Require().Equal(s.TimelockProgramID.String(), tx.To) + s.Require().Equal("RBACTimelock", tx.ContractType) + s.Require().Equal([]string{"RBACTimelock", "GrantRole"}, tx.Tags) + + hasAccess, err := accesscontroller.HasAccess(ctx, s.SolanaClient, accessController, target.PublicKey(), rpc.CommitmentConfirmed) + s.Require().NoError(err, "Failed to inspect role access") + s.Require().False(hasAccess, "NoSend should not broadcast GrantRole transaction") +} diff --git a/sdk/solana/timelock_configurer.go b/sdk/solana/timelock_configurer.go index 6585909a..808f880d 100644 --- a/sdk/solana/timelock_configurer.go +++ b/sdk/solana/timelock_configurer.go @@ -20,17 +20,43 @@ var _ sdk.TimelockConfigurer = (*TimelockConfigurer)(nil) // TimelockConfigurer configures timelock parameters on Solana chains. type TimelockConfigurer struct { *TimelockInspector - client *rpc.Client - auth solana.PrivateKey + client *rpc.Client + auth solana.PrivateKey + skipSend bool + authorityAccount solana.PublicKey +} + +type timelockConfigurerOption func(*TimelockConfigurer) + +// WithDoNotSendTimelockInstructionsOnChain configures the TimelockConfigurer to build +// transactions without sending them on chain. +func WithDoNotSendTimelockInstructionsOnChain() timelockConfigurerOption { + return func(c *TimelockConfigurer) { + c.skipSend = true + } +} + +// WithTimelockAuthorityAccount sets the authority account for timelock instructions. +// Defaults to the auth public key when unset. +func WithTimelockAuthorityAccount(authorityAccount solana.PublicKey) timelockConfigurerOption { + return func(c *TimelockConfigurer) { + c.authorityAccount = authorityAccount + } } // NewTimelockConfigurer creates a new TimelockConfigurer for Solana chains. -func NewTimelockConfigurer(client *rpc.Client, auth solana.PrivateKey) *TimelockConfigurer { - return &TimelockConfigurer{ +func NewTimelockConfigurer(client *rpc.Client, auth solana.PrivateKey, options ...timelockConfigurerOption) *TimelockConfigurer { + configurer := &TimelockConfigurer{ TimelockInspector: NewTimelockInspector(client), client: client, auth: auth, + authorityAccount: auth.PublicKey(), } + for _, opt := range options { + opt(configurer) + } + + return configurer } // UpdateDelay calls the UpdateDelay instruction on the Solana RBACTimelock program. @@ -68,12 +94,121 @@ func (c *TimelockConfigurer) UpdateDelay( }, nil } -// GrantRole grants a timelock role to an address. +// GrantRole grants a timelock role to an address via the BatchAddAccess instruction. func (c *TimelockConfigurer) GrantRole( ctx context.Context, timelockAddress string, role sdk.TimelockRole, targetAddress string, ) (types.TransactionResult, error) { - panic("not implemented") + target, err := solana.PublicKeyFromBase58(targetAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("invalid target address: %s", targetAddress) + } + if target.IsZero() { + return types.TransactionResult{}, fmt.Errorf("invalid target address: %s", targetAddress) + } + + instruction, err := buildGrantRoleInstruction(ctx, c.client, timelockAddress, role, target, c.authorityAccount) + if err != nil { + return types.TransactionResult{}, err + } + + if c.skipSend { + tx, txErr := NewTransactionFromInstruction( + instruction, rbacTimelockContractType, []string{rbacTimelockContractType, "GrantRole"}, + ) + if txErr != nil { + return types.TransactionResult{}, fmt.Errorf("unable to build grant role transaction: %w", txErr) + } + + return types.TransactionResult{ + Hash: "", + ChainFamily: chainsel.FamilySolana, + RawData: tx, + }, nil + } + + signature, tx, err := sendAndConfirmInstructions(ctx, c.client, c.auth, []solana.Instruction{instruction}, rpc.CommitmentConfirmed) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("unable to grant role: %w", err) + } + + return types.TransactionResult{ + Hash: signature, + ChainFamily: chainsel.FamilySolana, + RawData: tx, + }, nil +} + +func buildGrantRoleInstruction( + ctx context.Context, + client *rpc.Client, + timelockAddress string, + role sdk.TimelockRole, + target solana.PublicKey, + authority solana.PublicKey, +) (solana.Instruction, error) { + programID, timelockID, err := ParseContractAddress(timelockAddress) + if err != nil { + return nil, fmt.Errorf("unable to parse contract address: %w", err) + } + + bindings.SetProgramID(programID) + + bindingRole, err := TimelockRoleToBinding(role) + if err != nil { + return nil, err + } + + configPDA, err := FindTimelockConfigPDA(programID, timelockID) + if err != nil { + return nil, fmt.Errorf("unable to find timelock config pda: %w", err) + } + + config, err := GetTimelockConfig(ctx, client, configPDA) + if err != nil { + return nil, fmt.Errorf("unable to read timelock config: %w", err) + } + + roleAccessController, err := getRoleAccessController(config, bindingRole) + if err != nil { + return nil, fmt.Errorf("unable to resolve role access controller: %w", err) + } + + accessControllerProgramID, err := getAccountOwner(ctx, client, roleAccessController) + if err != nil { + return nil, fmt.Errorf("unable to resolve access controller program id: %w", err) + } + + instructionBuilder := bindings.NewBatchAddAccessInstruction( + timelockID, + bindingRole, + configPDA, + accessControllerProgramID, + roleAccessController, + authority, + ) + instructionBuilder.Append(solana.Meta(target)) + + instruction, err := instructionBuilder.ValidateAndBuild() + if err != nil { + return nil, fmt.Errorf("unable to build BatchAddAccess instruction: %w", err) + } + + return instruction, nil +} + +func getAccountOwner(ctx context.Context, client *rpc.Client, account solana.PublicKey) (solana.PublicKey, error) { + info, err := client.GetAccountInfoWithOpts(ctx, account, &rpc.GetAccountInfoOpts{ + Commitment: rpc.CommitmentConfirmed, + }) + if err != nil { + return solana.PublicKey{}, fmt.Errorf("unable to get account info for %s: %w", account, err) + } + if info == nil || info.Value == nil { + return solana.PublicKey{}, fmt.Errorf("account not found: %s", account) + } + + return info.Value.Owner, nil } diff --git a/sdk/solana/timelock_configurer_test.go b/sdk/solana/timelock_configurer_test.go index 2b6a6028..42c647ce 100644 --- a/sdk/solana/timelock_configurer_test.go +++ b/sdk/solana/timelock_configurer_test.go @@ -1,17 +1,22 @@ package solana import ( + "encoding/json" "errors" "fmt" "testing" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/sdk/solana/mocks" + "github.com/smartcontractkit/mcms/types" ) func TestNewTimelockConfigurer(t *testing.T) { @@ -25,6 +30,8 @@ func TestNewTimelockConfigurer(t *testing.T) { require.NotNil(t, configurer) require.Equal(t, auth, configurer.auth) + require.Equal(t, auth.PublicKey(), configurer.authorityAccount) + require.False(t, configurer.skipSend) } func TestTimelockConfigurer_UpdateDelay(t *testing.T) { //nolint:paralleltest @@ -95,3 +102,137 @@ func TestTimelockConfigurer_UpdateDelay(t *testing.T) { //nolint:paralleltest }) } } + +func TestTimelockConfigurer_GrantRoleRejectsInvalidInputs(t *testing.T) { + t.Parallel() + + auth, err := solana.PrivateKeyFromBase58(dummyPrivateKey) + require.NoError(t, err) + + timelockAddress := fmt.Sprintf("%s.%s", testTimelockProgramID.String(), testTimelockSeed) + target, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + configurer := NewTimelockConfigurer(&rpc.Client{}, auth) + + _, err = configurer.GrantRole(t.Context(), "bad ...format", sdk.TimelockRoleProposer, target.PublicKey().String()) + require.ErrorIs(t, err, ErrInvalidContractAddressFormat) + + _, err = configurer.GrantRole(t.Context(), timelockAddress, sdk.TimelockRoleAdmin, target.PublicKey().String()) + require.EqualError(t, err, "admin role is not grantable via access controller on solana") + + _, err = configurer.GrantRole(t.Context(), timelockAddress, sdk.TimelockRole(99), target.PublicKey().String()) + require.EqualError(t, err, "invalid timelock role: 99") + + _, err = configurer.GrantRole(t.Context(), timelockAddress, sdk.TimelockRoleProposer, "not-a-pubkey") + require.EqualError(t, err, "invalid target address: not-a-pubkey") + + _, err = configurer.GrantRole(t.Context(), timelockAddress, sdk.TimelockRoleProposer, solana.PublicKey{}.String()) + require.EqualError(t, err, "invalid target address: "+solana.PublicKey{}.String()) +} + +func TestTimelockConfigurer_GrantRole(t *testing.T) { //nolint:paralleltest + auth, err := solana.PrivateKeyFromBase58(dummyPrivateKey) + require.NoError(t, err) + + timelockAddress := fmt.Sprintf("%s.%s", testTimelockProgramID.String(), testTimelockSeed) + target, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + configPDA, err := FindTimelockConfigPDA(testTimelockProgramID, testTimelockSeed) + require.NoError(t, err) + + config := createTimelockConfig(t) + accessControllerProgramID, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + overrideAuthority, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + tests := []struct { + name string + options []timelockConfigurerOption + setup func(*testing.T, *mocks.JSONRPCClient) + wantHash string + assertion assert.ErrorAssertionFunc + }{ + { + name: "success send", + setup: func(t *testing.T, m *mocks.JSONRPCClient) { + t.Helper() + mockGetAccountInfo(t, m, configPDA, config, nil) + mockGetAccountOwner(t, m, config.ProposerRoleAccessController, accessControllerProgramID.PublicKey(), nil) + mockSolanaTransaction(t, m, 20, 5, + "2QUBE2GqS8PxnGP1EBrWpLw3La4XkEUz5NKXJTdTHoA43ANkf5fqKwZ8YPJVAi3ApefbbbCYJipMVzUa7kg3a7v6", + nil, nil) + }, + wantHash: "2QUBE2GqS8PxnGP1EBrWpLw3La4XkEUz5NKXJTdTHoA43ANkf5fqKwZ8YPJVAi3ApefbbbCYJipMVzUa7kg3a7v6", + assertion: assert.NoError, + }, + { + name: "success no send", + options: []timelockConfigurerOption{WithDoNotSendTimelockInstructionsOnChain()}, + setup: func(t *testing.T, m *mocks.JSONRPCClient) { + t.Helper() + mockGetAccountInfo(t, m, configPDA, config, nil) + mockGetAccountOwner(t, m, config.ProposerRoleAccessController, accessControllerProgramID.PublicKey(), nil) + }, + wantHash: "", + assertion: assert.NoError, + }, + { + name: "success authority override", + options: []timelockConfigurerOption{ + WithDoNotSendTimelockInstructionsOnChain(), + WithTimelockAuthorityAccount(overrideAuthority.PublicKey()), + }, + setup: func(t *testing.T, m *mocks.JSONRPCClient) { + t.Helper() + mockGetAccountInfo(t, m, configPDA, config, nil) + mockGetAccountOwner(t, m, config.ProposerRoleAccessController, accessControllerProgramID.PublicKey(), nil) + }, + wantHash: "", + assertion: assert.NoError, + }, + { + name: "error send fails", + setup: func(t *testing.T, m *mocks.JSONRPCClient) { + t.Helper() + t.Setenv("MCMS_SOLANA_MAX_RETRIES", "1") + mockGetAccountInfo(t, m, configPDA, config, nil) + mockGetAccountOwner(t, m, config.ProposerRoleAccessController, accessControllerProgramID.PublicKey(), nil) + mockSolanaTransaction(t, m, 20, 5, + "2QUBE2GqS8PxnGP1EBrWpLw3La4XkEUz5NKXJTdTHoA43ANkf5fqKwZ8YPJVAi3ApefbbbCYJipMVzUa7kg3a7v6", + nil, errors.New("send failed")) + }, + assertion: func(t assert.TestingT, err error, _ ...any) bool { + return assert.EqualError(t, err, "unable to grant role: unable to send instruction: send failed") + }, + }, + } + + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { + jsonRPCClient := mocks.NewJSONRPCClient(t) + client := rpc.NewWithCustomRPCClient(jsonRPCClient) + configurer := NewTimelockConfigurer(client, auth, tt.options...) + tt.setup(t, jsonRPCClient) + + got, err := configurer.GrantRole(t.Context(), timelockAddress, sdk.TimelockRoleProposer, target.PublicKey().String()) + + tt.assertion(t, err) + assert.Equal(t, tt.wantHash, got.Hash) + if err == nil && tt.wantHash == "" { + require.Equal(t, chainsel.FamilySolana, got.ChainFamily) + tx, ok := got.RawData.(types.Transaction) + require.True(t, ok) + require.Equal(t, testTimelockProgramID.String(), tx.To) + require.Equal(t, rbacTimelockContractType, tx.ContractType) + require.Equal(t, []string{rbacTimelockContractType, "GrantRole"}, tx.Tags) + + var additionalFields AdditionalFields + require.NoError(t, json.Unmarshal(tx.AdditionalFields, &additionalFields)) + require.NotEmpty(t, additionalFields.Accounts) + } + }) + } +} diff --git a/sdk/solana/timelock_converter.go b/sdk/solana/timelock_converter.go index fd89c514..f9c8cf06 100644 --- a/sdk/solana/timelock_converter.go +++ b/sdk/solana/timelock_converter.go @@ -296,7 +296,7 @@ func solanaInstructionToMcmsOperation( } transaction, err := NewTransaction(instruction.ProgramID().String(), data, (*big.Int)(nil), - accounts, "RBACTimelock", tags) + accounts, rbacTimelockContractType, tags) if err != nil { return []types.Operation{}, fmt.Errorf("unable to create new transaction: %w", err) } diff --git a/sdk/solana/timelock_converter_test.go b/sdk/solana/timelock_converter_test.go index 1da9c367..32e2864b 100644 --- a/sdk/solana/timelock_converter_test.go +++ b/sdk/solana/timelock_converter_test.go @@ -119,7 +119,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -138,7 +138,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -157,7 +157,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -176,7 +176,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -195,7 +195,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -213,7 +213,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -231,7 +231,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -263,7 +263,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -296,7 +296,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -315,7 +315,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -334,7 +334,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -353,7 +353,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -372,7 +372,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -390,7 +390,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, @@ -413,7 +413,7 @@ func TestTimelockConverter_ConvertBatchToChainOperations(t *testing.T) { {PublicKey: solana.MPK("t3ChqFTKHUFdjNPDf8CuhFGwkwzqR47LL7sDbeU99XD"), IsWritable: false, IsSigner: false}, }}), OperationMetadata: types.OperationMetadata{ - ContractType: "RBACTimelock", + ContractType: rbacTimelockContractType, Tags: []string{"tag1.1", "tag1.2", "tag2.1", "tag2.2"}, }, }, diff --git a/sdk/solana/timelock_role.go b/sdk/solana/timelock_role.go new file mode 100644 index 00000000..ed15eec1 --- /dev/null +++ b/sdk/solana/timelock_role.go @@ -0,0 +1,30 @@ +package solana + +import ( + "fmt" + + "github.com/smartcontractkit/mcms/sdk" + + bindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock" +) + +var timelockRoleBindings = map[sdk.TimelockRole]bindings.Role{ + sdk.TimelockRoleProposer: bindings.Proposer_Role, + sdk.TimelockRoleExecutor: bindings.Executor_Role, + sdk.TimelockRoleCanceller: bindings.Canceller_Role, + sdk.TimelockRoleBypasser: bindings.Bypasser_Role, +} + +// TimelockRoleToBinding maps sdk.TimelockRole to the Solana timelock program Role enum. +func TimelockRoleToBinding(role sdk.TimelockRole) (bindings.Role, error) { + if role == sdk.TimelockRoleAdmin { + return bindings.Role(0), fmt.Errorf("admin role is not grantable via access controller on solana") + } + + bindingRole, ok := timelockRoleBindings[role] + if !ok { + return bindings.Role(0), fmt.Errorf("invalid timelock role: %d", role) + } + + return bindingRole, nil +} diff --git a/sdk/solana/timelock_role_test.go b/sdk/solana/timelock_role_test.go new file mode 100644 index 00000000..b08664c9 --- /dev/null +++ b/sdk/solana/timelock_role_test.go @@ -0,0 +1,50 @@ +package solana + +import ( + "testing" + + "github.com/stretchr/testify/require" + + bindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock" + + "github.com/smartcontractkit/mcms/sdk" +) + +func TestTimelockRoleToBinding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + role sdk.TimelockRole + want bindings.Role + }{ + {name: "proposer", role: sdk.TimelockRoleProposer, want: bindings.Proposer_Role}, + {name: "executor", role: sdk.TimelockRoleExecutor, want: bindings.Executor_Role}, + {name: "canceller", role: sdk.TimelockRoleCanceller, want: bindings.Canceller_Role}, + {name: "bypasser", role: sdk.TimelockRoleBypasser, want: bindings.Bypasser_Role}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := TimelockRoleToBinding(tt.role) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestTimelockRoleToBindingRejectsAdmin(t *testing.T) { + t.Parallel() + + _, err := TimelockRoleToBinding(sdk.TimelockRoleAdmin) + require.EqualError(t, err, "admin role is not grantable via access controller on solana") +} + +func TestTimelockRoleToBindingRejectsInvalid(t *testing.T) { + t.Parallel() + + _, err := TimelockRoleToBinding(sdk.TimelockRole(99)) + require.EqualError(t, err, "invalid timelock role: 99") +} diff --git a/sdk/solana/transaction.go b/sdk/solana/transaction.go index c723314e..26fea03f 100644 --- a/sdk/solana/transaction.go +++ b/sdk/solana/transaction.go @@ -11,6 +11,8 @@ import ( "github.com/smartcontractkit/mcms/types" ) +const rbacTimelockContractType = "RBACTimelock" + func ValidateAdditionalFields(additionalFields json.RawMessage) error { fields := AdditionalFields{ Value: big.NewInt(0), diff --git a/sdk/solana/utils_test.go b/sdk/solana/utils_test.go index fb45df26..df18b64f 100644 --- a/sdk/solana/utils_test.go +++ b/sdk/solana/utils_test.go @@ -45,6 +45,25 @@ func mockGetAccountInfo( }).Once() } +func mockGetAccountOwner( + t *testing.T, mockJSONRPCClient *mocks.JSONRPCClient, account solana.PublicKey, owner solana.PublicKey, + mockError error, +) { + t.Helper() + + mockJSONRPCClient.EXPECT().CallForInto(anyContext, mock.Anything, "getAccountInfo", []any{ + account, rpc.M{"commitment": rpc.CommitmentConfirmed, "encoding": solana.EncodingBase64}, + }, + ).RunAndReturn(func(_ context.Context, output any, _ string, _ []any) error { + result, ok := output.(**rpc.GetAccountInfoResult) + require.True(t, ok) + + *result = &rpc.GetAccountInfoResult{Value: &rpc.Account{Owner: owner}} + + return mockError + }).Once() +} + func mockGetBlockTime( t *testing.T, client *mocks.JSONRPCClient, slot uint64, blockTime *solana.UnixTimeSeconds, mockBlockHeightError error,