From dc224a29249c83c74a38d9ca9d16fb00e192a4e2 Mon Sep 17 00:00:00 2001 From: amit-momin <108959691+amit-momin@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:57:30 -0500 Subject: [PATCH] Add Transaction Simulation to the TXM (#12503) * Added tx simulator to maintain chain specific simulation methods * Fixed linting * Fixed linting and regenerated config docs * Generated mock * Fixed config tests * Moved the tx simulator to the chain client * Removed client from Txm struct * Removed config from test helper * Added tests and logging * Added changeset * Fixed multinode test * Fixed linting * Fixed comment * Added test for non-OOC error * Reduced context initializations in tests * Updated to account for all types of OOC errors * Removed custom zk counter method and simplified error handling * Removed zkevm chain type * Changed simulate tx method return object * Cleaned up stale comments * Removed unused error message * Changed zk overflow validation method name * Reverted method name change --- .changeset/hungry-impalas-jog.md | 5 + common/client/models.go | 1 + common/client/multi_node_test.go | 8 ++ core/chains/evm/client/chain_client.go | 12 +- core/chains/evm/client/client.go | 7 ++ core/chains/evm/client/errors.go | 12 +- core/chains/evm/client/mocks/client.go | 22 ++++ core/chains/evm/client/null_client.go | 4 + .../evm/client/simulated_backend_client.go | 4 + core/chains/evm/client/tx_simulator.go | 55 +++++++++ core/chains/evm/client/tx_simulator_test.go | 113 ++++++++++++++++++ 11 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 .changeset/hungry-impalas-jog.md create mode 100644 core/chains/evm/client/tx_simulator.go create mode 100644 core/chains/evm/client/tx_simulator_test.go diff --git a/.changeset/hungry-impalas-jog.md b/.changeset/hungry-impalas-jog.md new file mode 100644 index 00000000000..efa23edabb2 --- /dev/null +++ b/.changeset/hungry-impalas-jog.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +Added a tx simulation feature to the chain client to enable testing for zk out-of-counter (OOC) errors diff --git a/common/client/models.go b/common/client/models.go index d0cf42a3844..66f1e9cf88b 100644 --- a/common/client/models.go +++ b/common/client/models.go @@ -18,6 +18,7 @@ const ( InsufficientFunds // Tx was rejected due to insufficient funds. ExceedsMaxFee // Attempt's fee was higher than the node's limit and got rejected. FeeOutOfValidRange // This error is returned when we use a fee price suggested from an RPC, but the network rejects the attempt due to an invalid range(mostly used by L2 chains). Retry by requesting a new suggested fee price. + OutOfCounters // The error returned when a transaction is too complex to be proven by zk circuits. This error is mainly returned by zk chains. sendTxReturnCodeLen // tracks the number of errors. Must always be last ) diff --git a/common/client/multi_node_test.go b/common/client/multi_node_test.go index 43e4127556a..9c09bd57d70 100644 --- a/common/client/multi_node_test.go +++ b/common/client/multi_node_test.go @@ -848,6 +848,14 @@ func TestMultiNode_SendTransaction_aggregateTxResults(t *testing.T) { ExpectedCriticalErr: "expected at least one response on SendTransaction", ResultsByCode: map[SendTxReturnCode][]error{}, }, + { + Name: "Zk out of counter error", + ExpectedTxResult: "not enough keccak counters to continue the execution", + ExpectedCriticalErr: "", + ResultsByCode: map[SendTxReturnCode][]error{ + OutOfCounters: {errors.New("not enough keccak counters to continue the execution")}, + }, + }, } for _, testCase := range testCases { diff --git a/core/chains/evm/client/chain_client.go b/core/chains/evm/client/chain_client.go index 1eb2347c474..52bc1263c75 100644 --- a/core/chains/evm/client/chain_client.go +++ b/core/chains/evm/client/chain_client.go @@ -38,7 +38,8 @@ type chainClient struct { RPCClient, rpc.BatchElem, ] - logger logger.SugaredLogger + logger logger.SugaredLogger + chainType config.ChainType } func NewChainClient( @@ -269,3 +270,12 @@ func (c *chainClient) TransactionReceipt(ctx context.Context, txHash common.Hash func (c *chainClient) LatestFinalizedBlock(ctx context.Context) (*evmtypes.Head, error) { return c.multiNode.LatestFinalizedBlock(ctx) } + +func (c *chainClient) CheckTxValidity(ctx context.Context, from common.Address, to common.Address, data []byte) *SendError { + msg := ethereum.CallMsg{ + From: from, + To: &to, + Data: data, + } + return SimulateTransaction(ctx, c, c.logger, c.chainType, msg) +} diff --git a/core/chains/evm/client/client.go b/core/chains/evm/client/client.go index ee33db97fd6..2758c9cf0a4 100644 --- a/core/chains/evm/client/client.go +++ b/core/chains/evm/client/client.go @@ -95,6 +95,9 @@ type Client interface { PendingCallContract(ctx context.Context, msg ethereum.CallMsg) ([]byte, error) IsL2() bool + + // Simulate the transaction prior to sending to catch zk out-of-counters errors ahead of time + CheckTxValidity(ctx context.Context, from common.Address, to common.Address, data []byte) *SendError } func ContextWithDefaultTimeout() (ctx context.Context, cancel context.CancelFunc) { @@ -371,3 +374,7 @@ func (client *client) IsL2() bool { func (client *client) LatestFinalizedBlock(_ context.Context) (*evmtypes.Head, error) { return nil, pkgerrors.New("not implemented. client was deprecated. New methods are added only to satisfy type constraints while we are migrating to new alternatives") } + +func (client *client) CheckTxValidity(ctx context.Context, from common.Address, to common.Address, data []byte) *SendError { + return NewSendError(pkgerrors.New("not implemented. client was deprecated. New methods are added only to satisfy type constraints while we are migrating to new alternatives")) +} diff --git a/core/chains/evm/client/errors.go b/core/chains/evm/client/errors.go index e58d3998927..37572bc8643 100644 --- a/core/chains/evm/client/errors.go +++ b/core/chains/evm/client/errors.go @@ -63,6 +63,7 @@ const ( TransactionAlreadyMined Fatal ServiceUnavailable + OutOfCounters ) type ClientErrors = map[int]*regexp.Regexp @@ -228,7 +229,11 @@ var zkSync = ClientErrors{ TransactionAlreadyInMempool: regexp.MustCompile(`known transaction. transaction with hash .* is already in the system`), } -var clients = []ClientErrors{parity, geth, arbitrum, metis, substrate, avalanche, nethermind, harmony, besu, erigon, klaytn, celo, zkSync} +var zkEvm = ClientErrors{ + OutOfCounters: regexp.MustCompile(`(?:: |^)not enough .* counters to continue the execution$`), +} + +var clients = []ClientErrors{parity, geth, arbitrum, metis, substrate, avalanche, nethermind, harmony, besu, erigon, klaytn, celo, zkSync, zkEvm} func (s *SendError) is(errorType int) bool { if s == nil || s.err == nil { @@ -310,6 +315,11 @@ func (s *SendError) IsServiceUnavailable() bool { return s.is(ServiceUnavailable) } +// IsOutOfCounters is a zk chain specific error returned if the transaction is too complex to prove on zk circuits +func (s *SendError) IsOutOfCounters() bool { + return s.is(OutOfCounters) +} + // IsTimeout indicates if the error was caused by an exceeded context deadline func (s *SendError) IsTimeout() bool { if s == nil { diff --git a/core/chains/evm/client/mocks/client.go b/core/chains/evm/client/mocks/client.go index e6c9da1cbe9..b3cdac3a6b6 100644 --- a/core/chains/evm/client/mocks/client.go +++ b/core/chains/evm/client/mocks/client.go @@ -7,6 +7,8 @@ import ( assets "github.com/smartcontractkit/chainlink-common/pkg/assets" + client "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" + common "github.com/ethereum/go-ethereum/common" commonclient "github.com/smartcontractkit/chainlink/v2/common/client" @@ -236,6 +238,26 @@ func (_m *Client) ChainID() (*big.Int, error) { return r0, r1 } +// CheckTxValidity provides a mock function with given fields: ctx, from, to, data +func (_m *Client) CheckTxValidity(ctx context.Context, from common.Address, to common.Address, data []byte) *client.SendError { + ret := _m.Called(ctx, from, to, data) + + if len(ret) == 0 { + panic("no return value specified for CheckTxValidity") + } + + var r0 *client.SendError + if rf, ok := ret.Get(0).(func(context.Context, common.Address, common.Address, []byte) *client.SendError); ok { + r0 = rf(ctx, from, to, data) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.SendError) + } + } + + return r0 +} + // Close provides a mock function with given fields: func (_m *Client) Close() { _m.Called() diff --git a/core/chains/evm/client/null_client.go b/core/chains/evm/client/null_client.go index e4bd7d1dd9a..3129bcff9b0 100644 --- a/core/chains/evm/client/null_client.go +++ b/core/chains/evm/client/null_client.go @@ -231,3 +231,7 @@ func (nc *NullClient) IsL2() bool { func (nc *NullClient) LatestFinalizedBlock(_ context.Context) (*evmtypes.Head, error) { return nil, nil } + +func (nc *NullClient) CheckTxValidity(_ context.Context, _ common.Address, _ common.Address, _ []byte) *SendError { + return nil +} diff --git a/core/chains/evm/client/simulated_backend_client.go b/core/chains/evm/client/simulated_backend_client.go index 5750887126a..9fe2ff88ba7 100644 --- a/core/chains/evm/client/simulated_backend_client.go +++ b/core/chains/evm/client/simulated_backend_client.go @@ -774,6 +774,10 @@ func (c *SimulatedBackendClient) ethGetLogs(ctx context.Context, result interfac } } +func (c *SimulatedBackendClient) CheckTxValidity(ctx context.Context, from common.Address, to common.Address, data []byte) *SendError { + return nil +} + func toCallMsg(params map[string]interface{}) ethereum.CallMsg { var callMsg ethereum.CallMsg toAddr, err := interfaceToAddress(params["to"]) diff --git a/core/chains/evm/client/tx_simulator.go b/core/chains/evm/client/tx_simulator.go new file mode 100644 index 00000000000..65e108bd227 --- /dev/null +++ b/core/chains/evm/client/tx_simulator.go @@ -0,0 +1,55 @@ +package client + +import ( + "context" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/common/config" +) + +type simulatorClient interface { + CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error +} + +// ZK chains can return an out-of-counters error +// This method allows a caller to determine if a tx would fail due to OOC error by simulating the transaction +// Used as an entry point in case custom simulation is required across different chains +func SimulateTransaction(ctx context.Context, client simulatorClient, lggr logger.SugaredLogger, chainType config.ChainType, msg ethereum.CallMsg) *SendError { + err := simulateTransactionDefault(ctx, client, msg) + return NewSendError(err) +} + +// eth_estimateGas returns out-of-counters (OOC) error if the transaction would result in an overflow +func simulateTransactionDefault(ctx context.Context, client simulatorClient, msg ethereum.CallMsg) error { + var result hexutil.Big + return client.CallContext(ctx, &result, "eth_estimateGas", toCallArg(msg), "pending") +} + +func toCallArg(msg ethereum.CallMsg) interface{} { + arg := map[string]interface{}{ + "from": msg.From, + "to": msg.To, + } + if len(msg.Data) > 0 { + arg["input"] = hexutil.Bytes(msg.Data) + } + if msg.Value != nil { + arg["value"] = (*hexutil.Big)(msg.Value) + } + if msg.Gas != 0 { + arg["gas"] = hexutil.Uint64(msg.Gas) + } + if msg.GasPrice != nil { + arg["gasPrice"] = (*hexutil.Big)(msg.GasPrice) + } + if msg.GasFeeCap != nil { + arg["maxFeePerGas"] = (*hexutil.Big)(msg.GasFeeCap) + } + if msg.GasTipCap != nil { + arg["maxPriorityFeePerGas"] = (*hexutil.Big)(msg.GasTipCap) + } + return arg +} diff --git a/core/chains/evm/client/tx_simulator_test.go b/core/chains/evm/client/tx_simulator_test.go new file mode 100644 index 00000000000..4e270d401bf --- /dev/null +++ b/core/chains/evm/client/tx_simulator_test.go @@ -0,0 +1,113 @@ +package client_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" +) + +func TestSimulateTx_Default(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + toAddress := testutils.NewAddress() + ctx := testutils.Context(t) + + t.Run("returns without error if simulation passes", func(t *testing.T) { + wsURL := testutils.NewWSServer(t, &cltest.FixtureChainID, func(method string, params gjson.Result) (resp testutils.JSONRPCResponse) { + switch method { + case "eth_subscribe": + resp.Result = `"0x00"` + resp.Notify = headResult + return + case "eth_unsubscribe": + resp.Result = "true" + return + case "eth_estimateGas": + resp.Result = `"0x100"` + } + return + }).WSURL().String() + + ethClient := mustNewChainClient(t, wsURL) + err := ethClient.Dial(ctx) + require.NoError(t, err) + + msg := ethereum.CallMsg{ + From: fromAddress, + To: &toAddress, + Data: []byte("0x00"), + } + sendErr := client.SimulateTransaction(ctx, ethClient, logger.TestSugared(t), "", msg) + require.Empty(t, sendErr) + }) + + t.Run("returns error if simulation returns zk out-of-counters error", func(t *testing.T) { + wsURL := testutils.NewWSServer(t, &cltest.FixtureChainID, func(method string, params gjson.Result) (resp testutils.JSONRPCResponse) { + switch method { + case "eth_subscribe": + resp.Result = `"0x00"` + resp.Notify = headResult + return + case "eth_unsubscribe": + resp.Result = "true" + return + case "eth_estimateGas": + resp.Error.Code = -32000 + resp.Result = `"0x100"` + resp.Error.Message = "not enough keccak counters to continue the execution" + } + return + }).WSURL().String() + + ethClient := mustNewChainClient(t, wsURL) + err := ethClient.Dial(ctx) + require.NoError(t, err) + + msg := ethereum.CallMsg{ + From: fromAddress, + To: &toAddress, + Data: []byte("0x00"), + } + sendErr := client.SimulateTransaction(ctx, ethClient, logger.TestSugared(t), "", msg) + require.Equal(t, true, sendErr.IsOutOfCounters()) + }) + + t.Run("returns without error if simulation returns non-OOC error", func(t *testing.T) { + wsURL := testutils.NewWSServer(t, &cltest.FixtureChainID, func(method string, params gjson.Result) (resp testutils.JSONRPCResponse) { + switch method { + case "eth_subscribe": + resp.Result = `"0x00"` + resp.Notify = headResult + return + case "eth_unsubscribe": + resp.Result = "true" + return + case "eth_estimateGas": + resp.Error.Code = -32000 + resp.Error.Message = "something went wrong" + } + return + }).WSURL().String() + + ethClient := mustNewChainClient(t, wsURL) + err := ethClient.Dial(ctx) + require.NoError(t, err) + + msg := ethereum.CallMsg{ + From: fromAddress, + To: &toAddress, + Data: []byte("0x00"), + } + sendErr := client.SimulateTransaction(ctx, ethClient, logger.TestSugared(t), "", msg) + require.Equal(t, false, sendErr.IsOutOfCounters()) + }) +}