diff --git a/deployment/common/changeset/example/solana_transfer_mcm.go b/deployment/common/changeset/example/solana_transfer_mcm.go new file mode 100644 index 00000000000..6fdc197fe73 --- /dev/null +++ b/deployment/common/changeset/example/solana_transfer_mcm.go @@ -0,0 +1,136 @@ +package example + +import ( + "errors" + "fmt" + "time" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/smartcontractkit/mcms" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink/deployment" + solanachangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset/solana" + "github.com/smartcontractkit/chainlink/deployment/common/changeset/state" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" +) + +var _ deployment.ChangeSetV2[TransferFromTimelockConfig] = TransferFromTimelock{} + +type TransferData struct { + To solana.PublicKey + Amount uint64 +} +type TransferFromTimelockConfig struct { + TimelockDelay time.Duration + AmountsPerChain map[uint64]TransferData +} + +// TransferFromTimelock is a changeset that transfer funds from the timelock signer PDA +// to the address provided in the config. It will return an mcms proposal to sign containing +// the funds transfer transaction. +type TransferFromTimelock struct{} + +// VerifyPreconditions checks if the deployer has enough SOL to fund the MCMS signers on each chain. +func (f TransferFromTimelock) VerifyPreconditions(e deployment.Environment, config TransferFromTimelockConfig) error { + // the number of accounts to fund per chain (bypasser, canceller, proposer, timelock) + for chainSelector, amountCfg := range config.AmountsPerChain { + solChain, ok := e.SolChains[chainSelector] + if !ok { + return fmt.Errorf("solana chain not found for selector %d", chainSelector) + } + if amountCfg.To.IsZero() { + return errors.New("destination address is empty") + } + addresses, err := e.ExistingAddresses.AddressesForChain(chainSelector) + if err != nil { + return fmt.Errorf("failed to get existing addresses: %w", err) + } + mcmState, err := state.MaybeLoadMCMSWithTimelockChainStateSolana(solChain, addresses) + if err != nil { + return fmt.Errorf("failed to load MCMS state: %w", err) + } + // Check if seeds are empty + if mcmState.TimelockSeed == [32]byte{} { + return errors.New("timelock seeds are empty, please deploy MCMS contracts first") + } + // Check if program IDs exists + if mcmState.TimelockProgram.IsZero() { + return errors.New("timelock program IDs are empty, please deploy timelock program first") + } + result, err := solChain.Client.GetBalance(e.GetContext(), solChain.DeployerKey.PublicKey(), rpc.CommitmentConfirmed) + if err != nil { + return fmt.Errorf("failed to get deployer balance: %w", err) + } + if result.Value < amountCfg.Amount { + return fmt.Errorf("deployer balance is insufficient, required: %d, actual: %d", amountCfg.Amount, result.Value) + } + } + return nil +} + +// Apply funds the MCMS signers on each chain. +func (f TransferFromTimelock) Apply(e deployment.Environment, config TransferFromTimelockConfig) (deployment.ChangesetOutput, error) { + timelocks := map[uint64]string{} + proposers := map[uint64]string{} + var batches []types.BatchOperation + inspectors, err := proposalutils.McmsInspectors(e) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get MCMS inspectors: %w", err) + } + for chainSelector, cfgAmounts := range config.AmountsPerChain { + solChain := e.SolChains[chainSelector] + addreses, err := e.ExistingAddresses.AddressesForChain(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get existing addresses: %w", err) + } + mcmState, err := state.MaybeLoadMCMSWithTimelockChainStateSolana(solChain, addreses) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to load MCMS state: %w", err) + } + timelockSignerPDA := state.GetTimelockSignerPDA(mcmState.TimelockProgram, mcmState.TimelockSeed) + timelockID := mcmssolanasdk.ContractAddress(mcmState.TimelockProgram, mcmssolanasdk.PDASeed(mcmState.TimelockSeed)) + proposerID := mcmssolanasdk.ContractAddress(mcmState.McmProgram, mcmssolanasdk.PDASeed(mcmState.ProposerMcmSeed)) + timelocks[chainSelector] = timelockID + proposers[chainSelector] = proposerID + ixs, err := solanachangeset.FundFromAddressIxs( + solChain, + timelockSignerPDA, + []solana.PublicKey{cfgAmounts.To}, + cfgAmounts.Amount) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to fund timelock signer on chain %d: %w", chainSelector, err) + } + + var transactions []types.Transaction + + for _, ix := range ixs { + solanaTx, err := mcmssolanasdk.NewTransactionFromInstruction(ix, "SystemProgram", []string{}) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to create transaction: %w", err) + } + transactions = append(transactions, solanaTx) + } + batches = append(batches, types.BatchOperation{ + ChainSelector: types.ChainSelector(chainSelector), + Transactions: transactions, + }) + } + proposal, err := proposalutils.BuildProposalFromBatchesV2( + e.GetContext(), + timelocks, + proposers, + inspectors, + batches, + "transfer funds from timelock singer", + config.TimelockDelay, + ) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to build proposal: %w", err) + } + return deployment.ChangesetOutput{ + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil +} diff --git a/deployment/common/changeset/example/solana_transfer_mcm_test.go b/deployment/common/changeset/example/solana_transfer_mcm_test.go new file mode 100644 index 00000000000..28a0cbc4a58 --- /dev/null +++ b/deployment/common/changeset/example/solana_transfer_mcm_test.go @@ -0,0 +1,242 @@ +package example_test + +import ( + "fmt" + "testing" + "time" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + chainselectors "github.com/smartcontractkit/chain-selectors" + mcmsSolana "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/changeset/example" + "github.com/smartcontractkit/chainlink/deployment/common/changeset/state" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/deployment/environment/memory" + "github.com/smartcontractkit/chainlink/v2/core/logger" +) + +// setupFundingTestEnv deploys all required contracts for the funding test +func setupFundingTestEnv(t *testing.T) deployment.Environment { + lggr := logger.TestLogger(t) + cfg := memory.MemoryEnvironmentConfig{ + SolChains: 1, + } + env := memory.NewMemoryEnvironment(t, lggr, zapcore.DebugLevel, cfg) + chainSelector := env.AllChainSelectorsSolana()[0] + + config := proposalutils.SingleGroupTimelockConfigV2(t) + testhelpers.SavePreloadedSolAddresses(t, env, chainSelector) + // Initialize the address book with a dummy address to avoid deploy precondition errors. + err := env.ExistingAddresses.Save(chainSelector, "dummyAddress", deployment.TypeAndVersion{Type: "dummy", Version: deployment.Version1_0_0}) + require.NoError(t, err) + + // Deploy MCMS and Timelock + env, err = changeset.Apply(t, env, nil, + changeset.Configure( + deployment.CreateLegacyChangeSet(changeset.DeployMCMSWithTimelockV2), + map[uint64]types.MCMSWithTimelockConfigV2{ + chainSelector: config, + }, + ), + ) + require.NoError(t, err) + + return env +} + +func TestTransferFromTimelockConfig_VerifyPreconditions(t *testing.T) { + lggr := logger.TestLogger(t) + validEnv := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{SolChains: 1}) + validEnv.SolChains[chainselectors.SOLANA_DEVNET.Selector] = deployment.SolChain{} + validSolChainSelector := validEnv.AllChainSelectorsSolana()[0] + receiverKey := solana.NewWallet().PublicKey() + cs := example.TransferFromTimelock{} + timelockID := mcmsSolana.ContractAddress( + solana.NewWallet().PublicKey(), + [32]byte{'t', 'e', 's', 't'}, + ) + err := validEnv.ExistingAddresses.Save(validSolChainSelector, timelockID, deployment.TypeAndVersion{ + Type: types.RBACTimelock, + Version: deployment.Version1_0_0, + }) + require.NoError(t, err) + + // Create an environment that simulates a chain where the MCMS contracts have not been deployed, + // e.g. missing the required addresses so that the state loader returns empty seeds. + noTimelockEnv := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{ + SolChains: 1, + }) + noTimelockEnv.SolChains[chainselectors.SOLANA_DEVNET.Selector] = deployment.SolChain{} + err = noTimelockEnv.ExistingAddresses.Save(chainselectors.SOLANA_DEVNET.Selector, "dummy", deployment.TypeAndVersion{ + Type: "Sometype", + Version: deployment.Version1_0_0, + }) + require.NoError(t, err) + + // Create an environment with a Solana chain that has an invalid (zero) underlying chain. + invalidSolChainEnv := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{ + SolChains: 0, + }) + invalidSolChainEnv.SolChains[validSolChainSelector] = deployment.SolChain{} + + tests := []struct { + name string + env deployment.Environment + config example.TransferFromTimelockConfig + expectedError string + }{ + { + name: "All preconditions satisfied", + env: validEnv, + config: example.TransferFromTimelockConfig{ + AmountsPerChain: map[uint64]example.TransferData{validSolChainSelector: { + Amount: 100, + To: receiverKey, + }}, + }, + expectedError: "", + }, + { + name: "No Solana chains found in environment", + env: memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{ + Bootstraps: 1, + Chains: 1, + SolChains: 0, + Nodes: 1, + }), + config: example.TransferFromTimelockConfig{ + AmountsPerChain: map[uint64]example.TransferData{validSolChainSelector: { + Amount: 100, + To: receiverKey, + }}, + }, + expectedError: fmt.Sprintf("solana chain not found for selector %d", validSolChainSelector), + }, + { + name: "Chain selector not found in environment", + env: validEnv, + config: example.TransferFromTimelockConfig{AmountsPerChain: map[uint64]example.TransferData{99999: { + Amount: 100, + To: receiverKey, + }}}, + expectedError: "solana chain not found for selector 99999", + }, + { + name: "timelock contracts not deployed (empty seeds)", + env: noTimelockEnv, + config: example.TransferFromTimelockConfig{ + AmountsPerChain: map[uint64]example.TransferData{chainselectors.SOLANA_DEVNET.Selector: { + Amount: 100, + To: receiverKey, + }}, + }, + expectedError: "timelock seeds are empty, please deploy MCMS contracts first", + }, + { + name: "Insufficient deployer balance", + env: validEnv, + config: example.TransferFromTimelockConfig{ + AmountsPerChain: map[uint64]example.TransferData{ + validSolChainSelector: { + Amount: 999999999999999999, + To: receiverKey, + }, + }, + }, + expectedError: "deployer balance is insufficient", + }, + { + name: "Insufficient deployer balance", + env: validEnv, + config: example.TransferFromTimelockConfig{ + AmountsPerChain: map[uint64]example.TransferData{ + validSolChainSelector: { + Amount: 999999999999999999, + To: receiverKey, + }, + }, + }, + expectedError: "deployer balance is insufficient", + }, + { + name: "Invalid Solana chain in environment", + env: invalidSolChainEnv, + config: example.TransferFromTimelockConfig{ + AmountsPerChain: map[uint64]example.TransferData{validSolChainSelector: { + Amount: 100, + To: receiverKey, + }}, + }, + expectedError: "failed to get existing addresses: chain selector 12463857294658392847: chain not found", + }, + { + name: "empty from field", + env: invalidSolChainEnv, + config: example.TransferFromTimelockConfig{ + AmountsPerChain: map[uint64]example.TransferData{validSolChainSelector: { + Amount: 100, + To: solana.PublicKey{}, + }}, + }, + expectedError: "destination address is empty", + }, + } + + 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.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} + +func TestTransferFromTimelockConfig_Apply(t *testing.T) { + env := setupFundingTestEnv(t) + cfgAmounts := example.TransferData{ + Amount: 100 * solana.LAMPORTS_PER_SOL, + To: solana.NewWallet().PublicKey(), + } + amountsPerChain := make(map[uint64]example.TransferData) + for chainSelector := range env.SolChains { + amountsPerChain[chainSelector] = cfgAmounts + } + config := example.TransferFromTimelockConfig{ + TimelockDelay: 1 * time.Second, + AmountsPerChain: amountsPerChain, + } + addresses, err := env.ExistingAddresses.AddressesForChain(env.AllChainSelectorsSolana()[0]) + require.NoError(t, err) + mcmState, err := state.MaybeLoadMCMSWithTimelockChainStateSolana(env.SolChains[env.AllChainSelectorsSolana()[0]], addresses) + require.NoError(t, err) + timelockSigner := state.GetTimelockSignerPDA(mcmState.TimelockProgram, mcmState.TimelockSeed) + mcmSigner := state.GetMCMSignerPDA(mcmState.McmProgram, mcmState.ProposerMcmSeed) + chainSelector := env.AllChainSelectorsSolana()[0] + solChain := env.SolChains[chainSelector] + memory.FundSolanaAccounts(env.GetContext(), t, []solana.PublicKey{timelockSigner, mcmSigner, solChain.DeployerKey.PublicKey()}, 150, solChain.Client) + + changesetInstance := example.TransferFromTimelock{} + + env, err = changeset.ApplyChangesetsV2(t, env, []changeset.ConfiguredChangeSet{ + changeset.Configure(changesetInstance, config), + }) + require.NoError(t, err) + + balance, err := solChain.Client.GetBalance(env.GetContext(), cfgAmounts.To, rpc.CommitmentConfirmed) + require.NoError(t, err) + t.Logf("Account: %s, Balance: %d", cfgAmounts.To, balance.Value) + + require.Equal(t, cfgAmounts.Amount, balance.Value) +} diff --git a/deployment/common/changeset/solana/helpers.go b/deployment/common/changeset/solana/helpers.go index e6dc93c6590..e23ac65ac20 100644 --- a/deployment/common/changeset/solana/helpers.go +++ b/deployment/common/changeset/solana/helpers.go @@ -9,25 +9,34 @@ import ( "github.com/smartcontractkit/chainlink/deployment" ) -// FundFromDeployerKey transfers SOL from the deployer to each provided account and waits for confirmations. -func FundFromDeployerKey(solChain deployment.SolChain, accounts []solana.PublicKey, amount uint64) error { +// FundFromAddressIxs transfers SOL from the given address to each provided account and waits for confirmations. +func FundFromAddressIxs(solChain deployment.SolChain, from solana.PublicKey, accounts []solana.PublicKey, amount uint64) ([]solana.Instruction, error) { var ixs []solana.Instruction for _, account := range accounts { // Create a transfer instruction using the provided builder. ix, err := system.NewTransferInstruction( amount, - solChain.DeployerKey.PublicKey(), // funding account (sender) - account, // recipient account + from, // funding account (sender) + account, // recipient account ).ValidateAndBuild() if err != nil { - return fmt.Errorf("failed to create transfer instruction: %w", err) + return nil, fmt.Errorf("failed to create transfer instruction: %w", err) } ixs = append(ixs, ix) } - err := solChain.Confirm(ixs) + return ixs, nil +} + +// FundFromDeployerKey transfers SOL from the deployer to each provided account and waits for confirmations. +func FundFromDeployerKey(solChain deployment.SolChain, accounts []solana.PublicKey, amount uint64) error { + ixs, err := FundFromAddressIxs(solChain, solChain.DeployerKey.PublicKey(), accounts, amount) + if err != nil { + return fmt.Errorf("failed to create transfer instructions: %w", err) + } + err = solChain.Confirm(ixs) if err != nil { - return fmt.Errorf("failed to create transaction: %w", err) + return fmt.Errorf("failed to confirm transaction: %w", err) } return nil }