mirror of
https://github.com/AthanorLabs/atomic-swap.git
synced 2026-01-08 21:58:07 -05:00
480 lines
14 KiB
Go
480 lines
14 KiB
Go
// Copyright 2023 The AthanorLabs/atomic-swap Authors
|
|
// SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
// Package extethclient provides libraries for interacting with an ethereum node
|
|
// using a specific private key.
|
|
package extethclient
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ethereum/go-ethereum/accounts/abi/bind"
|
|
ethcommon "github.com/ethereum/go-ethereum/common"
|
|
ethtypes "github.com/ethereum/go-ethereum/core/types"
|
|
"github.com/ethereum/go-ethereum/ethclient"
|
|
"github.com/ethereum/go-ethereum/params"
|
|
logging "github.com/ipfs/go-log"
|
|
|
|
"github.com/athanorlabs/atomic-swap/coins"
|
|
"github.com/athanorlabs/atomic-swap/common"
|
|
contracts "github.com/athanorlabs/atomic-swap/ethereum"
|
|
"github.com/athanorlabs/atomic-swap/ethereum/block"
|
|
)
|
|
|
|
var log = logging.Logger("ethereum/extethclient")
|
|
|
|
// EthClient provides management of a private key and other convenience functions layered
|
|
// on top of the go-ethereum client. You can still access the raw go-ethereum client via
|
|
// the Raw() method.
|
|
type EthClient interface {
|
|
Address() ethcommon.Address
|
|
SetAddress(addr ethcommon.Address)
|
|
PrivateKey() *ecdsa.PrivateKey
|
|
HasPrivateKey() bool
|
|
Endpoint() string
|
|
|
|
Balance(ctx context.Context) (*coins.WeiAmount, error)
|
|
ERC20Balance(ctx context.Context, token ethcommon.Address) (*coins.ERC20TokenAmount, error)
|
|
|
|
ERC20Info(ctx context.Context, tokenAddr ethcommon.Address) (*coins.ERC20TokenInfo, error)
|
|
|
|
SetGasPrice(uint64)
|
|
SetGasLimit(uint64)
|
|
SuggestGasPrice(ctx context.Context) (*big.Int, error)
|
|
CallOpts(ctx context.Context) *bind.CallOpts
|
|
TxOpts(ctx context.Context) (*bind.TransactOpts, error)
|
|
ChainID() *big.Int
|
|
Lock() // Lock the wallet so only one transaction runs at at time
|
|
Unlock() // Unlock the wallet after a transaction is complete
|
|
|
|
// Transfer transfers ETH to the given address, nonce Lock()/Unlock()
|
|
// handling is done internally. The gasLimit field when the destination
|
|
// address is not a contract.
|
|
Transfer(
|
|
ctx context.Context,
|
|
to ethcommon.Address,
|
|
amount *coins.WeiAmount,
|
|
gasLimit *uint64,
|
|
) (*ethtypes.Receipt, error)
|
|
|
|
// Sweep transfers all funds to the given address, nonce Lock()/Unlock()
|
|
// handling is done internally. Dust may be left is sending to a contract
|
|
// address, otherwise the balance afterward will be zero.
|
|
Sweep(ctx context.Context, to ethcommon.Address) (*ethtypes.Receipt, error)
|
|
|
|
// CancelTxWithNonce attempts to cancel a transaction with the given nonce
|
|
// by sending a zero-value tx to ourselves. Since the nonce is fixed, no
|
|
// locking is done. You can even intentionally run this method to fail some
|
|
// other method that has the lock and is waiting for a receipt.
|
|
CancelTxWithNonce(ctx context.Context, nonce uint64, gasPrice *big.Int) (*ethtypes.Receipt, error)
|
|
|
|
WaitForReceipt(ctx context.Context, txHash ethcommon.Hash) (*ethtypes.Receipt, error)
|
|
WaitForTimestamp(ctx context.Context, ts time.Time) error
|
|
LatestBlockTimestamp(ctx context.Context) (time.Time, error)
|
|
|
|
Close()
|
|
Raw() *ethclient.Client
|
|
}
|
|
|
|
type ethClient struct {
|
|
endpoint string
|
|
ec *ethclient.Client
|
|
ethPrivKey *ecdsa.PrivateKey
|
|
ethAddress ethcommon.Address
|
|
gasPrice *big.Int
|
|
gasLimit uint64
|
|
chainID *big.Int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewEthClient creates and returns our extended ethereum client/wallet. The passed context
|
|
// is only used for creation. The privKey can be nil if you are using an external signer.
|
|
func NewEthClient(
|
|
ctx context.Context,
|
|
env common.Environment,
|
|
endpoint string,
|
|
privKey *ecdsa.PrivateKey,
|
|
) (EthClient, error) {
|
|
ec, err := ethclient.Dial(endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
chainID, err := ec.ChainID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err = validateChainID(env, chainID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var addr ethcommon.Address
|
|
if privKey != nil {
|
|
addr = common.EthereumPrivateKeyToAddress(privKey)
|
|
}
|
|
|
|
return ðClient{
|
|
endpoint: endpoint,
|
|
ec: ec,
|
|
ethPrivKey: privKey,
|
|
ethAddress: addr,
|
|
chainID: chainID,
|
|
}, nil
|
|
}
|
|
|
|
func (c *ethClient) Address() ethcommon.Address {
|
|
return c.ethAddress
|
|
}
|
|
|
|
func (c *ethClient) SetAddress(addr ethcommon.Address) {
|
|
if c.HasPrivateKey() {
|
|
panic("SetAddress should not have been invoked when using an external signer")
|
|
}
|
|
c.ethAddress = addr
|
|
}
|
|
|
|
func (c *ethClient) PrivateKey() *ecdsa.PrivateKey {
|
|
return c.ethPrivKey
|
|
}
|
|
|
|
func (c *ethClient) HasPrivateKey() bool {
|
|
return c.ethPrivKey != nil
|
|
}
|
|
|
|
// Endpoint returns the endpoint URL that we are connected to
|
|
func (c *ethClient) Endpoint() string {
|
|
return c.endpoint
|
|
}
|
|
|
|
func (c *ethClient) Balance(ctx context.Context) (*coins.WeiAmount, error) {
|
|
addr := c.Address()
|
|
bal, err := c.ec.BalanceAt(ctx, addr, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return coins.NewWeiAmount(bal), nil
|
|
}
|
|
|
|
// SuggestGasPrice returns the underlying eth client's suggested gas price
|
|
// unless the user specified a fixed gas price to use, in which case the user
|
|
// supplied value is returned.
|
|
func (c *ethClient) SuggestGasPrice(ctx context.Context) (*big.Int, error) {
|
|
if c.gasPrice != nil {
|
|
return c.gasPrice, nil
|
|
}
|
|
return c.Raw().SuggestGasPrice(ctx)
|
|
}
|
|
|
|
func (c *ethClient) ERC20Balance(ctx context.Context, tokenAddr ethcommon.Address) (*coins.ERC20TokenAmount, error) {
|
|
tokenContract, err := contracts.NewIERC20(tokenAddr, c.ec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bal, err := tokenContract.BalanceOf(c.CallOpts(ctx), c.Address())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tokenInfo, err := c.erc20Info(ctx, tokenAddr, tokenContract)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return coins.NewERC20TokenAmountFromBigInt(bal, tokenInfo), nil
|
|
}
|
|
|
|
func (c *ethClient) erc20Info(
|
|
ctx context.Context,
|
|
tokenAddr ethcommon.Address,
|
|
tokenContract *contracts.IERC20,
|
|
) (*coins.ERC20TokenInfo, error) {
|
|
name, err := tokenContract.Name(c.CallOpts(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
symbol, err := tokenContract.Symbol(c.CallOpts(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO: Do we support ERC20 tokens that do not have this method?
|
|
decimals, err := tokenContract.Decimals(c.CallOpts(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return coins.NewERC20TokenInfo(tokenAddr, decimals, name, symbol), nil
|
|
}
|
|
|
|
func (c *ethClient) ERC20Info(ctx context.Context, tokenAddr ethcommon.Address) (*coins.ERC20TokenInfo, error) {
|
|
tokenContract, err := contracts.NewIERC20(tokenAddr, c.ec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return c.erc20Info(ctx, tokenAddr, tokenContract)
|
|
}
|
|
|
|
// SetGasPrice sets the ethereum gas price (in wei) for use in transactions. In most
|
|
// cases, you should not use this function and let the ethereum client determine the
|
|
// suggested gas price at the current time. Setting a value of zero reverts to using
|
|
// the raw ethereum client's suggested price.
|
|
func (c *ethClient) SetGasPrice(gasPrice uint64) {
|
|
if gasPrice == 0 {
|
|
c.gasPrice = nil
|
|
return
|
|
}
|
|
c.gasPrice = new(big.Int).SetUint64(gasPrice)
|
|
}
|
|
|
|
// SetGasLimit sets the ethereum gas limit to use (in wei). In most cases you should not
|
|
// use this function and let the ethereum client dynamically determine the gas limit based
|
|
// on a simulation of the contract transaction.
|
|
func (c *ethClient) SetGasLimit(gasLimit uint64) {
|
|
c.gasLimit = gasLimit
|
|
}
|
|
|
|
func (c *ethClient) CallOpts(ctx context.Context) *bind.CallOpts {
|
|
return &bind.CallOpts{
|
|
Pending: false,
|
|
From: c.ethAddress, // might be all zeros if using external signer
|
|
BlockNumber: nil,
|
|
Context: ctx,
|
|
}
|
|
}
|
|
|
|
func (c *ethClient) TxOpts(ctx context.Context) (*bind.TransactOpts, error) {
|
|
if !c.HasPrivateKey() {
|
|
panic("TxOpts() should not have been invoked when using an external signer")
|
|
}
|
|
|
|
txOpts, err := bind.NewKeyedTransactorWithChainID(c.ethPrivKey, c.chainID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
txOpts.Context = ctx
|
|
|
|
// TODO: set gas limit + price based on network (#153)
|
|
txOpts.GasPrice = c.gasPrice
|
|
txOpts.GasLimit = c.gasLimit
|
|
|
|
return txOpts, nil
|
|
}
|
|
|
|
func (c *ethClient) ChainID() *big.Int {
|
|
return c.chainID
|
|
}
|
|
|
|
// WaitForReceipt waits for the receipt for the given transaction to be available and returns it.
|
|
func (c *ethClient) WaitForReceipt(ctx context.Context, txHash ethcommon.Hash) (*ethtypes.Receipt, error) {
|
|
return block.WaitForReceipt(ctx, c.ec, txHash)
|
|
}
|
|
|
|
func (c *ethClient) WaitForTimestamp(ctx context.Context, ts time.Time) error {
|
|
hdr, err := block.WaitForEthBlockAfterTimestamp(ctx, c.ec, ts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Debugf("Wait complete for block %d with ts=%s >= %s",
|
|
hdr.Number.Uint64(),
|
|
time.Unix(int64(hdr.Time), 0).Format(common.TimeFmtSecs),
|
|
ts.Format(common.TimeFmtSecs),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (c *ethClient) LatestBlockTimestamp(ctx context.Context) (time.Time, error) {
|
|
hdr, err := c.ec.HeaderByNumber(ctx, nil)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return time.Unix(int64(hdr.Time), 0), nil
|
|
}
|
|
|
|
// Lock is used for 2 purposes:
|
|
// 1. Nonce synchronization: Any time you do a transaction, the lock should be
|
|
// grabbed before sending the transaction to the mempool and not released until
|
|
// after the transaction has been mined into a block (receipt is returned).
|
|
// 2. Transactions that must be done together atomically. In our case, the token
|
|
// approve(...) call and TransferFrom(...) call made by the SwapCreator
|
|
// contract must be performed as one atomic unit without another `approve` call
|
|
// made in the middle.
|
|
func (c *ethClient) Lock() {
|
|
c.mu.Lock()
|
|
}
|
|
|
|
func (c *ethClient) Unlock() {
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *ethClient) Close() {
|
|
c.ec.Close()
|
|
}
|
|
|
|
func (c *ethClient) Raw() *ethclient.Client {
|
|
return c.ec
|
|
}
|
|
|
|
// Transfer transfers ETH to the given address, nonce Lock()/Unlock() handling
|
|
// is done internally. The gasLimit parameter is required when transferring to a
|
|
// contract and ignored otherwise.
|
|
func (c *ethClient) Transfer(
|
|
ctx context.Context,
|
|
to ethcommon.Address,
|
|
amount *coins.WeiAmount,
|
|
gasLimit *uint64,
|
|
) (*ethtypes.Receipt, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
nonce, err := c.ec.NonceAt(ctx, c.ethAddress, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get nonce: %w", err)
|
|
}
|
|
|
|
gasPrice, err := c.ec.SuggestGasPrice(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get gas price: %w", err)
|
|
}
|
|
|
|
isContract, err := c.isContractAddress(ctx, to)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine if dest address is contract: %w", err)
|
|
}
|
|
|
|
if !isContract {
|
|
gasLimit = new(uint64)
|
|
*gasLimit = params.TxGas
|
|
} else {
|
|
if gasLimit == nil {
|
|
return nil, errors.New("gas limit is required when transferring to a contract")
|
|
}
|
|
}
|
|
|
|
return transfer(&transferConfig{
|
|
ctx: ctx,
|
|
ec: c.ec,
|
|
pk: c.ethPrivKey,
|
|
destAddr: to,
|
|
amount: amount,
|
|
gasLimit: *gasLimit,
|
|
gasPrice: coins.NewWeiAmount(gasPrice),
|
|
nonce: nonce,
|
|
})
|
|
}
|
|
|
|
func (c *ethClient) Sweep(ctx context.Context, to ethcommon.Address) (*ethtypes.Receipt, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
nonce, err := c.ec.NonceAt(ctx, c.ethAddress, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get nonce: %w", err)
|
|
}
|
|
|
|
gasPrice, err := c.ec.SuggestGasPrice(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get gas price: %w", err)
|
|
}
|
|
|
|
isContract, err := c.isContractAddress(ctx, to)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine if dest address is contract: %w", err)
|
|
}
|
|
|
|
// Sweeping to a contract address is problematic, as any overestimation of
|
|
// the needed gas leaves non-spendable dust in the wallet. If someone has a
|
|
// use-case in the future, we can add the feature.
|
|
if isContract {
|
|
return nil, errors.New("sweeping to contract addresses is not currently supported")
|
|
}
|
|
|
|
balance, err := c.Balance(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine balance: %w", err)
|
|
}
|
|
|
|
fees := coins.NewWeiAmount(new(big.Int).Mul(gasPrice, big.NewInt(int64(params.TxGas))))
|
|
|
|
if balance.Cmp(fees) <= 0 {
|
|
return nil, fmt.Errorf("balance of %s ETH too small for fees (%d gas * %s gas-price = %s ETH",
|
|
balance.AsEtherString(), params.TxGas, coins.NewWeiAmount(gasPrice).AsEtherString(), fees.AsEtherString())
|
|
}
|
|
|
|
amount := coins.NewWeiAmount(new(big.Int).Sub(balance.BigInt(), fees.BigInt()))
|
|
|
|
return transfer(&transferConfig{
|
|
ctx: ctx,
|
|
ec: c.ec,
|
|
pk: c.ethPrivKey,
|
|
destAddr: to,
|
|
amount: amount,
|
|
gasLimit: params.TxGas,
|
|
gasPrice: coins.NewWeiAmount(gasPrice),
|
|
nonce: nonce,
|
|
})
|
|
}
|
|
|
|
// CancelTxWithNonce attempts to cancel a transaction with the given nonce
|
|
// by sending a zero-value tx to ourselves. Since the nonce is fixed, no
|
|
// locking is done. You can even intentionally run this method to fail some
|
|
// other method that has the lock and is waiting for a receipt.
|
|
func (c *ethClient) CancelTxWithNonce(
|
|
ctx context.Context,
|
|
nonce uint64,
|
|
gasPrice *big.Int,
|
|
) (*ethtypes.Receipt, error) {
|
|
// no locking, nonce is fixed and we are not protecting it
|
|
return transfer(&transferConfig{
|
|
ctx: ctx,
|
|
ec: c.ec,
|
|
pk: c.ethPrivKey,
|
|
destAddr: c.ethAddress, // ourself
|
|
amount: coins.NewWeiAmount(big.NewInt(0)), // zero ETH
|
|
gasLimit: params.TxGas,
|
|
gasPrice: coins.NewWeiAmount(gasPrice),
|
|
nonce: nonce,
|
|
})
|
|
}
|
|
|
|
func (c *ethClient) isContractAddress(ctx context.Context, addr ethcommon.Address) (bool, error) {
|
|
bytecode, err := c.Raw().CodeAt(ctx, addr, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
isContract := len(bytecode) > 0
|
|
return isContract, nil
|
|
}
|
|
|
|
func validateChainID(env common.Environment, chainID *big.Int) error {
|
|
switch env {
|
|
case common.Mainnet:
|
|
if chainID.Cmp(big.NewInt(common.MainnetChainID)) != 0 {
|
|
return fmt.Errorf("expected Mainnet chain ID (%d), but found %s", common.MainnetChainID, chainID)
|
|
}
|
|
case common.Stagenet:
|
|
if chainID.Cmp(big.NewInt(common.SepoliaChainID)) != 0 {
|
|
return fmt.Errorf("expected Sepolia chain ID (%d), but found %s", common.SepoliaChainID, chainID)
|
|
}
|
|
case common.Development:
|
|
if chainID.Cmp(big.NewInt(common.GanacheChainID)) != 0 {
|
|
return fmt.Errorf("expected Ganache chain ID (%d), but found %s", common.GanacheChainID, chainID)
|
|
}
|
|
default:
|
|
panic("unhandled environment type")
|
|
}
|
|
|
|
return nil
|
|
}
|