Skip to content
Merged
7 changes: 5 additions & 2 deletions op-sync-tester/example_config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
synctesters:
local:
chain_id: 2151908
el_rpc: http://localhost:32988/
el_rpc: http://localhost:62654/
sepolia:
chain_id: 11155420
el_rpc: https://sepolia.optimism.io
el_rpc: https://sepolia.optimism.io
mainnet:
chain_id: 10
el_rpc: https://mainnet.optimism.io
8 changes: 8 additions & 0 deletions op-sync-tester/synctester/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ func SessionFromContext(ctx context.Context) (*Session, bool) {

type Session struct {
SessionID string

// Canonical view of the chain
CurrentState FCUState

InitialState FCUState
}

type FCUState struct {
Latest uint64
Safe uint64
Finalized uint64
Expand Down
82 changes: 82 additions & 0 deletions op-sync-tester/synctester/backend/el_reader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package backend

import (
"context"
"encoding/json"
"math/big"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
)

// ReadOnlyELBackend defines the minimal, read-only execution layer
// interface used by the sync tester and its mock backends.
// The interface exposes two flavors of block accessors:
// - JSON-returning methods (GetBlockByNumberJSON, GetBlockByHashJSON)
// which return the raw RPC payload exactly as delivered by the EL.
// These are useful for relaying the response from read-only exec layer directly
// - Typed methods (GetBlockByNumber, GetBlockByHash) which decode
// the RPC response into geth *types.Block for structured
// inspection in code.
// - Additional helpers include GetBlockReceipts and ChainId
//
// Implementation wraps ethclient.Client to forward RPC
// calls. For testing, a mock implementation can be provided to return
// deterministic values without requiring a live execution layer node.
type ReadOnlyELBackend interface {
GetBlockByNumberJSON(ctx context.Context, number rpc.BlockNumber, fullTx bool) (json.RawMessage, error)
GetBlockByHashJSON(ctx context.Context, hash common.Hash, fullTx bool) (json.RawMessage, error)
GetBlockByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Block, error)
GetBlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error)
GetBlockReceipts(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) ([]*types.Receipt, error)
ChainId(ctx context.Context) (hexutil.Big, error)
}

var _ ReadOnlyELBackend = (*ELReader)(nil)

type ELReader struct {
c *ethclient.Client
}

func NewELReader(c *ethclient.Client) *ELReader {
return &ELReader{c: c}
}

func (g *ELReader) GetBlockByNumberJSON(ctx context.Context, number rpc.BlockNumber, fullTx bool) (json.RawMessage, error) {
var raw json.RawMessage
if err := g.c.Client().CallContext(ctx, &raw, "eth_getBlockByNumber", number, fullTx); err != nil {
return nil, err
}
return raw, nil
}

func (g *ELReader) GetBlockByHashJSON(ctx context.Context, hash common.Hash, fullTx bool) (json.RawMessage, error) {
var raw json.RawMessage
if err := g.c.Client().CallContext(ctx, &raw, "eth_getBlockByHash", hash, fullTx); err != nil {
return nil, err
}
return raw, nil
}

func (g *ELReader) GetBlockByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Block, error) {
return g.c.BlockByNumber(ctx, big.NewInt(number.Int64()))
}

func (g *ELReader) GetBlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error) {
return g.c.BlockByHash(ctx, hash)
}

func (g *ELReader) GetBlockReceipts(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) ([]*types.Receipt, error) {
return g.c.BlockReceipts(ctx, blockNrOrHash)
}

func (g *ELReader) ChainId(ctx context.Context) (hexutil.Big, error) {
chainID, err := g.c.ChainID(ctx)
if err != nil {
return hexutil.Big{}, err
}
return hexutil.Big(*chainID), nil
}
135 changes: 99 additions & 36 deletions op-sync-tester/synctester/backend/sync_tester.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package backend

import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"sync"

"github.com/ethereum-optimism/optimism/op-service/eth"
Expand All @@ -16,7 +16,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/rpc"
"github.com/holiman/uint256"

"github.com/ethereum-optimism/optimism/op-sync-tester/synctester/backend/config"
sttypes "github.com/ethereum-optimism/optimism/op-sync-tester/synctester/backend/types"
Expand All @@ -34,13 +33,21 @@ type SyncTester struct {
log log.Logger
m metrics.Metricer

id sttypes.SyncTesterID
chainID eth.ChainID
elClient *ethclient.Client
id sttypes.SyncTesterID
chainID eth.ChainID

elReader ReadOnlyELBackend

sessions map[string]*Session
}

// HeaderNumberOnly is a lightweight header type that only contains the
// block number field. It is useful in contexts where the full Ethereum
// block header is not needed, and only the block number is required.
type HeaderNumberOnly struct {
Number *hexutil.Big `json:"number" gencodec:"required"`
}

var _ frontend.SyncBackend = (*SyncTester)(nil)
var _ frontend.EngineBackend = (*SyncTester)(nil)
var _ frontend.EthBackend = (*SyncTester)(nil)
Expand All @@ -51,14 +58,23 @@ func SyncTesterFromConfig(logger log.Logger, m metrics.Metricer, stID sttypes.Sy
if err != nil {
return nil, fmt.Errorf("failed to dial EL client: %w", err)
}
elReader := NewELReader(elClient)
return NewSyncTester(logger, m, stID, stCfg.ChainID, elReader), nil
}

func NewSyncTester(logger log.Logger, m metrics.Metricer, stID sttypes.SyncTesterID, chainID eth.ChainID, elReader ReadOnlyELBackend) *SyncTester {
return &SyncTester{
log: logger,
m: m,
id: stID,
chainID: stCfg.ChainID,
elClient: elClient,
chainID: chainID,
elReader: elReader,
sessions: make(map[string]*Session),
}, nil
}
}

func (s *SyncTester) storeSession(session *Session) {
s.sessions[session.SessionID] = session
}

func (s *SyncTester) fetchSession(ctx context.Context) (*Session, error) {
Expand All @@ -70,11 +86,12 @@ func (s *SyncTester) fetchSession(ctx context.Context) (*Session, error) {
defer s.mu.Unlock()
if existing, ok := s.sessions[session.SessionID]; ok {
s.log.Info("Using existing session", "session", existing)
return existing, nil
} else {
s.sessions[session.SessionID] = session
s.storeSession(session)
s.log.Info("Initialized new session", "session", session)
return session, nil
}
return session, nil
}

func (s *SyncTester) GetSession(ctx context.Context) error {
Expand Down Expand Up @@ -103,61 +120,107 @@ func (s *SyncTester) GetBlockReceipts(ctx context.Context, blockNrOrHash rpc.Blo
if err != nil {
return nil, err
}

receipts, err := s.elClient.BlockReceipts(ctx, blockNrOrHash)
if err != nil {
return nil, err
number, isNumber := blockNrOrHash.Number()
var receipts []*types.Receipt
if !isNumber {
// hash
receipts, err = s.elReader.GetBlockReceipts(ctx, blockNrOrHash)
if err != nil {
return nil, err
}
} else {
var target uint64
if target, err = s.checkBlockNumber(number, session); err != nil {
return nil, err
}
receipts, err = s.elReader.GetBlockReceipts(ctx, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(target)))
if err != nil {
return nil, err
}
}

if len(receipts) == 0 {
// Should never happen since every block except genesis has at least one deposit tx
return nil, ErrNoReceipts
}

if receipts[0].BlockNumber.Uint64() > session.Latest {
if receipts[0].BlockNumber.Uint64() > session.CurrentState.Latest {
return nil, ethereum.NotFound
}

return receipts, nil
}

func (s *SyncTester) GetBlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error) {
func (s *SyncTester) GetBlockByHash(ctx context.Context, hash common.Hash, fullTx bool) (json.RawMessage, error) {
session, err := s.fetchSession(ctx)
if err != nil {
return nil, err
}

block, err := s.elClient.BlockByHash(ctx, hash)
if err != nil {
var raw json.RawMessage
if raw, err = s.elReader.GetBlockByHashJSON(ctx, hash, fullTx); err != nil {
return nil, err
}

if block.NumberU64() > session.Latest {
var header HeaderNumberOnly
if err := json.Unmarshal(raw, &header); err != nil {
return nil, err
}
if header.Number.ToInt().Uint64() > session.CurrentState.Latest {
return nil, ethereum.NotFound
}
return raw, nil
}

return block, nil
func (s *SyncTester) checkBlockNumber(number rpc.BlockNumber, session *Session) (uint64, error) {
var target uint64
switch number {
case rpc.LatestBlockNumber:
target = session.CurrentState.Latest
case rpc.SafeBlockNumber:
target = session.CurrentState.Safe
case rpc.FinalizedBlockNumber:
target = session.CurrentState.Finalized
case rpc.PendingBlockNumber, rpc.EarliestBlockNumber:
// pending, earliest block label not supported
return 0, ethereum.NotFound
default:
if number.Int64() < 0 {
// safety guard for overflow
return 0, ethereum.NotFound
}
target = uint64(number.Int64())
// Short circuit for numeric request beyond sync tester canonical head
if target > session.CurrentState.Latest {
return 0, ethereum.NotFound
}
}
return target, nil
}

func (s *SyncTester) GetBlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) {
func (s *SyncTester) GetBlockByNumber(ctx context.Context, number rpc.BlockNumber, fullTx bool) (json.RawMessage, error) {
session, err := s.fetchSession(ctx)
if err != nil {
return nil, err
}

if number.Uint64() > session.Latest {
return nil, ethereum.NotFound
var target uint64
if target, err = s.checkBlockNumber(number, session); err != nil {
return nil, err
}

return s.elClient.BlockByNumber(ctx, number)
var raw json.RawMessage
if raw, err = s.elReader.GetBlockByNumberJSON(ctx, rpc.BlockNumber(target), fullTx); err != nil {
return nil, err
}
return raw, nil
}

func (s *SyncTester) ChainId(ctx context.Context) (eth.ChainID, error) {
_, err := s.fetchSession(ctx)
func (s *SyncTester) ChainId(ctx context.Context) (hexutil.Big, error) {
if _, err := s.fetchSession(ctx); err != nil {
return hexutil.Big{}, err
}
chainID, err := s.elReader.ChainId(ctx)
if err != nil {
return eth.ChainID(uint256.Int{}), err
return hexutil.Big{}, err
}

return s.chainID, nil
if chainID.ToInt().Cmp(s.chainID.ToBig()) != 0 {
return hexutil.Big{}, fmt.Errorf("chainID mismatch: config: %s, backend: %s", s.chainID, chainID.ToInt())
}
return hexutil.Big(*s.chainID.ToBig()), nil
}

func (s *SyncTester) GetPayloadV1(ctx context.Context, payloadID eth.PayloadID) (*eth.ExecutionPayload, error) {
Expand Down
Loading