Files
atomic-swap/ethereum/extethclient/eth_wallet_client.go
2023-04-05 20:16:41 -05:00

297 lines
8.0 KiB
Go

// Copyright 2023 Athanor Labs (ON)
// 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"
"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"
logging "github.com/ipfs/go-log"
"github.com/athanorlabs/atomic-swap/common"
contracts "github.com/athanorlabs/atomic-swap/ethereum"
"github.com/athanorlabs/atomic-swap/ethereum/block"
)
var log = logging.Logger("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) (*big.Int, error)
ERC20Balance(ctx context.Context, token ethcommon.Address) (*big.Int, error)
ERC20Info(ctx context.Context, token ethcommon.Address) (name string, symbol string, decimals uint8, err 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
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 &ethClient{
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) (*big.Int, error) {
addr := c.Address()
bal, err := c.ec.BalanceAt(ctx, addr, nil)
if err != nil {
return nil, err
}
return bal, nil
}
// SuggestedGasPrice 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, token ethcommon.Address) (*big.Int, error) {
tokenContract, err := contracts.NewIERC20(token, c.ec)
if err != nil {
return big.NewInt(0), err
}
return tokenContract.BalanceOf(c.CallOpts(ctx), c.Address())
}
func (c *ethClient) ERC20Info(ctx context.Context, token ethcommon.Address) (
name string,
symbol string,
decimals uint8,
err error,
) {
tokenContract, err := contracts.NewIERC20(token, c.ec)
if err != nil {
return "", "", 18, err
}
name, err = tokenContract.Name(c.CallOpts(ctx))
if err != nil {
return "", "", 18, err
}
symbol, err = tokenContract.Symbol(c.CallOpts(ctx))
if err != nil {
return "", "", 18, err
}
decimals, err = tokenContract.Decimals(c.CallOpts(ctx))
if err != nil {
return "", "", 18, err
}
return name, symbol, decimals, nil
}
// 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
}
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
}
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
}