Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 66 additions & 15 deletions node/derivation/base_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,49 @@ import (
"sync/atomic"
"testing"

"github.com/morph-l2/go-ethereum/common"
"github.com/morph-l2/go-ethereum/common/hexutil"
"github.com/morph-l2/go-ethereum/crypto/kzg4844"
"github.com/stretchr/testify/require"
)

// zero-value but correctly-sized hex so the beacon JSON decodes into a
// BlobSidecar. GetBlobSidecarsEnhanced does not verify blob contents (that
// happens downstream), it only counts sidecars, so dummy bytes are fine here.
// The fallback verifies blob content, so accept-path stubs must serve a real
// (blob, commitment) pair; the all-zero blob is the cheapest valid one.
var (
hex32 = "0x" + strings.Repeat("00", 32)
hex48 = "0x" + strings.Repeat("00", 48)

zeroBlobHex = "0x" + strings.Repeat("00", BlobSize)
zeroBlobCommitment = mustZeroBlobCommitment()
zeroBlobHash = KZGToVersionedHash(zeroBlobCommitment)

// Valid blob bytes served under the zero blob's commitment: count and
// commitment lookup pass, only verifyBlob catches it.
corruptBlobHex = "0x01" + strings.Repeat("00", BlobSize-1)
)

func sidecarJSON(index int) string {
return fmt.Sprintf(`{"block_root":%q,"slot":"1","blob":"0x00","index":"%d","kzg_commitment":%q,"kzg_proof":%q}`,
hex32, index, hex48, hex48)
func mustZeroBlobCommitment() kzg4844.Commitment {
var blob Blob
commitment, err := kzg4844.BlobToCommitment(blob.KZGBlob())
if err != nil {
panic(err)
}
return commitment
}

func sidecarJSON(index int, blobHex, commitmentHex string) string {
return fmt.Sprintf(`{"block_root":%q,"slot":"1","blob":%q,"index":"%d","kzg_commitment":%q,"kzg_proof":%q}`,
hex32, blobHex, index, commitmentHex, hex48)
}

// beaconBehavior controls what a stub beacon returns for blob_sidecars.
type beaconBehavior int

const (
beaconServesBlob beaconBehavior = iota // 200 with one sidecar
beaconServesEmpty // 200 with an empty list (pruned / not indexed)
beaconServerError // 500
beaconServesBlob beaconBehavior = iota // 200 with one valid sidecar
beaconServesEmpty // 200 with an empty list (pruned / not indexed)
beaconServerError // 500
beaconServesCorruptBlob // 200, right count and commitment, blob bytes do not match
beaconServesWrongCommitment // 200, right count, commitment of some other blob
)

// newStubBeacon serves the genesis + spec endpoints (needed for slot math) and
Expand All @@ -51,11 +70,15 @@ func newStubBeacon(t *testing.T, behavior beaconBehavior) (string, *int32) {
atomic.AddInt32(&blobHits, 1)
switch behavior {
case beaconServesBlob:
_, _ = w.Write([]byte(`{"data":[` + sidecarJSON(0) + `]}`))
_, _ = w.Write([]byte(`{"data":[` + sidecarJSON(0, zeroBlobHex, hexutil.Encode(zeroBlobCommitment[:])) + `]}`))
case beaconServesEmpty:
_, _ = w.Write([]byte(`{"data":[]}`))
case beaconServerError:
w.WriteHeader(http.StatusInternalServerError)
case beaconServesCorruptBlob:
_, _ = w.Write([]byte(`{"data":[` + sidecarJSON(0, corruptBlobHex, hexutil.Encode(zeroBlobCommitment[:])) + `]}`))
case beaconServesWrongCommitment:
_, _ = w.Write([]byte(`{"data":[` + sidecarJSON(0, zeroBlobHex, hex48) + `]}`))
}
default:
w.WriteHeader(http.StatusNotFound)
Expand All @@ -66,7 +89,7 @@ func newStubBeacon(t *testing.T, behavior beaconBehavior) (string, *int32) {
}

func oneHash() []IndexedBlobHash {
return []IndexedBlobHash{{Index: 0, Hash: common.Hash{}}}
return []IndexedBlobHash{{Index: 0, Hash: zeroBlobHash}}
}

type canceledHTTP struct {
Expand Down Expand Up @@ -133,6 +156,34 @@ func TestFallbackBeacon_FallsBackOnTransportError(t *testing.T) {
require.Positive(t, atomic.LoadInt32(fallbackHits))
}

// 200 with the right count and commitment but corrupted blob bytes must
// trigger fallback instead of handing bad bytes downstream.
func TestFallbackBeacon_FallsBackOnCorruptBlobContent(t *testing.T) {
primary, primaryHits := newStubBeacon(t, beaconServesCorruptBlob)
fallback, fallbackHits := newStubBeacon(t, beaconServesBlob)

c := NewFallbackBeaconClient([]string{primary, fallback}, nil, nil)
sidecars, err := fetch(t, c)
require.NoError(t, err)
require.Len(t, sidecars, 1)
require.Positive(t, atomic.LoadInt32(primaryHits))
require.Positive(t, atomic.LoadInt32(fallbackHits), "corrupt blob content must trigger fallback")
}

// 200 with the right count but none of the sidecars carries the requested
// hash (e.g. another fork's sidecars at the same slot) must trigger fallback.
func TestFallbackBeacon_FallsBackOnMissingRequestedHash(t *testing.T) {
primary, primaryHits := newStubBeacon(t, beaconServesWrongCommitment)
fallback, fallbackHits := newStubBeacon(t, beaconServesBlob)

c := NewFallbackBeaconClient([]string{primary, fallback}, nil, nil)
sidecars, err := fetch(t, c)
require.NoError(t, err)
require.Len(t, sidecars, 1)
require.Positive(t, atomic.LoadInt32(primaryHits))
require.Positive(t, atomic.LoadInt32(fallbackHits), "missing requested hash must trigger fallback")
}

func TestFallbackBeacon_StopsOnContextCancellation(t *testing.T) {
var primaryCalls, fallbackCalls int32
c := &FallbackBeaconClient{
Expand All @@ -153,11 +204,11 @@ func TestFallbackBeacon_StopsOnContextCancellation(t *testing.T) {
require.Zero(t, atomic.LoadInt32(&fallbackCalls))
}

// When every beacon fails to serve the blob, an error is returned and every
// endpoint's failure is recorded in metrics (exercised via a real *Metrics).
// When every beacon fails to serve a valid blob, an error is returned and
// every endpoint's failure is recorded in metrics.
func TestFallbackBeacon_AllFailReturnsError(t *testing.T) {
primary, primaryHits := newStubBeacon(t, beaconServesEmpty)
fallback, fallbackHits := newStubBeacon(t, beaconServerError)
fallback, fallbackHits := newStubBeacon(t, beaconServesCorruptBlob)

m := PrometheusMetrics("morphnode_test_" + t.Name())
c := NewFallbackBeaconClient([]string{primary, fallback}, nil, m)
Expand Down
74 changes: 55 additions & 19 deletions node/derivation/beacon.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"sync"

"github.com/morph-l2/go-ethereum/common"
"github.com/morph-l2/go-ethereum/common/hexutil"
"github.com/morph-l2/go-ethereum/core/types"
"github.com/morph-l2/go-ethereum/crypto/kzg4844"
"github.com/morph-l2/go-ethereum/params"
Expand Down Expand Up @@ -210,13 +211,10 @@ func dataAndHashesFromTxs(txs types.Transactions, targetTx *types.Transaction) [
}

// FallbackBeaconClient queries several beacon nodes in order for blob sidecars.
// A beacon is skipped and the next one tried when it errors, is unreachable, or
// answers with too few sidecars — a beacon that pruned the slot or has not
// indexed it yet still replies 200 with an empty/partial list, which is exactly
// the "temporarily failed to fetch blob" case seen in production and which a
// transport-level fallback would miss. The failing endpoint is recorded in the
// beacon_request_failure_total metric so a flaky node is visible on dashboards.
// With a single endpoint it behaves like a bare L1BeaconClient.
// A beacon is skipped and the next one tried when it errors, is unreachable,
// answers with too few sidecars, or serves blob content that fails
// verification against the requested hashes. Failing endpoints are recorded
// in the beacon_request_failure_total metric.
type FallbackBeaconClient struct {
clients []*L1BeaconClient
endpoints []string // parallel to clients, used only for logs/metrics
Expand All @@ -237,36 +235,74 @@ func NewFallbackBeaconClient(endpoints []string, log tmlog.Logger, metrics *Metr
}
}

// GetBlobSidecarsEnhanced tries each configured beacon in order and returns the
// first response that actually carries the requested blobs (at least len(hashes)
// sidecars, or at least one when no explicit hashes are requested). A beacon
// that errors or returns an incomplete set is recorded as a failure and skipped.
// If every beacon fails, the last error is returned.
// GetBlobSidecarsEnhanced tries each configured beacon in order and returns
// the first response carrying all requested blobs with verified content.
// A failing beacon is recorded and skipped; if every beacon fails, the last
// error is returned.
func (c *FallbackBeaconClient) GetBlobSidecarsEnhanced(ctx context.Context, ref L1BlockRef, hashes []IndexedBlobHash) ([]*BlobSidecar, error) {
var lastErr error
for i, cl := range c.clients {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
sidecars, err := cl.GetBlobSidecarsEnhanced(ctx, ref, hashes)
if err == nil && len(sidecars) > 0 && len(sidecars) >= len(hashes) {
return sidecars, nil
}
if err := ctx.Err(); err != nil {
return nil, err
if err == nil {
if len(sidecars) == 0 || len(sidecars) < len(hashes) {
err = fmt.Errorf("beacon returned %d sidecars, want at least %d", len(sidecars), len(hashes))
} else {
err = verifySidecars(sidecars, hashes)
}
}
if err == nil {
err = fmt.Errorf("beacon returned %d sidecars, want at least %d", len(sidecars), len(hashes))
return sidecars, nil
}
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
if c.metrics != nil {
c.metrics.IncBeaconRequestFailure(c.endpoints[i])
}
if c.log != nil {
c.log.Error("beacon failed to serve blob sidecars, trying next endpoint",
c.log.Error("beacon failed to serve valid blob sidecars, trying next endpoint",
"endpoint", c.endpoints[i], "err", err)
}
lastErr = err
}
return nil, lastErr
}

// verifySidecars checks that sidecars contains, for every requested hash, a
// blob whose bytes commit to that hash (matched via the commitment-derived
// versioned hash, authenticated by verifyBlob). Extra sidecars are ignored.
func verifySidecars(sidecars []*BlobSidecar, hashes []IndexedBlobHash) error {
if len(hashes) == 0 {
return nil
}
byHash := make(map[common.Hash]*BlobSidecar, len(sidecars))
for _, sidecar := range sidecars {
var commitment kzg4844.Commitment
copy(commitment[:], sidecar.KZGCommitment[:])
byHash[KZGToVersionedHash(commitment)] = sidecar
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
for i := range hashes {
expected := hashes[i].Hash
sidecar, ok := byHash[expected]
if !ok {
return fmt.Errorf("blob (hash=%s) not found in beacon response", expected.Hex())
}
b, err := hexutil.Decode(sidecar.Blob)
if err != nil {
return fmt.Errorf("failed to decode blob (hash=%s): %w", expected.Hex(), err)
}
if len(b) != BlobSize {
return fmt.Errorf("blob (hash=%s): unexpected length %d (want %d)", expected.Hex(), len(b), BlobSize)
}
var blob Blob
copy(blob[:], b)
if err := verifyBlob(&blob, expected); err != nil {
return err
}
}
return nil
}

// Note: ForceGetAllBlobs is defined in derivation.go in the same package

// GetBlobSidecarsEnhanced is an enhanced version of GetBlobSidecars method, combining two approaches to fetch blob data
Expand Down
22 changes: 9 additions & 13 deletions node/derivation/derivation.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,22 +565,18 @@ func (d *Derivation) fetchRollupDataByTxHash(txHash common.Hash, blockNumber uin
if len(blobHashes) > 0 {
d.logger.Info("Transaction contains blobs", "txHash", txHash, "blobCount", len(blobHashes))

// Initialize indexedBlobHashes as nil
var indexedBlobHashes []IndexedBlobHash

// Only try to build IndexedBlobHash array if not forcing get all blobs
// Try to get the block to build IndexedBlobHash array
// The block body is needed to compute blob indices; without them the
// beacon fallback has no hashes to verify against, so fail this
// attempt and let the next poll retry.
block, err := d.l1Client.BlockByNumber(d.ctx, big.NewInt(int64(blockNumber)))
if err == nil {
// Successfully got the block, now build IndexedBlobHash array
d.logger.Info("Building IndexedBlobHash array from block", "blockNumber", blockNumber)
indexedBlobHashes = dataAndHashesFromTxs(block.Transactions(), tx)
d.logger.Info("Built IndexedBlobHash array", "count", len(indexedBlobHashes))
} else {
d.logger.Info("Failed to get block, will try fetching all blobs", "blockNumber", blockNumber, "error", err)
if err != nil {
return nil, fmt.Errorf("failed to get block %d for blob indices: %w", blockNumber, err)
}
indexedBlobHashes := dataAndHashesFromTxs(block.Transactions(), tx)
d.logger.Info("Built IndexedBlobHash array", "count", len(indexedBlobHashes))

// Get all blobs corresponding to this timestamp
// The fallback client only returns sidecars whose content verified
// against indexedBlobHashes.
blobSidecars, err := d.l1BeaconClient.GetBlobSidecarsEnhanced(d.ctx, L1BlockRef{
Time: header.Time,
}, indexedBlobHashes)
Expand Down
Loading