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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions evm/changesets/transfer_native.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Package changesets provides reusable EVM changesets.
package changesets

import (
"errors"
"fmt"
"math/big"

"github.com/Masterminds/semver/v3"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
gethtypes "github.com/ethereum/go-ethereum/core/types"

cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
"github.com/smartcontractkit/chainlink-deployments-framework/operations"
)

var _ cldf.ChangeSetV2[TransferNativeInput] = TransferNative{}

// TransferNativeInput holds the parameters for a native token transfer.
type TransferNativeInput struct {
ChainSel uint64 `json:"chainSel" yaml:"chainSel"`
Address string `json:"address" yaml:"address"`
Amount *big.Int `json:"amount" yaml:"amount"`
}

// TransferNative is a changeset that transfers native funds from the deployer key to another address.
type TransferNative struct{}

// VerifyPreconditions validates the input and simulates the transfer to ensure it will succeed.
func (t TransferNative) VerifyPreconditions(e cldf.Environment, config TransferNativeInput) error {
if config.Address == "" {
return errors.New("address cannot be empty")
}
valid := common.IsHexAddress(config.Address)
if !valid {
return fmt.Errorf("address string %s cannot be converted to ETH hex address", config.Address)
}

if config.Amount.Cmp(big.NewInt(0)) < 1 {
return fmt.Errorf("amount must be positive value: %d", config.Amount)
}

chain, ok := e.BlockChains.EVMChains()[config.ChainSel]
if !ok {
return fmt.Errorf("chain not found for selector %d", config.ChainSel)
}

account := common.HexToAddress(config.Address)
// Validate the transfer will succeed with simulation
_, err := chain.Client.EstimateGas(e.GetContext(), ethereum.CallMsg{
From: chain.DeployerKey.From,
To: &account,
Value: config.Amount,
})
if err != nil {
return fmt.Errorf("transaction simulation failed: %w", err)
}

return nil
}

// Apply transfers native funds from the deployer key to the configured address on the given chain.
func (t TransferNative) Apply(e cldf.Environment, config TransferNativeInput) (cldf.ChangesetOutput, error) {
chain, ok := e.BlockChains.EVMChains()[config.ChainSel]
if !ok {
return cldf.ChangesetOutput{}, fmt.Errorf("chain not found for selector %d", config.ChainSel)
}

e.Logger.Infow("Starting transfer of native funds", "chainSelector", config.ChainSel, "fromAddress", chain.DeployerKey.From, "toAddress", config.Address, "amount", config.Amount)

_, err := operations.ExecuteSequence(
e.OperationsBundle,
TransferNativeSeq,
TransferNativeDeps{
Env: &e,
},
TransferNativeOpsInput{
ChainSel: config.ChainSel,
Address: common.HexToAddress(config.Address),
Amount: config.Amount,
},
)
if err != nil {
return cldf.ChangesetOutput{}, err
}

e.Logger.Infow("Completed transfer of native funds", "chainSelector", config.ChainSel, "fromAddress", chain.DeployerKey.From, "toAddress", config.Address, "amount", config.Amount)

return cldf.ChangesetOutput{}, nil
}

// TransferNativeSeq is the sequence that executes the native token transfer.
var TransferNativeSeq = operations.NewSequence(
"transfer-native-seq",
semver.MustParse("1.0.0"),
"Sequence to transfer native funds from the deployer key to another address",
func(b operations.Bundle, deps TransferNativeDeps, input TransferNativeOpsInput) (TransferNativeOutput, error) {
_, err := operations.ExecuteOperation(
b,
TransferNativeOp,
TransferNativeDeps{
Env: deps.Env,
},
input,
)
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("failed to transfer funds: %w", err)
}

return TransferNativeOutput{}, nil
},
)

// TransferNativeOpsInput is the input for the TransferNativeOp operation.
type TransferNativeOpsInput struct {
ChainSel uint64
Address common.Address
Amount *big.Int
Comment on lines +117 to +119
}

// TransferNativeOutput is the output for the TransferNativeOp operation.
type TransferNativeOutput struct{}

// TransferNativeDeps holds the dependencies for the TransferNativeOp operation.
type TransferNativeDeps struct {
Env *cldf.Environment
}

// TransferNativeOp is the operation that performs the native token transfer.
var TransferNativeOp = operations.NewOperation(
"transfer-native-op",
semver.MustParse("1.0.0"),
"Operation to transfer funds from the deployer key to another address",
func(b operations.Bundle, deps TransferNativeDeps, input TransferNativeOpsInput) (TransferNativeOutput, error) {
chain, ok := deps.Env.BlockChains.EVMChains()[input.ChainSel]
if !ok {
return TransferNativeOutput{}, fmt.Errorf("chain not found for selector %d", input.ChainSel)
}

nonce, err := chain.Client.NonceAt(b.GetContext(), chain.DeployerKey.From, nil)
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("could not get latest nonce for deployer key: %w", err)
Comment on lines +141 to +143
}

tipCap, err := chain.Client.SuggestGasTipCap(b.GetContext())
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("could not suggest gas tip cap: %w", err)
}

latestBlock, err := chain.Client.HeaderByNumber(b.GetContext(), nil)
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("could not get latest block: %w", err)
}
baseFee := latestBlock.BaseFee

feeCap := new(big.Int).Add(
new(big.Int).Mul(baseFee, big.NewInt(2)),
tipCap,
)

Comment on lines +146 to +161
account := input.Address

gasLimit, err := chain.Client.EstimateGas(b.GetContext(), ethereum.CallMsg{
From: chain.DeployerKey.From,
To: &account,
Value: input.Amount,
})
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("could not estimate gas for chain %d: %w", chain.Selector, err)
}

gasCost := new(big.Int).Mul(new(big.Int).SetUint64(gasLimit), feeCap)
gasPlusValue := new(big.Int).Add(gasCost, input.Amount)

bal, err := chain.Client.BalanceAt(b.GetContext(), chain.DeployerKey.From, nil)
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("could not get balance for deployer key: %w", err)
}

if bal.Cmp(gasPlusValue) < 0 {
return TransferNativeOutput{}, fmt.Errorf("deployer key balance %d is insufficient to cover transfer amount %d plus max gas cost %d", bal, input.Amount, gasCost)
}

baseTx := &gethtypes.DynamicFeeTx{
Nonce: nonce,
GasTipCap: tipCap,
GasFeeCap: feeCap,
Gas: gasLimit,
To: &account,
Value: input.Amount,
}
tx := gethtypes.NewTx(baseTx)

signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, tx)
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("could not sign transaction for account %s: %w", account.Hex(), err)
}

err = chain.Client.SendTransaction(b.GetContext(), signedTx)
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("failed to send transfer to %s on chain %d: %w", account.Hex(), chain.Selector, err)
}

_, err = chain.Confirm(signedTx)
if err != nil {
return TransferNativeOutput{}, fmt.Errorf("failed to confirm transfer to %s on chain %d (tx %s): %w", account.Hex(), chain.Selector, signedTx.Hash().Hex(), err)
}

return TransferNativeOutput{}, nil
},
)
147 changes: 147 additions & 0 deletions evm/changesets/transfer_native_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package changesets

import (
"math/big"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

chain_selectors "github.com/smartcontractkit/chain-selectors"
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment"
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime"
"github.com/smartcontractkit/chainlink-evm/pkg/utils"
)

func Test_TransferFunds_VerifyPreconditions(t *testing.T) {
t.Parallel()
selector := chain_selectors.TEST_90000001.Selector
rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
environment.WithEVMSimulated(t, []uint64{selector}),
))
require.NoError(t, err)

testCases := []struct {
name string
input TransferNativeInput
wantErr bool
}{
{
name: "valid transfer",
input: TransferNativeInput{
ChainSel: chain_selectors.TEST_90000001.Selector,
Address: "0x1234567890123456789012345678901234567890",
Amount: big.NewInt(1000000000000000000), // 1 ETH
},
wantErr: false,
},
{
name: "empty address",
input: TransferNativeInput{
ChainSel: chain_selectors.TEST_90000001.Selector,
Address: "",
Amount: big.NewInt(1000000000000000000),
},
wantErr: true,
},
{
name: "invalid address format",
input: TransferNativeInput{
ChainSel: chain_selectors.TEST_90000001.Selector,
Address: "not-a-valid-address",
Amount: big.NewInt(1000000000000000000),
},
wantErr: true,
},
{
name: "zero amount",
input: TransferNativeInput{
ChainSel: chain_selectors.TEST_90000001.Selector,
Address: "0x1234567890123456789012345678901234567890",
Amount: big.NewInt(0),
},
wantErr: true,
},
{
name: "invalid chain selector",
input: TransferNativeInput{
ChainSel: 0, // Invalid chain selector
Address: "0x1234567890123456789012345678901234567890",
Amount: big.NewInt(100),
},
wantErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tf := TransferNative{}
err := tf.VerifyPreconditions(rt.Environment(), tc.input)
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

func Test_TransferFundsChangeset(t *testing.T) {
t.Parallel()
selector := chain_selectors.TEST_90000001.Selector
rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
environment.WithEVMSimulated(t, []uint64{selector}),
))
require.NoError(t, err)

tf := TransferNative{}

t.Run("happy path", func(t *testing.T) {
t.Parallel()
Comment on lines +100 to +101
addr := utils.RandomAddress()
transferVal := big.NewInt(1_000_000_000) // transfer 1gwei

input := TransferNativeInput{
ChainSel: chain_selectors.TEST_90000001.Selector,
Address: addr.Hex(),
Amount: transferVal,
}

err = tf.VerifyPreconditions(rt.Environment(), input)
require.NoError(t, err)

_, err = tf.Apply(rt.Environment(), input)
require.NoError(t, err)

chain, ok := rt.Environment().BlockChains.EVMChains()[input.ChainSel]
require.True(t, ok)

bal, err := chain.Client.BalanceAt(t.Context(), addr, nil)
require.NoError(t, err)
require.Equal(t, transferVal, bal)
})

t.Run("insufficient funds", func(t *testing.T) {
t.Parallel()
chain, ok := rt.Environment().BlockChains.EVMChains()[chain_selectors.TEST_90000001.Selector]
require.True(t, ok)
bal, err := chain.Client.BalanceAt(t.Context(), chain.DeployerKey.From, nil)
require.NoError(t, err)

addr := utils.RandomAddress()
transferVal := bal // transfer entire balance, leaving no funds for gas

input := TransferNativeInput{
ChainSel: chain_selectors.TEST_90000001.Selector,
Address: addr.Hex(),
Amount: transferVal,
}

err = tf.VerifyPreconditions(rt.Environment(), input)
require.NoError(t, err)

_, err = tf.Apply(rt.Environment(), input)
require.Error(t, err)
})
}
Loading