From de7575bca5ae1cee741e40b1ce22f3baaa8ff717 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 14 Aug 2026 12:50:57 +0300 Subject: [PATCH 1/2] eth/executionclient: order packed logs by logIndex within a transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PackLogs sorted by (block, txIndex) with a non-stable sort.Slice, leaving logs emitted by the same transaction (same txIndex) in unspecified relative order. A single transaction can emit multiple order-dependent registry events — bulkRegisterValidator emits one ValidatorAdded per validator, each bumping the owner's per-owner nonce — so reordering them makes the event handler read nonces out of order and reject otherwise-valid registrations (MalformedEventError, with the nonce still bumped). This is usually masked (Go's sort is stable in practice for small inputs) but is unsound and can surface with larger batches or a runtime change. Add a logIndex tiebreaker so packing preserves canonical on-chain order both across and within a transaction, plus a regression test with shuffled same-transaction logs. --- eth/executionclient/logs.go | 14 +++++++++++--- eth/executionclient/logs_test.go | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/eth/executionclient/logs.go b/eth/executionclient/logs.go index 37d421d891..cea62baa46 100644 --- a/eth/executionclient/logs.go +++ b/eth/executionclient/logs.go @@ -14,12 +14,20 @@ type BlockLogs struct { // PackLogs packs logs into []BlockLogs by their block number. func PackLogs(logs []ethtypes.Log) []BlockLogs { - // Sort the logs by block number. + // Sort into canonical on-chain order. The Index (logIndex) tiebreaker is what keeps logs + // emitted by the same transaction in order: sort.Slice is not stable, and a single tx can + // emit multiple order-dependent registry events (e.g. bulkRegisterValidator emits one + // ValidatorAdded per validator, each bumping the owner's nonce), which the handler must + // process in order. Without it, same-tx logs could be reordered and valid registrations + // silently rejected on a nonce mismatch. sort.Slice(logs, func(i, j int) bool { - if logs[i].BlockNumber == logs[j].BlockNumber { + if logs[i].BlockNumber != logs[j].BlockNumber { + return logs[i].BlockNumber < logs[j].BlockNumber + } + if logs[i].TxIndex != logs[j].TxIndex { return logs[i].TxIndex < logs[j].TxIndex } - return logs[i].BlockNumber < logs[j].BlockNumber + return logs[i].Index < logs[j].Index }) var all []BlockLogs diff --git a/eth/executionclient/logs_test.go b/eth/executionclient/logs_test.go index 9bbd3a7507..14594fcf24 100644 --- a/eth/executionclient/logs_test.go +++ b/eth/executionclient/logs_test.go @@ -83,3 +83,26 @@ func TestPackLogs(t *testing.T) { assert.Equal(t, uint(0), result[0].Logs[0].TxIndex) // should be sorted assert.Equal(t, uint(1), result[0].Logs[1].TxIndex) } + +// TestPackLogsOrdersByLogIndexWithinTransaction covers logs emitted by the same transaction +// (same TxIndex, distinct logIndex) — e.g. bulkRegisterValidator, whose per-owner nonces require +// in-order processing. They must be packed in logIndex order, not left in the arbitrary order a +// non-stable sort by (block, tx) would leave them. Input is deliberately shuffled. +func TestPackLogsOrdersByLogIndexWithinTransaction(t *testing.T) { + logs := []types.Log{ + {BlockNumber: 5, TxIndex: 2, Index: 11}, + {BlockNumber: 5, TxIndex: 2, Index: 9}, + {BlockNumber: 5, TxIndex: 0, Index: 3}, // earlier tx in the same block + {BlockNumber: 5, TxIndex: 2, Index: 10}, + } + + result := PackLogs(logs) + assert.Len(t, result, 1) + assert.Equal(t, uint64(5), result[0].BlockNumber) + + gotIndexes := make([]uint, 0, len(result[0].Logs)) + for _, l := range result[0].Logs { + gotIndexes = append(gotIndexes, l.Index) + } + assert.Equal(t, []uint{3, 9, 10, 11}, gotIndexes) +} From 22997b18b497a6f105e9414989c205b79e7d0598 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 1 Sep 2026 14:28:50 +0300 Subject: [PATCH 2/2] eth/executionclient: unify log ordering so bloom recovery preserves logIndex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyLogsWithBloom re-sorted appended bloom-recovered logs with a (block, txIndex)-only comparator — the exact pre-fix ordering PackLogs just corrected — so within-transaction log order could be dropped on the recovery path. Extract the canonical sort into sortLogsCanonical and call it from both PackLogs and verifyLogsWithBloom, leaving a single log-ordering function so a second, divergent comparator can't reappear. --- eth/executionclient/bloom.go | 10 ++-------- eth/executionclient/logs.go | 24 +++++++++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/eth/executionclient/bloom.go b/eth/executionclient/bloom.go index b5104a753e..9c49bd5b13 100644 --- a/eth/executionclient/bloom.go +++ b/eth/executionclient/bloom.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "math/big" - "sort" "time" "github.com/ethereum/go-ethereum" @@ -81,14 +80,9 @@ func (ec *ExecutionClient) verifyLogsWithBloom(ctx context.Context, logs []ethty } } - // Re-sort if we appended recovered logs so downstream receives them in block/tx order. + // Appending recovered logs above leaves the slice unsorted, so re-sort before returning. if recovered { - sort.Slice(logs, func(i, j int) bool { - if logs[i].BlockNumber != logs[j].BlockNumber { - return logs[i].BlockNumber < logs[j].BlockNumber - } - return logs[i].TxIndex < logs[j].TxIndex - }) + sortLogsCanonical(logs) } return logs, nil diff --git a/eth/executionclient/logs.go b/eth/executionclient/logs.go index cea62baa46..be2823b25a 100644 --- a/eth/executionclient/logs.go +++ b/eth/executionclient/logs.go @@ -6,20 +6,21 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" ) -// BlockLogs holds a block's number and it's logs. +// BlockLogs holds a block's number and its logs. type BlockLogs struct { BlockNumber uint64 Logs []ethtypes.Log } -// PackLogs packs logs into []BlockLogs by their block number. -func PackLogs(logs []ethtypes.Log) []BlockLogs { - // Sort into canonical on-chain order. The Index (logIndex) tiebreaker is what keeps logs - // emitted by the same transaction in order: sort.Slice is not stable, and a single tx can - // emit multiple order-dependent registry events (e.g. bulkRegisterValidator emits one - // ValidatorAdded per validator, each bumping the owner's nonce), which the handler must - // process in order. Without it, same-tx logs could be reordered and valid registrations - // silently rejected on a nonce mismatch. +// sortLogsCanonical sorts logs in place into canonical on-chain order: block number, then +// transaction index, then log index. The log-index tiebreaker is what keeps logs from the +// same transaction ordered — sort.Slice is not stable, and one transaction can emit several +// order-dependent events (e.g. bulkRegisterValidator emits one ValidatorAdded per validator, +// each bumping the owner's nonce); without it, same-tx logs can reorder and valid +// registrations get silently rejected on a nonce mismatch. It is the only log-ordering +// function in the package: route every raw-log sort through it (e.g. bloom recovery) so a +// second, divergent comparator can't creep back in. +func sortLogsCanonical(logs []ethtypes.Log) { sort.Slice(logs, func(i, j int) bool { if logs[i].BlockNumber != logs[j].BlockNumber { return logs[i].BlockNumber < logs[j].BlockNumber @@ -29,6 +30,11 @@ func PackLogs(logs []ethtypes.Log) []BlockLogs { } return logs[i].Index < logs[j].Index }) +} + +// PackLogs packs logs into []BlockLogs by their block number. +func PackLogs(logs []ethtypes.Log) []BlockLogs { + sortLogsCanonical(logs) var all []BlockLogs for _, log := range logs {