Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions prdoc/pr_10950.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
title: 'fix(revive): handle transaction hash conflicts during re-org'
doc:
- audience: Runtime Dev
description: "## Summary\n\nFixes a UNIQUE constraint violation when processing\
\ blocks after a re-org:\n```\nUNIQUE constraint failed: transaction_hashes.transaction_hash\n\
```\n\n## Problem\n\nWhen a blockchain re-org occurs:\n1. Block A contains transaction\
\ TX1 \u2192 stored in `transaction_hashes`\n2. Server restarts (clearing the\
\ in-memory `block_number_to_hashes` map)\n3. Re-org happens, Block B (different\
\ hash) now contains the same TX1\n4. INSERT fails because TX1 already exists\
\ with old block_hash"
crates:
- name: pallet-revive-eth-rpc
bump: patch

This file was deleted.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 51 additions & 1 deletion substrate/frame/revive/rpc/src/receipt_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ impl<B: BlockInfoProvider> ReceiptProvider<B> {

query!(
r#"
INSERT INTO transaction_hashes (transaction_hash, block_hash, transaction_index)
INSERT OR REPLACE INTO transaction_hashes (transaction_hash, block_hash, transaction_index)
VALUES ($1, $2, $3)
"#,
transaction_hash,
Expand Down Expand Up @@ -765,6 +765,56 @@ mod tests {
return Ok(());
}

#[sqlx::test]
async fn test_reorg_same_transaction_hash(pool: SqlitePool) -> anyhow::Result<()> {
let provider = setup_sqlite_provider(pool).await;

// Build two blocks at the same height with the same transaction hash
let tx_hash = H256::from([42u8; 32]);

// Block A at height 1
let block_a = MockBlockInfo { hash: H256::from([1u8; 32]), number: 1 };
let ethereum_hash_a = H256::from([2u8; 32]);
let receipts_a = vec![(
TransactionSigned::default(),
ReceiptInfo {
transaction_hash: tx_hash,
transaction_index: U256::from(0),
..Default::default()
},
)];

provider.insert(&block_a, &receipts_a, &ethereum_hash_a).await?;

// Verify transaction points to block A
let (found_hash, _) = provider.find_transaction(&tx_hash).await.unwrap();
assert_eq!(found_hash, block_a.hash);

// Clear the in-memory map to simulate server restart
provider.block_number_to_hashes.lock().await.clear();

// Block B at same height 1 (re-org) with SAME transaction
let block_b = MockBlockInfo { hash: H256::from([3u8; 32]), number: 1 };
let ethereum_hash_b = H256::from([4u8; 32]);
let receipts_b = vec![(
TransactionSigned::default(),
ReceiptInfo {
transaction_hash: tx_hash, // Same tx hash!
transaction_index: U256::from(0),
..Default::default()
},
)];

// This should NOT fail with UNIQUE constraint violation
provider.insert(&block_b, &receipts_b, &ethereum_hash_b).await?;

// Transaction should now point to block B
let (found_hash, _) = provider.find_transaction(&tx_hash).await.unwrap();
assert_eq!(found_hash, block_b.hash);

Copy link
Contributor

@0xRVE 0xRVE Jan 30, 2026

Choose a reason for hiding this comment

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

Suggested change
assert_eq!(count(&provider.pool, "eth_to_substrate_blocks", None).await, 1);

Or it is ok to have block A still sitting in the table?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good point we can check in another PR, if we do clean up forked blocks

Ok(())
}

#[sqlx::test]
async fn test_receipts_count_per_block(pool: SqlitePool) -> anyhow::Result<()> {
let provider = setup_sqlite_provider(pool).await;
Expand Down
Loading