diff --git a/mcms/changesets/transfer-to-timelock/all/wire.go b/mcms/changesets/transfer-to-timelock/all/wire.go index 9761451..881e3ca 100644 --- a/mcms/changesets/transfer-to-timelock/all/wire.go +++ b/mcms/changesets/transfer-to-timelock/all/wire.go @@ -4,4 +4,6 @@ package all import ( _ "github.com/smartcontractkit/cld-changesets/mcms/evm/readers" _ "github.com/smartcontractkit/cld-changesets/mcms/evm/transfer-to-timelock" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/transfer-to-timelock" ) diff --git a/mcms/solana/transfer-to-timelock/changeset_test.go b/mcms/solana/transfer-to-timelock/changeset_test.go new file mode 100644 index 0000000..c5bbfd2 --- /dev/null +++ b/mcms/solana/transfer-to-timelock/changeset_test.go @@ -0,0 +1,249 @@ +package soltransfertotimelock_test + +import ( + "crypto/ecdsa" + "testing" + "time" + + "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" + cldfdatastore "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" + 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/pkg/logger" + mcmstypes "github.com/smartcontractkit/mcms/types" + + mcmBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/mcm" + + "github.com/smartcontractkit/cld-changesets/datastore/refkey" + "github.com/smartcontractkit/cld-changesets/internal/semvers" + "github.com/smartcontractkit/cld-changesets/internal/testutil/solanatest" + solstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils" + mcmsdeploy "github.com/smartcontractkit/cld-changesets/mcms/changesets/deploy" + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" + pdasol "github.com/smartcontractkit/cld-changesets/pkg/family/solana" + + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/deploy" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/transfer-to-timelock" +) + +// TestChangeset shares one Solana container across the transfer and idempotent subtests to +// avoid paying the ~30 s container-startup cost multiple times. OnlyAcceptOwnership keeps its +// own environment because it requires a pending-ownership state that conflicts with the other +// flows. +// +//nolint:paralleltest // global mcm.SetProgramID state; serialized via soltestutils.PreloadMCMS lock +func TestChangeset(t *testing.T) { + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + + t.Run("OnlyAcceptOwnership", func(t *testing.T) { //nolint:paralleltest + rt, chain, mcmsState := newTransferToTimelockTestEnv(t, selector) + + timelockSignerPDA := pdasol.GetTimelockSignerPDA(mcmsState.TimelockProgram, mcmsState.TimelockSeed) + proposerConfigPDA := pdasol.GetMCMConfigPDA(mcmsState.McmProgram, mcmsState.ProposerMcmSeed) + deployer := chain.DeployerKey.PublicKey() + + require.NoError(t, transferProposerMCMOnChain(t, chain, mcmsState, timelockSignerPDA)) + + assertMCMOwner(t, deployer, proposerConfigPDA, chain) + + err := rt.Exec( + runtime.ChangesetTask(transfertotimelock.Changeset{}, acceptOwnershipInput(selector)), + ) + require.NoError(t, err) + + err = rt.Exec(runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner})) + require.NoError(t, err) + require.Len(t, rt.State().Proposals, 1) + require.True(t, rt.State().Proposals[0].IsExecuted) + + assertMCMOwner(t, timelockSignerPDA, proposerConfigPDA, chain) + }) + + rt, chain, mcmsState := newTransferToTimelockTestEnv(t, selector) + timelockSignerPDA := pdasol.GetTimelockSignerPDA(mcmsState.TimelockProgram, mcmsState.TimelockSeed) + proposerConfigPDA := pdasol.GetMCMConfigPDA(mcmsState.McmProgram, mcmsState.ProposerMcmSeed) + transferInput := transferToTimelockInput(selector) + + t.Run("TransferOwnershipToTimelock", func(t *testing.T) { //nolint:paralleltest + deployer := chain.DeployerKey.PublicKey() + assertMCMOwner(t, deployer, proposerConfigPDA, chain) + + execTransferToTimelock(t, rt, transferInput) + require.Len(t, rt.State().Proposals, 1) + require.True(t, rt.State().Proposals[0].IsExecuted) + + assertMCMOwner(t, timelockSignerPDA, proposerConfigPDA, chain) + }) + + t.Run("IdempotentWhenAlreadyOwnedByTimelock", func(t *testing.T) { //nolint:paralleltest + ensureProposerOwnedByTimelock(t, rt, chain, timelockSignerPDA, proposerConfigPDA, transferInput) + + taskID, err := runtime.ExecChangeset(rt, transfertotimelock.Changeset{}, transferInput) + require.NoError(t, err) + + output, ok := rt.State().Outputs[taskID] + require.True(t, ok) + require.Empty(t, output.MCMSTimelockProposals, "expected no proposal when contract already owned by timelock") + }) +} + +func transferToTimelockInput(selector uint64) transfertotimelock.Input { + return transfertotimelock.Input{ + Cfg: transfertotimelock.Config{ + ContractsByChain: map[uint64][]refkey.RefKey{ + selector: {contractRef(selector, mcmscontracts.ProposerManyChainMultisig, "")}, + }, + }, + MCMS: &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + Description: "Transfer proposer MCM ownership to timelock", + TimelockDelay: mcmstypes.NewDuration(time.Second), + }, + } +} + +func acceptOwnershipInput(selector uint64) transfertotimelock.Input { + return transfertotimelock.Input{ + Cfg: transfertotimelock.Config{ + OnlyAcceptOwnership: true, + ContractsByChain: map[uint64][]refkey.RefKey{ + selector: {contractRef(selector, mcmscontracts.ProposerManyChainMultisig, "")}, + }, + }, + MCMS: &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + Description: "Accept proposer MCM ownership on timelock", + TimelockDelay: mcmstypes.NewDuration(time.Second), + }, + } +} + +func execTransferToTimelock(t *testing.T, rt *runtime.Runtime, input transfertotimelock.Input) { + t.Helper() + + err := rt.Exec( + runtime.ChangesetTask(transfertotimelock.Changeset{}, input), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + ) + require.NoError(t, err) +} + +func ensureProposerOwnedByTimelock( + t *testing.T, + rt *runtime.Runtime, + chain cldfsol.Chain, + timelockSignerPDA, proposerConfigPDA solana.PublicKey, + input transfertotimelock.Input, +) { + t.Helper() + + if mcmOwner(t, proposerConfigPDA, chain) == timelockSignerPDA { + return + } + + execTransferToTimelock(t, rt, input) + assertMCMOwner(t, timelockSignerPDA, proposerConfigPDA, chain) +} + +func transferProposerMCMOnChain( + t *testing.T, + chain cldfsol.Chain, + mcmsState *solstate.MCMSWithTimelockState, + timelockSignerPDA solana.PublicKey, +) error { + t.Helper() + + configPDA := pdasol.GetMCMConfigPDA(mcmsState.McmProgram, mcmsState.ProposerMcmSeed) + mcmBindings.SetProgramID(mcmsState.McmProgram) + + ix, err := mcmBindings.NewTransferOwnershipInstruction( + mcmsState.ProposerMcmSeed, + timelockSignerPDA, + configPDA, + chain.DeployerKey.PublicKey(), + ).ValidateAndBuild() + if err != nil { + return err + } + + return chain.Confirm([]solana.Instruction{&seededInstruction{Instruction: ix, programID: mcmsState.McmProgram}}) +} + +type seededInstruction struct { + *mcmBindings.Instruction + programID solana.PublicKey +} + +func (s *seededInstruction) ProgramID() solana.PublicKey { + return s.programID +} + +func newTransferToTimelockTestEnv( + t *testing.T, + selector uint64, +) (*runtime.Runtime, cldfsol.Chain, *solstate.MCMSWithTimelockState) { + t.Helper() + + programsPath, programIDs, _ := soltestutils.PreloadMCMS(t, selector) + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithSolanaContainer(t, []uint64{selector}, programsPath, programIDs), + environment.WithDatastore(solanatest.NewDataStoreWithMCMSPrograms(t, selector)), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + chain := rt.Environment().BlockChains.SolanaChains()[selector] + + err = rt.Exec( + runtime.ChangesetTask(mcmsdeploy.Changeset{}, mcmsdeploy.Input{ + ConfigByChain: map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cldftesthelpers.SingleGroupTimelockConfig(t), + }, + }), + ) + require.NoError(t, err) + + refs := rt.Environment().DataStore.Addresses().Filter(cldfdatastore.AddressRefByChainSelector(selector)) + mcmsState, err := solstate.MaybeLoadMCMSWithTimelockChainStateV2(refs) + require.NoError(t, err) + soltestutils.FundSignerPDAs(t, chain, mcmsState) + + return rt, chain, mcmsState +} + +func contractRef(chainSelector uint64, contractType cldf.ContractType, qualifier string) refkey.RefKey { + return refkey.New(chainSelector, cldfdatastore.ContractType(contractType), &semvers.V1_0_0, qualifier) +} + +func mcmOwner(t *testing.T, configPDA solana.PublicKey, chain cldfsol.Chain) solana.PublicKey { + t.Helper() + + var config mcmBindings.MultisigConfig + err := chain.GetAccountDataBorshInto(t.Context(), configPDA, &config) + require.NoError(t, err) + + return config.Owner +} + +func assertMCMOwner( + t *testing.T, + want solana.PublicKey, + configPDA solana.PublicKey, + chain cldfsol.Chain, +) { + t.Helper() + + require.Equal(t, want, mcmOwner(t, configPDA, chain)) +} diff --git a/mcms/solana/transfer-to-timelock/contract.go b/mcms/solana/transfer-to-timelock/contract.go new file mode 100644 index 0000000..8c9946a --- /dev/null +++ b/mcms/solana/transfer-to-timelock/contract.go @@ -0,0 +1,111 @@ +package soltransfertotimelock + +import ( + "fmt" + + solanago "github.com/gagliardetto/solana-go" + "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/cld-changesets/datastore/refkey" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" +) + +// OwnableContract identifies a Solana ownable program account to transfer. +type OwnableContract struct { + ProgramID solanago.PublicKey + Seed legacysolana.PDASeed + OwnerPDA solanago.PublicKey + Type cldf.ContractType +} + +func resolveOwnableContract(env cldf.Environment, chainSelector uint64, ref refkey.RefKey) (OwnableContract, error) { + if ref.ChainSelector != 0 && ref.ChainSelector != chainSelector { + return OwnableContract{}, fmt.Errorf( + "ref chain selector %d does not match chain %d", + ref.ChainSelector, + chainSelector, + ) + } + if ref.ChainSelector == 0 { + ref.ChainSelector = chainSelector + } + + resolved, err := ref.Resolve(env) + if err != nil { + return OwnableContract{}, err + } + + contractType := cldf.ContractType(resolved.Type) + programID, seed, err := legacysolana.DecodeAddressWithSeed(resolved.Address) + if err != nil { + account, parseErr := solanago.PublicKeyFromBase58(resolved.Address) + if parseErr != nil { + return OwnableContract{}, fmt.Errorf("parse contract address %q: %w", resolved.Address, parseErr) + } + + acProgram, acErr := accessControllerProgramFromDatastore(env, chainSelector, ref.Qualifier) + if acErr != nil { + return OwnableContract{}, acErr + } + + return OwnableContract{ + ProgramID: acProgram, + OwnerPDA: account, + Type: contractType, + }, nil + } + + ownerPDA, err := ownerPDAForSeededContract(programID, seed, contractType) + if err != nil { + return OwnableContract{}, err + } + + return OwnableContract{ + ProgramID: programID, + Seed: seed, + OwnerPDA: ownerPDA, + Type: contractType, + }, nil +} + +func ownerPDAForSeededContract( + programID solanago.PublicKey, + seed legacysolana.PDASeed, + contractType cldf.ContractType, +) (solanago.PublicKey, error) { + switch contractType { + case mcmscontracts.ProposerManyChainMultisig, + mcmscontracts.CancellerManyChainMultisig, + mcmscontracts.BypasserManyChainMultisig: + return familysolana.GetMCMConfigPDA(programID, seed), nil + case mcmscontracts.RBACTimelock: + return familysolana.GetTimelockConfigPDA(programID, seed), nil + default: + return solanago.PublicKey{}, fmt.Errorf("unsupported seeded contract type %q for transfer to timelock", contractType) + } +} + +func accessControllerProgramFromDatastore(env cldf.Environment, chainSelector uint64, qualifier string) (solanago.PublicKey, error) { + if env.DataStore == nil { + return solanago.PublicKey{}, fmt.Errorf("datastore not available for chain %d", chainSelector) + } + + ref, err := datastore.FindUniqueRef(env.DataStore.Addresses(), datastore.AddressRef{ + ChainSelector: chainSelector, + Type: datastore.ContractType(mcmscontracts.AccessControllerProgram), + Qualifier: qualifier, + }) + if err != nil { + return solanago.PublicKey{}, fmt.Errorf("resolve access controller program for chain %d: %w", chainSelector, err) + } + + programID, err := solanago.PublicKeyFromBase58(ref.Address) + if err != nil { + return solanago.PublicKey{}, fmt.Errorf("parse access controller program for chain %d: %w", chainSelector, err) + } + + return programID, nil +} diff --git a/mcms/solana/transfer-to-timelock/instructions.go b/mcms/solana/transfer-to-timelock/instructions.go new file mode 100644 index 0000000..2e98daa --- /dev/null +++ b/mcms/solana/transfer-to-timelock/instructions.go @@ -0,0 +1,106 @@ +package soltransfertotimelock + +import ( + "fmt" + + solanago "github.com/gagliardetto/solana-go" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + accessControllerBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/access_controller" + mcmBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/mcm" + + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" +) + +func transferOwnershipInstruction( + programID solanago.PublicKey, + seed legacysolana.PDASeed, + proposedOwner, ownerPDA, auth solanago.PublicKey, +) (solanago.Instruction, error) { + if (seed == legacysolana.PDASeed{}) { + return newSeedlessTransferOwnershipInstruction(programID, proposedOwner, ownerPDA, auth) + } + + return newSeededTransferOwnershipInstruction(programID, seed, proposedOwner, ownerPDA, auth) +} + +func acceptMCMSTransaction(contract OwnableContract, authority solanago.PublicKey) (mcmstypes.Transaction, error) { + acceptInstruction, err := acceptOwnershipInstruction(contract.ProgramID, contract.Seed, contract.OwnerPDA, authority) + if err != nil { + return mcmstypes.Transaction{}, fmt.Errorf("build accept ownership instruction: %w", err) + } + + acceptMCMSTx, err := mcmssolanasdk.NewTransactionFromInstruction(acceptInstruction, string(contract.Type), []string{}) + if err != nil { + return mcmstypes.Transaction{}, fmt.Errorf("build mcms transaction from accept ownership instruction: %w", err) + } + + return acceptMCMSTx, nil +} + +func acceptOwnershipInstruction( + programID solanago.PublicKey, + seed legacysolana.PDASeed, + ownerPDA, auth solanago.PublicKey, +) (solanago.Instruction, error) { + if (seed == legacysolana.PDASeed{}) { + return newSeedlessAcceptOwnershipInstruction(programID, ownerPDA, auth) + } + + return newSeededAcceptOwnershipInstruction(programID, seed, ownerPDA, auth) +} + +func newSeededTransferOwnershipInstruction( + programID solanago.PublicKey, + seed legacysolana.PDASeed, + proposedOwner, config, authority solanago.PublicKey, +) (solanago.Instruction, error) { + ix, err := mcmBindings.NewTransferOwnershipInstruction(seed, proposedOwner, config, authority).ValidateAndBuild() + + return &seededInstruction{Instruction: ix, programID: programID}, err +} + +func newSeededAcceptOwnershipInstruction( + programID solanago.PublicKey, + seed legacysolana.PDASeed, + config, authority solanago.PublicKey, +) (solanago.Instruction, error) { + ix, err := mcmBindings.NewAcceptOwnershipInstruction(seed, config, authority).ValidateAndBuild() + + return &seededInstruction{Instruction: ix, programID: programID}, err +} + +func newSeedlessTransferOwnershipInstruction( + programID, proposedOwner, config, authority solanago.PublicKey, +) (solanago.Instruction, error) { + ix, err := accessControllerBindings.NewTransferOwnershipInstruction(proposedOwner, config, authority).ValidateAndBuild() + + return &seedlessInstruction{Instruction: ix, programID: programID}, err +} + +func newSeedlessAcceptOwnershipInstruction( + programID, config, authority solanago.PublicKey, +) (solanago.Instruction, error) { + ix, err := accessControllerBindings.NewAcceptOwnershipInstruction(config, authority).ValidateAndBuild() + + return &seedlessInstruction{Instruction: ix, programID: programID}, err +} + +type seedlessInstruction struct { + *accessControllerBindings.Instruction + programID solanago.PublicKey +} + +func (s *seedlessInstruction) ProgramID() solanago.PublicKey { + return s.programID +} + +type seededInstruction struct { + *mcmBindings.Instruction + programID solanago.PublicKey +} + +func (s *seededInstruction) ProgramID() solanago.PublicKey { + return s.programID +} diff --git a/mcms/solana/transfer-to-timelock/operation.go b/mcms/solana/transfer-to-timelock/operation.go new file mode 100644 index 0000000..f3afd93 --- /dev/null +++ b/mcms/solana/transfer-to-timelock/operation.go @@ -0,0 +1,116 @@ +package soltransfertotimelock + +import ( + "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" + mcmstypes "github.com/smartcontractkit/mcms/types" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" +) + +// OpTransferToTimelockInput is the input for transferring one Solana contract to the timelock. +type OpTransferToTimelockInput struct { + Contract OwnableContract + TimelockSignerPDA solanago.PublicKey + OnlyAcceptOwnership bool +} + +// OpTransferToTimelockOutput is the output of a single contract transfer operation. +type OpTransferToTimelockOutput struct { + BatchOps []mcmstypes.BatchOperation +} + +// OpTransferToTimelock transfers one ownable Solana contract to the timelock signer PDA. +var OpTransferToTimelock = operations.NewOperation( + "solana-transfer-to-timelock", + semver.MustParse("1.0.0"), + "Transfer ownable Solana contract ownership to the MCMS timelock", + func(b operations.Bundle, chain cldfsol.Chain, in OpTransferToTimelockInput) (OpTransferToTimelockOutput, error) { + var out OpTransferToTimelockOutput + chainSelector := chain.Selector + + if !in.OnlyAcceptOwnership { + if chain.DeployerKey == nil { + return OpTransferToTimelockOutput{}, fmt.Errorf("missing deployer key for chain %d", chain.Selector) + } + + transferInstruction, err := transferOwnershipInstruction( + in.Contract.ProgramID, + in.Contract.Seed, + in.TimelockSignerPDA, + in.Contract.OwnerPDA, + chain.DeployerKey.PublicKey(), + ) + if err != nil { + return out, fmt.Errorf("create transfer ownership instruction: %w", err) + } + + b.Logger.Infow( + "confirming solana transfer ownership instruction", + "program", in.Contract.ProgramID.String(), + "contractType", in.Contract.Type, + ) + if err = chain.Confirm([]solanago.Instruction{transferInstruction}); err != nil { + return out, fmt.Errorf("confirm transfer ownership instruction: %w", err) + } + + owner, err := contractOwner(b.GetContext(), chain, in.Contract) + if err != nil { + return out, fmt.Errorf("read contract owner after transfer: %w", err) + } + if owner == in.TimelockSignerPDA { + b.Logger.Infof("contract %s already owned by timelock after transfer", in.Contract.Type) + return out, nil + } + } + + acceptMCMSTx, err := acceptMCMSTransaction(in.Contract, in.TimelockSignerPDA) + if err != nil { + return out, fmt.Errorf("create accept ownership mcms transaction: %w", err) + } + + out.BatchOps = append(out.BatchOps, mcmstypes.BatchOperation{ + ChainSelector: mcmstypes.ChainSelector(chainSelector), + Transactions: []mcmstypes.Transaction{acceptMCMSTx}, + }) + + return out, nil + }, +) + +func transferContractToTimelock( + b operations.Bundle, + chain cldfsol.Chain, + timelockSignerPDA solanago.PublicKey, + contract OwnableContract, + in transfertotimelock.ChainInput, +) ([]mcmstypes.BatchOperation, error) { + owner, err := contractOwner(b.GetContext(), chain, contract) + if err != nil { + return nil, fmt.Errorf("read contract owner: %w", err) + } + if owner == timelockSignerPDA { + b.Logger.Infof("contract %s already owned by timelock", contract.Type) + return nil, nil + } + + report, err := operations.ExecuteOperation( + b, + OpTransferToTimelock, + chain, + OpTransferToTimelockInput{ + Contract: contract, + TimelockSignerPDA: timelockSignerPDA, + OnlyAcceptOwnership: in.OnlyAcceptOwnership, + }, + ) + if err != nil { + return nil, err + } + + return report.Output.BatchOps, nil +} diff --git a/mcms/solana/transfer-to-timelock/owner.go b/mcms/solana/transfer-to-timelock/owner.go new file mode 100644 index 0000000..464a3b1 --- /dev/null +++ b/mcms/solana/transfer-to-timelock/owner.go @@ -0,0 +1,77 @@ +package soltransfertotimelock + +import ( + "context" + "fmt" + + solanago "github.com/gagliardetto/solana-go" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + + accessControllerBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/access_controller" + mcmBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/mcm" + timelockBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock" +) + +func contractOwner(ctx context.Context, chain cldfsol.Chain, contract OwnableContract) (solanago.PublicKey, error) { + switch contract.Type { + case mcmscontracts.ProposerManyChainMultisig, + mcmscontracts.CancellerManyChainMultisig, + mcmscontracts.BypasserManyChainMultisig: + var config mcmBindings.MultisigConfig + if err := chain.GetAccountDataBorshInto(ctx, contract.OwnerPDA, &config); err != nil { + return solanago.PublicKey{}, fmt.Errorf("read MCM config owner for %s: %w", contract.Type, err) + } + + return config.Owner, nil + case mcmscontracts.RBACTimelock: + var config timelockBindings.Config + if err := chain.GetAccountDataBorshInto(ctx, contract.OwnerPDA, &config); err != nil { + return solanago.PublicKey{}, fmt.Errorf("read timelock config owner: %w", err) + } + + return config.Owner, nil + case mcmscontracts.ProposerAccessControllerAccount, + mcmscontracts.ExecutorAccessControllerAccount, + mcmscontracts.CancellerAccessControllerAccount, + mcmscontracts.BypasserAccessControllerAccount: + var config accessControllerBindings.AccessController + if err := chain.GetAccountDataBorshInto(ctx, contract.OwnerPDA, &config); err != nil { + return solanago.PublicKey{}, fmt.Errorf("read access controller owner for %s: %w", contract.Type, err) + } + + return config.Owner, nil + default: + return solanago.PublicKey{}, fmt.Errorf("unsupported contract type %q for owner lookup", contract.Type) + } +} + +func validateContractOwner( + contract OwnableContract, + owner solanago.PublicKey, + deployer solanago.PublicKey, + timelock solanago.PublicKey, + onlyAccept bool, +) error { + if owner == timelock { + return nil + } + + if onlyAccept { + if owner != deployer { + return fmt.Errorf( + "contract %s: only accept ownership requires current owner to be deployer or timelock, got %s", + contract.Type, + owner.String(), + ) + } + + return nil + } + + if owner != deployer { + return fmt.Errorf("contract %s is not owned by the deployer key", contract.Type) + } + + return nil +} diff --git a/mcms/solana/transfer-to-timelock/register.go b/mcms/solana/transfer-to-timelock/register.go new file mode 100644 index 0000000..91adc57 --- /dev/null +++ b/mcms/solana/transfer-to-timelock/register.go @@ -0,0 +1,36 @@ +package soltransfertotimelock + +import ( + "fmt" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" +) + +func init() { + transfertotimelock.Registry.Register(Registration()) +} + +// Registration returns the Solana chain-family transfer-to-timelock registration. +func Registration() transfertotimelock.Registration { + return transfertotimelock.Registration{ + Family: chainselectors.FamilySolana, + Sequence: seqTransferToTimelock, + Verify: verifySolanaChains, + } +} + +func verifySolanaChains(env cldf.Environment, chains []transfertotimelock.ChainInput) error { + for _, in := range chains { + if err := validateMCMS(env, in); err != nil { + return err + } + if err := validateContracts(env, in); err != nil { + return fmt.Errorf("chain %d: %w", in.ChainSelector, err) + } + } + + return nil +} diff --git a/mcms/solana/transfer-to-timelock/sequence.go b/mcms/solana/transfer-to-timelock/sequence.go new file mode 100644 index 0000000..06027c7 --- /dev/null +++ b/mcms/solana/transfer-to-timelock/sequence.go @@ -0,0 +1,106 @@ +package soltransfertotimelock + +import ( + "errors" + "fmt" + + 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" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + 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" + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" +) + +var seqTransferToTimelock = operations.NewSequence( + "seq-solana-transfer-to-timelock", + &semvers.V1_0_0, + "Transfers ownable Solana contract ownership to the MCMS timelock", + runSolanaTransferToTimelock, +) + +func runSolanaTransferToTimelock( + b operations.Bundle, + deps transfertotimelock.Deps, + in transfertotimelock.ChainInput, +) (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 := transfertotimelock.EnvFromDeps(deps) + if in.MCMS == nil { + return sequenceutils.OnChainOutput{}, errors.New("MCMS timelock proposal input is required") + } + if len(in.Contracts) == 0 { + return sequenceutils.OnChainOutput{}, errors.New("no contracts provided for solana chain") + } + + timelockSignerPDA, err := timelockSignerPDA(env, in) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + var batchOps []mcmstypes.BatchOperation + for i, ref := range in.Contracts { + contract, err := resolveOwnableContract(env, in.ChainSelector, ref) + if err != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("contracts[%d]: %w", i, err) + } + + ops, err := transferContractToTimelock(b, chain, timelockSignerPDA, contract, in) + if err != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("contract %s: %w", contract.Type, err) + } + batchOps = append(batchOps, ops...) + } + + if len(batchOps) == 0 { + return sequenceutils.OnChainOutput{}, nil + } + + return sequenceutils.OnChainOutput{BatchOps: batchOps}, nil +} + +func timelockSignerPDA(env cldf.Environment, in transfertotimelock.ChainInput) (solanago.PublicKey, error) { + if in.MCMS == nil { + return solanago.PublicKey{}, errors.New("MCMS timelock proposal input is required") + } + + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilySolana) + if !ok { + return solanago.PublicKey{}, fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilySolana) + } + + timelockRef, err := reader.GetTimelockRef(env, in.ChainSelector, *in.MCMS) + if err != nil { + return solanago.PublicKey{}, fmt.Errorf("resolve timelock for chain %d: %w", in.ChainSelector, err) + } + + timelockProgram, timelockSeed, err := mcmssolanasdk.ParseContractAddress(timelockRef.Address) + 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 requireSolanaChain(env cldf.Environment, chainSelector uint64) (cldfsol.Chain, error) { + chain, ok := env.BlockChains.SolanaChains()[chainSelector] + if !ok { + return cldfsol.Chain{}, fmt.Errorf("solana chain %d not found in environment", chainSelector) + } + + return chain, nil +} diff --git a/mcms/solana/transfer-to-timelock/validate.go b/mcms/solana/transfer-to-timelock/validate.go new file mode 100644 index 0000000..d05086b --- /dev/null +++ b/mcms/solana/transfer-to-timelock/validate.go @@ -0,0 +1,103 @@ +package soltransfertotimelock + +import ( + "errors" + "fmt" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" +) + +func validateMCMS(env cldf.Environment, in transfertotimelock.ChainInput) error { + if in.MCMS == nil { + return errors.New("MCMS timelock proposal input is required") + } + + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilySolana) + if !ok { + return fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilySolana) + } + + timelockRef, err := reader.GetTimelockRef(env, in.ChainSelector, *in.MCMS) + if err != nil { + return fmt.Errorf("timelock not present on chain %d: %w", in.ChainSelector, err) + } + if _, _, err = mcmssolanasdk.ParseContractAddress(timelockRef.Address); err != nil { + return fmt.Errorf("invalid timelock ref on chain %d: %w", in.ChainSelector, err) + } + + mcmsRef, err := reader.GetMCMSRef(env, in.ChainSelector, *in.MCMS) + if err != nil { + return fmt.Errorf("mcms not present on chain %d: %w", in.ChainSelector, err) + } + if _, _, err = mcmssolanasdk.ParseContractAddress(mcmsRef.Address); err != nil { + return fmt.Errorf("invalid mcms ref on chain %d: %w", in.ChainSelector, err) + } + + return nil +} + +func validateContracts(env cldf.Environment, in transfertotimelock.ChainInput) error { + if len(in.Contracts) == 0 { + return errors.New("no contracts provided") + } + + chain, err := requireSolanaChain(env, in.ChainSelector) + if err != nil { + return err + } + if chain.DeployerKey == nil { + return fmt.Errorf("missing deployer key for chain %d", in.ChainSelector) + } + + seen := make(map[string]struct{}, len(in.Contracts)) + for i, ref := range in.Contracts { + key, keyErr := ref.Key() + if keyErr != nil { + return fmt.Errorf("contracts[%d]: %w", i, keyErr) + } + keyStr := fmt.Sprintf("%v", key) + if _, dup := seen[keyStr]; dup { + return fmt.Errorf("duplicate contract ref %v", key) + } + seen[keyStr] = struct{}{} + } + + timelockSignerPDA, err := timelockSignerPDA(env, in) + if err != nil { + return err + } + + deployer := chain.DeployerKey.PublicKey() + ctx := env.GetContext() + + contracts := make([]OwnableContract, len(in.Contracts)) + seenContracts := make(map[string]struct{}, len(in.Contracts)) + for i, ref := range in.Contracts { + contract, err := resolveOwnableContract(env, in.ChainSelector, ref) + if err != nil { + return fmt.Errorf("contracts[%d]: %w", i, err) + } + contractID := contract.OwnerPDA.String() + if _, dup := seenContracts[contractID]; dup { + return fmt.Errorf("duplicate contract %s", contractID) + } + seenContracts[contractID] = struct{}{} + contracts[i] = contract + } + + for _, contract := range contracts { + owner, err := contractOwner(ctx, chain, contract) + if err != nil { + return fmt.Errorf("contract %s: %w", contract.Type, err) + } + if err := validateContractOwner(contract, owner, deployer, timelockSignerPDA, in.OnlyAcceptOwnership); err != nil { + return err + } + } + + return nil +} diff --git a/mcms/solana/transfer-to-timelock/validate_test.go b/mcms/solana/transfer-to-timelock/validate_test.go new file mode 100644 index 0000000..e65106e --- /dev/null +++ b/mcms/solana/transfer-to-timelock/validate_test.go @@ -0,0 +1,181 @@ +package soltransfertotimelock + +import ( + "context" + "testing" + + "github.com/Masterminds/semver/v3" + solanago "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfchain "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" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + + "github.com/smartcontractkit/cld-changesets/datastore/refkey" + "github.com/smartcontractkit/cld-changesets/internal/semvers" + transfertotimelock "github.com/smartcontractkit/cld-changesets/mcms/changesets/transfer-to-timelock" + + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" +) + +func TestValidateContracts_duplicateResolvedContract(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + version1 := &semvers.V1_0_0 + version2 := semver.MustParse("1.1.0") + + mcmProgram := solanago.NewWallet().PublicKey() + timelockProgram := solanago.NewWallet().PublicKey() + accessControllerProgram := solanago.NewWallet().PublicKey() + proposerSeed := testPDASeed(1) + timelockSeed := testPDASeed(4) + sharedAccount := solanago.NewWallet().PublicKey() + + ds := datastore.NewMemoryDataStore() + addSolanaValidateRef(t, ds, selector, mcmscontracts.ManyChainMultisigProgram, version1, mcmProgram.String()) + addSolanaValidateRef(t, ds, selector, mcmscontracts.RBACTimelockProgram, version1, timelockProgram.String()) + addSolanaValidateRef(t, ds, selector, mcmscontracts.AccessControllerProgram, version1, accessControllerProgram.String()) + addSolanaValidateRef(t, ds, selector, mcmscontracts.ProposerManyChainMultisig, version1, mcmssolana.ContractAddress(mcmProgram, proposerSeed)) + addSolanaValidateRef(t, ds, selector, mcmscontracts.RBACTimelock, version1, mcmssolana.ContractAddress(timelockProgram, timelockSeed)) + addSolanaValidateRef(t, ds, selector, mcmscontracts.ProposerAccessControllerAccount, version1, sharedAccount.String()) + addSolanaValidateRef(t, ds, selector, mcmscontracts.ExecutorAccessControllerAccount, version2, sharedAccount.String()) + + ref1 := refkey.New(selector, datastore.ContractType(mcmscontracts.ProposerAccessControllerAccount), version1, "") + ref2 := refkey.New(selector, datastore.ContractType(mcmscontracts.ExecutorAccessControllerAccount), version2, "") + + deployerKey := solanago.NewWallet().PrivateKey + env := cldf.Environment{ + Logger: logger.Nop(), + DataStore: ds.Seal(), + GetContext: func() context.Context { + return t.Context() + }, + BlockChains: cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ + selector: cldfsol.Chain{ + Selector: selector, + DeployerKey: &deployerKey, + }, + }), + } + + err := validateContracts(env, transfertotimelock.ChainInput{ + ChainSelector: selector, + Contracts: []refkey.RefKey{ref1, ref2}, + MCMS: &cldf.MCMSTimelockProposalInput{}, + }) + require.ErrorContains(t, err, "duplicate contract "+sharedAccount.String()) +} + +func TestResolveOwnableContract_fillsChainSelector(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + version := &semvers.V1_0_0 + accessControllerProgram := solanago.NewWallet().PublicKey() + account := solanago.NewWallet().PublicKey() + + ds := datastore.NewMemoryDataStore() + addSolanaValidateRef(t, ds, selector, mcmscontracts.AccessControllerProgram, version, accessControllerProgram.String()) + addSolanaValidateRef(t, ds, selector, mcmscontracts.ProposerAccessControllerAccount, version, account.String()) + + env := cldf.Environment{ + Logger: logger.Nop(), + DataStore: ds.Seal(), + } + + ref := refkey.RefKey{ + Type: datastore.ContractType(mcmscontracts.ProposerAccessControllerAccount), + Version: version, + } + + got, err := resolveOwnableContract(env, selector, ref) + require.NoError(t, err) + require.Equal(t, account, got.OwnerPDA) +} + +func addSolanaValidateRef( + t *testing.T, + ds *datastore.MemoryDataStore, + selector uint64, + contractType cldf.ContractType, + version *semver.Version, + address string, +) { + t.Helper() + + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: selector, + Type: datastore.ContractType(contractType), + Version: version, + Address: address, + })) +} + +func testPDASeed(v byte) mcmssolana.PDASeed { + var seed mcmssolana.PDASeed + seed[0] = v + + return seed +} + +func TestValidateContractOwner(t *testing.T) { + t.Parallel() + + contract := OwnableContract{Type: mcmscontracts.ProposerManyChainMultisig} + deployer := solanago.NewWallet().PublicKey() + timelock := solanago.NewWallet().PublicKey() + other := solanago.NewWallet().PublicKey() + + tests := []struct { + name string + owner solanago.PublicKey + onlyAccept bool + wantErr string + }{ + { + name: "timelock already owns", + owner: timelock, + }, + { + name: "only accept with deployer owner", + owner: deployer, + onlyAccept: true, + }, + { + name: "only accept rejects third party", + owner: other, + onlyAccept: true, + wantErr: "only accept ownership requires current owner to be deployer or timelock", + }, + { + name: "full transfer requires deployer owner", + owner: deployer, + }, + { + name: "full transfer rejects third party", + owner: other, + wantErr: "not owned by the deployer key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateContractOwner(contract, tt.owner, deployer, timelock, tt.onlyAccept) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +}