fix: keep synth tx hashes stable across reorgs - #1520
Open
mrq1911 wants to merge 2 commits into
Open
Conversation
There was a problem hiding this comment.
Claude Code Review
Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.
Tip: disable this comment in your organization's Code Review settings.
Weight Diff Report8 extrinsic(s) changed across 1 pallet(s). New: 8. Removed: 0. pallet_dca
New extrinsics (8)
Threshold: ±10%. Base |
|
Crate versions that have been updated:
Runtime version has been increased. |
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.
A synth tx's hash currently changes when a reorg re-includes the same extrinsic in a different block, which breaks offchain indexing: the same transaction arrives under a new identity. This adds a reorg-stable envelope (v2) behind a block-height gate, and freezes the existing one (v1) for historical blocks.
Based on
v49.2.3.Why the hash moves today
transaction.hash()covers the whole envelope, and three fields are block-dependent:groupIndexis the easy one to miss: even a same-height reorg moves it if any other extrinsic or hook bucket appears or disappears.v2
The invariant is checkable in one line: nothing in the preimage depends on which block won a fork — only on the extrinsic (kind 1) or the height (kind 2). No block hash appears anywhere; a consumer that wants it can use
chain_getBlockHash(blockNumber), since an eth block number is the substrate height.Deliberately dropped from the identity:
fromis a sentinel that never signed anything andeth_getTransactionCounton it returns 0, so a nonce there is fiction either way.0says "not an account nonce" rather than fabricating a plausible one; identity lives entirely ininput.Hooks are anchored to height, not block hash
Init/finalization events belong to a block, not a transaction, so they are keyed on the height. Two siblings at one height therefore produce the same hook tx hash — deliberately. That is the situation frontier already handles for a real eth tx included in two forks:
write_hashesappends aTransactionMetadataper (hash, block) andload_hashresolves it with.find(|meta| is_canon(..)).Height alone was not sufficient, though.
origin_tagfolds inpallet_broadcast::IncrementalId, a global, never-reset counter (on_finalizekillsExecutionContext, not the counter), so its value at a hook depends on how many operations ran earlier in the block — two competing blocks hand the same logical operation a different id. So v2 re-keys the tag onto stable identifiers:DCAalready used the schedule id — stable, and it is the origin that actually occurs in hook phases.Xcmcarries a 32-byte message hash and v1 discards it in favour of the counter. v2 uses the message hash (low 40 bits, the room the tag layout leaves below the variant marker).v1 is frozen, not migrated
v1 hashes are already published — indexers hold them and
eth_getTransactionByHashresolves them out of the frontier mapping db — so changing them would rewrite the identity of historical activity in a way no reindex can repair.v1_envelope_hashes_are_frozenpins four concrete hashes covering every bucket class. The values were generated from the pre-change code and then confirmed identical after, so they prove the v1 path was untouched rather than merely blessing the new output. The test says as much, and says to revert rather than re-bless.Activation
SYNTH_V2_FROM = 14_700_000, gated on height so a re-sync re-derives every historical hash identically and the mapping db stays valid.This is provisional and needs an ops decision. It is ~14 days out at the 2.11 s/block measured on mainnet after the 2s rollout. It is also the one thing here that can make two nodes disagree: a node still on an older build past that height keeps emitting v1 hashes, so the height only works if RPC operators have upgraded by then, and it needs announcing.
Tests
Runtime crate (envelope in isolation):
v2_extrinsic_hash_survives_reinclusion_elsewhere— same extrinsic, different block hash, different height, different index; asserts the hash holds and that v1 churns, so the defect stays under testv2_hook_is_anchored_to_height_not_block_hash,v2_xcm_origin_keys_on_message_hash_not_the_counter,v2_distinct_buckets_do_not_collide,v2_nonce_is_zero_and_identity_lives_in_input,v2_origin_still_reaches_to,envelope_version_switches_at_the_activation_height,synth_v2_selector_matches_signaturev1_envelope_hashes_are_frozenNode crate (whole path, real mainnet events through
synthetic_txs_from_records, with extrinsics genuinely shifted as a competing block would shift them):v2_extrinsic_keeps_its_hash_when_reincluded_elsewherev1_extrinsic_hash_churns_on_reinclusionactivation_height_switches_the_envelopeNotes for review
runtime/hydradx/src/tests.rscallsRuntime::query_xcm_weight/query_delivery_fees, removed by theXcmPaymentApiv1→v2 change. The runtime-side tests above were therefore verified in a worktree wheresynthetic_logs.rsis byte-identical, and the end-to-end tests were put in the node crate so they can actually be run here. Worth fixingtests.rsseparately.to= origin comes fromTransactionPayment.TransactionFeePaid, so it is the one preimage input derived from execution rather than from the extrinsic itself. It is a pure function of the extrinsic in practice;v2_origin_still_reaches_topins it.synthetic_txs_from_recordsalready passes the block number through, soassemble_synth_txsdecides internally.