ethergo/backends/anvil/anvil.go
package anvil
import (
"context"
"errors"
"fmt"
"math"
"math/big"
"strings"
"sync"
"testing"
"time"
"github.com/ipfs/go-log"
"github.com/lmittmann/w3/w3types"
"github.com/ory/dockertest/v3"
"github.com/teivah/onecontext"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/accounts/keystore"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/params"
"github.com/google/uuid"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/require"
"github.com/synapsecns/sanguine/core"
"github.com/synapsecns/sanguine/core/dockerutil"
"github.com/synapsecns/sanguine/core/mapmutex"
"github.com/synapsecns/sanguine/core/processlog"
"github.com/synapsecns/sanguine/ethergo/backends"
"github.com/synapsecns/sanguine/ethergo/backends/base"
"github.com/synapsecns/sanguine/ethergo/chain"
"github.com/synapsecns/sanguine/ethergo/chain/client"
"github.com/synapsecns/sanguine/ethergo/signer/wallet"
)
const gasLimit = 10000000
// Backend contains the anvil test backend.
type Backend struct {
*base.Backend
// fundingMux is used to lock the wallets while funding
// since FundAccount is expected to add to existing balance
fundingMux mapmutex.StringerMapMutex
// chainConfig is the chain config
chainConfig *params.ChainConfig
// impersonationMux is used to lock the impersonation
impersonationMux sync.Mutex
// pool is the docker pool
pool *dockertest.Pool
// resource is the docker resource
resource *dockertest.Resource
}
// BackendName is the name of the anvil backend.
const BackendName = "anvil"
// BackendName returns the name of the backend.
func (b *Backend) BackendName() string {
return BackendName
}
// NewAnvilBackend creates a test anvil backend.
// nolint: cyclop
func NewAnvilBackend(ctx context.Context, t *testing.T, args *OptionBuilder) (*Backend, error) {
t.Helper()
pool, err := dockertest.NewPool("")
if err != nil {
return nil, fmt.Errorf("failed to create docker pool: %w", err)
}
pool.MaxWait = args.maxWait
commandArgs, err := args.Build()
if err != nil {
return nil, fmt.Errorf("failed to build command args: %w", err)
}
runOptions := &dockertest.RunOptions{
Repository: "ghcr.io/foundry-rs/foundry",
Tag: "nightly-1bac1b3d79243cea755800bf396c30a3d74741bf",
Platform: "linux/amd64",
Cmd: []string{strings.Join(append([]string{"anvil"}, commandArgs...), " ")},
Labels: map[string]string{
"test-id": uuid.New().String(),
},
ExposedPorts: []string{"8545"},
}
resource, err := pool.RunWithOptions(runOptions, func(config *docker.HostConfig) {
config.AutoRemove = args.autoremove
if args.restartPolicy != nil {
config.RestartPolicy = *args.restartPolicy
}
})
if err != nil {
return nil, fmt.Errorf("failed to run anvil container: %w", err)
}
logInfoChan := make(chan processlog.LogMetadata)
go func() {
defer close(logInfoChan)
err = dockerutil.TailContainerLogs(dockerutil.WithContext(ctx), dockerutil.WithResource(resource), dockerutil.WithPool(pool), dockerutil.WithProcessLogOptions(args.processOptions...), dockerutil.WithFollow(true), dockerutil.WithCallback(func(ctx context.Context, metadata processlog.LogMetadata) {
select {
case <-ctx.Done():
return
case logInfoChan <- metadata:
}
}))
if ctx.Err() != nil {
logger.Warn(err)
}
}()
otterscanMessage := ""
if args.enableOtterscan {
otterAddress, err := setupOtterscan(ctx, t, pool, resource, args)
if err != nil {
return nil, fmt.Errorf("failed to setup otterscan: %w", err)
}
otterscanMessage = fmt.Sprintf("otterscan is running at %s", otterAddress)
}
// Docker will hard kill the container in expiryseconds seconds (this is a test env).
// containers should be removed on their own, but this is a safety net.
// to prevent old containers from piling up, we set a timeout to remove the container.
if err := resource.Expire(args.expirySeconds); err != nil {
return nil, fmt.Errorf("failed to set expiry for anvil container: %w", err)
}
address := fmt.Sprintf("%s:%s", "http://localhost", dockerutil.GetPort(resource, "8545/tcp"))
var chainID *big.Int
pool.MaxWait = time.Minute
if err := pool.Retry(func() error {
rpcClient, err := ethclient.DialContext(ctx, address)
if err != nil {
return fmt.Errorf("failed to connect")
}
res, err := rpcClient.ChainID(ctx)
if err != nil {
return fmt.Errorf("failed to get chain id: %w", err)
}
chainID = core.CopyBigInt(res)
return nil
}); err != nil {
return nil, fmt.Errorf("failed to connect to anvil: %w", err)
}
if chainID == nil {
return nil, fmt.Errorf("chain id is nil")
}
chainConfig := args.GetHardfork().ToChainConfig(chainID)
chn, err := chain.New(ctx, &client.Config{
RPCUrl: []string{address},
ChainID: int(chainConfig.ChainID.Int64()),
})
if err != nil {
return nil, fmt.Errorf("failed to create chain for chain id %s: %w", chainID, err)
}
chn.SetChainConfig(chainConfig)
select {
case <-ctx.Done():
t.Errorf("context canceled before anvil node started")
case logInfo := <-logInfoChan:
logger.Warnf("started anvil node for chain %s as container %s. %s Logs will be stored at %s", chainID, strings.TrimPrefix(resource.Container.Name, "/"), otterscanMessage, logInfo.LogDir())
}
baseBackend, err := base.NewBaseBackend(ctx, t, chn)
if err != nil {
return nil, fmt.Errorf("failed to create base backend: %w", err)
}
backend := Backend{
Backend: baseBackend,
fundingMux: mapmutex.NewStringerMapMutex(),
chainConfig: chainConfig,
pool: pool,
resource: resource,
}
err = backend.storeWallets(args)
if err != nil {
return nil, fmt.Errorf("failed to store wallets on chain id %s: %w", chainID, err)
}
t.Cleanup(func() {
select {
case <-ctx.Done():
backend.TearDown()
default:
// do nothing, we don't want to purge the container if this is just a subtest
}
})
return &backend, nil
}
// TearDown purges docker resources associated with the backend.
func (b *Backend) TearDown() {
err := b.pool.Purge(b.resource)
if err != nil {
logger.Errorf("error purging anvil container: %w", err)
}
}
// BatchWithContext is a batch RPC call with context.
func (b *Backend) BatchWithContext(ctx context.Context, calls ...w3types.Caller) error {
return b.BatchContext(ctx, calls...)
}
func setupOtterscan(ctx context.Context, tb testing.TB, pool *dockertest.Pool, anvilResource *dockertest.Resource, args *OptionBuilder) (string, error) {
tb.Helper()
runOptions := &dockertest.RunOptions{
Repository: "otterscan/otterscan",
Tag: "latest",
Env: []string{
fmt.Sprintf("ERIGON_URL=http://localhost:%s", dockerutil.GetPort(anvilResource, "8545/tcp")),
},
Labels: map[string]string{
"test-id": uuid.New().String(),
},
ExposedPorts: []string{"5100"},
Platform: "linux/amd64", // otterscan *oficially* only supports linux/amd64, but this works fine.
}
resource, err := pool.RunWithOptions(runOptions, func(config *docker.HostConfig) {
config.AutoRemove = args.autoremove
if args.restartPolicy != nil {
config.RestartPolicy = *args.restartPolicy
}
})
// since this is ran in a gofunc, context cancelation errors expected during pull, etc
if err != nil && !errors.Is(err, context.Canceled) {
return "", fmt.Errorf("failed to run otterscan container: %w", err)
}
// Docker will hard kill the container in expiryseconds seconds (this is a test env).
// containers should be removed on their own, but this is a safety net.
// to prevent old containers from piling up, we set a timeout to remove the container.
if err := resource.Expire(args.expirySeconds); err != nil {
return "", fmt.Errorf("failed to set expiry for anvil container: %w", err)
}
logInfoChan := make(chan processlog.LogMetadata)
go func() {
defer close(logInfoChan)
err = dockerutil.TailContainerLogs(dockerutil.WithContext(ctx), dockerutil.WithResource(resource), dockerutil.WithPool(pool), dockerutil.WithProcessLogOptions(args.processOptions...), dockerutil.WithFollow(true), dockerutil.WithCallback(func(ctx context.Context, metadata processlog.LogMetadata) {
select {
case <-ctx.Done():
return
case logInfoChan <- metadata:
}
}))
if ctx.Err() != nil {
logger.Warn(err)
}
}()
select {
case <-ctx.Done():
tb.Errorf("context canceled before anvil node started")
case logInfo := <-logInfoChan:
// debug level stuff
logger.Debugf("started otterscan for anvil instance %s as container %s. Logs will be stored at %s", anvilResource.Container.Name, strings.TrimPrefix(resource.Container.Name, "/"), logInfo.LogDir())
return fmt.Sprintf("http://localhost:%s", dockerutil.GetPort(resource, "80/tcp")), nil
}
return "", fmt.Errorf("failed to start otterscan")
}
var logger = log.Logger("anvil-docker")
// storeWallets stores preseeded wallets w/ balances.
func (f *Backend) storeWallets(args *OptionBuilder) error {
derivationPath := args.GetDerivationPath()
derivIter := accounts.DefaultIterator(derivationPath)
maxAccounts := args.GetAccounts()
for i := 0; i < int(maxAccounts); i++ {
account := derivIter()
wall, err := wallet.FromSeedPhrase(args.GetMnemonic(), account)
if err != nil {
return fmt.Errorf("could not get seed phrase: %w", err)
}
f.Store(base.WalletToKey(f.Backend.T(), wall))
}
return nil
}
// ChainConfig gets the chain config.
func (f *Backend) ChainConfig() *params.ChainConfig {
return f.chainConfig
}
// Signer gets the signer for the chain.
func (f *Backend) Signer() types.Signer {
// Get latest block by nil
latestBlock, err := f.Client().HeaderByNumber(f.Context(), nil)
require.Nil(f.T(), err)
return types.MakeSigner(f.ChainConfig(), latestBlock.Number, latestBlock.Time)
}
// FundAccount funds an account with the given amount.
func (f *Backend) FundAccount(ctx context.Context, address common.Address, amount big.Int) {
ctx, cancel := onecontext.Merge(ctx, f.Context())
defer cancel()
anvilClient, err := Dial(ctx, f.RPCAddress())
require.Nilf(f.T(), err, "failed to dial anvil client on chain %d: %v", f.GetChainID(), err)
unlocker := f.fundingMux.Lock(address)
defer unlocker.Unlock()
prevBalance, err := f.Backend.BalanceAt(ctx, address, nil)
require.NoError(f.T(), err)
newBal := new(big.Int).Add(prevBalance, &amount)
// TODO(trajan0x): this can be fixed by looping through new accounts and sending eth when over the limit
// probably not worth it yet.
if !newBal.IsUint64() {
warnUint64Once.Do(func() {
logger.Warn("new balance overflows uint64, which is not allowed by the rpc api, using max_uint64 instead. Future warnings will be suppressed.")
})
newBal = new(big.Int).SetUint64(math.MaxUint64)
}
// TODO: this may cause issues when newBal overflows uint64
err = anvilClient.SetBalance(ctx, address, newBal.Uint64())
require.Nil(f.T(), err)
}
// WaitForConfirmation checks confirmation if the transaction is signed.
// nolint: cyclop
func (f *Backend) WaitForConfirmation(ctx context.Context, tx *types.Transaction) {
require.NotNil(f.T(), tx, "tx is nil")
v, r, s := tx.RawSignatureValues()
isUnsigned := isZero(v) && isZero(r) && isZero(s)
if isUnsigned {
warnUnsignedOnce.Do(func() {
logger.Warn("WaitForConfirmation called on unsigned (likely impersonated) transaction, this does nothing")
})
return
}
f.Backend.WaitForConfirmation(ctx, tx)
}
func isZero(val *big.Int) bool {
return val.Cmp(big.NewInt(0)) == 0
}
// GetFundedAccount gets a funded account.
func (f *Backend) GetFundedAccount(ctx context.Context, requestBalance *big.Int) *keystore.Key {
key := f.MockAccount()
f.Store(key)
f.FundAccount(ctx, key.Address, *requestBalance)
return key
}
// GetTxContext gets the tx context for the given address.
// TODO: dedupe w/ geth.
func (f *Backend) GetTxContext(ctx context.Context, address *common.Address) (res backends.AuthType) {
ctx, cancel := onecontext.Merge(ctx, f.Context())
defer cancel()
var acct *keystore.Key
// TODO handle storing accounts to conform to get tx context
if address != nil {
acct = f.GetAccount(*address)
if acct == nil {
f.T().Errorf("could not get account %s", address.String())
return res
}
} else {
acct = f.GetFundedAccount(ctx, new(big.Int).SetUint64(math.MaxUint64))
f.Store(acct)
}
auth, err := f.NewKeyedTransactorFromKey(acct.PrivateKey)
require.Nilf(f.T(), err, "could not get transactor for chain %d: %v", f.GetChainID(), err)
auth.GasPrice, err = f.SuggestGasPrice(ctx)
require.Nilf(f.T(), err, "could not get gas price for chain %d: %v", f.GetChainID(), err)
auth.GasLimit = gasLimit
return backends.AuthType{
TransactOpts: auth,
PrivateKey: acct.PrivateKey,
}
}
// ImpersonateAccount impersonates an account.
//
// Note *any* other call made to the backend will impersonate while this is being called
// in a future version, we'll wrap something like omnirpc to prevent other transactions submission calls from taking place
// in the meantime, this may cause race conditions.
//
// We also print a warning message to the console as an added precaution.
func (f *Backend) ImpersonateAccount(ctx context.Context, address common.Address, transact func(opts *bind.TransactOpts) *types.Transaction) error {
f.impersonationMux.Lock()
defer f.impersonationMux.Unlock()
f.warnImpersonation()
anvilClient, err := Dial(ctx, f.RPCAddress())
require.Nilf(f.T(), err, "could not dial anvil client rpc at %s for chain %d: %v", f.RPCAddress(), f.GetChainID(), err)
err = anvilClient.ImpersonateAccount(ctx, address)
require.Nilf(f.T(), err, "could not impersonate account %s for chain %d: %v", address.String(), f.GetChainID(), err)
defer func() {
err = anvilClient.StopImpersonatingAccount(ctx, address)
require.Nilf(f.T(), err, "could not stop impersonating account %s for chain %d: %v", address.String(), f.GetChainID(), err)
}()
tx := transact(&bind.TransactOpts{
Context: ctx,
From: address,
Signer: ImpersonatedSigner,
GasLimit: gasLimit,
NoSend: true,
})
// TODO: test both legacy and dynamic tx types
err = anvilClient.SendUnsignedTransaction(ctx, address, tx)
require.Nilf(f.T(), err, "could not send unsigned transaction for chain %d: %v from %s", f.GetChainID(), err, address.String())
return nil
}
func (f *Backend) warnImpersonation() {
warnImpersonationOnce.Do(func() {
f.T().Logf(`
Using Account Impersonation.
WARNING: This cannot be called concurrently with other impersonation calls.
Please make sure your callers are concurrency safe against account impersonation.
`)
})
}
// ImpersonatedSigner is a signer that does nothing for use in account impersonation w/ contracts.
func ImpersonatedSigner(address common.Address, transaction *types.Transaction) (*types.Transaction, error) {
return transaction, nil
}
// warnImpersonationOnce is used to log the impersonation warning message once.
// this is a global variable to prevent the message from being logged multiple times.
// normally, global variables are strongly discouraged, but we make an exception here
// considering how unexpected behavior can be if impersonate account is not used correctly.
var warnImpersonationOnce sync.Once
// warnUnsignedOnce warns if a tx is unsigned and thus not confirmable.
var warnUnsignedOnce sync.Once
var (
warnUint64Once sync.Once
_ backends.SimulatedTestBackend = &Backend{}
)