mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 06:14:47 -07:00
query: fixes
This commit is contained in:
@@ -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}")]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
crates/brk_query/.gitignore
vendored
2
crates/brk_query/.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
*.txt
|
*.txt
|
||||||
|
/*.md
|
||||||
|
!README.md
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/brk_client/tests/mempool_compat/.gitignore
vendored
Normal file
1
packages/brk_client/tests/mempool_compat/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*md
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user