Skip to content

fix(bitcoind_rpc): properly handle reorgs in FilterIter #1985

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

LagginTimes
Copy link
Contributor

@LagginTimes LagginTimes commented Jul 1, 2025

Replaces #1909.
Fixes #1848.

Description

This patch enhances FilterIter to robustly detect and handle chain reorgs. Reorgs are detected by checking if the block hash at the current height has changed, or if the block's prev_blockhash no longer matches the hash of the block at the previous height. This allows detection of reorgs that occur either at the current height or one block prior.

When a reorg is detected, the iterator rewinds up to MAX_REORG_DEPTH blocks to locate the most recent common ancestor. It then resets its internal state and resumes scanning from the fork point on the new chain.

This update also includes tests that simulate reorgs — including back-to-back reorgs at the same height — and verifies that FilterIter correctly switches to the new chain and yields accurate events.

Changelog notice

  • Improve FilterIter to detect and handle reorgs at the current height or its parent.

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@LagginTimes LagginTimes added this to the Wallet 2.1.0 milestone Jul 1, 2025
@LagginTimes LagginTimes moved this to In Progress in BDK Chain Jul 1, 2025
@LagginTimes LagginTimes self-assigned this Jul 1, 2025
@notmandatory notmandatory added the bug Something isn't working label Jul 2, 2025
@LagginTimes LagginTimes moved this from In Progress to Needs Review in BDK Chain Jul 7, 2025
Copy link
Collaborator

@ValuedMammal ValuedMammal left a comment

Choose a reason for hiding this comment

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

Thanks for picking this up @LagginTimes

Copy link
Collaborator

@ValuedMammal ValuedMammal left a comment

Choose a reason for hiding this comment

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

It's missing a test for max reorg depth.

Co-authored-by: valued mammal <valuedmammal@protonmail.com>
@LagginTimes
Copy link
Contributor Author

It's missing a test for max reorg depth.

The test for this is still WIP but I pushed out the changes that resolved the other open comments.

ValuedMammal
ValuedMammal previously approved these changes Jul 11, 2025
Copy link
Collaborator

@ValuedMammal ValuedMammal left a comment

Choose a reason for hiding this comment

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

ACK c2ae4da

Comment on lines +88 to 89
/// Returns `None` if the remote height is less than the height of this [`FilterIter`].
pub fn get_tip(&mut self) -> Result<Option<BlockId>, Error> {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe a little out of scope for this PR, but the documentation here is a little lacking.

get_tip, as I understand, serves the following purposes:

  1. Initialize the FilerIter. Without calling it, FilterIter returns nothing.
  2. If it returns None, it means we are up to date with remote.

Is this the case?

If so, purpose 2. is not satisfied because reorgs are not detected by get_tip.

@ValuedMammal are you able to provide some clarity, thanks!

Copy link
Collaborator

Choose a reason for hiding this comment

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

get_tip is used to set the start and stop height of a scan and is only necessary to call once per instance of FilterIter. During review I was going to mention that I think get_tip should actually clear self.blocks, because I didn't anticipate the user interleaving calls to get_tip and next.

Copy link
Member

@evanlinjin evanlinjin Jul 18, 2025

Choose a reason for hiding this comment

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

@ValuedMammal Thank you for the update.

The internal implementation of FilterIter implies that it is a single-use structure (construct once, call .next till end). However, the API design of it implies something different.

What do you think about changing the API to properly represent a "single-use" workflow? I.e. I would change the following:

  • Remove or unexpose add_spk{s}.
  • Change constructor methods to also take in spks. Return None if there are no spks provided.
  • Make sure get_tip logic is run on the first call to next.
  • Rename get_tip to init which would return the target height.

(Separate PR of course)

Let me know what you think.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like this idea, and can make a follow up PR to implement this.

Copy link
Member

@evanlinjin evanlinjin left a comment

Choose a reason for hiding this comment

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

Thanks for taking this forward.

I addressed my own review comments in commit 30e25d1 of evanlinjin:bdk @ filter_iter. Let me know what you think. Check commit message for rationale.

* Remove `MAX_REORG_DEPTH` check and error variant. For the scenario
  where the `FilterIter` is of a different network than the receiver, the
  genesis block will be emitted, thus making the receiver error out.
* Fix `Iterator::next` impl so that we only mutate internal state after
  we are certain that no more errors will be returned.
* `Error::NoScripts` is no longer returned since I could not justify a
  rationale for it.
* Refactor `Iterator::next` impl be cleaner.
Copy link
Collaborator

@ValuedMammal ValuedMammal left a comment

Choose a reason for hiding this comment

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

I'm not convinced that this represents an objective improvement over Musab's PR.

@evanlinjin
Copy link
Member

evanlinjin commented Jul 23, 2025

I'm not convinced that this represents an objective improvement over Musab's PR.

@ValuedMammal are you able to provide more context? I'm assuming you are referring to changes in 071fe40?

Here is my justification why these changes are superior:

  • Separation of mutating/non-mutating code - No state mutations made if anything errors - without this, we may miss blocks if network drops. Better readability.
  • No duplicated logic - We only need to check whether prev_blockhash matches in one place (rather than two places).
  • Remove MAX_REORG_DEPTH - As MAX_REORG_DEPTH does not provide any additional security and 100 block reorgs are possible. We are not checking PoW anyway; a malicious node can freely waste our time/return gibberish.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
Status: Needs Review
Development

Successfully merging this pull request may close these issues.

FilterIter may not handle reorgs properly.
5 participants