Skip to content

Commit

Permalink
Add tests to jsonrpc, starting w/ opstack
Browse files Browse the repository at this point in the history
  • Loading branch information
msf committed Jul 26, 2024
1 parent dc987f4 commit ec37fc9
Show file tree
Hide file tree
Showing 11 changed files with 3,290 additions and 45 deletions.
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test:
CGO_ENABLED=1 go test -timeout=$(TEST_TIMEOUT) -race -bench=. -benchmem -cover ./...

gen-mocks: bin/moq ./client/jsonrpc/ ./client/duneapi/
./bin/moq -pkg jsonrpc_mock -out ./mocks/jsonrpc/httpclient.go ./client/jsonrpc HTTPClient
./bin/moq -pkg jsonrpc_mock -out ./mocks/jsonrpc/rpcnode.go ./client/jsonrpc BlockchainClient
./bin/moq -pkg duneapi_mock -out ./mocks/duneapi/client.go ./client/duneapi BlockchainIngester

Expand Down
9 changes: 0 additions & 9 deletions client/jsonrpc/arbitrum_nitro.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"

"github.com/duneanalytics/blockchain-ingester/models"
Expand All @@ -18,14 +17,6 @@ type ArbitrumNitroClient struct {

var _ BlockchainClient = &ArbitrumNitroClient{}

func NewArbitrumNitroClient(log *slog.Logger, cfg Config) (*ArbitrumNitroClient, error) {
rpcClient, err := newClient(log.With("module", "jsonrpc"), cfg)
if err != nil {
return nil, err
}
return &ArbitrumNitroClient{*rpcClient}, nil
}

// BlockByNumber returns the block with the given blockNumber.
// it uses 3 different methods to get the block:
// 1. eth_getBlockByNumber
Expand Down
45 changes: 18 additions & 27 deletions client/jsonrpc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,44 +29,36 @@ const (
)

type rpcClient struct {
client *retryablehttp.Client
cfg Config
log *slog.Logger
bufPool *sync.Pool
cfg Config
client HTTPClient
httpHeaders map[string]string
log *slog.Logger
wrkPool *ants.Pool
}

func NewClient(logger *slog.Logger, cfg Config) (BlockchainClient, error) {
func NewClient(log *slog.Logger, cfg Config) (BlockchainClient, error) {
// use the production http client w/ retries
return NewRPCClient(log, NewHTTPClient(log), cfg)
}

func NewRPCClient(log *slog.Logger, client HTTPClient, cfg Config) (BlockchainClient, error) {
rpcClient, err := newClient(log.With("module", "jsonrpc"), client, cfg)
if err != nil {
return nil, err
}
switch cfg.EVMStack {
case models.OpStack:
return NewOpStackClient(logger, cfg)
return &OpStackClient{*rpcClient}, nil
case models.ArbitrumNitro:
return NewArbitrumNitroClient(logger, cfg)
return &ArbitrumNitroClient{*rpcClient}, nil
default:
return nil, fmt.Errorf("unsupported EVM stack: %s", cfg.EVMStack)
}
}

func newClient(log *slog.Logger, cfg Config) (*rpcClient, error) { // revive:disable-line:unexported-return
client := retryablehttp.NewClient()
client.RetryMax = MaxRetries
client.Logger = log
checkRetry := func(ctx context.Context, resp *http.Response, err error) (bool, error) {
yes, err2 := retryablehttp.DefaultRetryPolicy(ctx, resp, err)
if yes {
if resp == nil {
log.Warn("Retrying request to RPC client", "error", err2)
} else {
log.Warn("Retrying request to RPC client", "statusCode", resp.Status, "error", err2)
}
}
return yes, err2
}
client.CheckRetry = checkRetry
client.Backoff = retryablehttp.LinearJitterBackoff
client.HTTPClient.Timeout = DefaultRequestTimeout

func newClient(log *slog.Logger, client HTTPClient, cfg Config,
) (*rpcClient, error) { // revive:disable-line:unexported-return
if cfg.TotalRPCConcurrency == 0 {
cfg.TotalRPCConcurrency = DefaultMaxRPCConcurrency
}
Expand Down Expand Up @@ -98,8 +90,7 @@ func newClient(log *slog.Logger, cfg Config) (*rpcClient, error) { // revive:dis

func (c *rpcClient) LatestBlockNumber() (int64, error) {
buf := c.bufPool.Get().(*bytes.Buffer)
defer c.bufPool.Put(buf)
buf.Reset()
defer c.putBuffer(buf)

err := c.getResponseBody(context.Background(), "eth_blockNumber", []any{}, buf)
if err != nil {
Expand Down
34 changes: 34 additions & 0 deletions client/jsonrpc/httpclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package jsonrpc

import (
"context"
"log/slog"
"net/http"

"github.com/hashicorp/go-retryablehttp"
)

type HTTPClient interface {
Do(req *retryablehttp.Request) (*http.Response, error)
}

func NewHTTPClient(log *slog.Logger) *retryablehttp.Client {
client := retryablehttp.NewClient()
client.RetryMax = MaxRetries
client.Logger = log
checkRetry := func(ctx context.Context, resp *http.Response, err error) (bool, error) {
yes, err2 := retryablehttp.DefaultRetryPolicy(ctx, resp, err)
if yes {
if resp == nil {
log.Warn("Retrying request to RPC client", "error", err2)
} else {
log.Warn("Retrying request to RPC client", "statusCode", resp.Status, "error", err2)
}
}
return yes, err2
}
client.CheckRetry = checkRetry
client.Backoff = retryablehttp.LinearJitterBackoff
client.HTTPClient.Timeout = DefaultRequestTimeout
return client
}
110 changes: 110 additions & 0 deletions client/jsonrpc/httpclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package jsonrpc_test

import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"os"

"github.com/davecgh/go-spew/spew"
"github.com/duneanalytics/blockchain-ingester/client/jsonrpc"
jsonrpc_mock "github.com/duneanalytics/blockchain-ingester/mocks/jsonrpc"
"github.com/duneanalytics/blockchain-ingester/models"
"github.com/hashicorp/go-retryablehttp"
)

type jsonRPCRequest struct {
Method string `json:"method"`
Params []interface{} `json:"params"`
HTTPHeaders http.Header
}

type jsonRPCResponse struct {
Body io.Reader
StatusCode int // optional, default to 200
ContentType string // optional, default to "application/json"
}

type MockedRequest struct {
Req jsonRPCRequest
Resp jsonRPCResponse
}

func MockHTTPRequests(requests []MockedRequest) *jsonrpc_mock.HTTPClientMock {
return &jsonrpc_mock.HTTPClientMock{
DoFunc: func(req *retryablehttp.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
return nil, fmt.Errorf("expected POST method, got %s", req.Method)
}
// we use httpretryable.Client, so we can't use req.Body directly
// we need to read the body and then reset it

body, err := req.BodyBytes()
if err != nil {
return nil, err
}
var jsonReq jsonRPCRequest
if err := json.Unmarshal(body, &jsonReq); err != nil {
return nil, err
}
jsonReqParams := fmt.Sprintf("%+v", jsonReq.Params)
// looking for a matching request
for _, r := range requests {
if r.Req.Method == jsonReq.Method {
// we do this because reflect.DeepEquals() Comparison fails on map[string]any != map[string]string
if jsonReqParams != fmt.Sprintf("%+v", r.Req.Params) {
continue
}
// this is a match, validate registered headers
for k, v := range r.Req.HTTPHeaders {
if req.Header.Get(k) != v[0] {
return nil, fmt.Errorf("expected header %s to be %s, got %s", k, v[0], req.Header.Get(k))
}
}
// all headers match, return the response
resp := &http.Response{
StatusCode: 200,
Body: io.NopCloser(r.Resp.Body),
Header: make(http.Header),
}
if r.Resp.StatusCode != 0 {
resp.StatusCode = r.Resp.StatusCode
}
resp.Header.Set("Content-Type", "application/json")
if r.Resp.ContentType != "" {
resp.Header.Set("Content-Type", r.Resp.ContentType)
}
return resp, nil
}
}
if jsonReq.Method == "eth_blockNumber" {
resp := &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x7a549b"}`))),
}
return resp, nil
}
return nil, fmt.Errorf("no matching request found, req: %+v", jsonReq)
},
}
}

func NewTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

func NewTestRPCClient(httpClient jsonrpc.HTTPClient, stack models.EVMStack) (jsonrpc.BlockchainClient, error) {
return jsonrpc.NewRPCClient(NewTestLogger(), httpClient, jsonrpc.Config{EVMStack: stack})
}

func readFileForTest(filename string) *bytes.Buffer {
data, err := os.ReadFile(filename)
if err != nil {
log.Panicf("Failed to read file: %v", err)
}
return bytes.NewBuffer(data)
}
9 changes: 0 additions & 9 deletions client/jsonrpc/opstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"context"
"fmt"
"log/slog"
"time"

"github.com/duneanalytics/blockchain-ingester/models"
Expand All @@ -17,14 +16,6 @@ type OpStackClient struct {

var _ BlockchainClient = &OpStackClient{}

func NewOpStackClient(log *slog.Logger, cfg Config) (*OpStackClient, error) {
rpcClient, err := newClient(log.With("module", "jsonrpc"), cfg)
if err != nil {
return nil, err
}
return &OpStackClient{*rpcClient}, nil
}

// BlockByNumber returns the block with the given blockNumber.
// it uses 3 different methods to get the block:
// 1. eth_getBlockByNumber
Expand Down
56 changes: 56 additions & 0 deletions client/jsonrpc/opstack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package jsonrpc_test

import (
"context"
"testing"

"github.com/duneanalytics/blockchain-ingester/models"
"github.com/stretchr/testify/require"
)

func TestOpStackBasic(t *testing.T) {
getBlockByNumberResponse := readFileForTest("testdata/opstack-eth_getBlockByNumber.json")
getBlockReceiptsResponse := readFileForTest("testdata/opstack-eth_getBlockReceipts.json")
debugtraceBlockByNumberResponse := readFileForTest("testdata/opstack-debug_traceBlockByNumber.json")
blockNumberHex := "0x7a549b"
blockNumber := int64(8017051)
httpClientMock := MockHTTPRequests(
[]MockedRequest{
{
Req: jsonRPCRequest{
Method: "eth_getBlockByNumber",
Params: []interface{}{blockNumberHex, true},
},
Resp: jsonRPCResponse{
Body: getBlockByNumberResponse,
},
},
{
Req: jsonRPCRequest{
Method: "eth_getBlockReceipts",
Params: []interface{}{blockNumberHex},
},
Resp: jsonRPCResponse{
Body: getBlockReceiptsResponse,
},
},
{
Req: jsonRPCRequest{
Method: "debug_traceBlockByNumber",
Params: []interface{}{blockNumberHex, map[string]string{"tracer": "callTracer"}},
},
Resp: jsonRPCResponse{
Body: debugtraceBlockByNumberResponse,
},
},
})

opstack, err := NewTestRPCClient(httpClientMock, models.OpStack)
require.NoError(t, err)

block, err := opstack.BlockByNumber(context.Background(), blockNumber)
require.NoError(t, err)
require.NotNil(t, block)
require.Equal(t, blockNumber, block.BlockNumber)
require.False(t, block.Errored())
}
Loading

0 comments on commit ec37fc9

Please sign in to comment.