fix: read events when the node's sdk drifts from the runtime's - #1504
Merged
Conversation
|
Crate versions that have been updated:
Runtime version has been increased. |
There was a problem hiding this comment.
Pull request overview
This PR hardens node-side synthetic log extraction by making System::Events decoding tolerant to node/runtime polkadot-sdk drift, preventing “event-free blocks” when a single mis-decoded event record breaks the typed Vec<EventRecord> decode.
Changes:
- Introduces a tiered
System::Eventsreader (Native→Compat→Partial) to recover events even when balances event variant indices drift between node SDK and on-chain runtime. - Updates synthetic-log storage override to use the compat reader and to emit one-time skew diagnostics instead of silently returning an empty event list on decode failures.
- Adds node dependency on
pallet-balancesto support explicit balances event layout decoding.
Reviewed changes
Copilot reviewed 4 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| node/src/synthetic_logs/storage_override.rs | Switches event loading to the compat reader and adds one-time warning/error logging for SDK/runtime skew and decode failure. |
| node/src/synthetic_logs/mod.rs | Exposes the new compat_events module in the synthetic-logs subsystem. |
| node/src/synthetic_logs/compat_events.rs | Adds tiered, version-tolerant decoding of System::Events, including an explicit balances layout and a resilient per-record recovery path plus tests. |
| node/Cargo.toml | Adds pallet-balances dependency required by the compat decoder. |
| Cargo.lock | Lockfile update reflecting the added dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+112
to
+136
| while !input.is_empty() && records.len() + skipped < count { | ||
| if let Some(record) = decode_record(&mut input) { | ||
| records.push(record); | ||
| continue; | ||
| } | ||
| // scan for the next offset that yields a decodable record. | ||
| let bad = input; | ||
| let resync = (1..bad.len()).find(|&off| { | ||
| let candidate = &bad[off..]; | ||
| if !is_phase_start(candidate) { | ||
| return false; | ||
| } | ||
| let mut probe = candidate; | ||
| decode_record(&mut probe).is_some() | ||
| }); | ||
| match resync { | ||
| Some(off) => { | ||
| input = &bad[off..]; | ||
| skipped += 1; | ||
| } | ||
| None => break, | ||
| } | ||
| } | ||
| Some((records, skipped, input.len())) | ||
| } |
|
Quick benchmark at commit c60ea80 has been executed successfully. |
enthusiastmartin
approved these changes
Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Master port of #1503. On
masterthe sdk-skew bug is dormant, not absent: node and runtime are bothstable2506, so the typed decode succeeds today. It wakes the moment thestable2603runtime upgrade lands and flips which side is ahead. The fork-collision fix (§2), thePartialdata-loss fixes (§3) and the envelope rework (§4) apply here regardless.Verified on this branch that the decode work is a no-op on the happy path: the mainnet fixture decodes via
Native, so with node and runtime in agreement behaviour is byte-identical to today. The new tiers engage only once a decode actually fails.Three bugs in the node-side synthetic-logs layer, plus an envelope rework so synth txs are legible to ordinary EVM tooling.
1. Node/runtime sdk skew silently zeroed synth logs (live on mainnet)
v49.2.0is a node-only release onpolkadot-stable2603while the runtime stays onstable2506. Upstream polkadot-sdk#7250 insertedMintedCredit(11) andBurnedDebt(13) into the middle ofpallet_balances::Event, shiftingBurned11→12,Issued15→17,Rescinded16→18,Locked17→19,Frozen19→21.Synth logs decode
System::Eventswith the node's compiledRuntimeEvent, so the chain'sIssued{amount}(16 B) is read asRestored{who,amount}(48 B) → the wholeVec<EventRecord>decode fails →Decode::decode(..).ok()turns that intoVec::new()and the block looks event-free. No log, no metric, no RPC error.Measured on mainnet:
rpc.hydradx.cloudandhydration-rpc.n.dwellir.comreturn 0 logs for every block paying fees in native HDX (~18% of blocks), including the wormhole core-bridgeLogMessagePublishedraised by substrate-dispatchedEVM.call— so guardians never observed NTT DAI seq 2–5.Fix —
compat_events::read_events, in descending fidelity:Native→ typed decode, unchanged fast path, free when node and runtime agreeCompat→ explicit per-sdkpallet_balances::Eventlayout, other pallets via the node's own types → exactPartial→ resync + a whole-blobEVM.Logsweep (see §3)2. Tx-hash domain keyed on the parent hash → collisions on every fork
block_domainfoldedparent_hash + block_number. Sibling blocks at the same height share a parent, so every field feedingkeccak(RLP(tx))was identical for both — two blocks minting the same synth tx hash, which frontier indexes by hash. On a chain whose fork rate warranted an emergency release, that is live. Now keyed on the block's own hash, distinct by construction.3.
Partiallost runs of records, and did so silentlyTwo compounding defects, both found by replaying block 13384676 (the DAI transfer that published wormhole sequence 2) on a skewed node:
LogMessagePublished. The record itself was intact at byte offset 1710 (phase=ApplyExtrinsic(3),pallet=0x5a,variant=0x00); a mis-decoded neighbour ate it.CompatandPartialshared oneOnce, so once a recoverable skew reported itself, all subsequent real data loss went unreported.There is also a second sdk skew beyond balances: that block carries
ConvictionVoting.VotedandScheduler.{Scheduled,Canceled}, whose layouts also moved between stable2506 and stable2603. Per-pallet compat layouts are therefore whack-a-mole and cannot be a guarantee.Fix — a whole-blob sweep for
EVM.Logrecords, deduped and merged into whatever the sequential walk produced. It works because anEVM.Logrecord's bytes depend on nothing that skews:phase ‖ pallet ‖ variant ‖ address ‖ topics ‖ data ‖ record_topics. Guards: pallet byte must be this runtime's EVM index, variant must beLog, ≤4 log topics, empty record-topic vec.Partialnow reports on its own counter with power-of-two backoff and carries arecoveredcount.This guarantees the wormhole class specifically. Other records (e.g. ERC-20
Transfer) can still be incomplete on a doubly-skewed node — only metadata-driven decoding, or a node whose runtime matches the chain, closes that gap.4. Envelope rework
from0xdeadbeef…beef0x73796e74680000000000000000000073796e7468— asciisynth..........synthtoTransactionFeePaid), sentinel when there is nonevalue0— nothing should see a phantom native transfernoncebucket_nonce, up to2^64-4(2×i64::MAX)(block << 16) | group— monotonic, unique per (block, group), int64-safeinputmarker ‖ parent_hash ‖ block_numberhydrationSynthV1(bytes32,bytes32,uint64), selector0xf1f8393ainputis now an ordinary EVM call, soethers/viem/castdecode it with the signature alone and explorers with a 4byte lookup render it as a named call; versioning rides on the selector, so a layout change makes stale decoders fail loudly instead of misreading bytes. Verified live withcast decode-calldata, bothbytes32values cross-checked against the substrate side.Rationale for the rest: the old hook nonces overflow the
bigintcolumn indexers keep nonces in (and ~92% of synth txs are hook buckets); a repeating(from, nonce)breaks it as a key and trips replacement-detection;fromstays the marker so no unsigned tx is attributed to a user and no real account's nonce sequence is entangled;extrinsicHashininputmakes substrate↔EVM correlation a field read. HashingOpaqueExtrinsicneeds noRuntimeCalldecode, so none of it is exposed to §1.Version bump
hydradx-runtime434→435 +spec_version434→435, because the fix touchesruntime/hydradx/src/evm/and version-check requires modified crates bumped with runtime major ==spec_version. Bookkeeping only — the synth path is native code in the node binary (the runtime API was removed in9c4da722c), so this still ships as a node-only release. Demonstrated: the fixed node reports compiled runtime 429 while executing chain spec 433 and serves the fixed logs.Verification
Tests — 19
synthetic_logs, 11event_logs, 6 node = 36. Notablynode_balances_layout_is_known(fails on the next mid-enum event insertion),synth_selector_matches_signature, andwormhole_event_survives_undecodable_neighbours— which replays real mainnet events from block 13384676 and asserts sequence 2 comes out.On a real node — built this branch, ran it against the pruned mainnet snapshot, synced to head:
rpc.hydradx.cloud: blocks recovered with 2–113 logs each, never fewereth_getTransactionByHash+ receipt and appear inblock.transactionsNot a substitute for putting eth-rpc nodes on a release whose runtime matches the chain (
v50.0.0today).