Skip to content

Commit

Permalink
Transfer testutils (#12968)
Browse files Browse the repository at this point in the history
* Add first version of evm utils

* Remove unused context util

* Add WSServer tests

* Add NewLegacyTransaction test

* Update NewTestChainScopedConfig to apply correct defaults

* Move testutils

* Fix lint

* Add changeset
  • Loading branch information
dimriou authored Apr 29, 2024
1 parent 9293126 commit c977815
Show file tree
Hide file tree
Showing 6 changed files with 371 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-rice-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink": minor
---

Moved test functions under evm package to support evm extraction #internal
198 changes: 198 additions & 0 deletions core/chains/evm/testutils/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package testutils

import (
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"

"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"

evmclmocks "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client/mocks"
)

func NewEthClientMock(t *testing.T) *evmclmocks.Client {
return evmclmocks.NewClient(t)
}

func NewEthClientMockWithDefaultChain(t *testing.T) *evmclmocks.Client {
c := NewEthClientMock(t)
c.On("ConfiguredChainID").Return(FixtureChainID).Maybe()
//c.On("IsL2").Return(false).Maybe()
return c
}

// JSONRPCHandler is called with the method and request param(s).
// respResult will be sent immediately. notifyResult is optional, and sent after a short delay.
type JSONRPCHandler func(reqMethod string, reqParams gjson.Result) JSONRPCResponse

type JSONRPCResponse struct {
Result, Notify string // raw JSON (i.e. quoted strings etc.)

Error struct {
Code int
Message string
}
}

type testWSServer struct {
t *testing.T
s *httptest.Server
mu sync.RWMutex
wsconns []*websocket.Conn
wg sync.WaitGroup
}

// NewWSServer starts a websocket server which invokes callback for each message received.
// If chainID is set, then eth_chainId calls will be automatically handled.
func NewWSServer(t *testing.T, chainID *big.Int, callback JSONRPCHandler) (ts *testWSServer) {
ts = new(testWSServer)
ts.t = t
ts.wsconns = make([]*websocket.Conn, 0)
handler := ts.newWSHandler(chainID, callback)
ts.s = httptest.NewServer(handler)
t.Cleanup(ts.Close)
return
}

func (ts *testWSServer) Close() {
if func() bool {
ts.mu.Lock()
defer ts.mu.Unlock()
if ts.wsconns == nil {
ts.t.Log("Test WS server already closed")
return false
}
ts.s.CloseClientConnections()
ts.s.Close()
for _, ws := range ts.wsconns {
ws.Close()
}
ts.wsconns = nil // nil indicates server closed
return true
}() {
ts.wg.Wait()
}
}

func (ts *testWSServer) WSURL() *url.URL {
return WSServerURL(ts.t, ts.s)
}

// WSServerURL returns a ws:// url for the server
func WSServerURL(t *testing.T, s *httptest.Server) *url.URL {
u, err := url.Parse(s.URL)
require.NoError(t, err, "Failed to parse url")
u.Scheme = "ws"
return u
}

func (ts *testWSServer) MustWriteBinaryMessageSync(t *testing.T, msg string) {
ts.mu.Lock()
defer ts.mu.Unlock()
conns := ts.wsconns
if len(conns) != 1 {
t.Fatalf("expected 1 conn, got %d", len(conns))
}
conn := conns[0]
err := conn.WriteMessage(websocket.BinaryMessage, []byte(msg))
require.NoError(t, err)
}

func (ts *testWSServer) newWSHandler(chainID *big.Int, callback JSONRPCHandler) (handler http.HandlerFunc) {
if callback == nil {
callback = func(method string, params gjson.Result) (resp JSONRPCResponse) { return }
}
t := ts.t
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
return func(w http.ResponseWriter, r *http.Request) {
ts.mu.Lock()
if ts.wsconns == nil { // closed
ts.mu.Unlock()
return
}
ts.wg.Add(1)
defer ts.wg.Done()
conn, err := upgrader.Upgrade(w, r, nil)
if !assert.NoError(t, err, "Failed to upgrade WS connection") {
ts.mu.Unlock()
return
}
defer conn.Close()
ts.wsconns = append(ts.wsconns, conn)
ts.mu.Unlock()

for {
_, data, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseAbnormalClosure) {
ts.t.Log("Websocket closing")
return
}
ts.t.Logf("Failed to read message: %v", err)
return
}
ts.t.Log("Received message", string(data))
req := gjson.ParseBytes(data)
if !req.IsObject() {
ts.t.Logf("Request must be object: %v", req.Type)
return
}
if e := req.Get("error"); e.Exists() {
ts.t.Logf("Received jsonrpc error: %v", e)
continue
}
m := req.Get("method")
if m.Type != gjson.String {
ts.t.Logf("Method must be string: %v", m.Type)
return
}

var resp JSONRPCResponse
if chainID != nil && m.String() == "eth_chainId" {
resp.Result = `"0x` + chainID.Text(16) + `"`
} else if m.String() == "eth_syncing" {
resp.Result = "false"
} else {
resp = callback(m.String(), req.Get("params"))
}
id := req.Get("id")
var msg string
if resp.Error.Message != "" {
msg = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"error":{"code":%d,"message":"%s"}}`, id, resp.Error.Code, resp.Error.Message)
} else {
msg = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, id, resp.Result)
}
ts.t.Logf("Sending message: %v", msg)
ts.mu.Lock()
err = conn.WriteMessage(websocket.BinaryMessage, []byte(msg))
ts.mu.Unlock()
if err != nil {
ts.t.Logf("Failed to write message: %v", err)
return
}

if resp.Notify != "" {
time.Sleep(100 * time.Millisecond)
msg := fmt.Sprintf(`{"jsonrpc":"2.0","method":"eth_subscription","params":{"subscription":"0x00","result":%s}}`, resp.Notify)
ts.t.Log("Sending message", msg)
ts.mu.Lock()
err = conn.WriteMessage(websocket.BinaryMessage, []byte(msg))
ts.mu.Unlock()
if err != nil {
ts.t.Logf("Failed to write message: %v", err)
return
}
}
}
}
}
29 changes: 29 additions & 0 deletions core/chains/evm/testutils/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package testutils

import (
"testing"

"github.com/smartcontractkit/chainlink-common/pkg/logger"

"github.com/smartcontractkit/chainlink/v2/core/chains/evm/config"
"github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/toml"
"github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils/big"
)

func NewTestChainScopedConfig(t testing.TB, overrideFn func(c *toml.EVMConfig)) config.ChainScopedConfig {
var chainID = (*big.Big)(FixtureChainID)
evmCfg := &toml.EVMConfig{
ChainID: chainID,
Chain: toml.Defaults(chainID),
}

if overrideFn != nil {
// We need to get the chainID from the override function first to load the correct chain defaults.
// Then we apply the override values on top
overrideFn(evmCfg)
evmCfg.Chain = toml.Defaults(evmCfg.ChainID)
overrideFn(evmCfg)
}

return config.NewTOMLChainScopedConfig(evmCfg, logger.Test(t))
}
22 changes: 22 additions & 0 deletions core/chains/evm/testutils/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package testutils

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/toml"
)

func TestNewTestChainScopedConfigOverride(t *testing.T) {
c := NewTestChainScopedConfig(t, func(c *toml.EVMConfig) {
finalityDepth := uint32(100)
c.FinalityDepth = &finalityDepth
})

// Overrides values
assert.Equal(t, uint32(100), c.EVM().FinalityDepth())
// fallback.toml values
assert.Equal(t, false, c.EVM().GasEstimator().EIP1559DynamicFees())

}
76 changes: 76 additions & 0 deletions core/chains/evm/testutils/evmtypes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package testutils

import (
"crypto/rand"
"fmt"
"math"
"math/big"
mrand "math/rand"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"

evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types"
evmutils "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils"
ubig "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils/big"
)

// FixtureChainID matches the chain always added by fixtures.sql
// It is set to 0 since no real chain ever has this ID and allows a virtual
// "test" chain ID to be used without clashes
var FixtureChainID = big.NewInt(0)

// SimulatedChainID is the chain ID for the go-ethereum simulated backend
var SimulatedChainID = big.NewInt(1337)

// NewRandomEVMChainID returns a suitable random chain ID that will not conflict
// with fixtures
func NewRandomEVMChainID() *big.Int {
id := mrand.Int63n(math.MaxInt32) + 10000
return big.NewInt(id)
}

// NewAddress return a random new address
func NewAddress() common.Address {
return common.BytesToAddress(randomBytes(20))
}

func randomBytes(n int) []byte {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
panic(err)
}
return b
}

// Head given the value convert it into an Head
func Head(val interface{}) *evmtypes.Head {
var h evmtypes.Head
time := uint64(0)
switch t := val.(type) {
case int:
h = evmtypes.NewHead(big.NewInt(int64(t)), evmutils.NewHash(), evmutils.NewHash(), time, ubig.New(FixtureChainID))
case uint64:
h = evmtypes.NewHead(big.NewInt(int64(t)), evmutils.NewHash(), evmutils.NewHash(), time, ubig.New(FixtureChainID))
case int64:
h = evmtypes.NewHead(big.NewInt(t), evmutils.NewHash(), evmutils.NewHash(), time, ubig.New(FixtureChainID))
case *big.Int:
h = evmtypes.NewHead(t, evmutils.NewHash(), evmutils.NewHash(), time, ubig.New(FixtureChainID))
default:
panic(fmt.Sprintf("Could not convert %v of type %T to Head", val, val))
}
return &h
}

func NewLegacyTransaction(nonce uint64, to common.Address, value *big.Int, gasLimit uint32, gasPrice *big.Int, data []byte) *types.Transaction {
tx := types.LegacyTx{
Nonce: nonce,
To: &to,
Value: value,
Gas: uint64(gasLimit),
GasPrice: gasPrice,
Data: data,
}
return types.NewTx(&tx)
}
41 changes: 41 additions & 0 deletions core/chains/evm/testutils/timeout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package testutils

import (
"testing"
"time"
)

type Awaiter chan struct{}

func NewAwaiter() Awaiter { return make(Awaiter) }

func (a Awaiter) ItHappened() { close(a) }

func (a Awaiter) AssertHappened(t *testing.T, expected bool) {
t.Helper()
select {
case <-a:
if !expected {
t.Fatal("It happened")
}
default:
if expected {
t.Fatal("It didn't happen")
}
}
}

func (a Awaiter) AwaitOrFail(t testing.TB, durationParams ...time.Duration) {
t.Helper()

duration := 10 * time.Second
if len(durationParams) > 0 {
duration = durationParams[0]
}

select {
case <-a:
case <-time.After(duration):
t.Fatal("Timed out waiting for Awaiter to get ItHappened")
}
}

0 comments on commit c977815

Please sign in to comment.