query: fixes

This commit is contained in:
nym21
2026-04-30 19:19:09 +02:00
parent 9b42b40a36
commit 1068ad4e8f
10 changed files with 165 additions and 35 deletions

View File

@@ -153,7 +153,7 @@ pub enum Error {
#[error("Request weight {requested} exceeds maximum {max}")] #[error("Request weight {requested} exceeds maximum {max}")]
WeightExceeded { requested: usize, max: usize }, 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, TooManyUtxos,
#[error("Deserialization error: {0}")] #[error("Deserialization error: {0}")]

View File

@@ -143,13 +143,8 @@ impl Sfl {
if nrate <= rate { if nrate <= rate {
continue; continue;
} }
match picked { if picked.is_none_or(|(_, _, _, prate)| nrate > prate) {
None => picked = Some((add, nf, nv, nrate)), picked = Some((add, nf, nv, nrate));
Some((_, _, _, prate)) => {
if nrate > prate {
picked = Some((add, nf, nv, nrate));
}
}
} }
} }
match picked { match picked {

View File

@@ -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<Item = (&Txid, &Txid)> {
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) { pub fn bury(&mut self, txid: Txid, tx: Transaction, entry: TxEntry, removal: TxRemoval) {
let now = Instant::now(); let now = Instant::now();
self.tombstones self.tombstones

View File

@@ -1 +1,3 @@
*.txt *.txt
/*.md
!README.md

View File

@@ -173,9 +173,8 @@ impl Query {
let prefix = u32::from(type_index).to_be_bytes(); 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. // 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 let outpoints: Vec<(TxIndex, Vout)> = store
.prefix(prefix) .prefix(prefix)
.map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout())) .map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout()))

View File

@@ -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<Vec<ReplacementNode>> {
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<Txid> = 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<ReplacementNode> = 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<Vec<u64>> { pub fn transaction_times(&self, txids: &[Txid]) -> Result<Vec<u64>> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
let entries = mempool.entries(); let entries = mempool.entries();

View File

@@ -9,7 +9,7 @@ use brk_mempool::Mempool;
use brk_reader::Reader; use brk_reader::Reader;
use brk_rpc::Client; use brk_rpc::Client;
use brk_types::{BlockHash, BlockHashPrefix, Height, SyncStatus}; use brk_types::{BlockHash, BlockHashPrefix, Height, SyncStatus};
use vecdb::{AnyVec, ReadOnlyClone, ReadableVec, Ro}; use vecdb::{ReadOnlyClone, ReadableVec, Ro};
#[cfg(feature = "tokio")] #[cfg(feature = "tokio")]
mod r#async; mod r#async;
@@ -63,8 +63,7 @@ impl Query {
/// Current computed height (series) /// Current computed height (series)
pub fn computed_height(&self) -> Height { pub fn computed_height(&self) -> Height {
let len = self.computer().distribution.supply_state.len(); Height::from(self.computer().distribution.supply_state.stamp())
Height::from(len.saturating_sub(1))
} }
/// Minimum of indexed and computed heights /// Minimum of indexed and computed heights
@@ -73,11 +72,13 @@ impl Query {
} }
/// Tip block hash, cached in the indexer. /// Tip block hash, cached in the indexer.
#[inline]
pub fn tip_blockhash(&self) -> BlockHash { pub fn tip_blockhash(&self) -> BlockHash {
self.indexer().tip_blockhash() self.indexer().tip_blockhash()
} }
/// Tip block hash prefix for cache etags. /// Tip block hash prefix for cache etags.
#[inline]
pub fn tip_hash_prefix(&self) -> BlockHashPrefix { pub fn tip_hash_prefix(&self) -> BlockHashPrefix {
BlockHashPrefix::from(&self.tip_blockhash()) BlockHashPrefix::from(&self.tip_blockhash())
} }

View File

@@ -0,0 +1 @@
*md

View File

@@ -28,6 +28,8 @@ from typing import Any
import pytest import pytest
import requests import requests
from brk_client import BrkClient
# Make `_lib` and `_endpoints` importable from any nested test file. # Make `_lib` and `_endpoints` importable from any nested test file.
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
@@ -118,7 +120,16 @@ class LiveData:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def brk(): 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") @pytest.fixture(scope="session")
@@ -127,12 +138,12 @@ def mempool():
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def check_servers(brk, mempool): def check_servers(mempool):
"""Fail fast if either server is unreachable.""" """Fail fast if either server is unreachable."""
try: try:
brk.get("/api/blocks/tip/height") BrkClient(BRK_BASE).get_text("/api/blocks/tip/height")
except Exception as e: 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: try:
mempool.get("/api/blocks/tip/height") mempool.get("/api/blocks/tip/height")
except Exception as e: except Exception as e:

View File

@@ -1,35 +1,103 @@
"""GET /api/v1/difficulty-adjustment""" """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", "progressPercent", "difficultyChange", "estimatedRetargetDate",
"remainingBlocks", "remainingTime", "previousRetarget", "remainingBlocks", "remainingTime", "previousRetarget",
"previousTime", "nextRetargetHeight", "timeAvg", "previousTime", "nextRetargetHeight", "timeAvg",
"adjustedTimeAvg", "timeOffset", "expectedBlocks", "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): def test_difficulty_adjustment_shape(brk, mempool):
"""Difficulty adjustment must have the same structure.""" """Response must have every key mempool.space returns, with matching types."""
path = "/api/v1/difficulty-adjustment" path = "/api/v1/difficulty-adjustment"
b = brk.get_json(path) b = brk.get_difficulty_adjustment()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
for key in DIFFICULTY_KEYS: missing = REQUIRED_KEYS - set(b.keys())
assert key in b, f"brk missing '{key}'" assert not missing, f"brk missing keys: {missing}"
def test_difficulty_adjustment_values_sane(brk, mempool): def test_difficulty_adjustment_chain_values_match_mempool(brk, mempool):
"""Progress must be 0-100 %, remaining blocks must be 0-2016.""" """Chain-derived fields must equal mempool.space's within float tolerance."""
path = "/api/v1/difficulty-adjustment" b = brk.get_difficulty_adjustment()
for label, client in [("brk", brk), ("mempool", mempool)]: m = mempool.get_json("/api/v1/difficulty-adjustment")
d = client.get_json(path) assert_same_values(
assert 0 <= d["progressPercent"] <= 100, ( {k: b[k] for k in CHAIN_DETERMINISTIC_FIELDS},
f"{label} progressPercent out of range: {d['progressPercent']}" {k: m[k] for k in CHAIN_DETERMINISTIC_FIELDS},
) )
assert 0 <= d["remainingBlocks"] <= 2016, (
f"{label} remainingBlocks out of range: {d['remainingBlocks']}"
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)"
) )