Skip to content

Commit 8e3f82a

Browse files
committed
ethereum: fix non-transitive trigger ordering causing sort panics
EthereumTrigger::cmp compared Call triggers (and Call/Log pairs) by transaction_index, but compared Log/Log pairs by log_index alone, ignoring transaction_index entirely. Those two comparisons only agree when log_index happens to increase in lockstep with transaction_index across the whole set being sorted, which isn't guaranteed for every trigger source. When it doesn't hold, the combined ordering is not transitive: a Log in an earlier transaction can end up compared as greater than a Log in a later transaction if the earlier one has a higher log_index within its own transaction. This crashes graph-node in production with a panic from Rust's stable sort ("user-provided comparison function does not correctly implement a total order") inside BlockWithTriggers::new_with_triggers, which calls trigger_data.sort() on a freshly-built Vec<Trigger>. Fix the Log/Log comparison to key on transaction_index first, with log_index only as a tie-breaker within the same transaction, matching how Call/Call and Call/Log comparisons already work. Also rewrite the Call/Log and Log/Call arms with Ordering::then to remove the duplicate guarded/unguarded match arm pairs, now that both arms share the same primary-then-secondary-key logic. Added a regression test (test_trigger_ordering_is_transitive) that constructs the minimal counter-example and asserts both the pairwise comparisons and that sorting the resulting Vec doesn't panic.
1 parent 6838f4e commit 8e3f82a

2 files changed

Lines changed: 56 additions & 21 deletions

File tree

‎chain/ethereum/src/tests.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,37 @@ fn test_trigger_dedup() {
235235

236236
assert_eq!(block_with_triggers.trigger_data, expected);
237237
}
238+
239+
#[test]
240+
fn test_trigger_ordering_is_transitive() {
241+
// Regression test: `EthereumTrigger::cmp` compared `Call` triggers (and `Call`/`Log` pairs)
242+
// by `transaction_index`, but compared `Log`/`Log` pairs by `log_index` alone, ignoring
243+
// `transaction_index`. Those two comparisons only agree when `log_index` happens to increase
244+
// in lockstep with `transaction_index`, which does not hold for every trigger source (for
245+
// example log/call triggers that were fetched independently and merged). When it doesn't
246+
// hold, the combined ordering isn't transitive, which crashes Rust's sort with "user-provided
247+
// comparison function does not correctly implement a total order".
248+
//
249+
// Here, `log_a` is in an earlier transaction than `log_c` but has a higher `log_index`:
250+
let log_a = EthereumTrigger::Log(LogRef::FullLog(create_log(5, 50), None));
251+
let call_b = EthereumTrigger::Call(Arc::new(EthereumCall {
252+
transaction_index: 5,
253+
..Default::default()
254+
}));
255+
let log_c = EthereumTrigger::Log(LogRef::FullLog(create_log(6, 10), None));
256+
257+
// `log_a` and `call_b` share a transaction, so `log_a < call_b` (events before calls in the
258+
// same transaction). `call_b` is in an earlier transaction than `log_c`, so `call_b < log_c`.
259+
// Transitivity requires `log_a < log_c`.
260+
assert_eq!(log_a.cmp(&call_b), std::cmp::Ordering::Less);
261+
assert_eq!(call_b.cmp(&log_c), std::cmp::Ordering::Less);
262+
assert_eq!(
263+
log_a.cmp(&log_c),
264+
std::cmp::Ordering::Less,
265+
"transitivity violated: log_a < call_b < log_c but log_a is not < log_c"
266+
);
267+
268+
// The actual regression: sorting a `Vec` containing this combination used to panic.
269+
let mut triggers = vec![log_c, call_b, log_a];
270+
triggers.sort();
271+
}

‎chain/ethereum/src/trigger.rs‎

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -378,27 +378,28 @@ impl Ord for EthereumTrigger {
378378
// Calls are ordered by their tx indexes
379379
(Self::Call(a), Self::Call(b)) => a.transaction_index.cmp(&b.transaction_index),
380380

381-
// Events are ordered by their log index
382-
(Self::Log(a), Self::Log(b)) => a.log_index().cmp(&b.log_index()),
383-
384-
// Calls vs. events are logged by their tx index;
385-
// if they are from the same transaction, events come first
386-
(Self::Call(a), Self::Log(b))
387-
if a.transaction_index == b.transaction_index().unwrap() =>
388-
{
389-
Ordering::Greater
390-
}
391-
(Self::Log(a), Self::Call(b))
392-
if a.transaction_index().unwrap() == b.transaction_index =>
393-
{
394-
Ordering::Less
395-
}
396-
(Self::Call(a), Self::Log(b)) => {
397-
a.transaction_index.cmp(&b.transaction_index().unwrap())
398-
}
399-
(Self::Log(a), Self::Call(b)) => {
400-
a.transaction_index().unwrap().cmp(&b.transaction_index)
401-
}
381+
// Events are ordered by their tx index first, and by their log index within a
382+
// transaction. Comparing by tx index first (instead of log index alone) keeps this
383+
// consistent with the Call/Log orderings below, which also key on tx index first;
384+
// log index is not guaranteed to increase in lockstep with tx index for every
385+
// trigger source, and a mismatch between the two would make the overall ordering
386+
// non-transitive.
387+
(Self::Log(a), Self::Log(b)) => a
388+
.transaction_index()
389+
.cmp(&b.transaction_index())
390+
.then_with(|| a.log_index().cmp(&b.log_index())),
391+
392+
// Calls vs. events are ordered by their tx index; if they are from the same
393+
// transaction, events come first.
394+
(Self::Call(a), Self::Log(b)) => a
395+
.transaction_index
396+
.cmp(&b.transaction_index().unwrap())
397+
.then(Ordering::Greater),
398+
(Self::Log(a), Self::Call(b)) => a
399+
.transaction_index()
400+
.unwrap()
401+
.cmp(&b.transaction_index)
402+
.then(Ordering::Less),
402403
}
403404
}
404405
}

0 commit comments

Comments
 (0)