diff --git a/bridge/standard/pkg/node/node.go b/bridge/standard/pkg/node/node.go index 6d02744d3..0eab54d5a 100644 --- a/bridge/standard/pkg/node/node.go +++ b/bridge/standard/pkg/node/node.go @@ -6,8 +6,8 @@ import ( "encoding/json" "fmt" "log/slog" + "math/big" "net/http" - "net/url" "strings" "time" @@ -20,6 +20,7 @@ import ( "github.com/primev/mev-commit/bridge/standard/pkg/store" l1gateway "github.com/primev/mev-commit/contracts-abi/clients/L1Gateway" settlementgateway "github.com/primev/mev-commit/contracts-abi/clients/SettlementGateway" + "github.com/primev/mev-commit/x/contracts/ethwrapper" "github.com/primev/mev-commit/x/contracts/events" "github.com/primev/mev-commit/x/contracts/events/publisher" "github.com/primev/mev-commit/x/contracts/transactor" @@ -243,9 +244,29 @@ func (n *Node) createGatewayContract( return fmt.Errorf("failed to parse contract ABI: %w", err) } + opts := []ethwrapper.EthClientOptions{ + ethwrapper.EthClientWithMaxRetries(3), + } + + if component == "l1" { + opts = append( + opts, + ethwrapper.EthClientWithBlockNumOverride(finalizedBlockNumGetter), + ) + } + + wrappedClient, err := ethwrapper.NewClient( + logger.With("component", fmt.Sprintf("%s/ethwrapper", component)), + []string{rpcURL}, + opts..., + ) + if err != nil { + return fmt.Errorf("failed to create wrapped client: %w", err) + } + monitor := txmonitor.New( signer.GetAddress(), - client, + wrappedClient, txmonitor.NewEVMHelperWithLogger( client, logger.With("component", fmt.Sprintf("%s/evmhelper", component)), @@ -292,51 +313,23 @@ func (n *Node) createGatewayContract( ) n.metrics.MustRegister(evtMgr.Metrics()...) - parsedURL, err := url.Parse(rpcURL) - if err != nil { - return fmt.Errorf("failed to parse URL: %w", err) - } - - switch parsedURL.Scheme { - case "ws", "wss": - p := publisher.NewWSPublisher( - st, - logger, - client, - evtMgr, - ) - n.startables = append( - n.startables, - StartableObjWithDesc{ - Startable: StartableFunc( - func(ctx context.Context) <-chan struct{} { - return p.Start(ctx, contractAddr) - }, - ), - Desc: fmt.Sprintf("%s/publisher", component), - }, - ) - case "http", "https": - p := publisher.NewHTTPPublisher( - st, - logger, - client, - evtMgr, - ) - n.startables = append( - n.startables, - StartableObjWithDesc{ - Startable: StartableFunc( - func(ctx context.Context) <-chan struct{} { - return p.Start(ctx, contractAddr) - }, - ), - Desc: fmt.Sprintf("%s/publisher", component), - }, - ) - default: - return fmt.Errorf("unsupported scheme: %s", parsedURL.Scheme) - } + p := publisher.NewHTTPPublisher( + st, + logger, + wrappedClient, + evtMgr, + ) + n.startables = append( + n.startables, + StartableObjWithDesc{ + Startable: StartableFunc( + func(ctx context.Context) <-chan struct{} { + return p.Start(ctx, contractAddr) + }, + ), + Desc: fmt.Sprintf("%s/publisher", component), + }, + ) switch component { case "l1": @@ -399,3 +392,29 @@ func setupMetricsNamespace(namespace string) { events.Namespace = namespace transactor.Namespace = namespace } + +// BlockResponse is used to get the block number from the eth_getBlockByNumber RPC response. +type BlockResponse struct { + Number string `json:"number"` +} + +// FinalizedBlockNumGetter gets the finalized block number from the Ethereum client. +func finalizedBlockNumGetter(ctx context.Context, cli ethwrapper.EthClient) (uint64, error) { + client, ok := cli.(*ethclient.Client) + if !ok { + return cli.BlockNumber(ctx) + } + var blockResponse BlockResponse + + // Call the eth_getBlockByNumber RPC with "finalized" + err := client.Client().CallContext(ctx, &blockResponse, "eth_getBlockByNumber", "finalized", false) + if err != nil { + return 0, fmt.Errorf("failed to get the finalized block: %w", err) + } + + // Convert the hex block number to a big.Int + blockNumber := new(big.Int) + blockNumber.SetString(blockResponse.Number[2:], 16) // Strip "0x" and convert from hex + + return blockNumber.Uint64(), nil +} diff --git a/oracle/pkg/node/l1client.go b/oracle/pkg/node/l1client.go deleted file mode 100644 index 467eac454..000000000 --- a/oracle/pkg/node/l1client.go +++ /dev/null @@ -1,170 +0,0 @@ -package node - -import ( - "context" - "errors" - "fmt" - "log/slog" - "math/big" - "math/rand/v2" - "time" - - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/primev/mev-commit/oracle/pkg/l1Listener" -) - -var _ l1Listener.EthClient = (*l1Client)(nil) - -// errRetry is returned when retry maxRetries is exhausted. -var errRetry = errors.New("retry attempts exhausted") - -// l1ClientOptions is a functional option for l1Client. -type l1ClientOptions func(*l1Client) - -// l1ClientWithBlockNumberDrift sets the block number drift -// for the BlockNumber method. -func l1ClientWithBlockNumberDrift(drift int) l1ClientOptions { - return func(c *l1Client) { c.blockNumberDrift = drift } -} - -// l1ClientWithWinnersOverride randomly sets the winner in -// the block header extra data for the HeaderByNumber method. -func l1ClientWithWinnersOverride(winners []string) l1ClientOptions { - return func(c *l1Client) { c.winnersOverride = winners } -} - -// l1ClientWithMaxRetries sets the maximum number -// of retries on error for all L1 connections. -func l1ClientWithMaxRetries(retries int) l1ClientOptions { - return func(c *l1Client) { c.maxRetries = retries } -} - -// newL1Client creates a new L1 client with the given RPC URLs. -func newL1Client(logger *slog.Logger, l1RpcUrls []string, opts ...l1ClientOptions) (*l1Client, error) { - c := &l1Client{logger: logger} - - var errs error - for _, url := range l1RpcUrls { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - cli, err := ethclient.DialContext(ctx, url) - cancel() - if err != nil { - errs = errors.Join(errs, fmt.Errorf("failed to dial L1 RPC URL %s: %w", url, err)) - continue - } - c.clients = append( - c.clients, - struct { - url string - cli l1Listener.EthClient - }{ - url, - cli, - }, - ) - } - if errs != nil { - return nil, errs - } - - for _, opt := range opts { - opt(c) - } - return c, nil -} - -// l1Client is an Ethereum client that can connect to multiple L1 nodes. -// If an operation fails, it will automatically try the next L1 node in the list. -// When all L1 nodes are exhausted, it will retry the operation up to maxRetries times. -type l1Client struct { - logger *slog.Logger - clients []struct { - url string - cli l1Listener.EthClient - } - - // Options. - blockNumberDrift int - winnersOverride []string - maxRetries int -} - -// BlockNumber returns the latest block number. -func (c *l1Client) BlockNumber(ctx context.Context) (uint64, error) { - var errs error - for i := range c.maxRetries { - for _, l1 := range c.clients { - switch bn, err := l1.cli.BlockNumber(ctx); { - case err == nil: - c.logger.Debug("get block number succeeded", "attempt", i, "block", bn) - return bn - uint64(c.blockNumberDrift), nil - case errors.Is(err, ethereum.NotFound): - return 0, err - default: - errs = errors.Join(errs, fmt.Errorf("get block number from %s: %w", l1.url, err)) - c.logger.Warn("get block number failed", "url", l1.url, "attempt", i, "error", err) - } - } - if i < c.maxRetries-1 { - d := time.Duration(1+rand.Int64N(6)) * time.Second - c.logger.Info("get block number retry", "in", d) - time.Sleep(d) - } - } - return 0, fmt.Errorf("get block number: %w: %w", errRetry, errs) -} - -// BlockByNumber returns the block with the given number. -func (c *l1Client) BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) { - var errs error - for i := range c.maxRetries { - for _, l1 := range c.clients { - switch b, err := l1.cli.BlockByNumber(ctx, number); { - case err == nil: - c.logger.Debug("get block by number succeeded", "attempt", i, "block", b) - return b, nil - case errors.Is(err, ethereum.NotFound): - return nil, err - default: - errs = errors.Join(errs, fmt.Errorf("get block by number from %s: %w", l1.url, err)) - c.logger.Warn("get block by number failed", "url", l1.url, "attempt", i, "error", err) - } - } - if i < c.maxRetries-1 { - d := time.Duration(1+rand.Int64N(6)) * time.Second - c.logger.Info("get block by number retry", "in", d) - time.Sleep(d) - } - } - return nil, fmt.Errorf("get block by number: %w: %w", errRetry, errs) -} - -// HeaderByNumber returns the block header of the block with the given number. -func (c *l1Client) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { - var errs error - for i := range c.maxRetries { - for _, l1 := range c.clients { - switch h, err := l1.cli.HeaderByNumber(ctx, number); { - case err == nil: - c.logger.Debug("get header by number succeeded", "attempt", i, "header", h) - if len(c.winnersOverride) > 0 { - h.Extra = []byte(c.winnersOverride[rand.IntN(len(c.winnersOverride))]) - } - return h, nil - case errors.Is(err, ethereum.NotFound): - return nil, err - default: - errs = errors.Join(errs, fmt.Errorf("get header by number from %s: %w", l1.url, err)) - c.logger.Warn("get header by number failed", "url", l1.url, "attempt", i, "error", err) - } - } - if i < c.maxRetries-1 { - d := time.Duration(1+rand.Int64N(6)) * time.Second - c.logger.Info("get header by number retry", "in", d) - time.Sleep(d) - } - } - return nil, fmt.Errorf("get header by number: %w: %w", errRetry, errs) -} diff --git a/oracle/pkg/node/node.go b/oracle/pkg/node/node.go index d5ef1b9f2..08c0b3dce 100644 --- a/oracle/pkg/node/node.go +++ b/oracle/pkg/node/node.go @@ -25,6 +25,7 @@ import ( "github.com/primev/mev-commit/oracle/pkg/l1Listener" "github.com/primev/mev-commit/oracle/pkg/store" "github.com/primev/mev-commit/oracle/pkg/updater" + "github.com/primev/mev-commit/x/contracts/ethwrapper" "github.com/primev/mev-commit/x/contracts/events" "github.com/primev/mev-commit/x/contracts/events/publisher" "github.com/primev/mev-commit/x/contracts/transactor" @@ -197,19 +198,19 @@ func NewNode(opts *Options) (*Node, error) { TransactOpts: *tOpts, } - l1ClientOpts := []l1ClientOptions{ - l1ClientWithMaxRetries(30), + l1ClientOpts := []ethwrapper.EthClientOptions{ + ethwrapper.EthClientWithMaxRetries(30), } if opts.LaggerdMode > 0 { l1ClientOpts = append( l1ClientOpts, - l1ClientWithBlockNumberDrift(opts.LaggerdMode), + ethwrapper.EthClientWithBlockNumberDrift(opts.LaggerdMode), ) } if len(opts.OverrideWinners) > 0 { l1ClientOpts = append( l1ClientOpts, - l1ClientWithWinnersOverride(opts.OverrideWinners), + ethwrapper.EthClientWithWinnersOverride(opts.OverrideWinners), ) for _, winner := range opts.OverrideWinners { nd.logger.Info("setting builder mapping", "builderName", winner, "builderAddress", winner) @@ -228,7 +229,7 @@ func NewNode(opts *Options) (*Node, error) { } } } - l1Client, err := newL1Client( + l1Client, err := ethwrapper.NewClient( nd.logger, opts.L1RPCUrls, l1ClientOpts..., @@ -249,13 +250,20 @@ func NewNode(opts *Options) (*Node, error) { l1LisClosed := l1Lis.Start(ctx) healthChecker.Register(health.CloseChannelHealthCheck("l1_listener", l1LisClosed)) + rawClient := l1Client.RawClient() + if rawClient == nil { + nd.logger.Error("failed to get raw client") + cancel() + return nil, errors.New("failed to get ethclient") + } + updtr, err := updater.NewUpdater( nd.logger.With("component", "updater"), l1Client, st, evtMgr, oracleTransactorSession, - txmonitor.NewEVMHelperWithLogger(l1Client.clients[0].cli.(*ethclient.Client), nd.logger, contracts), + txmonitor.NewEVMHelperWithLogger(rawClient, nd.logger, contracts), ) if err != nil { nd.logger.Error("failed to instantiate updater", "error", err) diff --git a/x/contracts/ethwrapper/client.go b/x/contracts/ethwrapper/client.go new file mode 100644 index 000000000..85ae976ce --- /dev/null +++ b/x/contracts/ethwrapper/client.go @@ -0,0 +1,249 @@ +package ethwrapper + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "math/rand/v2" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Methods of the Ethereum client that are overridden. +type EthClient interface { + BlockNumber(ctx context.Context) (uint64, error) + BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) + NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) + FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) +} + +// errRetry is returned when retry maxRetries is exhausted. +var errRetry = errors.New("retry attempts exhausted") + +// EthClientOptions is a functional option for Client. +type EthClientOptions func(*Client) + +// EthClientWithBlockNumberDrift sets the block number drift +// for the BlockNumber method. +func EthClientWithBlockNumberDrift(drift int) EthClientOptions { + return func(c *Client) { c.blockNumberDrift = drift } +} + +// EthClientWithWinnersOverride randomly sets the winner in +// the block header extra data for the HeaderByNumber method. +func EthClientWithWinnersOverride(winners []string) EthClientOptions { + return func(c *Client) { c.winnersOverride = winners } +} + +// EthClientWithMaxRetries sets the maximum number +// of retries on error for all rpc connections. +func EthClientWithMaxRetries(retries int) EthClientOptions { + return func(c *Client) { c.maxRetries = retries } +} + +// EthClientWithBlockNumOverride overrides the block number function. +func EthClientWithBlockNumOverride(fn func(context.Context, EthClient) (uint64, error)) EthClientOptions { + return func(c *Client) { + c.blockNumFn = fn + } +} + +// NewClient creates a new ethclient with the given RPC URLs. +func NewClient(logger *slog.Logger, rpcUrls []string, opts ...EthClientOptions) (*Client, error) { + c := &Client{logger: logger} + + var errs error + for _, url := range rpcUrls { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + cli, err := ethclient.DialContext(ctx, url) + cancel() + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to dial client RPC URL %s: %w", url, err)) + continue + } + c.clients = append( + c.clients, + struct { + url string + cli EthClient + }{ + url, + cli, + }, + ) + } + if errs != nil { + return nil, errs + } + + for _, opt := range opts { + opt(c) + } + if c.blockNumFn == nil { + c.blockNumFn = func(ctx context.Context, cli EthClient) (uint64, error) { + return cli.BlockNumber(ctx) + } + } + return c, nil +} + +// Client is an Ethereum client that can connect to multiple RPCs. +// If an operation fails, it will automatically try the next client node in the list. +// When all client nodes are exhausted, it will retry the operation up to maxRetries times. +type Client struct { + logger *slog.Logger + clients []struct { + url string + cli EthClient + } + + // Options. + blockNumberDrift int + winnersOverride []string + maxRetries int + blockNumFn func(context.Context, EthClient) (uint64, error) +} + +// RawClient returns the first raw ethclient. +func (c *Client) RawClient() *ethclient.Client { + client, ok := c.clients[0].cli.(*ethclient.Client) + if !ok { + return nil + } + return client +} + +// BlockNumber returns the latest block number. +func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { + var errs error + for i := range c.maxRetries { + for _, client := range c.clients { + switch bn, err := c.blockNumFn(ctx, client.cli); { + case err == nil: + c.logger.Debug("get block number succeeded", "attempt", i, "block", bn) + return bn - uint64(c.blockNumberDrift), nil + case errors.Is(err, ethereum.NotFound): + return 0, err + default: + errs = errors.Join(errs, fmt.Errorf("get block number from %s: %w", client.url, err)) + c.logger.Warn("get block number failed", "url", client.url, "attempt", i, "error", err) + } + } + if i < c.maxRetries-1 { + d := time.Duration(1+rand.Int64N(6)) * time.Second + c.logger.Info("get block number retry", "in", d) + time.Sleep(d) + } + } + return 0, fmt.Errorf("get block number: %w: %w", errRetry, errs) +} + +// BlockByNumber returns the block with the given number. +func (c *Client) BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) { + var errs error + for i := range c.maxRetries { + for _, client := range c.clients { + switch b, err := client.cli.BlockByNumber(ctx, number); { + case err == nil: + c.logger.Debug("get block by number succeeded", "attempt", i, "block", b) + return b, nil + case errors.Is(err, ethereum.NotFound): + return nil, err + default: + errs = errors.Join(errs, fmt.Errorf("get block by number from %s: %w", client.url, err)) + c.logger.Warn("get block by number failed", "url", client.url, "attempt", i, "error", err) + } + } + if i < c.maxRetries-1 { + d := time.Duration(1+rand.Int64N(6)) * time.Second + c.logger.Info("get block by number retry", "in", d) + time.Sleep(d) + } + } + return nil, fmt.Errorf("get block by number: %w: %w", errRetry, errs) +} + +// HeaderByNumber returns the block header of the block with the given number. +func (c *Client) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + var errs error + for i := range c.maxRetries { + for _, client := range c.clients { + switch h, err := client.cli.HeaderByNumber(ctx, number); { + case err == nil: + c.logger.Debug("get header by number succeeded", "attempt", i, "header", h) + if len(c.winnersOverride) > 0 { + h.Extra = []byte(c.winnersOverride[rand.IntN(len(c.winnersOverride))]) + } + return h, nil + case errors.Is(err, ethereum.NotFound): + return nil, err + default: + errs = errors.Join(errs, fmt.Errorf("get header by number from %s: %w", client.url, err)) + c.logger.Warn("get header by number failed", "url", client.url, "attempt", i, "error", err) + } + } + if i < c.maxRetries-1 { + d := time.Duration(1+rand.Int64N(6)) * time.Second + c.logger.Info("get header by number retry", "in", d) + time.Sleep(d) + } + } + return nil, fmt.Errorf("get header by number: %w: %w", errRetry, errs) +} + +// NonceAt returns the nonce of the account at the given block number. +func (c *Client) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) { + var errs error + for i := range c.maxRetries { + for _, client := range c.clients { + switch nonce, err := client.cli.NonceAt(ctx, account, blockNumber); { + case err == nil: + c.logger.Debug("get nonce succeeded", "attempt", i, "nonce", nonce) + return nonce, nil + case errors.Is(err, ethereum.NotFound): + return 0, err + default: + errs = errors.Join(errs, fmt.Errorf("get nonce from %s: %w", client.url, err)) + c.logger.Warn("get nonce failed", "url", client.url, "attempt", i, "error", err) + } + } + if i < c.maxRetries-1 { + d := time.Duration(1+rand.Int64N(6)) * time.Second + c.logger.Info("get nonce retry", "in", d) + time.Sleep(d) + } + } + return 0, fmt.Errorf("get nonce: %w: %w", errRetry, errs) +} + +// FilterLogs returns the logs that satisfy the given filter query. +func (c *Client) FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { + var errs error + for i := range c.maxRetries { + for _, client := range c.clients { + switch logs, err := client.cli.FilterLogs(ctx, query); { + case err == nil: + c.logger.Debug("filter logs succeeded", "attempt", i, "logs", logs) + return logs, nil + case errors.Is(err, ethereum.NotFound): + return nil, err + default: + errs = errors.Join(errs, fmt.Errorf("filter logs from %s: %w", client.url, err)) + c.logger.Warn("filter logs failed", "url", client.url, "attempt", i, "error", err) + } + } + if i < c.maxRetries-1 { + d := time.Duration(1+rand.Int64N(6)) * time.Second + c.logger.Info("filter logs retry", "in", d) + time.Sleep(d) + } + } + return nil, fmt.Errorf("filter logs: %w: %w", errRetry, errs) +} diff --git a/oracle/pkg/node/l1client_test.go b/x/contracts/ethwrapper/client_test.go similarity index 58% rename from oracle/pkg/node/l1client_test.go rename to x/contracts/ethwrapper/client_test.go index 11649c693..4018ff887 100644 --- a/oracle/pkg/node/l1client_test.go +++ b/x/contracts/ethwrapper/client_test.go @@ -1,4 +1,4 @@ -package node +package ethwrapper import ( "bytes" @@ -7,20 +7,24 @@ import ( "io" "log/slog" "math/big" + "reflect" "testing" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/primev/mev-commit/oracle/pkg/l1Listener" ) var ( - _ l1Listener.EthClient = (*ethClientMock)(nil) + _ EthClient = (*ethClientMock)(nil) ) type ethClientMock struct { blockNumberFn func(context.Context) (uint64, error) blockByNumberFn func(context.Context, *big.Int) (*types.Block, error) headerByNumberFn func(context.Context, *big.Int) (*types.Header, error) + nonceAtFn func(context.Context, common.Address, *big.Int) (uint64, error) + filterLogsFn func(context.Context, ethereum.FilterQuery) ([]types.Log, error) } func (m *ethClientMock) BlockNumber(ctx context.Context) (uint64, error) { @@ -35,7 +39,15 @@ func (m *ethClientMock) HeaderByNumber(ctx context.Context, number *big.Int) (*t return m.headerByNumberFn(ctx, number) } -func TestL1Client(t *testing.T) { +func (m *ethClientMock) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) { + return m.nonceAtFn(ctx, account, blockNumber) +} + +func (m *ethClientMock) FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { + return m.filterLogsFn(ctx, query) +} + +func TestClient(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) t.Run("BlockNumber", func(t *testing.T) { t.Parallel() @@ -45,11 +57,11 @@ func TestL1Client(t *testing.T) { wantCalls = 1 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -60,6 +72,9 @@ func TestL1Client(t *testing.T) { }, }}, maxRetries: 1, + blockNumFn: func(c context.Context, cli EthClient) (uint64, error) { + return cli.BlockNumber(c) + }, } haveNumber, err := client.BlockNumber(context.Background()) @@ -83,11 +98,11 @@ func TestL1Client(t *testing.T) { wantCalls = 1 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -99,6 +114,9 @@ func TestL1Client(t *testing.T) { }}, blockNumberDrift: int(wantDrift), maxRetries: 1, + blockNumFn: func(c context.Context, cli EthClient) (uint64, error) { + return cli.BlockNumber(c) + }, } haveNumber, err := client.BlockNumber(context.Background()) @@ -121,11 +139,11 @@ func TestL1Client(t *testing.T) { wantCalls = 3 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -136,6 +154,9 @@ func TestL1Client(t *testing.T) { }, }}, maxRetries: wantCalls, + blockNumFn: func(c context.Context, cli EthClient) (uint64, error) { + return cli.BlockNumber(c) + }, } _, err := client.BlockNumber(context.Background()) @@ -158,11 +179,11 @@ func TestL1Client(t *testing.T) { wantCalls = 1 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -195,11 +216,11 @@ func TestL1Client(t *testing.T) { wantCalls = 3 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -232,11 +253,11 @@ func TestL1Client(t *testing.T) { wantCalls = 1 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -270,11 +291,11 @@ func TestL1Client(t *testing.T) { wantCalls = 1 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -311,11 +332,11 @@ func TestL1Client(t *testing.T) { wantCalls = 3 haveCalls = 0 ) - client := l1Client{ + client := Client{ logger: logger, clients: []struct { url string - cli l1Listener.EthClient + cli EthClient }{{ url: "mock", cli: ðClientMock{ @@ -339,4 +360,189 @@ func TestL1Client(t *testing.T) { t.Errorf("HeaderByNumber(...):\nhave calls: %d\nwant calls: %d", haveCalls, wantCalls) } }) + + t.Run("NonceAt", func(t *testing.T) { + t.Parallel() + + var ( + wantAccount = common.Address{} + wantNumber = big.NewInt(42) + wantNonce = uint64(1) + wantCalls = 1 + haveCalls = 0 + ) + client := Client{ + logger: logger, + clients: []struct { + url string + cli EthClient + }{{ + url: "mock", + cli: ðClientMock{ + nonceAtFn: func(context.Context, common.Address, *big.Int) (uint64, error) { + haveCalls++ + return wantNonce, nil + }, + }, + }}, + maxRetries: 1, + } + + haveNonce, err := client.NonceAt(context.Background(), wantAccount, wantNumber) + if err != nil { + t.Errorf("NonceAt(...): unexpected error: %v", err) + } + if haveNonce != wantNonce { + t.Errorf("NonceAt(...):\nhave nonce: %d\nwant nonce: %d", haveNonce, wantNonce) + } + if haveCalls != wantCalls { + t.Errorf("NonceAt(...):\nhave calls: %d\nwant calls: %d", haveCalls, wantCalls) + } + }) + + t.Run("NonceAt Retry Error", func(t *testing.T) { + t.Parallel() + + var ( + mockErr = errors.New("nonce at error") + wantCalls = 3 + haveCalls = 0 + ) + client := Client{ + logger: logger, + clients: []struct { + url string + cli EthClient + }{{ + url: "mock", + cli: ðClientMock{ + nonceAtFn: func(context.Context, common.Address, *big.Int) (uint64, error) { + haveCalls++ + return 0, mockErr + }, + }, + }}, + maxRetries: wantCalls, + } + + _, err := client.NonceAt(context.Background(), common.Address{}, big.NewInt(42)) + if haveErr, wantErr := err, errRetry; !errors.Is(haveErr, wantErr) { + t.Errorf("NonceAt(...):\nhave error: %v\nwant error: %v", haveErr, wantErr) + } + if haveErr, wantErr := err, mockErr; !errors.Is(haveErr, wantErr) { + t.Errorf("NonceAt(...):\nhave error: %v\nwant error: %v", haveErr, wantErr) + } + if haveCalls != wantCalls { + t.Errorf("NonceAt(...):\nhave calls: %d\nwant calls: %d", haveCalls, wantCalls) + } + }) + + t.Run("FilterLogs", func(t *testing.T) { + t.Parallel() + + var ( + wantQuery = ethereum.FilterQuery{} + wantLogs = []types.Log{{}} + wantCalls = 1 + haveCalls = 0 + ) + client := Client{ + logger: logger, + clients: []struct { + url string + cli EthClient + }{{ + url: "mock", + cli: ðClientMock{ + filterLogsFn: func(context.Context, ethereum.FilterQuery) ([]types.Log, error) { + haveCalls++ + return wantLogs, nil + }, + }, + }}, + maxRetries: 1, + } + + haveLogs, err := client.FilterLogs(context.Background(), wantQuery) + if err != nil { + t.Errorf("FilterLogs(...): unexpected error: %v", err) + } + if !reflect.DeepEqual(haveLogs, wantLogs) { + t.Errorf("FilterLogs(...):\nhave logs: %v\nwant logs: %v", haveLogs, wantLogs) + } + if haveCalls != wantCalls { + t.Errorf("FilterLogs(...):\nhave calls: %d\nwant calls: %d", haveCalls, wantCalls) + } + }) + + t.Run("FilterLogs Retry Error", func(t *testing.T) { + t.Parallel() + + var ( + mockErr = errors.New("filter logs error") + wantCalls = 3 + haveCalls = 0 + ) + client := Client{ + logger: logger, + clients: []struct { + url string + cli EthClient + }{{ + url: "mock", + cli: ðClientMock{ + filterLogsFn: func(context.Context, ethereum.FilterQuery) ([]types.Log, error) { + haveCalls++ + return nil, mockErr + }, + }, + }}, + maxRetries: wantCalls, + } + + _, err := client.FilterLogs(context.Background(), ethereum.FilterQuery{}) + if haveErr, wantErr := err, errRetry; !errors.Is(haveErr, wantErr) { + t.Errorf("FilterLogs(...):\nhave error: %v\nwant error: %v", haveErr, wantErr) + } + if haveErr, wantErr := err, mockErr; !errors.Is(haveErr, wantErr) { + t.Errorf("FilterLogs(...):\nhave error: %v\nwant error: %v", haveErr, wantErr) + } + if haveCalls != wantCalls { + t.Errorf("FilterLogs(...):\nhave calls: %d\nwant calls: %d", haveCalls, wantCalls) + } + }) + + t.Run("BlockNumber func", func(t *testing.T) { + t.Parallel() + + var ( + mockErr = errors.New("block number error") + ) + client := Client{ + logger: logger, + clients: []struct { + url string + cli EthClient + }{{ + url: "mock", + cli: ðClientMock{ + blockNumberFn: func(context.Context) (uint64, error) { + return uint64(42), nil + }, + }, + }}, + maxRetries: 1, + blockNumFn: func(context.Context, EthClient) (uint64, error) { + return 0, mockErr + }, + } + + _, err := client.BlockNumber(context.Background()) + if haveErr, wantErr := err, errRetry; !errors.Is(haveErr, wantErr) { + t.Errorf("BlockNumber(...):\nhave error: %v\nwant error: %v", haveErr, wantErr) + } + if haveErr, wantErr := err, mockErr; !errors.Is(haveErr, wantErr) { + t.Errorf("BlockNumber(...):\nhave error: %v\nwant error: %v", haveErr, wantErr) + } + }) }