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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions e2e/tests/solana/timelock_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
}
147 changes: 141 additions & 6 deletions sdk/solana/timelock_configurer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I think it should be possible to use the same solution used for the Configurer type and embed instructionCollection. It should, in theory, reduce a bit of duplication.

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
}
Loading
Loading