Skip to content

Commit

Permalink
Add Transaction Simulation to the TXM (#12503)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
amit-momin committed Mar 27, 2024
1 parent 96e3901 commit dc224a2
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/hungry-impalas-jog.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions common/client/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
8 changes: 8 additions & 0 deletions common/client/multi_node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion core/chains/evm/client/chain_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ type chainClient struct {
RPCClient,
rpc.BatchElem,
]
logger logger.SugaredLogger
logger logger.SugaredLogger
chainType config.ChainType
}

func NewChainClient(
Expand Down Expand Up @@ -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)
}
7 changes: 7 additions & 0 deletions core/chains/evm/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"))
}
12 changes: 11 additions & 1 deletion core/chains/evm/client/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const (
TransactionAlreadyMined
Fatal
ServiceUnavailable
OutOfCounters
)

type ClientErrors = map[int]*regexp.Regexp
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions core/chains/evm/client/mocks/client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions core/chains/evm/client/null_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions core/chains/evm/client/simulated_backend_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
55 changes: 55 additions & 0 deletions core/chains/evm/client/tx_simulator.go
Original file line number Diff line number Diff line change
@@ -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
}
113 changes: 113 additions & 0 deletions core/chains/evm/client/tx_simulator_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}

0 comments on commit dc224a2

Please sign in to comment.