Skip to content

Commit ca19762

Browse files
committed
feat: transfer native
1 parent f0e341a commit ca19762

2 files changed

Lines changed: 355 additions & 0 deletions

File tree

evm/changesets/transfer_native.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Package changesets provides reusable EVM changesets.
2+
package changesets
3+
4+
import (
5+
"errors"
6+
"fmt"
7+
"math/big"
8+
9+
"github.com/Masterminds/semver/v3"
10+
"github.com/ethereum/go-ethereum"
11+
"github.com/ethereum/go-ethereum/common"
12+
gethtypes "github.com/ethereum/go-ethereum/core/types"
13+
14+
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
15+
"github.com/smartcontractkit/chainlink-deployments-framework/operations"
16+
)
17+
18+
var _ cldf.ChangeSetV2[TransferNativeInput] = TransferNative{}
19+
20+
// TransferNativeInput holds the parameters for a native token transfer.
21+
type TransferNativeInput struct {
22+
ChainSel uint64 `json:"chainSel" yaml:"chainSel"`
23+
Address string `json:"address" yaml:"address"`
24+
Amount *big.Int `json:"amount" yaml:"amount"`
25+
}
26+
27+
// TransferNative is a changeset that transfers native funds from the deployer key to another address.
28+
type TransferNative struct{}
29+
30+
// VerifyPreconditions validates the input and simulates the transfer to ensure it will succeed.
31+
func (t TransferNative) VerifyPreconditions(e cldf.Environment, config TransferNativeInput) error {
32+
if config.Address == "" {
33+
return errors.New("address cannot be empty")
34+
}
35+
valid := common.IsHexAddress(config.Address)
36+
if !valid {
37+
return fmt.Errorf("address string %s cannot be converted to ETH hex address", config.Address)
38+
}
39+
40+
if config.Amount.Cmp(big.NewInt(0)) < 1 {
41+
return fmt.Errorf("amount must be positive value: %d", config.Amount)
42+
}
43+
44+
chain, ok := e.BlockChains.EVMChains()[config.ChainSel]
45+
if !ok {
46+
return fmt.Errorf("chain not found for selector %d", config.ChainSel)
47+
}
48+
49+
account := common.HexToAddress(config.Address)
50+
// Validate the transfer will succeed with simulation
51+
_, err := chain.Client.EstimateGas(e.GetContext(), ethereum.CallMsg{
52+
From: chain.DeployerKey.From,
53+
To: &account,
54+
Value: config.Amount,
55+
})
56+
if err != nil {
57+
return fmt.Errorf("transaction simulation failed: %w", err)
58+
}
59+
60+
return nil
61+
}
62+
63+
// Apply transfers native funds from the deployer key to the configured address on the given chain.
64+
func (t TransferNative) Apply(e cldf.Environment, config TransferNativeInput) (cldf.ChangesetOutput, error) {
65+
chain, ok := e.BlockChains.EVMChains()[config.ChainSel]
66+
if !ok {
67+
return cldf.ChangesetOutput{}, fmt.Errorf("chain not found for selector %d", config.ChainSel)
68+
}
69+
70+
e.Logger.Infow("Starting transfer of native funds", "chainSelector", config.ChainSel, "fromAddress", chain.DeployerKey.From, "toAddress", config.Address, "amount", config.Amount)
71+
72+
_, err := operations.ExecuteSequence(
73+
e.OperationsBundle,
74+
TransferNativeSeq,
75+
TransferNativeDeps{
76+
Env: &e,
77+
},
78+
TransferNativeOpsInput{
79+
ChainSel: config.ChainSel,
80+
Address: common.HexToAddress(config.Address),
81+
Amount: config.Amount,
82+
},
83+
)
84+
if err != nil {
85+
return cldf.ChangesetOutput{}, err
86+
}
87+
88+
e.Logger.Infow("Completed transfer of native funds", "chainSelector", config.ChainSel, "fromAddress", chain.DeployerKey.From, "toAddress", config.Address, "amount", config.Amount)
89+
90+
return cldf.ChangesetOutput{}, nil
91+
}
92+
93+
// TransferNativeSeq is the sequence that executes the native token transfer.
94+
var TransferNativeSeq = operations.NewSequence(
95+
"transfer-native-seq",
96+
semver.MustParse("1.0.0"),
97+
"Sequence to transfer native funds from the deployer key to another address",
98+
func(b operations.Bundle, deps TransferNativeDeps, input TransferNativeOpsInput) (TransferNativeOutput, error) {
99+
_, err := operations.ExecuteOperation(
100+
b,
101+
TransferNativeOp,
102+
TransferNativeDeps{
103+
Env: deps.Env,
104+
},
105+
input,
106+
)
107+
if err != nil {
108+
return TransferNativeOutput{}, fmt.Errorf("failed to transfer funds: %w", err)
109+
}
110+
111+
return TransferNativeOutput{}, nil
112+
},
113+
)
114+
115+
// TransferNativeOpsInput is the input for the TransferNativeOp operation.
116+
type TransferNativeOpsInput struct {
117+
ChainSel uint64
118+
Address common.Address
119+
Amount *big.Int
120+
}
121+
122+
// TransferNativeOutput is the output for the TransferNativeOp operation.
123+
type TransferNativeOutput struct{}
124+
125+
// TransferNativeDeps holds the dependencies for the TransferNativeOp operation.
126+
type TransferNativeDeps struct {
127+
Env *cldf.Environment
128+
}
129+
130+
// TransferNativeOp is the operation that performs the native token transfer.
131+
var TransferNativeOp = operations.NewOperation(
132+
"transfer-native-op",
133+
semver.MustParse("1.0.0"),
134+
"Operation to transfer funds from the deployer key to another address",
135+
func(b operations.Bundle, deps TransferNativeDeps, input TransferNativeOpsInput) (TransferNativeOutput, error) {
136+
chain, ok := deps.Env.BlockChains.EVMChains()[input.ChainSel]
137+
if !ok {
138+
return TransferNativeOutput{}, fmt.Errorf("chain not found for selector %d", input.ChainSel)
139+
}
140+
141+
nonce, err := chain.Client.NonceAt(b.GetContext(), chain.DeployerKey.From, nil)
142+
if err != nil {
143+
return TransferNativeOutput{}, fmt.Errorf("could not get latest nonce for deployer key: %w", err)
144+
}
145+
146+
tipCap, err := chain.Client.SuggestGasTipCap(b.GetContext())
147+
if err != nil {
148+
return TransferNativeOutput{}, fmt.Errorf("could not suggest gas tip cap: %w", err)
149+
}
150+
151+
latestBlock, err := chain.Client.HeaderByNumber(b.GetContext(), nil)
152+
if err != nil {
153+
return TransferNativeOutput{}, fmt.Errorf("could not get latest block: %w", err)
154+
}
155+
baseFee := latestBlock.BaseFee
156+
157+
feeCap := new(big.Int).Add(
158+
new(big.Int).Mul(baseFee, big.NewInt(2)),
159+
tipCap,
160+
)
161+
162+
account := input.Address
163+
164+
gasLimit, err := chain.Client.EstimateGas(b.GetContext(), ethereum.CallMsg{
165+
From: chain.DeployerKey.From,
166+
To: &account,
167+
Value: input.Amount,
168+
})
169+
if err != nil {
170+
return TransferNativeOutput{}, fmt.Errorf("could not estimate gas for chain %d: %w", chain.Selector, err)
171+
}
172+
173+
gasCost := new(big.Int).Mul(new(big.Int).SetUint64(gasLimit), feeCap)
174+
gasPlusValue := new(big.Int).Add(gasCost, input.Amount)
175+
176+
bal, err := chain.Client.BalanceAt(b.GetContext(), chain.DeployerKey.From, nil)
177+
if err != nil {
178+
return TransferNativeOutput{}, fmt.Errorf("could not get balance for deployer key: %w", err)
179+
}
180+
181+
if bal.Cmp(gasPlusValue) < 0 {
182+
return TransferNativeOutput{}, fmt.Errorf("deployer key balance %d is insufficient to cover transfer amount %d plus max gas cost %d", bal, input.Amount, gasCost)
183+
}
184+
185+
baseTx := &gethtypes.DynamicFeeTx{
186+
Nonce: nonce,
187+
GasTipCap: tipCap,
188+
GasFeeCap: feeCap,
189+
Gas: gasLimit,
190+
To: &account,
191+
Value: input.Amount,
192+
}
193+
tx := gethtypes.NewTx(baseTx)
194+
195+
signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, tx)
196+
if err != nil {
197+
return TransferNativeOutput{}, fmt.Errorf("could not sign transaction for account %s: %w", account.Hex(), err)
198+
}
199+
200+
err = chain.Client.SendTransaction(b.GetContext(), signedTx)
201+
if err != nil {
202+
return TransferNativeOutput{}, fmt.Errorf("failed to send transfer to %s on chain %d: %w", account.Hex(), chain.Selector, err)
203+
}
204+
205+
_, err = chain.Confirm(signedTx)
206+
if err != nil {
207+
return TransferNativeOutput{}, fmt.Errorf("failed to confirm transfer to %s on chain %d (tx %s): %w", account.Hex(), chain.Selector, signedTx.Hash().Hex(), err)
208+
}
209+
210+
return TransferNativeOutput{}, nil
211+
},
212+
)
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package changesets
2+
3+
import (
4+
"math/big"
5+
"testing"
6+
7+
chain_selectors "github.com/smartcontractkit/chain-selectors"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment"
12+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime"
13+
14+
"github.com/smartcontractkit/chainlink-evm/pkg/utils"
15+
)
16+
17+
func Test_TransferFunds_VerifyPreconditions(t *testing.T) {
18+
selector := chain_selectors.TEST_90000001.Selector
19+
rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
20+
environment.WithEVMSimulated(t, []uint64{selector}),
21+
))
22+
require.NoError(t, err)
23+
24+
testCases := []struct {
25+
name string
26+
input TransferNativeInput
27+
wantErr bool
28+
}{
29+
{
30+
name: "valid transfer",
31+
input: TransferNativeInput{
32+
ChainSel: chain_selectors.TEST_90000001.Selector,
33+
Address: "0x1234567890123456789012345678901234567890",
34+
Amount: big.NewInt(1000000000000000000), // 1 ETH
35+
},
36+
wantErr: false,
37+
},
38+
{
39+
name: "empty address",
40+
input: TransferNativeInput{
41+
ChainSel: chain_selectors.TEST_90000001.Selector,
42+
Address: "",
43+
Amount: big.NewInt(1000000000000000000),
44+
},
45+
wantErr: true,
46+
},
47+
{
48+
name: "invalid address format",
49+
input: TransferNativeInput{
50+
ChainSel: chain_selectors.TEST_90000001.Selector,
51+
Address: "not-a-valid-address",
52+
Amount: big.NewInt(1000000000000000000),
53+
},
54+
wantErr: true,
55+
},
56+
{
57+
name: "zero amount",
58+
input: TransferNativeInput{
59+
ChainSel: chain_selectors.TEST_90000001.Selector,
60+
Address: "0x1234567890123456789012345678901234567890",
61+
Amount: big.NewInt(0),
62+
},
63+
wantErr: true,
64+
},
65+
{
66+
name: "invalid chain selector",
67+
input: TransferNativeInput{
68+
ChainSel: 0, // Invalid chain selector
69+
Address: "0x1234567890123456789012345678901234567890",
70+
Amount: big.NewInt(100),
71+
},
72+
wantErr: true,
73+
},
74+
}
75+
76+
for _, tc := range testCases {
77+
t.Run(tc.name, func(t *testing.T) {
78+
tf := TransferNative{}
79+
err := tf.VerifyPreconditions(rt.Environment(), tc.input)
80+
if tc.wantErr {
81+
assert.Error(t, err)
82+
} else {
83+
assert.NoError(t, err)
84+
}
85+
})
86+
}
87+
}
88+
89+
func Test_TransferFundsChangeset(t *testing.T) {
90+
selector := chain_selectors.TEST_90000001.Selector
91+
rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
92+
environment.WithEVMSimulated(t, []uint64{selector}),
93+
))
94+
require.NoError(t, err)
95+
96+
tf := TransferNative{}
97+
98+
t.Run("happy path", func(t *testing.T) {
99+
addr := utils.RandomAddress()
100+
transferVal := big.NewInt(1_000_000_000) // transfer 1gwei
101+
102+
input := TransferNativeInput{
103+
ChainSel: chain_selectors.TEST_90000001.Selector,
104+
Address: addr.Hex(),
105+
Amount: transferVal,
106+
}
107+
108+
err = tf.VerifyPreconditions(rt.Environment(), input)
109+
require.NoError(t, err)
110+
111+
_, err = tf.Apply(rt.Environment(), input)
112+
require.NoError(t, err)
113+
114+
chain, ok := rt.Environment().BlockChains.EVMChains()[input.ChainSel]
115+
require.True(t, ok)
116+
117+
bal, err := chain.Client.BalanceAt(t.Context(), addr, nil)
118+
require.NoError(t, err)
119+
require.Equal(t, transferVal, bal)
120+
})
121+
122+
t.Run("insufficient funds", func(t *testing.T) {
123+
chain, ok := rt.Environment().BlockChains.EVMChains()[chain_selectors.TEST_90000001.Selector]
124+
require.True(t, ok)
125+
bal, err := chain.Client.BalanceAt(t.Context(), chain.DeployerKey.From, nil)
126+
require.NoError(t, err)
127+
128+
addr := utils.RandomAddress()
129+
transferVal := bal // transfer entire balance, leaving no funds for gas
130+
131+
input := TransferNativeInput{
132+
ChainSel: chain_selectors.TEST_90000001.Selector,
133+
Address: addr.Hex(),
134+
Amount: transferVal,
135+
}
136+
137+
err = tf.VerifyPreconditions(rt.Environment(), input)
138+
require.NoError(t, err)
139+
140+
_, err = tf.Apply(rt.Environment(), input)
141+
require.Error(t, err)
142+
})
143+
}

0 commit comments

Comments
 (0)