From 1068ad4e8f96a450566799fd7e733828b4b86c95 Mon Sep 17 00:00:00 2001 From: nym21 Date: Thu, 30 Apr 2026 19:19:09 +0200 Subject: [PATCH] query: fixes --- crates/brk_error/src/lib.rs | 2 +- .../src/steps/rebuilder/linearize/sfl.rs | 9 +- .../src/stores/tx_graveyard/mod.rs | 9 ++ crates/brk_query/.gitignore | 2 + crates/brk_query/src/impl/addr.rs | 3 +- crates/brk_query/src/impl/mempool.rs | 44 ++++++++ crates/brk_query/src/lib.rs | 7 +- .../tests/mempool_compat/.gitignore | 1 + .../tests/mempool_compat/conftest.py | 19 +++- .../general/test_difficulty_adjustment.py | 104 +++++++++++++++--- 10 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 packages/brk_client/tests/mempool_compat/.gitignore diff --git a/crates/brk_error/src/lib.rs b/crates/brk_error/src/lib.rs index 2b7b8a791..8c6602617 100644 --- a/crates/brk_error/src/lib.rs +++ b/crates/brk_error/src/lib.rs @@ -153,7 +153,7 @@ pub enum Error { #[error("Request weight {requested} exceeds maximum {max}")] WeightExceeded { requested: usize, max: usize }, - #[error("Too many unspent transaction outputs (>500). Contact support to raise limits.")] + #[error("Too many unspent transaction outputs (>1000).")] TooManyUtxos, #[error("Deserialization error: {0}")] diff --git a/crates/brk_mempool/src/steps/rebuilder/linearize/sfl.rs b/crates/brk_mempool/src/steps/rebuilder/linearize/sfl.rs index d7972d589..98945f073 100644 --- a/crates/brk_mempool/src/steps/rebuilder/linearize/sfl.rs +++ b/crates/brk_mempool/src/steps/rebuilder/linearize/sfl.rs @@ -143,13 +143,8 @@ impl Sfl { if nrate <= rate { continue; } - match picked { - None => picked = Some((add, nf, nv, nrate)), - Some((_, _, _, prate)) => { - if nrate > prate { - picked = Some((add, nf, nv, nrate)); - } - } + if picked.is_none_or(|(_, _, _, prate)| nrate > prate) { + picked = Some((add, nf, nv, nrate)); } } match picked { diff --git a/crates/brk_mempool/src/stores/tx_graveyard/mod.rs b/crates/brk_mempool/src/stores/tx_graveyard/mod.rs index c62026b63..b7e6cd253 100644 --- a/crates/brk_mempool/src/stores/tx_graveyard/mod.rs +++ b/crates/brk_mempool/src/stores/tx_graveyard/mod.rs @@ -43,6 +43,15 @@ impl TxGraveyard { }) } + /// Every `Replaced` tombstone, yielded as (predecessor_txid, + /// replacer_txid). Caller walks the replacer chain forward to find + /// each tree's terminal replacer. + pub fn replaced_iter(&self) -> impl Iterator { + self.tombstones + .iter() + .filter_map(|(txid, ts)| ts.replaced_by().map(|by| (txid, by))) + } + pub fn bury(&mut self, txid: Txid, tx: Transaction, entry: TxEntry, removal: TxRemoval) { let now = Instant::now(); self.tombstones diff --git a/crates/brk_query/.gitignore b/crates/brk_query/.gitignore index 2211df63d..b56bb87bc 100644 --- a/crates/brk_query/.gitignore +++ b/crates/brk_query/.gitignore @@ -1 +1,3 @@ *.txt +/*.md +!README.md diff --git a/crates/brk_query/src/impl/addr.rs b/crates/brk_query/src/impl/addr.rs index 4e537b6a4..77a39cfb8 100644 --- a/crates/brk_query/src/impl/addr.rs +++ b/crates/brk_query/src/impl/addr.rs @@ -173,9 +173,8 @@ impl Query { let prefix = u32::from(type_index).to_be_bytes(); - // Match mempool.space's electrs cap: refuse addresses with >500 UTXOs. // Bounds worst-case work and response size, prevents heavy-address DDoS. - const MAX_UTXOS: usize = 500; + const MAX_UTXOS: usize = 1000; let outpoints: Vec<(TxIndex, Vout)> = store .prefix(prefix) .map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout())) diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index 9e728fbe2..b135af917 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -378,6 +378,50 @@ impl Query { }) } + /// Recent RBF replacements across the whole mempool, matching + /// mempool.space's `GET /api/v1/replacements` and + /// `GET /api/v1/fullrbf/replacements`. Each entry is a complete + /// replacement tree rooted at the latest replacer; same shape as + /// `tx_rbf().replacements`. Sorted most-recent-first by root + /// `time`. When `full_rbf_only` is true, only trees with at least + /// one non-signaling predecessor are returned. + pub fn recent_replacements(&self, full_rbf_only: bool) -> Result> { + let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; + let txs = mempool.txs(); + let entries = mempool.entries(); + let graveyard = mempool.graveyard(); + + // Collect every distinct tree-root replacer. A predecessor's + // `by` may itself have been replaced; walk forward through + // chained Replaced tombstones until reaching a tx that's no + // longer flagged as replaced (live, Vanished, or unknown). + let mut roots: FxHashSet = FxHashSet::default(); + for (_, by) in graveyard.replaced_iter() { + let mut root = by.clone(); + while let Some(TxRemoval::Replaced { by: next }) = + graveyard.get(&root).map(TxTombstone::reason) + { + root = next.clone(); + } + roots.insert(root); + } + + let mut trees: Vec = roots + .iter() + .filter_map(|root| { + Self::build_rbf_node(root, None, &txs, &entries, &graveyard).map(|mut node| { + node.tx.full_rbf = Some(node.full_rbf); + node.interval = None; + node + }) + }) + .filter(|node| !full_rbf_only || node.full_rbf) + .collect(); + + trees.sort_by(|a, b| b.time.cmp(&a.time)); + Ok(trees) + } + pub fn transaction_times(&self, txids: &[Txid]) -> Result> { let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let entries = mempool.entries(); diff --git a/crates/brk_query/src/lib.rs b/crates/brk_query/src/lib.rs index d862b0d64..60e84b769 100644 --- a/crates/brk_query/src/lib.rs +++ b/crates/brk_query/src/lib.rs @@ -9,7 +9,7 @@ use brk_mempool::Mempool; use brk_reader::Reader; use brk_rpc::Client; use brk_types::{BlockHash, BlockHashPrefix, Height, SyncStatus}; -use vecdb::{AnyVec, ReadOnlyClone, ReadableVec, Ro}; +use vecdb::{ReadOnlyClone, ReadableVec, Ro}; #[cfg(feature = "tokio")] mod r#async; @@ -63,8 +63,7 @@ impl Query { /// Current computed height (series) pub fn computed_height(&self) -> Height { - let len = self.computer().distribution.supply_state.len(); - Height::from(len.saturating_sub(1)) + Height::from(self.computer().distribution.supply_state.stamp()) } /// Minimum of indexed and computed heights @@ -73,11 +72,13 @@ impl Query { } /// Tip block hash, cached in the indexer. + #[inline] pub fn tip_blockhash(&self) -> BlockHash { self.indexer().tip_blockhash() } /// Tip block hash prefix for cache etags. + #[inline] pub fn tip_hash_prefix(&self) -> BlockHashPrefix { BlockHashPrefix::from(&self.tip_blockhash()) } diff --git a/packages/brk_client/tests/mempool_compat/.gitignore b/packages/brk_client/tests/mempool_compat/.gitignore new file mode 100644 index 000000000..d5578b94e --- /dev/null +++ b/packages/brk_client/tests/mempool_compat/.gitignore @@ -0,0 +1 @@ +*md diff --git a/packages/brk_client/tests/mempool_compat/conftest.py b/packages/brk_client/tests/mempool_compat/conftest.py index e046b313c..969ecf061 100644 --- a/packages/brk_client/tests/mempool_compat/conftest.py +++ b/packages/brk_client/tests/mempool_compat/conftest.py @@ -28,6 +28,8 @@ from typing import Any import pytest import requests +from brk_client import BrkClient + # Make `_lib` and `_endpoints` importable from any nested test file. sys.path.insert(0, str(Path(__file__).parent)) @@ -118,7 +120,16 @@ class LiveData: @pytest.fixture(scope="session") def brk(): - return ApiClient(BRK_BASE, "brk") + """Typed brk_client SDK pointed at the brk server under test. + + All tests must go through this fixture's typed methods (e.g. + `brk.get_difficulty_adjustment()`). If a typed method is missing or + broken, that's an SDK finding — fix the bindgen before adapting the + test. Never reach into raw HTTP from a compat test. + """ + client = BrkClient(BRK_BASE) + yield client + client.close() @pytest.fixture(scope="session") @@ -127,12 +138,12 @@ def mempool(): @pytest.fixture(scope="session", autouse=True) -def check_servers(brk, mempool): +def check_servers(mempool): """Fail fast if either server is unreachable.""" try: - brk.get("/api/blocks/tip/height") + BrkClient(BRK_BASE).get_text("/api/blocks/tip/height") except Exception as e: - pytest.exit(f"brk server not reachable at {brk.base_url}: {e}") + pytest.exit(f"brk server not reachable at {BRK_BASE}: {e}") try: mempool.get("/api/blocks/tip/height") except Exception as e: diff --git a/packages/brk_client/tests/mempool_compat/general/test_difficulty_adjustment.py b/packages/brk_client/tests/mempool_compat/general/test_difficulty_adjustment.py index b1d1c59bf..ee7e1fca8 100644 --- a/packages/brk_client/tests/mempool_compat/general/test_difficulty_adjustment.py +++ b/packages/brk_client/tests/mempool_compat/general/test_difficulty_adjustment.py @@ -1,35 +1,103 @@ """GET /api/v1/difficulty-adjustment""" -from _lib import assert_same_structure, show +import time + +from _lib import assert_same_structure, assert_same_values, show -DIFFICULTY_KEYS = [ +REQUIRED_KEYS = { "progressPercent", "difficultyChange", "estimatedRetargetDate", "remainingBlocks", "remainingTime", "previousRetarget", "previousTime", "nextRetargetHeight", "timeAvg", "adjustedTimeAvg", "timeOffset", "expectedBlocks", -] +} + +# Fields derived purely from chain state (heights and confirmed block +# timestamps) — these must match mempool.space within float tolerance. +# All other fields depend on the wall clock at request time and will drift. +CHAIN_DETERMINISTIC_FIELDS = { + "progressPercent", "remainingBlocks", "nextRetargetHeight", + "previousTime", "previousRetarget", +} -def test_difficulty_adjustment(brk, mempool): - """Difficulty adjustment must have the same structure.""" +def test_difficulty_adjustment_shape(brk, mempool): + """Response must have every key mempool.space returns, with matching types.""" path = "/api/v1/difficulty-adjustment" - b = brk.get_json(path) + b = brk.get_difficulty_adjustment() m = mempool.get_json(path) show("GET", path, b, m) assert_same_structure(b, m) - for key in DIFFICULTY_KEYS: - assert key in b, f"brk missing '{key}'" + missing = REQUIRED_KEYS - set(b.keys()) + assert not missing, f"brk missing keys: {missing}" -def test_difficulty_adjustment_values_sane(brk, mempool): - """Progress must be 0-100 %, remaining blocks must be 0-2016.""" - path = "/api/v1/difficulty-adjustment" - for label, client in [("brk", brk), ("mempool", mempool)]: - d = client.get_json(path) - assert 0 <= d["progressPercent"] <= 100, ( - f"{label} progressPercent out of range: {d['progressPercent']}" - ) - assert 0 <= d["remainingBlocks"] <= 2016, ( - f"{label} remainingBlocks out of range: {d['remainingBlocks']}" +def test_difficulty_adjustment_chain_values_match_mempool(brk, mempool): + """Chain-derived fields must equal mempool.space's within float tolerance.""" + b = brk.get_difficulty_adjustment() + m = mempool.get_json("/api/v1/difficulty-adjustment") + assert_same_values( + {k: b[k] for k in CHAIN_DETERMINISTIC_FIELDS}, + {k: m[k] for k in CHAIN_DETERMINISTIC_FIELDS}, + ) + + +def test_difficulty_adjustment_invariants(brk): + """Cross-field invariants and protocol-level bounds.""" + d = brk.get_difficulty_adjustment() + now_ms = int(time.time() * 1000) + + assert 0 <= d["progressPercent"] <= 100 + assert 0 <= d["remainingBlocks"] <= 2016 + assert d["nextRetargetHeight"] % 2016 == 0 + + blocks_done = 2016 - d["remainingBlocks"] + assert abs(d["progressPercent"] - blocks_done / 2016 * 100) < 1e-6 + + # Bitcoin protocol clamps a single retarget to a 4× factor in either + # direction → difficulty change ∈ [-75%, +300%]. previousRetarget reports + # the same quantity historically, so the same bound applies. + assert -75.0 <= d["difficultyChange"] <= 300.0 + assert -75.0 <= d["previousRetarget"] <= 300.0 + + # expectedBlocks: wall-clock-derived count of blocks that should have + # arrived in the current epoch. Bounded above by ~2 epochs in any sane + # state (a much larger value would mean clock skew or epoch-start bug). + assert 0 <= d["expectedBlocks"] <= 2 * 2016 + + # timeAvg in milliseconds. Sanity: between 1s and 1h per block. + assert 1_000 <= d["timeAvg"] <= 3_600_000 + assert 1_000 <= d["adjustedTimeAvg"] <= 3_600_000 + + # remainingTime is constructed as remainingBlocks * timeAvg in brk. + assert d["remainingTime"] == d["remainingBlocks"] * d["timeAvg"] + + assert d["estimatedRetargetDate"] > now_ms + assert d["previousTime"] * 1000 < now_ms + + +def test_difficulty_adjustment_previous_time_matches_chain(brk): + """previousTime must be the timestamp of the block at the most recent retarget.""" + d = brk.get_difficulty_adjustment() + epoch_start_height = d["nextRetargetHeight"] - 2016 + epoch_start_hash = brk.get_block_by_height(epoch_start_height) + epoch_start_block = brk.get_block(epoch_start_hash) + assert d["previousTime"] == epoch_start_block["timestamp"], ( + f"previousTime={d['previousTime']} but block at height " + f"{epoch_start_height} has timestamp={epoch_start_block['timestamp']}" + ) + + +def test_difficulty_adjustment_next_retarget_aligned_with_tip(brk): + """The tip must sit inside the epoch ending at nextRetargetHeight.""" + d = brk.get_difficulty_adjustment() + tip = int(brk.get_block_tip_height()) + assert d["nextRetargetHeight"] - 2016 <= tip < d["nextRetargetHeight"], ( + f"tip={tip} not in current epoch " + f"[{d['nextRetargetHeight'] - 2016}, {d['nextRetargetHeight']})" + ) + assert d["remainingBlocks"] == d["nextRetargetHeight"] - tip - 1 \ + or d["remainingBlocks"] == d["nextRetargetHeight"] - tip, ( + "remainingBlocks must equal blocks left to next retarget " + "(off-by-one tolerated for tip-race during request)" )