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{} )