Skip to content

fix: keep synth tx hashes stable across reorgs - #1520

Open
mrq1911 wants to merge 2 commits into
polkadot-stable2603from
synth-tx-hash-v2
Open

fix: keep synth tx hashes stable across reorgs#1520
mrq1911 wants to merge 2 commits into
polkadot-stable2603from
synth-tx-hash-v2

Conversation

@mrq1911

@mrq1911 mrq1911 commented Sep 2, 2026

Copy link
Copy Markdown
Member

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:

input  4..36   substrateBlockHash        ← different block, different hash
input 68..100  bucket = extrinsic INDEX  ← shifts if the extrinsic's position changes
nonce          (blockNumber << 16) | groupIndex
                                         ← shifts with height AND with groupIndex, the
                                           bucket's ordinal among *this block's* buckets

groupIndex is the easy one to miss: even a same-height reorg moves it if any other extrinsic or hook bucket appears or disappears.

v2

hydrationSynthV2(bytes32 anchor, uint8 kind, uint64 bucket)      selector 0xdbec8e25

  kind=1  extrinsic   anchor = extrinsicHash          bucket = 0
  kind=2  hook        anchor = uint256(blockNumber)   bucket = phase | stableOriginTag

  nonce = 0     from = sentinel (unchanged)     to = origin (unchanged)

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:

  • the extrinsic index — pure position. There is one bucket per extrinsic, so the extrinsic hash already identifies it. Consumers join by extrinsic hash instead, which is the thing that survives a reorg.
  • the noncefrom is a sentinel that never signed anything and eth_getTransactionCount on it returns 0, so a nonce there is fiction either way. 0 says "not an account nonce" rather than fabricating a plausible one; identity lives entirely in input.

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_hashes appends a TransactionMetadata per (hash, block) and load_hash resolves it with .find(|meta| is_canon(..)).

Height alone was not sufficient, though. origin_tag folds in pallet_broadcast::IncrementalId, a global, never-reset counter (on_finalize kills ExecutionContext, 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:

  • DCA already used the schedule id — stable, and it is the origin that actually occurs in hook phases.
  • Xcm carries 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).
  • the rest have only a counter. They are extrinsic-scoped in practice; if one ever appears as a hook origin it will churn, which is left visible rather than papered over.

v1 is frozen, not migrated

v1 hashes are already published — indexers hold them and eth_getTransactionByHash resolves 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_frozen pins 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 test
  • v2_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_signature
  • v1_envelope_hashes_are_frozen

Node 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_elsewhere
  • v1_extrinsic_hash_churns_on_reinclusion
  • activation_height_switches_the_envelope

Notes for review

  • The runtime crate's lib-test target does not compile on this branch, independently of this change: runtime/hydradx/src/tests.rs calls Runtime::query_xcm_weight / query_delivery_fees, removed by the XcmPaymentApi v1→v2 change. The runtime-side tests above were therefore verified in a worktree where synthetic_logs.rs is byte-identical, and the end-to-end tests were put in the node crate so they can actually be run here. Worth fixing tests.rs separately.
  • to = origin comes from TransactionPayment.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_to pins it.
  • No node-side changes were needed for the gate: synthetic_txs_from_records already passes the block number through, so assemble_synth_txs decides internally.

@claude claude Bot 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.

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.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Weight Diff Report

8 extrinsic(s) changed across 1 pallet(s). New: 8. Removed: 0.

pallet_dca

Extrinsic RefTime Proof Size Reads Writes
on_initialize_with_buy_trade +3.9% (253.6M → 263.5M) +0.6% (31038 → 31230) 23 → 24 (+1)
on_initialize_with_buy_trade_with_insufficient_fee_asset +2.3% (461.9M → 472.5M) +0.6% (31038 → 31230) 46 → 47 (+1)
on_initialize_with_sell_trade +3.7% (257.6M → 267.1M) +0.6% (31038 → 31230) 23 → 24 (+1)
on_initialize_with_sell_trade_with_insufficient_fee_asset +1.7% (464.1M → 472.0M) +0.6% (31038 → 31230) 46 → 47 (+1)
on_initialize_with_empty_block +0.6% (20.3M → 20.4M) +0.5% (3494 → 3510) 1 → 2 (+1)
schedule +4.3% (225.7M → 235.3M) +0.6% (28534 → 28710) 24 → 25 (+1)
terminate +0.4% (99.5M → 99.9M)
unlock_reserves -1.2% (61.6M → 60.9M)
New extrinsics (8)
Pallet Extrinsic RefTime Proof Reads Writes
pallet_dca set_migration_enabled 21.5M 0 0 1
pallet_ice submit_solution 558.8M 14005 39 23
pallet_ice set_protocol_fee 18.9M 0 0 1
pallet_ice set_solver_mode 19.2M 0 0 1
pallet_intent submit_intent 88.4M 6190 8 7
pallet_intent remove_intent 74.6M 4714 5 6
pallet_intent cleanup_intent 78.9M 4714 6 6
pallet_lazy_executor dispatch_top_base_weight 123.1M 6196 9 2

Threshold: ±10%. Base Weight::from_parts(ref_time, proof_size) compared; per-unit components ignored.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Crate versions that have been updated:

  • hydradx: v16.0.2 -> v16.0.3
  • hydradx-runtime: v440.0.0 -> v441.0.0

Runtime version has been increased.

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.

1 participant