global: fixes

This commit is contained in:
nym21
2026-05-03 00:57:22 +02:00
parent 2b8a0a8cf7
commit 9cb5f2c880
13 changed files with 120 additions and 74 deletions

View File

@@ -9026,7 +9026,7 @@ impl BrkClient {
/// Address transactions /// Address transactions
/// ///
/// Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. /// Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.
/// ///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
/// ///

View File

@@ -90,19 +90,20 @@ impl Query {
} }
/// Esplora `/address/:address/txs` first page: up to `mempool_limit` /// Esplora `/address/:address/txs` first page: up to `mempool_limit`
/// mempool (newest first) followed by the first `chain_limit` /// mempool entries (newest first), then chain entries fill the response
/// confirmed. Pagination is path-style via `/txs/chain/:after_txid`. /// up to `total_limit`. Pagination is path-style via `/txs/chain/:after_txid`.
pub fn addr_txs( pub fn addr_txs(
&self, &self,
addr: Addr, addr: Addr,
total_limit: usize,
mempool_limit: usize, mempool_limit: usize,
chain_limit: usize,
) -> Result<Vec<Transaction>> { ) -> Result<Vec<Transaction>> {
let mut out = if self.mempool().is_some() { let mut out = if self.mempool().is_some() {
self.addr_mempool_txs(&addr, mempool_limit)? self.addr_mempool_txs(&addr, mempool_limit)?
} else { } else {
Vec::new() Vec::new()
}; };
let chain_limit = total_limit.saturating_sub(out.len());
out.extend(self.addr_txs_chain(&addr, None, chain_limit)?); out.extend(self.addr_txs_chain(&addr, None, chain_limit)?);
Ok(out) Ok(out)
} }

View File

@@ -16,7 +16,9 @@ impl Query {
let max_height = Height::from(indexer.vecs.blocks.blockhash.len().saturating_sub(1)); let max_height = Height::from(indexer.vecs.blocks.blockhash.len().saturating_sub(1));
if height > max_height { if height > max_height {
return Err(Error::OutOfRange("Block height out of range".into())); return Err(Error::OutOfRange(format!(
"Block height {height} out of range (tip {max_height})"
)));
} }
let position = indexer.vecs.blocks.position.collect_one(height).data()?; let position = indexer.vecs.blocks.position.collect_one(height).data()?;

View File

@@ -13,25 +13,25 @@ use crate::Query;
const RECENT_REPLACEMENTS_LIMIT: usize = 25; const RECENT_REPLACEMENTS_LIMIT: usize = 25;
impl Query { impl Query {
fn require_mempool(&self) -> Result<&Mempool> {
self.mempool().ok_or(Error::MempoolNotAvailable)
}
pub fn mempool_info(&self) -> Result<MempoolInfo> { pub fn mempool_info(&self) -> Result<MempoolInfo> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; Ok(self.require_mempool()?.info())
Ok(mempool.info())
} }
pub fn mempool_txids(&self) -> Result<Vec<Txid>> { pub fn mempool_txids(&self) -> Result<Vec<Txid>> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let txs = self.require_mempool()?.txs();
let txs = mempool.txs();
Ok(txs.keys().cloned().collect()) Ok(txs.keys().cloned().collect())
} }
pub fn recommended_fees(&self) -> Result<RecommendedFees> { pub fn recommended_fees(&self) -> Result<RecommendedFees> {
self.mempool() self.require_mempool().map(|m| m.fees())
.map(|mempool| mempool.fees())
.ok_or(Error::MempoolNotAvailable)
} }
pub fn mempool_blocks(&self) -> Result<Vec<MempoolBlock>> { pub fn mempool_blocks(&self) -> Result<Vec<MempoolBlock>> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let mempool = self.require_mempool()?;
let block_stats = mempool.block_stats(); let block_stats = mempool.block_stats();
@@ -90,8 +90,7 @@ impl Query {
} }
pub fn mempool_recent(&self) -> Result<Vec<MempoolRecentTx>> { pub fn mempool_recent(&self) -> Result<Vec<MempoolRecentTx>> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; Ok(self.require_mempool()?.txs().recent().to_vec())
Ok(mempool.txs().recent().to_vec())
} }
/// CPFP cluster for `txid`. Returns the mempool cluster when the txid is /// CPFP cluster for `txid`. Returns the mempool cluster when the txid is
@@ -289,7 +288,7 @@ impl Query {
/// walks `predecessors_of` backward to build the tree. `replaces` /// walks `predecessors_of` backward to build the tree. `replaces`
/// is the requested tx's own direct predecessors. /// is the requested tx's own direct predecessors.
pub fn tx_rbf(&self, txid: &Txid) -> Result<RbfResponse> { pub fn tx_rbf(&self, txid: &Txid) -> Result<RbfResponse> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let mempool = self.require_mempool()?;
let txs = mempool.txs(); let txs = mempool.txs();
let entries = mempool.entries(); let entries = mempool.entries();
let graveyard = mempool.graveyard(); let graveyard = mempool.graveyard();
@@ -422,7 +421,7 @@ impl Query {
/// true, only trees with at least one non-signaling predecessor /// true, only trees with at least one non-signaling predecessor
/// are returned. /// are returned.
pub fn recent_replacements(&self, full_rbf_only: bool) -> Result<Vec<ReplacementNode>> { pub fn recent_replacements(&self, full_rbf_only: bool) -> Result<Vec<ReplacementNode>> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let mempool = self.require_mempool()?;
let txs = mempool.txs(); let txs = mempool.txs();
let entries = mempool.entries(); let entries = mempool.entries();
let graveyard = mempool.graveyard(); let graveyard = mempool.graveyard();
@@ -450,15 +449,17 @@ impl Query {
.collect()) .collect())
} }
/// `first_seen` Unix-second timestamps for each txid, matching
/// mempool.space's `POST /api/v1/transaction-times`. Returns 0 for
/// unknown txids, in input order.
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 entries = self.require_mempool()?.entries();
let entries = mempool.entries();
Ok(txids Ok(txids
.iter() .iter()
.map(|txid| { .map(|txid| {
entries entries
.get(&TxidPrefix::from(txid)) .get(&TxidPrefix::from(txid))
.map(|e| usize::from(e.first_seen) as u64) .map(|e| u64::from(e.first_seen))
.unwrap_or(0) .unwrap_or(0)
}) })
.collect()) .collect())

View File

@@ -65,8 +65,17 @@ impl Query {
TARGET_BLOCK_TIME TARGET_BLOCK_TIME
}; };
// Per-block time needed over remaining blocks to land the epoch at
// 2016 * TARGET_BLOCK_TIME. Matches mempool.space's adjustedTimeAvg.
let target_total = BLOCKS_PER_EPOCH as u64 * TARGET_BLOCK_TIME;
let adjusted_time_avg = if remaining_blocks > 0 {
target_total.saturating_sub(elapsed_time) / remaining_blocks as u64
} else {
TARGET_BLOCK_TIME
};
// Estimate remaining time and retarget date // Estimate remaining time and retarget date
let remaining_time = remaining_blocks as u64 * time_avg; let remaining_time = remaining_blocks as u64 * adjusted_time_avg;
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
@@ -131,7 +140,7 @@ impl Query {
previous_time, previous_time,
next_retarget_height: Height::from(next_retarget_height), next_retarget_height: Height::from(next_retarget_height),
time_avg: time_avg * 1000, time_avg: time_avg * 1000,
adjusted_time_avg: time_avg * 1000, adjusted_time_avg: adjusted_time_avg * 1000,
time_offset, time_offset,
expected_blocks, expected_blocks,
}) })

View File

@@ -50,12 +50,12 @@ impl AddrRoutes for ApiRouter<AppState> {
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, false); let strategy = state.addr_strategy(Version::ONE, &path.addr, false);
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, 50, 25)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, 50, 50)).await
}, |op| op }, |op| op
.id("get_address_txs") .id("get_address_txs")
.addrs_tag() .addrs_tag()
.summary("Address transactions") .summary("Address transactions")
.description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*") .description("Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
.json_response::<Vec<Transaction>>() .json_response::<Vec<Transaction>>()
.not_modified() .not_modified()
.bad_request() .bad_request()

View File

@@ -137,6 +137,13 @@ impl From<Timestamp> for usize {
} }
} }
impl From<Timestamp> for u64 {
#[inline]
fn from(value: Timestamp) -> Self {
u64::from(value.0)
}
}
impl From<Date> for Timestamp { impl From<Date> for Timestamp {
#[inline] #[inline]
fn from(value: Date) -> Self { fn from(value: Date) -> Self {

View File

@@ -10442,7 +10442,7 @@ class BrkClient extends BrkClientBase {
/** /**
* Address transactions * Address transactions
* *
* Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. * Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.
* *
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
* *

View File

@@ -7793,7 +7793,7 @@ class BrkClient(BrkClientBase):
def get_address_txs(self, address: Addr) -> List[Transaction]: def get_address_txs(self, address: Addr) -> List[Transaction]:
"""Address transactions. """Address transactions.
Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*

View File

@@ -49,67 +49,44 @@ def test_address_txs_shape_dynamic(brk, mempool, live_addrs):
@pytest.mark.parametrize("addr", STATIC_ADDRS) @pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_ordering(brk, addr): def test_address_txs_ordering(brk, addr):
"""All entries must be confirmed and heights monotonically non-increasing.""" """Response is mempool-prefix (unconfirmed, newest-first) + chain-suffix (confirmed, height-desc)."""
b = brk.get_address_txs(addr) b = brk.get_address_txs(addr)
if not b: if not b:
pytest.skip(f"{addr} has no txs in brk") pytest.skip(f"{addr} has no txs in brk")
for tx in b:
assert tx["status"]["confirmed"] is True, ( confirmed_flags = [tx["status"]["confirmed"] for tx in b]
f"{addr} returned unconfirmed tx {tx['txid']} (this endpoint is chain-only on brk)" assert confirmed_flags == sorted(confirmed_flags), (
) f"{addr}: confirmed flags must be False*..*True* (mempool prefix then chain), got "
heights = [tx["status"]["block_height"] for tx in b] f"{confirmed_flags[:10]}..."
)
chain = [tx for tx in b if tx["status"]["confirmed"]]
heights = [tx["status"]["block_height"] for tx in chain]
assert heights == sorted(heights, reverse=True), ( assert heights == sorted(heights, reverse=True), (
f"{addr} not newest-first by height: {heights[:5]}..." f"{addr} chain segment not newest-first by height: {heights[:5]}..."
) )
@pytest.mark.parametrize("addr", STATIC_ADDRS) @pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_limit(brk, addr): def test_address_txs_limit(brk, addr):
"""Hard cap of 50 confirmed txs per call.""" """Hard cap of 50 entries per call (mempool first, chain fills remainder)."""
b = brk.get_address_txs(addr) b = brk.get_address_txs(addr)
assert len(b) <= 50, f"{addr} returned {len(b)} txs, exceeds 50-cap" assert len(b) <= 50, f"{addr} returned {len(b)} txs, exceeds 50-cap"
@pytest.mark.parametrize("addr", STABLE_ADDRS) @pytest.mark.parametrize("addr", STABLE_ADDRS)
def test_address_txs_top_match_stable(brk, mempool, addr): def test_address_txs_top_match_stable(brk, mempool, addr):
"""For inactive historical addresses, brk and mempool agree on first-page order.""" """For inactive historical addresses, the confirmed tail must agree exactly with mempool.space."""
b_txids = [t["txid"] for t in brk.get_address_txs(addr)] b_chain = [t["txid"] for t in brk.get_address_txs(addr) if t["status"]["confirmed"]]
m_txids = [t["txid"] for t in mempool.get_json(f"/api/address/{addr}/txs")] m_chain = [
assert b_txids == m_txids, ( t["txid"]
f"{addr} first-page txid order diverges:\n" for t in mempool.get_json(f"/api/address/{addr}/txs")
f" brk: {b_txids[:5]}...\n" if t["status"]["confirmed"]
f" mempool: {m_txids[:5]}..." ]
) assert b_chain == m_chain, (
f"{addr} confirmed-tail txid order diverges:\n"
f" brk: {b_chain[:5]}...\n"
def test_address_txs_pagination(brk, mempool): f" mempool: {m_chain[:5]}..."
"""`after_txid` returns a fresh, strictly-older page; matches mempool.space."""
addr = "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r"
first = brk.get_address_txs(addr)
assert len(first) == 50, f"expected full first page, got {len(first)}"
last_txid = first[-1]["txid"]
last_height = first[-1]["status"]["block_height"]
second = brk.get_address_txs(addr, after_txid=last_txid)
assert second, "second page must be non-empty for a 5700-tx address"
first_txids = {t["txid"] for t in first}
second_txids = {t["txid"] for t in second}
assert not (first_txids & second_txids), "pagination must not return overlapping txs"
for tx in second:
assert tx["status"]["block_height"] <= last_height, (
f"page 2 tx {tx['txid']} at height {tx['status']['block_height']} "
f"exceeds page-1 tail height {last_height}"
)
m_second = mempool.get_json(f"/api/address/{addr}/txs?after_txid={last_txid}")
b_ids = [t["txid"] for t in second]
m_ids = [t["txid"] for t in m_second]
assert b_ids == m_ids, (
f"page-2 order diverges from mempool:\n"
f" brk: {b_ids[:5]}...\n"
f" mempool: {m_ids[:5]}..."
) )

View File

@@ -69,8 +69,8 @@ def test_difficulty_adjustment_invariants(brk):
assert 1_000 <= d["timeAvg"] <= 3_600_000 assert 1_000 <= d["timeAvg"] <= 3_600_000
assert 1_000 <= d["adjustedTimeAvg"] <= 3_600_000 assert 1_000 <= d["adjustedTimeAvg"] <= 3_600_000
# remainingTime is constructed as remainingBlocks * timeAvg in brk. # remainingTime is remainingBlocks * adjustedTimeAvg (matches mempool.space).
assert d["remainingTime"] == d["remainingBlocks"] * d["timeAvg"] assert d["remainingTime"] == d["remainingBlocks"] * d["adjustedTimeAvg"]
assert d["estimatedRetargetDate"] > now_ms assert d["estimatedRetargetDate"] > now_ms
assert d["previousTime"] * 1000 < now_ms assert d["previousTime"] * 1000 < now_ms

View File

@@ -55,3 +55,26 @@ def test_cpfp_malformed_short(brk, bad):
with pytest.raises(BrkError) as exc_info: with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/cpfp/{bad}") brk.get_text(f"/api/v1/cpfp/{bad}")
assert exc_info.value.status == 400 assert exc_info.value.status == 400
def test_cpfp_mempool_unconfirmed(brk, mempool):
"""Unconfirmed mempool tx: brk and mempool.space agree on cpfp shape."""
txids = mempool.get_json("/api/mempool/txids")
if not txids:
pytest.skip("mempool.space mempool currently empty")
for txid in txids[:50]:
try:
b = brk.get_cpfp(txid)
except BrkError:
continue
try:
m = mempool.get_json(f"/api/v1/cpfp/{txid}")
except Exception:
continue
show("GET", f"/api/v1/cpfp/{txid}", b, m)
assert_same_structure(b, m)
assert isinstance(b.get("ancestors"), list)
assert isinstance(b.get("descendants", []), list)
return
pytest.skip("no shared unconfirmed tx between brk and mempool.space")

View File

@@ -49,3 +49,29 @@ def test_tx_status_malformed_unknown(brk):
with pytest.raises(BrkError) as exc_info: with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/status") brk.get_text(f"/api/tx/{bad}/status")
assert exc_info.value.status == 404 assert exc_info.value.status == 404
def test_tx_status_mempool_unconfirmed(brk, mempool):
"""Unconfirmed mempool tx: status must be confirmed=false with no block fields."""
txids = mempool.get_json("/api/mempool/txids")
if not txids:
pytest.skip("mempool.space mempool currently empty")
for txid in txids[:25]:
try:
b = brk.get_tx_status(txid)
except BrkError:
continue
if b.get("confirmed"):
continue
try:
m = mempool.get_json(f"/api/tx/{txid}/status")
except Exception:
continue
if m.get("confirmed"):
continue
show("GET", f"/api/tx/{txid}/status", b, m)
assert_same_values(b, m)
assert b["confirmed"] is False
return
pytest.skip("no shared unconfirmed tx between brk and mempool.space")