-
Notifications
You must be signed in to change notification settings - Fork 398
[draft] FilterIter API redesign #2000
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
base: master
Are you sure you want to change the base?
[draft] FilterIter API redesign #2000
Conversation
/// Hard cap on how far to walk back when a reorg is detected. | ||
const MAX_REORG_DEPTH: u32 = 100; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand that the main motivation for this is to protect against attacks from a malicious/faulty node?
This is not going to work as BDK does not check PoW (not currently). The node can just cheaply construct multiple <100 block reorgs and exhaust resources that way.
The only way to protect against attacks is to check PoW.
Note that I've already mentioned this here: #1985 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still need to understand the rationale of this PR (since we already had #1985 going). If you can expand on this, it would be appreciated.
I can see how the reorg-logic is simpler here (since it uses the pre-cached headers instead of having the check both the checkpoints/headers). Is this the main reasoning?
I'm assuming the main rationale for header-caching is so that it will be easier/faster to emit CheckPoint
s with Header
s once that becomes available. Is this correct?
/// Get the remote tip. | ||
/// | ||
/// Returns `None` if the remote height is not strictly greater than the height of this | ||
/// [`FilterIter`]. | ||
/// Returns `None` if the remote height is less than the height of this [`FilterIter`]. | ||
pub fn get_tip(&mut self) -> Result<Option<BlockId>, Error> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this call is required to "initiate" the iterator, we should mention it somewhere in the docs.
crates/bitcoind_rpc/src/bip158.rs
Outdated
if cp.hash() == fetched_hash { | ||
// ensure this block also exists in self | ||
self.blocks.insert(height, cp.hash()); | ||
// Ensure this block also exists in `self`. | ||
self.headers.insert(height, (fetched_hash, header)); | ||
return Ok(cp.block_id()); | ||
} | ||
// remember conflicts | ||
self.blocks.insert(height, fetched_hash); | ||
cp = cp.prev().expect("must break before genesis"); | ||
// Remember conflicts. | ||
self.headers.insert(height, (fetched_hash, header)); | ||
cp = cp.prev().ok_or(Error::ReorgDepthExceeded)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
if cp.hash() == fetched_hash { | |
// ensure this block also exists in self | |
self.blocks.insert(height, cp.hash()); | |
// Ensure this block also exists in `self`. | |
self.headers.insert(height, (fetched_hash, header)); | |
return Ok(cp.block_id()); | |
} | |
// remember conflicts | |
self.blocks.insert(height, fetched_hash); | |
cp = cp.prev().expect("must break before genesis"); | |
// Remember conflicts. | |
self.headers.insert(height, (fetched_hash, header)); | |
cp = cp.prev().ok_or(Error::ReorgDepthExceeded)?; | |
// Ensure visited blocks also exist in `self`. | |
self.headers.insert(height, (fetched_hash, header)); | |
if cp.hash() == fetched_hash { | |
return Ok(cp.block_id()); | |
} | |
cp = cp.prev().ok_or(Error::ReorgDepthExceeded)?; |
crates/bitcoind_rpc/src/bip158.rs
Outdated
// is also the point of agreement with `self.cp`. | ||
// We return blocks up to and including the initial height, all of the matching blocks, | ||
// and blocks in the terminal range. | ||
let tail_range = self.stop.saturating_sub(9)..=self.stop; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: What do you think about extracting 9
out as a constant?
} | ||
// Fetch next header. | ||
let mut height = self.height; | ||
let mut hash = self.client.get_block_hash(height as u64)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: You can move this inside the loop to avoid duplicating code. Do break (hash, header)
to access the value outside the loop.
crates/bitcoind_rpc/src/bip158.rs
Outdated
let prev_height = height.saturating_sub(1); | ||
match self.headers.get(&prev_height).copied() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We do not want to continue looping once we reach genesis.
let prev_height = height.saturating_sub(1); | |
match self.headers.get(&prev_height).copied() { | |
let prev_cached_header = height | |
.checked_sub(1) | |
.and_then(|h| self.headers.get(&h)) | |
.copied(); | |
match prev_cached_header { |
// If the filter matches any of our watched SPKs, fetch the full | ||
// block and prepare the next event. | ||
let next_event = if self.spks.is_empty() { | ||
Err(Error::NoScripts) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you able to justify this error variant?
Updates the test to assert the matching heights in `.matched`.
..that defines the number of recent block IDs to include in a chain update.
… fallible The logic of `find_base` is moved inside the `new_with_checkpoint` constructor, which will error if a point of agreement (PoA) isn't found. As a result, `get_tip` is now only responsible for setting the stop height, which means it can be called multiple times on a single instance of FilterIter. `cp` field is removed from FilterIter struct, since it is now only used in the constructor to find a base and set the start height. There's no longer a need to remember conflicting blocks on the way to finding the PoA, now that we store all newly fetched headers during `next`.
- use `checked_sub` to get the previous header - remove unneeded type alias - rename `headers` method - increase documentation
89b1584
to
e368af5
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've demonstrated that the chain-update construction logic is unsound with the following test: 21fa815.
My intuition tells me that making the FilterIter
checkpoint-centric would make it easier to ensure correctness here. To make this current approach work would mean copying everying in the CheckPoint
into FilterIter::headers
which would be counter-intuitive.
/// Number of recent blocks from the tip to be returned in a chain update. | ||
const CHAIN_SUFFIX_LEN: u32 = 10; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we include a rationale for this?
From my understanding, this will make it faster to find the "point of agreement" if there is a reorg of depth < 10.
On second though, I'm not sure how impactful this is - reorgs are relatively rare and rescanning blocks should be pretty fast with filters?
FilterIter
detects reorgs
Previously
FilterIter
did not detect or handle reorgs betweennext
calls, meaning that if a reorg occurred, we might process blocks from a stale fork potentially resulting in an invalid wallet state.This PR aims to fix that by adding logic to explicitly check for and respond to a reorg on every call to
next
.Notes to the reviewers
new_with_checkpoint
now returns an error if a point of agreement with the local checkpoint cannot be found. This prevents the scan from proceeding on divergent chains and surfaces reorgs and misconfigs early.FilterIter
can now detect chain reorgs during block filter iteration. When a reorg is detected, it backtracks and re-fetches data for previous heights in order to maintain a consistent chain of headers. A hard capMAX_REORG_DEPTH
is enforced on the number of blocks to backtrack to prevent resource exhaustion in the case of a faulty or malicious node.FilterIter
now stores block headers for all of the scanned blocks. This enables the calling code to store a local chain of headers.Changelog notice
bdk_bitcoind_rpc
FilterIter::headers
for getting the block headers included in a scan.BREAKING:
FilterIter::new_with_checkpoint
to return aResult
.ReorgDepthExceeded
enum variant tobip158::Error
Checklists
All Submissions:
New Features:
Bugfixes: