From 7597ff04f23cb88a0213b1f452e8a38ba970df5a Mon Sep 17 00:00:00 2001 From: Ardit Marku Date: Tue, 3 Dec 2024 14:09:41 +0200 Subject: [PATCH] Enable transaction validation with local state index --- Makefile | 6 +- api/server.go | 27 +++- api/utils.go | 7 + bootstrap/bootstrap.go | 1 + cmd/run/cmd.go | 12 +- config/config.go | 10 ++ services/requester/pool.go | 62 ++++---- services/requester/requester.go | 169 +++++++++++++++------- tests/helpers.go | 37 ++--- tests/integration_test.go | 25 ++-- tests/web3js/eth_failure_handling_test.js | 82 +++++++++-- 11 files changed, 307 insertions(+), 131 deletions(-) diff --git a/Makefile b/Makefile index 14badd505..ece681220 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,8 @@ start-local: --gas-price=0 \ --log-writer=console \ --profiler-enabled=true \ - --profiler-port=6060 + --profiler-port=6060 \ + --tx-state-validation=local-index # Use this after running `make build`, to test out the binary .PHONY: start-local-bin @@ -73,4 +74,5 @@ start-local-bin: --gas-price=0 \ --log-writer=console \ --profiler-enabled=true \ - --profiler-port=6060 + --profiler-port=6060 \ + --tx-state-validation=local-index diff --git a/api/server.go b/api/server.go index e1a9dc5ec..b5006616e 100644 --- a/api/server.go +++ b/api/server.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/onflow/go-ethereum/core" gethVM "github.com/onflow/go-ethereum/core/vm" gethLog "github.com/onflow/go-ethereum/log" "github.com/onflow/go-ethereum/rpc" @@ -427,6 +428,17 @@ type responseHandler struct { metrics metrics.Collector } +var knownErrors = []error{ + errs.ErrRateLimit, + errs.ErrInvalid, + errs.ErrFailedTransaction, + errs.ErrEndpointNotSupported, + gethVM.ErrExecutionReverted, + core.ErrNonceTooLow, + core.ErrNonceTooHigh, + core.ErrInsufficientFunds, +} + const errMethodNotFound = -32601 const errCodePanic = -32603 @@ -471,11 +483,7 @@ func (w *responseHandler) Write(data []byte) (int, error) { } // don't error log known handled errors - if !errorIs(errMsg, errs.ErrRateLimit) && - !errorIs(errMsg, errs.ErrInvalid) && - !errorIs(errMsg, errs.ErrFailedTransaction) && - !errorIs(errMsg, errs.ErrEndpointNotSupported) && - !errorIs(errMsg, gethVM.ErrExecutionReverted) { + if !isKnownError(errMsg) { // log the response error as a warning l.Warn().Err(errors.New(errMsg)).Msg("API response") } @@ -505,6 +513,11 @@ func (w *responseHandler) WriteHeader(statusCode int) { w.ResponseWriter.WriteHeader(statusCode) } -func errorIs(msg string, err error) bool { - return strings.Contains(msg, err.Error()) +func isKnownError(errMsg string) bool { + for _, err := range knownErrors { + if strings.Contains(errMsg, err.Error()) { + return true + } + } + return false } diff --git a/api/utils.go b/api/utils.go index 49524dc78..42650f690 100644 --- a/api/utils.go +++ b/api/utils.go @@ -12,6 +12,7 @@ import ( errs "github.com/onflow/flow-evm-gateway/models/errors" "github.com/onflow/flow-evm-gateway/storage" "github.com/onflow/go-ethereum/common" + "github.com/onflow/go-ethereum/core" "github.com/onflow/go-ethereum/core/types" "github.com/onflow/go-ethereum/rpc" "github.com/rs/zerolog" @@ -124,6 +125,12 @@ func handleError[T any](err error, log zerolog.Logger, collector metrics.Collect return zero, err case errors.As(err, &revertedErr): return zero, revertedErr + case errors.Is(err, core.ErrNonceTooLow): + return zero, err + case errors.Is(err, core.ErrNonceTooHigh): + return zero, err + case errors.Is(err, core.ErrInsufficientFunds): + return zero, err default: collector.ApiErrorOccurred() log.Error().Err(err).Msg("api error") diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index f21f41af2..7eaf4f57e 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -211,6 +211,7 @@ func (b *Bootstrap) StartAPIServer(ctx context.Context) error { b.client, b.publishers.Transaction, b.logger, + b.config, ) blocksProvider := replayer.NewBlocksProvider( diff --git a/cmd/run/cmd.go b/cmd/run/cmd.go index 6c6819770..20b8e6d46 100644 --- a/cmd/run/cmd.go +++ b/cmd/run/cmd.go @@ -241,6 +241,14 @@ func parseConfigFromFlags() error { log.Warn().Msg("wallet API is enabled. Ensure this is not used in production environments.") } + if txStateValidation == config.LocalIndexValidation { + cfg.TxStateValidation = config.LocalIndexValidation + } else if txStateValidation == config.TxSealValidation { + cfg.TxStateValidation = config.TxSealValidation + } else { + return fmt.Errorf("unknown tx state validation: %s", txStateValidation) + } + return nil } @@ -261,7 +269,8 @@ var ( cloudKMSProjectID, cloudKMSLocationID, cloudKMSKeyRingID, - walletKey string + walletKey, + txStateValidation string streamTimeout int @@ -303,4 +312,5 @@ func init() { Cmd.Flags().BoolVar(&cfg.ProfilerEnabled, "profiler-enabled", false, "Run the profiler server to capture pprof data.") Cmd.Flags().StringVar(&cfg.ProfilerHost, "profiler-host", "localhost", "Host for the Profiler server") Cmd.Flags().IntVar(&cfg.ProfilerPort, "profiler-port", 6060, "Port for the Profiler server") + Cmd.Flags().StringVar(&txStateValidation, "tx-state-validation", "tx-seal", "Sets the transaction validation mechanism. It can validate using the local state index, or wait for the outer Flow transaction to seal. Available values ('local-index' / 'tx-seal'), defaults to 'tx-seal'.") } diff --git a/config/config.go b/config/config.go index 65304317e..b46cd9aea 100644 --- a/config/config.go +++ b/config/config.go @@ -23,6 +23,13 @@ const EmulatorInitCadenceHeight = uint64(0) // We don't use 0 as it has a special meaning to represent latest block in the AN API context. const LiveNetworkInitCadenceHeight = uint64(1) +type TxStateValidation string + +const ( + LocalIndexValidation = "local-index" + TxSealValidation = "tx-seal" +) + type Config struct { // DatabaseDir is where the database should be stored. DatabaseDir string @@ -85,4 +92,7 @@ type Config struct { ProfilerHost string // ProfilerPort is the port for the profiler server ProfilerPort int + // TxStateValidation sets the transaction validation mechanism. It can validate + // using the local state index, or wait for the outer Flow transaction to seal. + TxStateValidation string } diff --git a/services/requester/pool.go b/services/requester/pool.go index 9b0074efd..c19c3a9e9 100644 --- a/services/requester/pool.go +++ b/services/requester/pool.go @@ -12,6 +12,7 @@ import ( "github.com/rs/zerolog" "github.com/sethvargo/go-retry" + "github.com/onflow/flow-evm-gateway/config" "github.com/onflow/flow-evm-gateway/models" errs "github.com/onflow/flow-evm-gateway/models/errors" ) @@ -30,6 +31,7 @@ type TxPool struct { client *CrossSporkClient pool *sync.Map txPublisher *models.Publisher[*gethTypes.Transaction] + config config.Config // todo add methods to inspect transaction pool state } @@ -37,12 +39,14 @@ func NewTxPool( client *CrossSporkClient, transactionsPublisher *models.Publisher[*gethTypes.Transaction], logger zerolog.Logger, + config config.Config, ) *TxPool { return &TxPool{ logger: logger.With().Str("component", "tx-pool").Logger(), client: client, txPublisher: transactionsPublisher, pool: &sync.Map{}, + config: config, } } @@ -61,37 +65,41 @@ func (t *TxPool) Send( return err } - // add to pool and delete after transaction is sealed or errored out - t.pool.Store(evmTx.Hash(), evmTx) - defer t.pool.Delete(evmTx.Hash()) - - backoff := retry.WithMaxDuration(time.Minute*1, retry.NewConstant(time.Second*1)) - return retry.Do(ctx, backoff, func(ctx context.Context) error { - res, err := t.client.GetTransactionResult(ctx, flowTx.ID()) - if err != nil { - return fmt.Errorf("failed to retrieve flow transaction result %s: %w", flowTx.ID(), err) - } - // retry until transaction is sealed - if res.Status < flow.TransactionStatusSealed { - return retry.RetryableError(fmt.Errorf("transaction %s not sealed", flowTx.ID())) - } - - if res.Error != nil { - if err, ok := parseInvalidError(res.Error); ok { - return err + if t.config.TxStateValidation == config.TxSealValidation { + // add to pool and delete after transaction is sealed or errored out + t.pool.Store(evmTx.Hash(), evmTx) + defer t.pool.Delete(evmTx.Hash()) + + backoff := retry.WithMaxDuration(time.Minute*1, retry.NewConstant(time.Second*1)) + return retry.Do(ctx, backoff, func(ctx context.Context) error { + res, err := t.client.GetTransactionResult(ctx, flowTx.ID()) + if err != nil { + return fmt.Errorf("failed to retrieve flow transaction result %s: %w", flowTx.ID(), err) + } + // retry until transaction is sealed + if res.Status < flow.TransactionStatusSealed { + return retry.RetryableError(fmt.Errorf("transaction %s not sealed", flowTx.ID())) } - t.logger.Error().Err(res.Error). - Str("flow-id", flowTx.ID().String()). - Str("evm-id", evmTx.Hash().Hex()). - Msg("flow transaction error") + if res.Error != nil { + if err, ok := parseInvalidError(res.Error); ok { + return err + } + + t.logger.Error().Err(res.Error). + Str("flow-id", flowTx.ID().String()). + Str("evm-id", evmTx.Hash().Hex()). + Msg("flow transaction error") - // hide specific cause since it's an implementation issue - return fmt.Errorf("failed to submit flow evm transaction %s", evmTx.Hash()) - } + // hide specific cause since it's an implementation issue + return fmt.Errorf("failed to submit flow evm transaction %s", evmTx.Hash()) + } + + return nil + }) + } - return nil - }) + return nil } // this will extract the evm specific error from the Flow transaction error message diff --git a/services/requester/requester.go b/services/requester/requester.go index a2da7d38a..942769132 100644 --- a/services/requester/requester.go +++ b/services/requester/requester.go @@ -17,6 +17,7 @@ import ( "github.com/onflow/flow-go/fvm/evm/offchain/query" evmTypes "github.com/onflow/flow-go/fvm/evm/types" "github.com/onflow/go-ethereum/common" + gethCore "github.com/onflow/go-ethereum/core" "github.com/onflow/go-ethereum/core/txpool" "github.com/onflow/go-ethereum/core/types" "github.com/rs/zerolog" @@ -201,6 +202,12 @@ func (e *EVM) SendRawTransaction(ctx context.Context, data []byte) (common.Hash, return common.Hash{}, errs.NewTxGasPriceTooLowError(e.config.GasPrice) } + if e.config.TxStateValidation == config.LocalIndexValidation { + if err := e.validateTransactionWithState(tx, from); err != nil { + return common.Hash{}, err + } + } + txData := hex.EncodeToString(data) hexEncodedTx, err := cadence.NewString(txData) if err != nil { @@ -239,57 +246,6 @@ func (e *EVM) SendRawTransaction(ctx context.Context, data []byte) (common.Hash, return tx.Hash(), nil } -// buildTransaction creates a flow transaction from the provided script with the arguments -// and signs it with the configured COA account. -func (e *EVM) buildTransaction(ctx context.Context, script []byte, args ...cadence.Value) (*flow.Transaction, error) { - // building and signing transactions should be blocking, so we don't have keys conflict - e.mux.Lock() - defer e.mux.Unlock() - - var ( - g = errgroup.Group{} - err1, err2 error - latestBlock *flow.Block - index uint32 - seqNum uint64 - ) - // execute concurrently so we can speed up all the information we need for tx - g.Go(func() error { - latestBlock, err1 = e.client.GetLatestBlock(ctx, true) - return err1 - }) - g.Go(func() error { - index, seqNum, err2 = e.getSignerNetworkInfo(ctx) - return err2 - }) - if err := g.Wait(); err != nil { - return nil, err - } - - address := e.config.COAAddress - flowTx := flow.NewTransaction(). - SetScript(script). - SetProposalKey(address, index, seqNum). - SetReferenceBlockID(latestBlock.ID). - SetPayer(address) - - for _, arg := range args { - if err := flowTx.AddArgument(arg); err != nil { - return nil, fmt.Errorf("failed to add argument: %s, with %w", arg, err) - } - } - - if err := flowTx.SignEnvelope(address, index, e.signer); err != nil { - return nil, fmt.Errorf( - "failed to sign transaction envelope for address: %s and index: %d, with: %w", - address, - index, - err) - } - - return flowTx, nil -} - func (e *EVM) GetBalance( address common.Address, height uint64, @@ -597,7 +553,112 @@ func (e *EVM) dryRunTx( return result, nil } -func AddOne64th(n uint64) uint64 { - // NOTE: Go's integer division floors, but that is desirable here - return n + (n / 64) +// buildTransaction creates a flow transaction from the provided script with the arguments +// and signs it with the configured COA account. +func (e *EVM) buildTransaction(ctx context.Context, script []byte, args ...cadence.Value) (*flow.Transaction, error) { + // building and signing transactions should be blocking, so we don't have keys conflict + e.mux.Lock() + defer e.mux.Unlock() + + var ( + g = errgroup.Group{} + err1, err2 error + latestBlock *flow.Block + index uint32 + seqNum uint64 + ) + // execute concurrently so we can speed up all the information we need for tx + g.Go(func() error { + latestBlock, err1 = e.client.GetLatestBlock(ctx, true) + return err1 + }) + g.Go(func() error { + index, seqNum, err2 = e.getSignerNetworkInfo(ctx) + return err2 + }) + if err := g.Wait(); err != nil { + return nil, err + } + + address := e.config.COAAddress + flowTx := flow.NewTransaction(). + SetScript(script). + SetProposalKey(address, index, seqNum). + SetReferenceBlockID(latestBlock.ID). + SetPayer(address) + + for _, arg := range args { + if err := flowTx.AddArgument(arg); err != nil { + return nil, fmt.Errorf("failed to add argument: %s, with %w", arg, err) + } + } + + if err := flowTx.SignEnvelope(address, index, e.signer); err != nil { + return nil, fmt.Errorf( + "failed to sign transaction envelope for address: %s and index: %d, with: %w", + address, + index, + err) + } + + return flowTx, nil +} + +// validateTransactionWithState checks if the given tx has the correct +// nonce & balance, according to the local state. +func (e *EVM) validateTransactionWithState( + tx *types.Transaction, + from common.Address, +) error { + height, err := e.blocks.LatestEVMHeight() + if err != nil { + return err + } + view, err := e.getBlockView(height) + if err != nil { + return err + } + + nonce, err := view.GetNonce(from) + if err != nil { + return err + } + + // Ensure the transaction adheres to nonce ordering + if tx.Nonce() < nonce { + return fmt.Errorf( + "%w: next nonce %v, tx nonce %v", + gethCore.ErrNonceTooLow, + nonce, + tx.Nonce(), + ) + } + + if tx.Nonce() > nonce { + return fmt.Errorf( + "%w: tx nonce %v, next nonce %v", + gethCore.ErrNonceTooHigh, + tx.Nonce(), + nonce, + ) + } + + // Ensure the transactor has enough funds to cover the transaction costs + cost := tx.Cost() + balance, err := view.GetBalance(from) + if err != nil { + return err + } + + if balance.Cmp(cost) < 0 { + return fmt.Errorf( + "%w: balance %v, tx cost %v, overshot %v", + gethCore.ErrInsufficientFunds, + balance, + cost, + new(big.Int).Sub(cost, balance), + ) + } + + return nil } diff --git a/tests/helpers.go b/tests/helpers.go index 6d2869376..8fa46ca60 100644 --- a/tests/helpers.go +++ b/tests/helpers.go @@ -138,24 +138,25 @@ func servicesSetup(t *testing.T) (emulator.Emulator, func()) { // default config cfg := config.Config{ - DatabaseDir: t.TempDir(), - AccessNodeHost: "localhost:3569", // emulator - RPCPort: 8545, - RPCHost: "127.0.0.1", - FlowNetworkID: "flow-emulator", - EVMNetworkID: evmTypes.FlowEVMPreviewNetChainID, - Coinbase: common.HexToAddress(coinbaseAddress), - COAAddress: service.Address, - COAKey: service.PrivateKey, - GasPrice: new(big.Int).SetUint64(150), - LogLevel: zerolog.DebugLevel, - LogWriter: testLogWriter(), - StreamTimeout: time.Second * 30, - StreamLimit: 10, - RateLimit: 500, - WSEnabled: true, - MetricsPort: 8443, - FilterExpiry: time.Second * 5, + DatabaseDir: t.TempDir(), + AccessNodeHost: "localhost:3569", // emulator + RPCPort: 8545, + RPCHost: "127.0.0.1", + FlowNetworkID: "flow-emulator", + EVMNetworkID: evmTypes.FlowEVMPreviewNetChainID, + Coinbase: common.HexToAddress(coinbaseAddress), + COAAddress: service.Address, + COAKey: service.PrivateKey, + GasPrice: new(big.Int).SetUint64(150), + LogLevel: zerolog.DebugLevel, + LogWriter: testLogWriter(), + StreamTimeout: time.Second * 30, + StreamLimit: 10, + RateLimit: 500, + WSEnabled: true, + MetricsPort: 8443, + FilterExpiry: time.Second * 5, + TxStateValidation: config.LocalIndexValidation, } bootstrapDone := make(chan struct{}) diff --git a/tests/integration_test.go b/tests/integration_test.go index f0c39158d..a4816bde9 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -60,18 +60,19 @@ func Test_ConcurrentTransactionSubmission(t *testing.T) { require.NoError(t, err) cfg := config.Config{ - DatabaseDir: t.TempDir(), - AccessNodeHost: grpcHost, - RPCPort: 8545, - RPCHost: "127.0.0.1", - FlowNetworkID: "flow-emulator", - EVMNetworkID: types.FlowEVMPreviewNetChainID, - Coinbase: eoaTestAccount, - COAAddress: *createdAddr, - COAKeys: keys, - GasPrice: new(big.Int).SetUint64(0), - LogLevel: zerolog.DebugLevel, - LogWriter: testLogWriter(), + DatabaseDir: t.TempDir(), + AccessNodeHost: grpcHost, + RPCPort: 8545, + RPCHost: "127.0.0.1", + FlowNetworkID: "flow-emulator", + EVMNetworkID: types.FlowEVMPreviewNetChainID, + Coinbase: eoaTestAccount, + COAAddress: *createdAddr, + COAKeys: keys, + GasPrice: new(big.Int).SetUint64(0), + LogLevel: zerolog.DebugLevel, + LogWriter: testLogWriter(), + TxStateValidation: config.TxSealValidation, } // todo change this test to use ingestion and emulator directly so we can completely remove diff --git a/tests/web3js/eth_failure_handling_test.js b/tests/web3js/eth_failure_handling_test.js index 02c31bb6b..14ee31bc9 100644 --- a/tests/web3js/eth_failure_handling_test.js +++ b/tests/web3js/eth_failure_handling_test.js @@ -1,9 +1,9 @@ const { assert } = require('chai') -const helpers = require("./helpers") +const helpers = require('./helpers') const conf = require("./config") const web3 = conf.web3 -it('transfer failure due to too high nonce', async () => { +it('should fail when nonce too high', async () => { let receiver = web3.eth.accounts.create() try { @@ -16,14 +16,14 @@ it('transfer failure due to too high nonce', async () => { nonce: 1337, // invalid }) } catch (e) { - assert.include(e.message, "nonce too high") + assert.include(e.message, 'nonce too high: tx nonce 1337, next nonce 0') return } - assert.fail("should not reach") + assert.fail('should not reach') }) -it('transfer failure due to too low nonce', async () => { +it('should fail when nonce too low', async () => { let receiver = web3.eth.accounts.create() // increase nonce @@ -45,14 +45,14 @@ it('transfer failure due to too low nonce', async () => { nonce: 0, // invalid }) } catch (e) { - assert.include(e.message, "nonce too low") + assert.include(e.message, 'nonce too low: next nonce 1, tx nonce 0') return } - assert.fail("should not reach") + assert.fail('should not reach') }) -it('transfer failure due to insufficient gas price', async () => { +it('should fail when insufficient gas price', async () => { let receiver = web3.eth.accounts.create() try { @@ -64,9 +64,71 @@ it('transfer failure due to insufficient gas price', async () => { gasLimit: 55_000, }) } catch (e) { - assert.include(e.message, "the minimum accepted gas price for transactions is: 150") + assert.include(e.message, 'the minimum accepted gas price for transactions is: 150') return } - assert.fail("should not reach") + assert.fail('should not reach') +}) + +it('should fail when insufficient balance for transfer', async () => { + let receiver = web3.eth.accounts.create() + + await helpers.signAndSend({ + from: conf.eoa.address, + to: receiver.address, + value: 10_000_000, + gasPrice: conf.minGasPrice, + gasLimit: 55_000, + }) + + let signedTx = await receiver.signTransaction({ + from: receiver.address, + to: conf.eoa.address, + value: 10_100_000, + gasPrice: conf.minGasPrice, + gasLimit: 23_000, + }) + let response = await helpers.callRPCMethod( + 'eth_sendRawTransaction', + [signedTx.rawTransaction] + ) + assert.equal(200, response.status) + assert.isDefined(response.body) + + assert.equal( + response.body.error.message, + 'insufficient funds for gas * price + value: balance 10000000, tx cost 13550000, overshot 3550000' + ) +}) + +it('should fail when insufficient balance for transfer + gas', async () => { + let receiver = web3.eth.accounts.create() + + await helpers.signAndSend({ + from: conf.eoa.address, + to: receiver.address, + value: 10_000_000, + gasPrice: conf.minGasPrice, + gasLimit: 55_000, + }) + + let signedTx = await receiver.signTransaction({ + from: receiver.address, + to: conf.eoa.address, + value: 7_000_000, + gasPrice: conf.minGasPrice, + gasLimit: 23_000, + }) + let response = await helpers.callRPCMethod( + 'eth_sendRawTransaction', + [signedTx.rawTransaction] + ) + assert.equal(200, response.status) + assert.isDefined(response.body) + + assert.equal( + response.body.error.message, + 'insufficient funds for gas * price + value: balance 10000000, tx cost 10450000, overshot 450000' + ) })