Decode block events with the metadata at that block - #121
Conversation
Read the runtime version at each block and decode its events with the metadata that block ran, caching one metadata per spec version. Runtime upgrades insert event variants and shift the indices of later ones, so head metadata mis-reads historical events and drops everything after the first shifted variant in a block.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 61a1263. Configure here.
| pub async fn events(&self) -> Result<Events<SubstrateConfig>, Error> { | ||
| let events = self.client.events().at(self.hash).await?; | ||
| Ok(events) | ||
| let metadata = self.metadata.at(self.hash).await?; |
There was a problem hiding this comment.
Wrong metadata on upgrade blocks
Medium Severity
events() and events_from_segments resolve metadata via metadata.at(self.hash), which reads post-execution state. A block runs on the parent runtime, so on a CodeUpdated block this returns the new metadata while events were still encoded with the old one—the same variant-shift corruption this PR aims to fix, narrowed to each upgrade block.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 61a1263. Configure here.


We were pinning one metadata at chain head and using it to decode events from any block. That's wrong across runtime upgrades: mainnet went from spec 9 to 11 and the Balances pallet inserted event variants along the way, shifting
Burnedfrom index 11 to 12,Issued15 to 17 andRescinded16 to 18. Decoding an older block with head metadata reads those variants as something else, consumes the wrong number of bytes, and every event after the first shifted one in that block is garbled or dropped. Sincebalances.Burnedfires on essentially any block with a transfer, most of history was affected, and the staking paths swallow decode errors, so a from-genesis reindex would quietly under-populate share prices, nominators and the deposit tables rather than fail.So now each block's events are decoded with the metadata that block actually ran. I read the runtime version at the block and cache one metadata per spec version, which is bounded by the number of runtimes the chain has had.
events()no longer goes through subxt's events client (it can't take explicit metadata) and instead readsSystem::Eventsdirectly and decodes against the cached metadata;events_from_segmentsgets the same treatment. The client-level V14 pin stays for storage and constants.This costs one extra
state_getRuntimeVersionper block, which I think is the right trade for not silently losing events.The two staking tests that were failing on this now pass against the same blocks they always read, and I made them surface the decode error instead of swallowing it, which is why this showed up as a bare "not found" before. I also added a test on block 6721910 that pins the mechanism: it has
balances.Burnedat position 1 andDomainEpochCompletedat 18, so under head metadata the whole tail of the block disappears.