Skip to content

Commit e39a622

Browse files
authored
feat: add a simulated chain provider for EVM testing (#184)
This adds a geth backed simulated chain provider for testing purposes. It provides an option to allow for the automated mining of blocks, which is useful for testing scenarios that require block confirmations.
1 parent 42b1c40 commit e39a622

File tree

11 files changed

+904
-2
lines changed

11 files changed

+904
-2
lines changed

.changeset/full-pans-go.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
Adds a Simulated EVM Chain Provider using the go-ethereum `simulated` package as the backend.

.mockery.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@ require-template-schema-exists: true
1111
template: testify
1212
template-schema: '{{.Template}}.schema.json'
1313
packages:
14-
#github.com/smartcontractkit/chainlink-deployments-framework/...
14+
github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/provider:
15+
config:
16+
all: false
17+
interfaces:
18+
ContractCaller:
19+

chain/evm/provider/helpers.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"math/big"
8+
"strings"
9+
10+
"github.com/ethereum/go-ethereum"
11+
"github.com/ethereum/go-ethereum/common"
12+
"github.com/ethereum/go-ethereum/core/types"
13+
)
14+
15+
// ContractCaller is an interface that defines the CallContract method. This is copied from the
16+
// go-ethereum package method to limit the scope of dependencies provided to the functions.
17+
type ContractCaller interface {
18+
CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error)
19+
}
20+
21+
// getErrorReasonFromTx retrieves the error reason from a transaction by simulating the call
22+
// using the CallContract method. If the transaction reverts, it attempts to extract the
23+
// error reason from the returned error.
24+
func getErrorReasonFromTx(
25+
ctx context.Context,
26+
caller ContractCaller,
27+
from common.Address,
28+
tx *types.Transaction,
29+
receipt *types.Receipt,
30+
) (string, error) {
31+
call := ethereum.CallMsg{
32+
From: from,
33+
To: tx.To(),
34+
Data: tx.Data(),
35+
Value: tx.Value(),
36+
Gas: tx.Gas(),
37+
GasPrice: tx.GasPrice(),
38+
}
39+
40+
if _, err := caller.CallContract(ctx, call, receipt.BlockNumber); err != nil {
41+
reason, perr := getJSONErrorData(err)
42+
43+
// If the reason exists and we had no issues parsing it, we return it
44+
if perr == nil {
45+
return reason, nil
46+
}
47+
48+
// If we get no information from parsing the error, we return the original error from
49+
// CallContract
50+
if reason == "" {
51+
return err.Error(), nil
52+
}
53+
}
54+
55+
return "", fmt.Errorf("tx %s reverted with no reason", tx.Hash().Hex())
56+
}
57+
58+
// getJSONErrorData extracts the error data from a JSON Error.
59+
func getJSONErrorData(err error) (string, error) {
60+
if err == nil {
61+
return "", errors.New("cannot parse nil error")
62+
}
63+
64+
// Define a custom interface that matches the structure of the JSON error because it is a
65+
// private type in go-ethereum.
66+
//
67+
// https://github.com/ethereum/go-ethereum/blob/0983cd789ee1905aedaed96f72793e5af8466f34/rpc/json.go#L140
68+
type jsonError interface {
69+
Error() string
70+
ErrorCode() int
71+
ErrorData() any
72+
}
73+
74+
var jerr jsonError
75+
ok := errors.As(err, &jerr)
76+
if !ok {
77+
return "", fmt.Errorf("error must be of type jsonError: %w", err)
78+
}
79+
80+
data := fmt.Sprintf("%s", jerr.ErrorData())
81+
if data == "" && strings.Contains(jerr.Error(), "missing trie node") {
82+
return "", errors.New("missing trie node, likely due to not using an archive node")
83+
}
84+
85+
return data, nil
86+
}

chain/evm/provider/helpers_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package provider
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"math/big"
7+
"testing"
8+
9+
"github.com/ethereum/go-ethereum/common"
10+
"github.com/ethereum/go-ethereum/core/types"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/mock"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func Test_getErrorReasonFromTx(t *testing.T) {
17+
t.Parallel()
18+
19+
var (
20+
tx = types.NewTransaction(
21+
1, // nonce
22+
common.HexToAddress("0xabc123"), // to address
23+
big.NewInt(1000000000000000000), // value: 1 ETH
24+
21000, // gas limit
25+
big.NewInt(20000000000), // gas price: 20 Gwei
26+
[]byte{0xde, 0xad, 0xbe, 0xef}, // data
27+
)
28+
)
29+
30+
tests := []struct {
31+
name string
32+
beforeFunc func(caller *MockContractCaller)
33+
giveTx *types.Transaction
34+
giveReceipt *types.Receipt
35+
wantReason string
36+
wantErr string
37+
}{
38+
{
39+
name: "no transaction error",
40+
beforeFunc: func(caller *MockContractCaller) {
41+
caller.EXPECT().CallContract(
42+
mock.Anything,
43+
mock.AnythingOfType("ethereum.CallMsg"),
44+
mock.AnythingOfType("*big.Int"),
45+
).Return([]byte{}, nil)
46+
},
47+
giveTx: tx,
48+
giveReceipt: &types.Receipt{},
49+
wantErr: "reverted with no reason",
50+
},
51+
{
52+
name: "transaction error with reason",
53+
beforeFunc: func(caller *MockContractCaller) {
54+
caller.EXPECT().CallContract(
55+
mock.Anything,
56+
mock.AnythingOfType("ethereum.CallMsg"),
57+
mock.AnythingOfType("*big.Int"),
58+
).Return(nil, &jsonError{
59+
Code: 100,
60+
Message: "test error message",
61+
Data: []byte("test error data"),
62+
})
63+
},
64+
giveTx: tx,
65+
giveReceipt: &types.Receipt{},
66+
wantReason: "test error data",
67+
},
68+
{
69+
name: "transaction error with no reason (non json error)",
70+
beforeFunc: func(caller *MockContractCaller) {
71+
caller.EXPECT().CallContract(
72+
mock.Anything,
73+
mock.AnythingOfType("ethereum.CallMsg"),
74+
mock.AnythingOfType("*big.Int"),
75+
).Return(nil, errors.New("error message"))
76+
},
77+
giveTx: tx,
78+
giveReceipt: &types.Receipt{},
79+
wantReason: "error message",
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
87+
caller := NewMockContractCaller(t)
88+
if tt.beforeFunc != nil {
89+
tt.beforeFunc(caller)
90+
}
91+
92+
got, err := getErrorReasonFromTx(
93+
t.Context(), caller, common.HexToAddress("0x123"), tt.giveTx, tt.giveReceipt,
94+
)
95+
96+
if tt.wantErr != "" {
97+
require.ErrorContains(t, err, tt.wantErr)
98+
} else {
99+
require.NoError(t, err)
100+
assert.Equal(t, tt.wantReason, got)
101+
}
102+
})
103+
}
104+
}
105+
106+
func Test_parseError(t *testing.T) {
107+
t.Parallel()
108+
109+
tests := []struct {
110+
name string
111+
give error
112+
want string
113+
wantErr string
114+
}{
115+
{
116+
name: "valid error",
117+
give: &jsonError{
118+
Code: 100,
119+
Message: "execution reverted",
120+
Data: "0x12345678",
121+
},
122+
want: "0x12345678",
123+
},
124+
{
125+
name: "nil error",
126+
give: nil,
127+
wantErr: "cannot parse nil error",
128+
},
129+
{
130+
name: "invalid error type",
131+
give: errors.New("invalid"),
132+
wantErr: "error must be of type jsonError",
133+
},
134+
{
135+
name: "trie error",
136+
give: &jsonError{
137+
Code: -32000,
138+
Message: "missing trie node",
139+
Data: []byte{},
140+
},
141+
wantErr: "missing trie node",
142+
},
143+
}
144+
145+
for _, tt := range tests {
146+
t.Run(tt.name, func(t *testing.T) {
147+
t.Parallel()
148+
149+
got, err := getJSONErrorData(tt.give)
150+
151+
if tt.wantErr != "" {
152+
require.ErrorContains(t, err, tt.wantErr)
153+
} else {
154+
require.NoError(t, err)
155+
assert.Equal(t, tt.want, got)
156+
}
157+
})
158+
}
159+
}
160+
161+
// Dummy implementation of jsonError to satisfy the interface
162+
type jsonError struct {
163+
Code int `json:"code"`
164+
Message string `json:"message"`
165+
Data any `json:"data,omitempty"`
166+
}
167+
168+
func (err *jsonError) Error() string {
169+
if err.Message == "" {
170+
return fmt.Sprintf("json-rpc error %d", err.Code)
171+
}
172+
173+
return err.Message
174+
}
175+
176+
func (err *jsonError) ErrorCode() int {
177+
return err.Code
178+
}
179+
180+
func (err *jsonError) ErrorData() interface{} {
181+
return err.Data
182+
}

0 commit comments

Comments
 (0)