ethergo/backends/base/base.go
package base
import (
"bytes"
"context"
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/synapsecns/sanguine/core"
"github.com/synapsecns/sanguine/ethergo/signer/wallet"
"math/big"
"os"
"sync"
"testing"
"time"
"github.com/Flaque/filet"
"github.com/brianvoe/gofakeit/v6"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"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/ipfs/go-log"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/synapsecns/sanguine/ethergo/chain"
"github.com/synapsecns/sanguine/ethergo/chain/client"
"github.com/synapsecns/sanguine/ethergo/contracts"
"github.com/synapsecns/sanguine/ethergo/signer/nonce"
"github.com/synapsecns/sanguine/ethergo/util"
"github.com/teivah/onecontext"
"k8s.io/apimachinery/pkg/util/wait"
)
var logger = log.Logger("backend-base-logger")
// Backend contains common functions across backends and can be used to extend a backend.
type Backend struct {
// chain is the chain to be used by the backend
chain.Chain
// Manager is the nonce manager
nonce.Manager
// ctx is the context of the backend
//nolint: containedctx
ctx context.Context
// tb contains the testing object
t *testing.T
// store stores the accounts
store *InMemoryKeyStore
// verifiedContracts is a list of verified contracts
verifiedContracts map[common.Address]VerifiedContract
// verificationMutex is the mutex for the verification
verifiedMux sync.RWMutex
}
// VerifiedContract is a contract that has been verified.
type VerifiedContract struct {
contracts.ContractType
contracts.DeployedContract
}
// T returns the testing object.
func (b *Backend) T() *testing.T {
return b.t
}
// SetT sets the testing object.
func (b *Backend) SetT(t *testing.T) {
t.Helper()
b.t = t
}
func (b *Backend) Store(key *keystore.Key) {
b.store.Store(key)
}
func (b *Backend) GetAccount(a common.Address) *keystore.Key {
return b.store.GetAccount(a)
}
// NewBaseBackend creates a new base backend.
//
//nolint:staticcheck
func NewBaseBackend(ctx context.Context, t *testing.T, chn chain.Chain) (*Backend, error) {
t.Helper()
b := &Backend{
Chain: chn,
ctx: ctx,
t: t,
Manager: nonce.NewNonceManager(ctx, chn, chn.GetBigChainID()),
store: NewInMemoryKeyStore(),
verifiedContracts: map[common.Address]VerifiedContract{},
}
return b, nil
}
// Client fetches an eth client fro the backend.
func (b *Backend) Client() client.EVMClient {
return b.Chain
}
// see: https://git.io/JGsC1
// taken from geth, used to speed up tests.
const (
VeryLightScryptN = 2
VeryLightScryptP = 1
)
// MockAccount creates a new mock account.
// TODO: dry this up w/ mocks.
func MockAccount(t *testing.T) *keystore.Key {
t.Helper()
kstr := keystore.NewKeyStore(filet.TmpDir(t, ""), VeryLightScryptN, VeryLightScryptP)
password := gofakeit.Password(true, true, true, false, false, 10)
acct, err := kstr.NewAccount(password)
assert.Nil(t, err)
data, err := os.ReadFile(acct.URL.Path)
assert.Nil(t, err)
key, err := keystore.DecryptKey(data, password)
assert.Nil(t, err)
return key
}
// MockAccount creates a new mock account.
func (b *Backend) MockAccount() *keystore.Key {
return MockAccount(b.t)
}
var logOnce sync.Once
// EnableLocalDebug enables local tx debugging. It is exported so it can be disabled
// and disabled by default to speed up the ci.
// Note: there's currently a bug causing this to fail if tenderly is disabled.
var EnableLocalDebug = os.Getenv("CI") == ""
// VerifyContract calls the contract verification hook (e.g. tenderly).
func (b *Backend) VerifyContract(contractType contracts.ContractType, contract contracts.DeployedContract) (resError error) {
// TODO actually verify the contract against abi locally: https://pkg.go.dev/github.com/iden3/tx-forwarder/eth/contracts/verifier
// until then we go ahead and run a code at to ensure the correct address was used, this helps avoid extremely hard to debug prob
go func() {
code, err := b.Client().CodeAt(b.ctx, contract.Address(), nil)
if !errors.Is(err, context.Canceled) {
require.Nil(b.T(), err)
require.NotEmpty(b.T(), code, "contract of type %s (metadata %s) not found", contractType.ContractName(), contract.String())
}
}()
var wg sync.WaitGroup
// skip items on the blacklist.
if IsVerificationBlacklisted(contractType) {
return nil
}
wg.Wait()
b.verifiedMux.Lock()
defer b.verifiedMux.Unlock()
b.verifiedContracts[contract.Address()] = VerifiedContract{
ContractType: contractType,
DeployedContract: contract,
}
return errors.Wrap(resError, "error verifying contract")
}
var (
errorSig = []byte{0x08, 0xc3, 0x79, 0xa0} // Keccak256("Error(string)")[:4]
abiString, _ = abi.NewType("string", "", nil)
)
// WaitForConfirmation waits for transaction confirmation.
// nolint: cyclop
func (b *Backend) WaitForConfirmation(parentCtx context.Context, transaction *types.Transaction) {
ctx, cancel := onecontext.Merge(b.ctx, parentCtx)
defer cancel()
//nolint: contextcheck
WaitForConfirmation(ctx, b.Client(), transaction, time.Millisecond*500)
// check or an error, if there is one log it
go func() {
txReceipt, err := b.TransactionReceipt(b.ctx, transaction.Hash())
if err != nil {
logger.Warnf("could not get tx receipt: %v on tx %s", err, transaction.Hash())
return
}
callMessage, err := util.TxToCall(transaction)
if err != nil {
logger.Warnf("could not convert tx to call: %w", err)
return
}
res, err := b.CallContract(b.ctx, *callMessage, big.NewInt(0).Sub(txReceipt.BlockNumber, big.NewInt(1)))
if err != nil {
errMessage := fmt.Sprintf("could not call contract: %v on tx: %s", err, transaction.Hash())
if b.RPCAddress() != "" {
errMessage += fmt.Sprintf("\nFor more info run (before the process stops): cast run --rpc-url %s %s --trace-printer ", b.RPCAddress(), transaction.Hash())
if core.GetEnvBool("TRACELY_ENABLED", false) {
errMessage += b.addCastLabels(transaction, callMessage.From)
}
}
logger.Error(errMessage)
return
}
if bytes.Equal(res, errorSig) {
vs, err := abi.Arguments{{Type: abiString}}.UnpackValues(res[4:])
if err != nil {
logger.Errorf("could not unpack revert: %w", err)
return
}
//nolint: forcetypeassert
errMessage := fmt.Sprintf("tx %s reverted: %v", transaction.Hash(), vs[0].(string))
if b.RPCAddress() != "" {
errMessage += fmt.Sprintf("\nFor more info run (before the process stops): cast run --rpc-url %s %s --trace-printer", b.RPCAddress(), transaction.Hash())
}
logger.Error(errMessage)
}
}()
}
// Context gets the context from the backend.
func (b *Backend) Context() context.Context {
return b.ctx
}
// ImpersonateAccount impersonates an account.
func (b *Backend) ImpersonateAccount(_ context.Context, _ common.Address, _ func(opts *bind.TransactOpts) *types.Transaction) error {
return errors.New("account impersonation is not implemented on this backend")
}
// ConfirmationClient waits for confirmation.
//
//go:generate go run github.com/vektra/mockery/v2 --name ConfirmationClient --output ./mocks --case=underscore
type ConfirmationClient interface {
ethereum.TransactionReader
ethereum.TransactionSender
ethereum.ChainStateReader
}
// WaitForConfirmation is a helper that can be called by various inheriting funcs.
// it blocks until the transaction is confirmed.
// nolint: cyclop
func WaitForConfirmation(ctx context.Context, client ConfirmationClient, transaction *types.Transaction, timeout time.Duration) {
// if tx is nil , we should panic here so we can see the call context
_ = transaction.Hash()
const debugTimeout = time.Second * 5
start := time.Now()
logIfShould := func(locker *sync.Once, template string, args ...interface{}) {
if time.Since(start) > debugTimeout {
locker.Do(func() {
logger.Debugf(template, args...)
})
}
}
txConfirmedCtx, cancel := context.WithCancel(ctx)
var logWaitOnce, logFetchErrOnce, logErrOnce sync.Once
wait.UntilWithContext(txConfirmedCtx, func(ctx context.Context) {
tx, isPending, err := client.TransactionByHash(txConfirmedCtx, transaction.Hash())
logIfShould(&logWaitOnce, "waiting for tx %s", transaction.Hash())
// it's possible that this transaction is impersonated. If thats the case, eth_getTransactionByHash will return an error
// since the signature is not valid. In this case, we need to use the transaction hash to get the receipt instead, as this will
// return the sender from the rpc rather than tryng to derive it ourselves.
if err != nil && !errors.Is(err, ethereum.NotFound) {
receipt, receiptErr := client.TransactionReceipt(ctx, transaction.Hash())
if err != nil {
err = multierror.Append(err, receiptErr)
}
if receipt != nil {
if receipt.Status == types.ReceiptStatusFailed {
rawJSON, _ := transaction.MarshalJSON()
logger.Errorf("transaction %s with body %s reverted", transaction, string(rawJSON))
}
cancel()
return
}
}
// there's still an error, even with the receipt. We'll log the fetch error
if err != nil {
logIfShould(&logFetchErrOnce, "could not fetch transaction: %v", err)
}
if !isPending && tx != nil {
receipt, err := client.TransactionReceipt(ctx, tx.Hash())
if err != nil {
if receipt.Status == types.ReceiptStatusFailed {
rawJSON, _ := transaction.MarshalJSON()
logger.Errorf("transaction %s with body %s reverted", transaction, string(rawJSON))
}
}
cancel()
} else if !isPending || time.Since(start) > debugTimeout {
err := client.SendTransaction(ctx, transaction)
if err != nil {
ogErr := err
call, err := util.TxToCall(transaction)
if err != nil {
logger.Errorf("could not convert tx to call: %v", err)
return
}
realNonce, err := client.NonceAt(ctx, call.From, nil)
if err != nil {
logger.Errorf("could not get nonce: %v", err)
return
}
logIfShould(&logErrOnce, "could not send transaction (from %s, account nonce %d, tx nonce: %d, tx hash: %s): %v", call.From, realNonce, transaction.Nonce(), transaction.Hash(), ogErr)
}
}
}, timeout)
}
//nolint:staticcheck
var _ chain.Chain = &Backend{}
// WalletToKey converts a wallet into a storable key
// TODO: test me
func WalletToKey(tb testing.TB, wall wallet.Wallet) *keystore.Key {
tb.Helper()
kstr := keystore.NewKeyStore(filet.TmpDir(tb, ""), VeryLightScryptN, VeryLightScryptP)
password := gofakeit.Password(true, true, true, false, false, 10)
acct, err := kstr.ImportECDSA(wall.PrivateKey(), password)
require.Nil(tb, err)
data, err := os.ReadFile(acct.URL.Path)
require.Nil(tb, err)
key, err := keystore.DecryptKey(data, password)
require.Nil(tb, err)
return key
}