Skip to content

fix: read events when the node's sdk drifts from the runtime's - #1504

Merged
enthusiastmartin merged 10 commits into
masterfrom
fix/synth-event-decode-master
Jul 31, 2026
Merged

fix: read events when the node's sdk drifts from the runtime's#1504
enthusiastmartin merged 10 commits into
masterfrom
fix/synth-event-decode-master

Conversation

@mrq1911

@mrq1911 mrq1911 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Master port of #1503. On master the sdk-skew bug is dormant, not absent: node and runtime are both stable2506, so the typed decode succeeds today. It wakes the moment the stable2603 runtime upgrade lands and flips which side is ahead. The fork-collision fix (§2), the Partial data-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.0 is a node-only release on polkadot-stable2603 while the runtime stays on stable2506. Upstream polkadot-sdk#7250 inserted MintedCredit(11) and BurnedDebt(13) into the middle of pallet_balances::Event, shifting Burned 11→12, Issued 15→17, Rescinded 16→18, Locked 17→19, Frozen 19→21.

Synth logs decode System::Events with the node's compiled RuntimeEvent, so the chain's Issued{amount} (16 B) is read as Restored{who,amount} (48 B) → the whole Vec<EventRecord> decode fails → Decode::decode(..).ok() turns that into Vec::new() and the block looks event-free. No log, no metric, no RPC error.

Measured on mainnet: rpc.hydradx.cloud and hydration-rpc.n.dwellir.com return 0 logs for every block paying fees in native HDX (~18% of blocks), including the wormhole core-bridge LogMessagePublished raised by substrate-dispatched EVM.call — so guardians never observed NTT DAI seq 2–5.

Fixcompat_events::read_events, in descending fidelity:

  • Native → typed decode, unchanged fast path, free when node and runtime agree
  • Compat → explicit per-sdk pallet_balances::Event layout, other pallets via the node's own types → exact
  • Partial → resync + a whole-blob EVM.Log sweep (see §3)

2. Tx-hash domain keyed on the parent hash → collisions on every fork

block_domain folded parent_hash + block_number. Sibling blocks at the same height share a parent, so every field feeding keccak(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. Partial lost runs of records, and did so silently

Two compounding defects, both found by replaying block 13384676 (the DAI transfer that published wormhole sequence 2) on a skewed node:

  • Mis-consumption, not skipping. A wrong-layout record still parses while consuming the wrong byte count, swallowing whatever follows. That block's 50 events collapsed into 27 records with only 6 reported skips, and 12 of 19 logs vanished — including the LogMessagePublished. The record itself was intact at byte offset 1710 (phase=ApplyExtrinsic(3), pallet=0x5a, variant=0x00); a mis-decoded neighbour ate it.
  • Shared latch. Compat and Partial shared one Once, 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.Voted and Scheduler.{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.Log records, deduped and merged into whatever the sequential walk produced. It works because an EVM.Log record'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 be Log, ≤4 log topics, empty record-topic vec. Partial now reports on its own counter with power-of-two backoff and carries a recovered count.

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

field before now
from 0xdeadbeef…beef 0x73796e74680000000000000000000073796e7468 — ascii synth..........synth
to sentinel origin account (from TransactionFeePaid), sentinel when there is none
value group index 0 — nothing should see a phantom native transfer
nonce bucket_nonce, up to 2^64-4 (i64::MAX) (block << 16) | group — monotonic, unique per (block, group), int64-safe
input marker ‖ parent_hash ‖ block_number abi calldata hydrationSynthV1(bytes32,bytes32,uint64), selector 0xf1f8393a

input is now an ordinary EVM call, so ethers/viem/cast decode 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 with cast decode-calldata, both bytes32 values cross-checked against the substrate side.

Rationale for the rest: the old hook nonces overflow the bigint column 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; from stays the marker so no unsigned tx is attributed to a user and no real account's nonce sequence is entangled; extrinsicHash in input makes substrate↔EVM correlation a field read. Hashing OpaqueExtrinsic needs no RuntimeCall decode, so none of it is exposed to §1.

Version bump

hydradx-runtime 434→435 + spec_version 434→435, because the fix touches runtime/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 in 9c4da722c), 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, 11 event_logs, 6 node = 36. Notably node_balances_layout_is_known (fails on the next mid-enum event insertion), synth_selector_matches_signature, and wormhole_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:

  • all four original NTT blocks captured: seq 2, 3, 4, 5 with their full event sets
  • A/B vs rpc.hydradx.cloud: blocks recovered with 2–113 logs each, never fewer
  • vs a stable2506 node as ground truth: 900/900 blocks identical, 0 missed, 0 extra
  • 20/20 synth txs resolve via eth_getTransactionByHash + receipt and appear in block.transactions

Not a substitute for putting eth-rpc nodes on a release whose runtime matches the chain (v50.0.0 today).

Copilot AI review requested due to automatic review settings July 30, 2026 20:22
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Crate versions that have been updated:

  • hydradx: v15.1.6 -> v15.1.7
  • hydradx-runtime: v434.0.0 -> v435.0.0

Runtime version has been increased.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::Events reader (NativeCompatPartial) 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-balances to 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()))
}
@github-actions

Copy link
Copy Markdown

Quick benchmark at commit c60ea80 has been executed successfully.
View results

@enthusiastmartin
enthusiastmartin merged commit c1a6de4 into master Jul 31, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants