Skip to content

Commit

Permalink
feat: add example changeset to transfer from timelock signer account (#…
Browse files Browse the repository at this point in the history
…16476)

* feat: add changeset to transfer from timelock signer account

* fix: linting errors

* fix: code cleanup

* fix: use NewTransactionFromInstruction

* fix: cleanup accounts signer set
  • Loading branch information
ecPablo authored and krehermann committed Feb 27, 2025
1 parent 9bee27e commit b17c12a
Show file tree
Hide file tree
Showing 3 changed files with 394 additions and 7 deletions.
136 changes: 136 additions & 0 deletions deployment/common/changeset/example/solana_transfer_mcm.go
Original file line number Diff line number Diff line change
@@ -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
}
242 changes: 242 additions & 0 deletions deployment/common/changeset/example/solana_transfer_mcm_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit b17c12a

Please sign in to comment.