synapsecns/sanguine

View on GitHub
ethergo/backends/base/base.go

Summary

Maintainability
B
6 hrs
Test Coverage
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
}