diff --git a/mcms/changesets/doc.go b/mcms/changesets/doc.go new file mode 100644 index 0000000..6ba4a99 --- /dev/null +++ b/mcms/changesets/doc.go @@ -0,0 +1,34 @@ +// Package changesets provides multi-family MCMS changesets. +// +// Start here when looking for a changeset to use. Each subdirectory is a +// self-contained changeset with a chain-agnostic entrypoint ([Changeset], Config, +// and optionally a registry for per-family sequences). +// +// # Multi-family changesets (this directory) +// +// - set-config — configure MCMS contracts across chains +// - deploy — deploy the standard MCMS topology +// - deploy-custom-topology — deploy an arbitrary MCMS topology +// - transfer-to-timelock — transfer contract ownership to a timelock +// - firedrill — MCMS firedrill operations +// +// Import path pattern: +// +// github.com/smartcontractkit/cld-changesets/mcms/changesets/ +// +// Blank-import the chain families you need, for example: +// +// _ "github.com/smartcontractkit/cld-changesets/mcms/evm/set-config" +// _ "github.com/smartcontractkit/cld-changesets/mcms/solana/set-config" +// +// # Family-only changesets +// +// Some changesets are inherently specific to one chain family and have no +// chain-agnostic wrapper. Solana-only changesets live under +// mcms/solana/changesets (for example fund-mcm-pdas). EVM-only changesets +// would follow mcms/evm/changesets if added in the future. +// +// Family implementation packages (mcms/evm/, mcms/solana/) contain +// sequences and operations registered by multi-family changesets via init. +// Solana-only changesets live under mcms/solana/changesets. +package changesets diff --git a/mcms/solana/changesets/doc.go b/mcms/solana/changesets/doc.go new file mode 100644 index 0000000..c259331 --- /dev/null +++ b/mcms/solana/changesets/doc.go @@ -0,0 +1,16 @@ +// Package changesets holds Solana-only MCMS changesets. +// +// Multi-family changesets (set-config, deploy, firedrill, and others) live under +// mcms/changesets and register per-family implementations from mcms/solana/ +// and mcms/evm/. Solana-only changesets that have no chain-agnostic layer +// — because the concept is inherently Solana-specific — live here instead. +// +// # Available changesets +// +// - fund-mcm-pdas — fund MCMS signer PDAs with lamports +// (github.com/smartcontractkit/cld-changesets/mcms/solana/changesets/fund-mcm-pdas) +// +// Import path pattern: +// +// github.com/smartcontractkit/cld-changesets/mcms/solana/changesets/ +package changesets diff --git a/mcms/solana/changesets/fund-mcm-pdas/changeset.go b/mcms/solana/changesets/fund-mcm-pdas/changeset.go new file mode 100644 index 0000000..df7e7d1 --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/changeset.go @@ -0,0 +1,91 @@ +package fundmcmpdas + +import ( + "errors" + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/cld-changesets/internal/maputil" +) + +var _ cldf.ChangeSetV2[Config] = Changeset{} + +// Changeset funds MCMS signer PDAs on each configured Solana chain. +type Changeset struct{} + +func (Changeset) VerifyPreconditions(env cldf.Environment, config Config) error { + if len(config.FundingPerChain) == 0 { + return errors.New("no funding config provided") + } + + for chainSelector, chainCfg := range config.FundingPerChain { + if _, ok := env.BlockChains.SolanaChains()[chainSelector]; !ok { + return fmt.Errorf("solana chain %d not found in environment", chainSelector) + } + if err := validateMCMSRefs(env, chainSelector, chainCfg); err != nil { + return err + } + if err := validateDeployerBalance(env, chainSelector, chainCfg); err != nil { + return err + } + } + + return nil +} + +func (Changeset) Apply(e cldf.Environment, config Config) (cldf.ChangesetOutput, error) { + deps := Deps{ + BlockChains: e.BlockChains, + DataStore: e.DataStore, + } + + var agg sequenceutils.OnChainOutput + + for _, chainSelector := range maputil.SortedMapKeys(config.FundingPerChain) { + chainCfg := config.FundingPerChain[chainSelector] + + var mergeErr error + agg, mergeErr = sequenceutils.ExecuteOnChainSequenceAndMerge( + e.OperationsBundle, + deps, + SeqFundMCMPDAs, + ChainInput{ + ChainSelector: chainSelector, + FundingConfig: chainCfg, + }, + agg, + ) + if mergeErr != nil { + return buildOutput(e, agg, mergeErr) + } + } + + return buildOutput(e, agg, nil) +} + +func buildOutput( + e cldf.Environment, + agg sequenceutils.OnChainOutput, + err error, +) (cldf.ChangesetOutput, error) { + ds := datastore.NewMemoryDataStore() + if metaErr := ds.WriteMetadata(agg.Metadata); metaErr != nil { + return cldf.ChangesetOutput{DataStore: ds}, + fmt.Errorf("failed to write metadata to datastore: %w", metaErr) + } + + partialOutput := cldf.ChangesetOutput{DataStore: ds} + if err != nil { + return partialOutput, err + } + + out, buildErr := cldf.NewOutputBuilder(e, ds).Build() + if buildErr != nil { + return out, fmt.Errorf("build changeset output: %w", buildErr) + } + + return out, nil +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/changeset_test.go b/mcms/solana/changesets/fund-mcm-pdas/changeset_test.go new file mode 100644 index 0000000..1cdb2b5 --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/changeset_test.go @@ -0,0 +1,335 @@ +package fundmcmpdas + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/rpc" + chainselectors "github.com/smartcontractkit/chain-selectors" + solCommonUtil "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "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/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" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/stretchr/testify/require" + + soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils" + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" +) + +//nolint:paralleltest // global mcm.SetProgramID state; shared Solana CTF container +func TestChangeset(t *testing.T) { + selector1 := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + selector2 := chainselectors.TEST_33333333333333333333333333333333333333333333.Selector + + rt1 := testRuntime(t, selector1) + env1 := configureFundMCMSignersEnv(t, rt1.Environment(), selector1, rpcWithBalance(t, 1_000), true) + cs := Changeset{} + + t.Run("VerifyPreconditions", func(t *testing.T) { + tests := []struct { + name string + env cldf.Environment + config Config + expectedError string + }{ + { + name: "all preconditions satisfied", + env: env1, + config: Config{ + FundingPerChain: map[uint64]FundingConfig{selector1: { + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }}, + }, + }, + { + name: "no funding config", + env: env1, + config: Config{}, + expectedError: "no funding config provided", + }, + { + name: "no solana chains found in environment", + env: func() cldf.Environment { + env := rt1.Environment() + env.BlockChains = cldf_chain.NewBlockChains(nil) + + return env + }(), + config: Config{ + FundingPerChain: map[uint64]FundingConfig{selector1: { + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }}, + }, + expectedError: fmt.Sprintf("solana chain %d not found in environment", selector1), + }, + { + name: "chain selector not found in environment", + env: env1, + config: Config{FundingPerChain: map[uint64]FundingConfig{99999: { + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }}}, + expectedError: "solana chain 99999 not found in environment", + }, + { + name: "insufficient deployer balance", + env: configureFundMCMSignersEnv(t, rt1.Environment(), selector1, rpcWithBalance(t, 1), true), + config: Config{ + FundingPerChain: map[uint64]FundingConfig{selector1: { + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }}, + }, + expectedError: "deployer balance is insufficient", + }, + { + name: "missing deployer key", + env: func() cldf.Environment { + env := configureFundMCMSignersEnv(t, rt1.Environment(), selector1, rpcWithBalance(t, 1_000), true) + chain := env.BlockChains.SolanaChains()[selector1] + chain.DeployerKey = nil + env.BlockChains = cldf_chain.NewBlockChains(map[uint64]cldf_chain.BlockChain{selector1: chain}) + + return env + }(), + config: Config{ + FundingPerChain: map[uint64]FundingConfig{selector1: { + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }}, + }, + expectedError: "deployer key missing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := cs.VerifyPreconditions(tt.env, tt.config) + if tt.expectedError == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.ErrorContains(t, err, tt.expectedError) + } + }) + } + + t.Run("mcms contracts not deployed", func(t *testing.T) { + rt2 := testRuntime(t, selector2) + env := configureFundMCMSignersEnv(t, rt2.Environment(), selector2, rpcWithBalance(t, 1_000), false) + err := cs.VerifyPreconditions(env, Config{ + FundingPerChain: map[uint64]FundingConfig{selector2: { + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }}, + }) + require.ErrorContains(t, err, "resolve timelock ref") + }) + }) + + t.Run("Apply", func(t *testing.T) { + var confirmed [][]solana.Instruction + + env := configureFundMCMSignersEnv(t, rt1.Environment(), selector1, nil, true) + chain := env.BlockChains.SolanaChains()[selector1] + require.NotNil(t, chain.DeployerKey) + deployerKey := *chain.DeployerKey + chain.Confirm = func(instructions []solana.Instruction, _ ...solCommonUtil.TxModifier) error { + confirmed = append(confirmed, instructions) + return nil + } + env.BlockChains = cldf_chain.NewBlockChains(map[uint64]cldf_chain.BlockChain{selector1: chain}) + env.OperationsBundle = optest.NewBundle(t) + + cfgAmounts := FundingConfig{ + ProposeMCM: 100 * solana.LAMPORTS_PER_SOL, + CancellerMCM: 350 * solana.LAMPORTS_PER_SOL, + BypasserMCM: 75 * solana.LAMPORTS_PER_SOL, + Timelock: 83 * solana.LAMPORTS_PER_SOL, + } + + refs := testFundingRefs(t, env, selector1, "") + _, err := cs.Apply(env, Config{ + FundingPerChain: map[uint64]FundingConfig{selector1: cfgAmounts}, + }) + require.NoError(t, err) + require.Len(t, confirmed, 4) + + gotBalances := map[solana.PublicKey]uint64{} + for _, instructionSet := range confirmed { + require.Len(t, instructionSet, 1) + ix := instructionSet[0] + require.True(t, ix.ProgramID().Equals(system.ProgramID)) + accounts := ix.Accounts() + require.Len(t, accounts, 2) + require.True(t, accounts[0].PublicKey.Equals(deployerKey.PublicKey())) + data, dataErr := ix.Data() + require.NoError(t, dataErr) + decoded, decodeErr := system.DecodeInstruction(accounts, data) + require.NoError(t, decodeErr) + transfer, ok := decoded.Impl.(*system.Transfer) + require.True(t, ok) + require.NotNil(t, transfer.Lamports) + gotBalances[accounts[1].PublicKey] = *transfer.Lamports + } + + require.Equal(t, cfgAmounts.Timelock, gotBalances[refs.TimelockSigner]) + require.Equal(t, cfgAmounts.ProposeMCM, gotBalances[refs.ProposerSigner]) + require.Equal(t, cfgAmounts.CancellerMCM, gotBalances[refs.CancellerSigner]) + require.Equal(t, cfgAmounts.BypasserMCM, gotBalances[refs.BypasserSigner]) + }) + + t.Run("Apply_sequenceError", func(t *testing.T) { + selector := uint64(99999) + env := rt1.Environment() + env.BlockChains = cldf_chain.NewBlockChains(nil) + env.OperationsBundle = optest.NewBundle(t) + + _, err := cs.Apply(env, Config{ + FundingPerChain: map[uint64]FundingConfig{ + selector: {ProposeMCM: 1}, + }, + }) + require.Error(t, err) + }) +} + +type fundingRefSet struct { + TimelockSigner solana.PublicKey + ProposerSigner solana.PublicKey + CancellerSigner solana.PublicKey + BypasserSigner solana.PublicKey +} + +func testFundingRefs(t *testing.T, env cldf.Environment, selector uint64, qualifier string) fundingRefSet { + t.Helper() + + targets, err := ResolveFundingTargets(env, selector, FundingConfig{Qualifier: qualifier}) + require.NoError(t, err) + require.Len(t, targets, 4) + + return fundingRefSet{ + TimelockSigner: targets[0].Address, + ProposerSigner: targets[1].Address, + CancellerSigner: targets[2].Address, + BypasserSigner: targets[3].Address, + } +} + +func configureFundMCMSignersEnv( + t *testing.T, + base cldf.Environment, + selector uint64, + client *rpc.Client, + completeState bool, +) cldf.Environment { + t.Helper() + + env := base + env.DataStore = newMCMSDataStore(t, selector, completeState) + + chain := env.BlockChains.SolanaChains()[selector] + if client != nil { + chain.Client = client + } + env.BlockChains = cldf_chain.NewBlockChains(map[uint64]cldf_chain.BlockChain{selector: chain}) + + return env +} + +func newMCMSDataStore(t *testing.T, selector uint64, completeState bool) datastore.DataStore { + t.Helper() + + mcmProgram := solana.NewWallet().PublicKey() + timelockProgram := solana.NewWallet().PublicKey() + proposerSeed := testPDASeed(1) + cancellerSeed := testPDASeed(2) + bypasserSeed := testPDASeed(3) + timelockSeed := testPDASeed(4) + + ds := datastore.NewMemoryDataStore() + version := semver.MustParse("1.0.0") + + if !completeState { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: mcmssolana.ContractAddress(mcmProgram, bypasserSeed), + ChainSelector: selector, + Type: datastore.ContractType(mcmscontracts.BypasserManyChainMultisig), + Version: version, + })) + + return ds.Seal() + } + + for _, ref := range []struct { + typ cldf.ContractType + address string + }{ + {mcmscontracts.RBACTimelock, mcmssolana.ContractAddress(timelockProgram, timelockSeed)}, + {mcmscontracts.ProposerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, proposerSeed)}, + {mcmscontracts.CancellerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, cancellerSeed)}, + {mcmscontracts.BypasserManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, bypasserSeed)}, + } { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: ref.address, + ChainSelector: selector, + Type: datastore.ContractType(ref.typ), + Version: version, + })) + } + + return ds.Seal() +} + +func testRuntime(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) + + return rt +} + +func rpcWithBalance(t *testing.T, balance uint64) *rpc.Client { + t.Helper() + + response := fmt.Sprintf(`{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":%d},"id":1}`, balance) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(server.Close) + + return rpc.New(server.URL) +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/doc.go b/mcms/solana/changesets/fund-mcm-pdas/doc.go new file mode 100644 index 0000000..0e1577c --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/doc.go @@ -0,0 +1,32 @@ +// Package fundmcmpdas funds MCMS signer PDAs on Solana chains. +// +// This is a Solana-only changeset: PDAs and signer funding are Solana-specific +// concepts. Everything — changeset, sequences, and operations — lives in this +// package under mcms/solana/changesets. +// +// # Usage +// +// Import the MCMS reader so datastore refs can be resolved: +// +// import ( +// fundmcmpdas "github.com/smartcontractkit/cld-changesets/mcms/solana/changesets/fund-mcm-pdas" +// _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" +// ) +// +// Testing: +// +// rt.Exec(runtime.ChangesetTask(fundmcmpdas.Changeset{}, fundmcmpdas.Config{ +// FundingPerChain: map[uint64]fundmcmpdas.FundingConfig{ +// selector: { +// ProposeMCM: 100, +// CancellerMCM: 100, +// BypasserMCM: 100, +// Timelock: 100, +// }, +// }, +// })) +// +// CLD: +// +// registry.Add("fund_mcm_pdas", Configure(fundmcmpdas.Changeset{}).WithEnvInput()) +package fundmcmpdas diff --git a/mcms/solana/changesets/fund-mcm-pdas/helpers.go b/mcms/solana/changesets/fund-mcm-pdas/helpers.go new file mode 100644 index 0000000..48e6403 --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/helpers.go @@ -0,0 +1,103 @@ +package fundmcmpdas + +import ( + "fmt" + + solanago "github.com/gagliardetto/solana-go" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" +) + +// FundingTarget is a signer PDA and the lamports to send to it. +type FundingTarget struct { + Address solanago.PublicKey `json:"address"` + Amount uint64 `json:"amount"` +} + +// ResolveFundingTargets resolves MCMS and timelock signer PDAs from the environment datastore. +func ResolveFundingTargets(e cldf.Environment, chainSelector uint64, cfg FundingConfig) ([]FundingTarget, error) { + reader, ok := cldf.GetMCMSReaderRegistry().Get(chainselectors.FamilySolana) + if !ok { + return nil, fmt.Errorf("no MCMS reader registered for family %q", chainselectors.FamilySolana) + } + + qualifier := cfg.Qualifier + timelockRef, err := reader.GetTimelockRef(e, chainSelector, cldf.MCMSTimelockProposalInput{Qualifier: qualifier}) + if err != nil { + return nil, fmt.Errorf("resolve timelock ref for chain %d: %w", chainSelector, err) + } + proposerRef, err := reader.GetMCMSRef(e, chainSelector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + Qualifier: qualifier, + }) + if err != nil { + return nil, fmt.Errorf("resolve proposer ref for chain %d: %w", chainSelector, err) + } + cancellerRef, err := reader.GetMCMSRef(e, chainSelector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionCancel, + Qualifier: qualifier, + }) + if err != nil { + return nil, fmt.Errorf("resolve canceller ref for chain %d: %w", chainSelector, err) + } + bypasserRef, err := reader.GetMCMSRef(e, chainSelector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + Qualifier: qualifier, + }) + if err != nil { + return nil, fmt.Errorf("resolve bypasser ref for chain %d: %w", chainSelector, err) + } + + timelockSigner, err := timelockSignerPDAFromRef(timelockRef.Address) + if err != nil { + return nil, fmt.Errorf("parse timelock signer PDA for chain %d: %w", chainSelector, err) + } + proposerSigner, err := mcmsSignerPDAFromRef(proposerRef.Address) + if err != nil { + return nil, fmt.Errorf("parse proposer signer PDA for chain %d: %w", chainSelector, err) + } + cancellerSigner, err := mcmsSignerPDAFromRef(cancellerRef.Address) + if err != nil { + return nil, fmt.Errorf("parse canceller signer PDA for chain %d: %w", chainSelector, err) + } + bypasserSigner, err := mcmsSignerPDAFromRef(bypasserRef.Address) + if err != nil { + return nil, fmt.Errorf("parse bypasser signer PDA for chain %d: %w", chainSelector, err) + } + + return []FundingTarget{ + {Address: timelockSigner, Amount: cfg.Timelock}, + {Address: proposerSigner, Amount: cfg.ProposeMCM}, + {Address: cancellerSigner, Amount: cfg.CancellerMCM}, + {Address: bypasserSigner, Amount: cfg.BypasserMCM}, + }, nil +} + +func timelockSignerPDAFromRef(address string) (solanago.PublicKey, error) { + program, seed, err := mcmssolanasdk.ParseContractAddress(address) + if err != nil { + return solanago.PublicKey{}, err + } + + var pdaSeed legacysolana.PDASeed + copy(pdaSeed[:], seed[:]) + + return familysolana.GetTimelockSignerPDA(program, pdaSeed), nil +} + +func mcmsSignerPDAFromRef(address string) (solanago.PublicKey, error) { + program, seed, err := mcmssolanasdk.ParseContractAddress(address) + if err != nil { + return solanago.PublicKey{}, err + } + + var pdaSeed legacysolana.PDASeed + copy(pdaSeed[:], seed[:]) + + return familysolana.GetMCMSignerPDA(program, pdaSeed), nil +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/helpers_test.go b/mcms/solana/changesets/fund-mcm-pdas/helpers_test.go new file mode 100644 index 0000000..c79be2e --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/helpers_test.go @@ -0,0 +1,145 @@ +package fundmcmpdas + +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" + "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/mcms/solana/readers" +) + +func TestResolveFundingTargets(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + mcmProgram := solanago.NewWallet().PublicKey() + timelockProgram := solanago.NewWallet().PublicKey() + proposerSeed := testPDASeed(1) + cancellerSeed := testPDASeed(2) + bypasserSeed := testPDASeed(3) + timelockSeed := testPDASeed(4) + + ds := datastore.NewMemoryDataStore() + version := semver.MustParse("1.0.0") + for _, ref := range []struct { + typ cldf.ContractType + address string + }{ + {mcmscontracts.RBACTimelock, mcmssolana.ContractAddress(timelockProgram, timelockSeed)}, + {mcmscontracts.ProposerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, proposerSeed)}, + {mcmscontracts.CancellerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, cancellerSeed)}, + {mcmscontracts.BypasserManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, bypasserSeed)}, + } { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: ref.address, + ChainSelector: selector, + Type: datastore.ContractType(ref.typ), + Version: version, + })) + } + + env := helperTestEnv(ds.Seal(), selector) + cfg := FundingConfig{ + ProposeMCM: 10, + CancellerMCM: 20, + BypasserMCM: 30, + Timelock: 40, + } + + targets, err := ResolveFundingTargets(env, selector, cfg) + require.NoError(t, err) + require.Len(t, targets, 4) + require.Equal(t, uint64(40), targets[0].Amount) + require.Equal(t, uint64(10), targets[1].Amount) + require.Equal(t, uint64(20), targets[2].Amount) + require.Equal(t, uint64(30), targets[3].Amount) + + timelockSigner, err := mcmssolana.FindTimelockSignerPDA(timelockProgram, timelockSeed) + require.NoError(t, err) + proposerSigner, err := mcmssolana.FindSignerPDA(mcmProgram, proposerSeed) + require.NoError(t, err) + cancellerSigner, err := mcmssolana.FindSignerPDA(mcmProgram, cancellerSeed) + require.NoError(t, err) + bypasserSigner, err := mcmssolana.FindSignerPDA(mcmProgram, bypasserSeed) + require.NoError(t, err) + + require.Equal(t, timelockSigner, targets[0].Address) + require.Equal(t, proposerSigner, targets[1].Address) + require.Equal(t, cancellerSigner, targets[2].Address) + require.Equal(t, bypasserSigner, targets[3].Address) +} + +func TestResolveFundingTargets_errors(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + + env := helperTestEnv(datastore.NewMemoryDataStore().Seal(), selector) + _, err := ResolveFundingTargets(env, selector, FundingConfig{}) + require.ErrorContains(t, err, "resolve timelock ref") + + ds := datastore.NewMemoryDataStore() + version := semver.MustParse("1.0.0") + mcmProgram := solanago.NewWallet().PublicKey() + for _, ref := range []struct { + typ cldf.ContractType + address string + }{ + {mcmscontracts.RBACTimelock, "timelock"}, + {mcmscontracts.ProposerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(1))}, + {mcmscontracts.CancellerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(2))}, + {mcmscontracts.BypasserManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(3))}, + } { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: ref.address, + ChainSelector: selector, + Type: datastore.ContractType(ref.typ), + Version: version, + })) + } + env = helperTestEnv(ds.Seal(), selector) + _, err = ResolveFundingTargets(env, selector, FundingConfig{}) + require.ErrorContains(t, err, "parse timelock signer PDA") +} + +func TestSignerPDAFromRef_errors(t *testing.T) { + t.Parallel() + + _, err := mcmsSignerPDAFromRef("not-valid") + require.Error(t, err) + + _, err = timelockSignerPDAFromRef("not-valid") + require.Error(t, err) +} + +func helperTestEnv(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}, + }), + } +} + +func testPDASeed(v byte) mcmssolana.PDASeed { + var seed mcmssolana.PDASeed + for i := range seed { + seed[i] = v + } + + return seed +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/operation.go b/mcms/solana/changesets/fund-mcm-pdas/operation.go new file mode 100644 index 0000000..50399ef --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/operation.go @@ -0,0 +1,46 @@ +package fundmcmpdas + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/gagliardetto/solana-go" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + solana2 "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" +) + +// OpFundKeyInput is the input of a Solana PDA funding operation. +type OpFundKeyInput struct { + Target solana.PublicKey `json:"target"` + Amount uint64 `json:"amount"` +} + +// OpFundKeyOutput is the output of a Solana PDA funding operation. +type OpFundKeyOutput struct { + Confirmed bool `json:"confirmed"` +} + +// OpFundKey funds a single Solana account from the chain deployer key. +var OpFundKey = operations.NewOperation( + "solana-fund-key", + semver.MustParse("1.0.0"), + "Funds a Solana account from the deployer key", + func(b operations.Bundle, deps cldfsol.Chain, in OpFundKeyInput) (OpFundKeyOutput, error) { + if deps.DeployerKey == nil { + return OpFundKeyOutput{}, fmt.Errorf("missing deployer key for chain %d", deps.Selector) + } + err := solana2.FundFromDeployerKey( + deps, + []solana.PublicKey{in.Target}, + in.Amount, + ) + if err != nil { + return OpFundKeyOutput{}, fmt.Errorf("failed to fund target %s: %w", in.Target, err) + } + b.Logger.Infow("funding success", "target", in.Target, "amount", in.Amount) + + return OpFundKeyOutput{Confirmed: true}, nil + }, +) diff --git a/mcms/solana/changesets/fund-mcm-pdas/operation_test.go b/mcms/solana/changesets/fund-mcm-pdas/operation_test.go new file mode 100644 index 0000000..f9e39a5 --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/operation_test.go @@ -0,0 +1,104 @@ +package fundmcmpdas + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + chainselectors "github.com/smartcontractkit/chain-selectors" + solCommonUtil "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common" + cldf_solana "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/operations/optest" + "github.com/stretchr/testify/require" +) + +func TestOpFundKey(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + deployerKey := solana.NewWallet().PrivateKey + target := solana.NewWallet().PublicKey() + var confirmed []solana.Instruction + + chain := cldf_solana.Chain{ + Selector: selector, + DeployerKey: &deployerKey, + Confirm: func(instructions []solana.Instruction, _ ...solCommonUtil.TxModifier) error { + confirmed = append(confirmed, instructions...) + return nil + }, + } + + report, err := operations.ExecuteOperation( + optest.NewBundle(t), + OpFundKey, + chain, + OpFundKeyInput{ + Target: target, + Amount: 42, + }, + ) + require.NoError(t, err) + require.True(t, report.Output.Confirmed) + require.Len(t, confirmed, 1) + + ix := confirmed[0] + require.True(t, ix.ProgramID().Equals(system.ProgramID)) + accounts := ix.Accounts() + require.True(t, accounts[0].PublicKey.Equals(deployerKey.PublicKey())) + require.True(t, accounts[1].PublicKey.Equals(target)) +} + +func TestOpFundKey_missingDeployerKey(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + _, err := operations.ExecuteOperation( + optest.NewBundle(t), + OpFundKey, + cldf_solana.Chain{Selector: selector}, + OpFundKeyInput{ + Target: solana.NewWallet().PublicKey(), + Amount: 1, + }, + ) + require.ErrorContains(t, err, "missing deployer key") +} + +func TestOpFundKey_confirmError(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + deployerKey := solana.NewWallet().PrivateKey + chain := cldf_solana.Chain{ + Selector: selector, + DeployerKey: &deployerKey, + Confirm: func(_ []solana.Instruction, _ ...solCommonUtil.TxModifier) error { + return assertAnError("confirm failed") + }, + } + + _, err := operations.ExecuteOperation( + optest.NewBundle(t), + OpFundKey, + chain, + OpFundKeyInput{ + Target: solana.NewWallet().PublicKey(), + Amount: 1, + }, + ) + require.ErrorContains(t, err, "failed to fund target") +} + +func assertAnError(msg string) error { + return &testError{msg: msg} +} + +type testError struct { + msg string +} + +func (e *testError) Error() string { + return e.msg +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/sequence.go b/mcms/solana/changesets/fund-mcm-pdas/sequence.go new file mode 100644 index 0000000..165bf6e --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/sequence.go @@ -0,0 +1,84 @@ +package fundmcmpdas + +import ( + "fmt" + + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" +) + +// SeqFundSolanaMCMPDAsInput is the input for the low-level Solana fund-mcm-pdas sequence. +type SeqFundSolanaMCMPDAsInput struct { + ChainSelector uint64 `json:"chainSelector"` + Targets []FundingTarget `json:"targets"` +} + +// SeqFundSolanaMCMPDAs funds each provided signer PDA on a Solana chain. +var SeqFundSolanaMCMPDAs = operations.NewSequence( + "seq-solana-fund-mcm-pdas", + &semvers.V1_0_0, + "Funds MCMS signer PDAs on a Solana chain", + func(b operations.Bundle, deps cldfsol.Chain, in SeqFundSolanaMCMPDAsInput) (sequenceutils.OnChainOutput, error) { + if in.ChainSelector != deps.Selector { + return sequenceutils.OnChainOutput{}, fmt.Errorf("mismatch between input chain selector and selector defined within dependencies: %d != %d", in.ChainSelector, deps.Selector) + } + + for i, target := range in.Targets { + if target.Amount == 0 { + continue + } + + _, err := operations.ExecuteOperation( + b, + OpFundKey, + deps, + OpFundKeyInput{ + Target: target.Address, + Amount: target.Amount, + }, + ) + if err != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("fund target[%d]: %w", i, err) + } + } + + return sequenceutils.OnChainOutput{}, nil + }, +) + +// SeqFundMCMPDAs resolves MCMS signer PDAs from the datastore and funds them on a Solana chain. +var SeqFundMCMPDAs = operations.NewSequence( + "seq-solana-fund-mcm-pdas-from-refs", + &semvers.V1_0_0, + "Funds MCMS signer PDAs on a Solana chain from datastore refs", + func(b operations.Bundle, deps Deps, in 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 := EnvFromDeps(deps) + targets, err := ResolveFundingTargets(env, in.ChainSelector, in.FundingConfig) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + seqReport, err := operations.ExecuteSequence( + b, + SeqFundSolanaMCMPDAs, + chain, + SeqFundSolanaMCMPDAsInput{ + ChainSelector: in.ChainSelector, + Targets: targets, + }, + ) + if err != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("failed to execute Solana fund-mcm-pdas sequence: %w", err) + } + + return seqReport.Output, nil + }, +) diff --git a/mcms/solana/changesets/fund-mcm-pdas/sequence_test.go b/mcms/solana/changesets/fund-mcm-pdas/sequence_test.go new file mode 100644 index 0000000..9cb39c3 --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/sequence_test.go @@ -0,0 +1,163 @@ +package fundmcmpdas + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/gagliardetto/solana-go" + chainselectors "github.com/smartcontractkit/chain-selectors" + solCommonUtil "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common" + 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/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/operations/optest" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/stretchr/testify/require" + + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" +) + +func TestSeqFundSolanaMCMPDAs(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + deployerKey := solana.NewWallet().PrivateKey + var confirmed int + solChain := cldfsol.Chain{ + Selector: selector, + DeployerKey: &deployerKey, + Confirm: func(_ []solana.Instruction, _ ...solCommonUtil.TxModifier) error { + confirmed++ + return nil + }, + } + + targets := []FundingTarget{ + {Address: solana.NewWallet().PublicKey(), Amount: 1}, + {Address: solana.NewWallet().PublicKey(), Amount: 0}, + {Address: solana.NewWallet().PublicKey(), Amount: 2}, + } + + report, err := operations.ExecuteSequence( + optest.NewBundle(t), + SeqFundSolanaMCMPDAs, + solChain, + SeqFundSolanaMCMPDAsInput{ + ChainSelector: selector, + Targets: targets, + }, + ) + require.NoError(t, err) + require.Empty(t, report.Output.BatchOps) + require.Equal(t, 2, confirmed) +} + +func TestSeqFundSolanaMCMPDAs_chainMismatch(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + deployerKey := solana.NewWallet().PrivateKey + + _, err := operations.ExecuteSequence( + optest.NewBundle(t), + SeqFundSolanaMCMPDAs, + cldfsol.Chain{Selector: selector, DeployerKey: &deployerKey}, + SeqFundSolanaMCMPDAsInput{ChainSelector: selector + 1}, + ) + require.ErrorContains(t, err, "mismatch between input chain selector") +} + +func TestSeqFundMCMPDAs_errors(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + + _, err := operations.ExecuteSequence( + optest.NewBundle(t), + SeqFundMCMPDAs, + Deps{BlockChains: cldfchain.NewBlockChains(nil)}, + ChainInput{ChainSelector: selector}, + ) + require.ErrorContains(t, err, "solana chain") + + deps := Deps{ + BlockChains: cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }), + DataStore: datastore.NewMemoryDataStore().Seal(), + } + _, err = operations.ExecuteSequence( + optest.NewBundle(t), + SeqFundMCMPDAs, + deps, + ChainInput{ChainSelector: selector}, + ) + require.ErrorContains(t, err, "resolve timelock ref") +} + +func TestSeqFundMCMPDAs_success(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + deployerKey := solana.NewWallet().PrivateKey + solChain := cldfsol.Chain{ + Selector: selector, + DeployerKey: &deployerKey, + Confirm: func(_ []solana.Instruction, _ ...solCommonUtil.TxModifier) error { + return nil + }, + } + + ds := newSequenceDataStore(t, selector) + deps := Deps{ + BlockChains: cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{selector: solChain}), + DataStore: ds, + } + + _, err := operations.ExecuteSequence( + optest.NewBundle(t), + SeqFundMCMPDAs, + deps, + ChainInput{ + ChainSelector: selector, + FundingConfig: FundingConfig{ + ProposeMCM: 1, + CancellerMCM: 2, + BypasserMCM: 3, + Timelock: 4, + }, + }, + ) + require.NoError(t, err) +} + +func newSequenceDataStore(t *testing.T, selector uint64) datastore.DataStore { + t.Helper() + + mcmProgram := solana.NewWallet().PublicKey() + timelockProgram := solana.NewWallet().PublicKey() + version := semver.MustParse("1.0.0") + ds := datastore.NewMemoryDataStore() + + for _, ref := range []struct { + typ cldf.ContractType + address string + }{ + {mcmscontracts.RBACTimelock, mcmssolana.ContractAddress(timelockProgram, testPDASeed(4))}, + {mcmscontracts.ProposerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(1))}, + {mcmscontracts.CancellerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(2))}, + {mcmscontracts.BypasserManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(3))}, + } { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: ref.address, + ChainSelector: selector, + Type: datastore.ContractType(ref.typ), + Version: version, + })) + } + + return ds.Seal() +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/types.go b/mcms/solana/changesets/fund-mcm-pdas/types.go new file mode 100644 index 0000000..207f0e3 --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/types.go @@ -0,0 +1,46 @@ +package fundmcmpdas + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +// FundingConfig holds the lamport amounts to send to each MCMS signer PDA on a chain. +type FundingConfig struct { + ProposeMCM uint64 `json:"proposeMcm"` + CancellerMCM uint64 `json:"cancellerMcm"` + BypasserMCM uint64 `json:"bypasserMcm"` + Timelock uint64 `json:"timelock"` + Qualifier string `json:"qualifier,omitempty"` +} + +// RequiredFunding returns the total lamports required to fund all MCMS PDAs on a chain. +func (c FundingConfig) RequiredFunding() uint64 { + return c.ProposeMCM + c.CancellerMCM + c.BypasserMCM + c.Timelock +} + +// Config holds the funding amounts per chain for the changeset. +type Config struct { + FundingPerChain map[uint64]FundingConfig `json:"fundingPerChain"` +} + +// Deps is the read-only dependency bundle available to the fund sequence. +type Deps struct { + BlockChains chain.BlockChains + DataStore cldfdatastore.DataStore +} + +// ChainInput is the per-chain request for the fund-mcm-pdas sequence. +type ChainInput struct { + ChainSelector uint64 + FundingConfig FundingConfig +} + +// EnvFromDeps reconstructs the environment fields sequences need for ref resolution. +func EnvFromDeps(deps Deps) cldf.Environment { + return cldf.Environment{ + BlockChains: deps.BlockChains, + DataStore: deps.DataStore, + } +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/types_test.go b/mcms/solana/changesets/fund-mcm-pdas/types_test.go new file mode 100644 index 0000000..845baec --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/types_test.go @@ -0,0 +1,37 @@ +package fundmcmpdas + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" +) + +func TestFundingConfigRequiredFunding(t *testing.T) { + t.Parallel() + + cfg := FundingConfig{ + ProposeMCM: 100, + CancellerMCM: 200, + BypasserMCM: 300, + Timelock: 400, + } + require.Equal(t, uint64(1000), cfg.RequiredFunding()) +} + +func TestEnvFromDeps(t *testing.T) { + t.Parallel() + + ds := cldfdatastore.NewMemoryDataStore().Seal() + blockChains := chain.NewBlockChains(nil) + deps := Deps{ + BlockChains: blockChains, + DataStore: ds, + } + + env := EnvFromDeps(deps) + require.Equal(t, blockChains, env.BlockChains) + require.Equal(t, ds, env.DataStore) +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/validate.go b/mcms/solana/changesets/fund-mcm-pdas/validate.go new file mode 100644 index 0000000..8e33728 --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/validate.go @@ -0,0 +1,41 @@ +package fundmcmpdas + +import ( + "fmt" + + "github.com/gagliardetto/solana-go/rpc" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +func validateMCMSRefs(e cldf.Environment, chainSelector uint64, cfg FundingConfig) error { + if _, err := ResolveFundingTargets(e, chainSelector, cfg); err != nil { + return fmt.Errorf("validate funding targets for chain %d: %w", chainSelector, err) + } + + return nil +} + +func validateDeployerBalance(e cldf.Environment, chainSelector uint64, cfg FundingConfig) error { + chain, ok := e.BlockChains.SolanaChains()[chainSelector] + if !ok { + return fmt.Errorf("solana chain %d not found in environment", chainSelector) + } + if chain.Client == nil { + return fmt.Errorf("solana client missing for chain %d", chainSelector) + } + if chain.DeployerKey == nil { + return fmt.Errorf("deployer key missing for chain %d", chainSelector) + } + + result, err := chain.Client.GetBalance(e.GetContext(), chain.DeployerKey.PublicKey(), rpc.CommitmentConfirmed) + if err != nil { + return fmt.Errorf("failed to get deployer balance for chain %d: %w", chainSelector, err) + } + + requiredAmount := cfg.RequiredFunding() + if result.Value < requiredAmount { + return fmt.Errorf("deployer balance is insufficient for chain %d, required: %d, actual: %d", chainSelector, requiredAmount, result.Value) + } + + return nil +} diff --git a/mcms/solana/changesets/fund-mcm-pdas/validate_test.go b/mcms/solana/changesets/fund-mcm-pdas/validate_test.go new file mode 100644 index 0000000..80341ce --- /dev/null +++ b/mcms/solana/changesets/fund-mcm-pdas/validate_test.go @@ -0,0 +1,207 @@ +package fundmcmpdas + +import ( + "context" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/gagliardetto/solana-go" + 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" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/stretchr/testify/require" + + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" +) + +func TestValidateMCMSRefs(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + version := semver.MustParse("1.0.0") + + tests := []struct { + name string + refs []validateRefSpec + wantErr string + }{ + { + name: "missing timelock ref", + wantErr: "resolve timelock ref", + }, + { + name: "missing proposer ref", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, mcmssolana.ContractAddress(solana.NewWallet().PublicKey(), testPDASeed(4))}, + }, + wantErr: "resolve proposer ref", + }, + { + name: "invalid timelock address", + refs: []validateRefSpec{ + {mcmscontracts.RBACTimelock, "timelock"}, + {mcmscontracts.ProposerManyChainMultisig, mcmssolana.ContractAddress(solana.NewWallet().PublicKey(), testPDASeed(1))}, + {mcmscontracts.CancellerManyChainMultisig, mcmssolana.ContractAddress(solana.NewWallet().PublicKey(), testPDASeed(2))}, + {mcmscontracts.BypasserManyChainMultisig, mcmssolana.ContractAddress(solana.NewWallet().PublicKey(), testPDASeed(3))}, + }, + wantErr: "parse timelock signer PDA", + }, + { + name: "success", + refs: completeMCMSRefs(), + }, + } + + 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 := validateMCMSRefs( + validateTestEnv(ds.Seal(), selector), + selector, + FundingConfig{}, + ) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestValidateDeployerBalance(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + deployerKey := solana.NewWallet().PrivateKey + + tests := []struct { + name string + chain cldfsol.Chain + cfg FundingConfig + wantErr string + }{ + { + name: "missing client", + chain: cldfsol.Chain{Selector: selector, DeployerKey: &deployerKey}, + wantErr: "solana client missing", + }, + { + name: "missing deployer key", + chain: cldfsol.Chain{Selector: selector, Client: rpcWithBalance(t, 1000)}, + wantErr: "deployer key missing", + }, + { + name: "insufficient balance", + chain: cldfsol.Chain{ + Selector: selector, + Client: rpcWithBalance(t, 10), + DeployerKey: &deployerKey, + }, + cfg: FundingConfig{ + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }, + wantErr: "deployer balance is insufficient", + }, + { + name: "success", + chain: cldfsol.Chain{ + Selector: selector, + Client: rpcWithBalance(t, 1000), + DeployerKey: &deployerKey, + }, + cfg: FundingConfig{ + ProposeMCM: 100, + CancellerMCM: 100, + BypasserMCM: 100, + Timelock: 100, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateDeployerBalance( + validateTestEnv(datastore.NewMemoryDataStore().Seal(), selector, tt.chain), + selector, + tt.cfg, + ) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +type validateRefSpec struct { + contractType cldf.ContractType + address string +} + +func completeMCMSRefs() []validateRefSpec { + mcmProgram := solana.NewWallet().PublicKey() + timelockProgram := solana.NewWallet().PublicKey() + + return []validateRefSpec{ + {mcmscontracts.RBACTimelock, mcmssolana.ContractAddress(timelockProgram, testPDASeed(4))}, + {mcmscontracts.ProposerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(1))}, + {mcmscontracts.CancellerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(2))}, + {mcmscontracts.BypasserManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, testPDASeed(3))}, + } +} + +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, chains ...cldfsol.Chain) cldf.Environment { + blockChains := chain.NewBlockChains(map[uint64]chain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }) + if len(chains) > 0 { + blockChains = chain.NewBlockChains(map[uint64]chain.BlockChain{selector: chains[0]}) + } + + return cldf.Environment{ + Logger: logger.Nop(), + DataStore: ds, + GetContext: context.Background, + BlockChains: blockChains, + } +}