global: fixes

This commit is contained in:
nym21
2026-05-02 00:42:16 +02:00
parent 6f879a5551
commit 2b8a0a8cf7
99 changed files with 4308 additions and 1525 deletions

View File

@@ -2,47 +2,130 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
@pytest.fixture(params=[
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # P2PKH — early block reward
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # P2SH
], ids=["p2pkh", "p2sh"])
def static_addr(request):
"""Well-known addresses that always exist."""
return request.param
KNOWN_ADDR_TYPES = {
"p2pk", "p2pkh", "p2sh", "v0_p2wpkh", "v0_p2wsh", "v1_p2tr",
"multisig", "op_return", "p2a", "empty", "unknown",
}
# Static fixtures: stable addresses with known shapes.
STATIC_ADDRS = [
"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", # genesis coinbase, p2pkh — heavy path
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # p2pkh — exercises tx_count divergence
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # p2sh
]
# Satoshi's genesis pubkey (uncompressed). Brk-only: mempool returns 400.
SATOSHI_GENESIS_PUBKEY = (
"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f"
)
def test_address_info_static(brk, mempool, static_addr):
"""Address stats structure must match for well-known addresses."""
path = f"/api/address/{static_addr}"
b = brk.get_json(path)
def _tx_count_tolerance(m_tx_count: int) -> int:
"""Allow drift between brk's distinct-tx and mempool's output-count semantics."""
import math
return max(5, math.ceil(0.05 * m_tx_count))
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_info_shape(brk, mempool, addr):
"""Typed brk response must structurally match mempool and echo the input address."""
path = f"/api/address/{addr}"
b = brk.get_address(addr)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
assert b["address"] == m["address"]
assert b["address"] == addr
assert "addr_type" in b
assert "type_index" in b["chain_stats"]
assert "realized_price" in b["chain_stats"]
def test_address_info_discovered(brk, mempool, live_addrs):
"""Address stats structure must match for each discovered type."""
def test_address_info_shape_dynamic(brk, mempool, live_addrs):
"""Same shape contract over each live-discovered scriptpubkey type."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs:
path = f"/api/address/{addr}"
b = brk.get_json(path)
b = brk.get_address(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", b, m)
assert_same_structure(b, m)
assert b["address"] == m["address"]
assert b["address"] == addr
def test_address_chain_stats_close(brk, mempool, live_addrs):
"""Chain stats values must be close for each discovered address."""
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_chain_stats_match(brk, mempool, addr):
"""Funded/spent counts and sums must match exactly; tx_count tolerated within 5% (min 5)."""
path = f"/api/address/{addr}"
b = brk.get_address(addr)["chain_stats"]
m = mempool.get_json(path)["chain_stats"]
show("GET", f"{path} [chain_stats]", b, m)
for key in ("funded_txo_count", "funded_txo_sum", "spent_txo_count", "spent_txo_sum"):
assert b[key] == m[key], (
f"{addr} {key}: brk={b[key]} vs mempool={m[key]}"
)
tol = _tx_count_tolerance(m["tx_count"])
assert abs(b["tx_count"] - m["tx_count"]) <= tol, (
f"{addr} tx_count drift {abs(b['tx_count'] - m['tx_count'])} > tol {tol}: "
f"brk={b['tx_count']} vs mempool={m['tx_count']}"
)
def test_address_chain_stats_match_dynamic(brk, mempool, live_addrs):
"""Same equality/tolerance contract on dynamically discovered addresses."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs:
path = f"/api/address/{addr}"
b = brk.get_json(path)["chain_stats"]
b = brk.get_address(addr)["chain_stats"]
m = mempool.get_json(path)["chain_stats"]
show("GET", f"{path} [chain_stats, {atype}]", b, m)
assert_same_structure(b, m)
assert abs(b["tx_count"] - m["tx_count"]) <= 5, (
f"{atype} tx_count: brk={b['tx_count']} vs mempool={m['tx_count']}"
for key in ("funded_txo_count", "funded_txo_sum", "spent_txo_count", "spent_txo_sum"):
assert b[key] == m[key], (
f"{atype} {addr} {key}: brk={b[key]} vs mempool={m[key]}"
)
tol = _tx_count_tolerance(m["tx_count"])
assert abs(b["tx_count"] - m["tx_count"]) <= tol, (
f"{atype} {addr} tx_count drift {abs(b['tx_count'] - m['tx_count'])} > tol {tol}: "
f"brk={b['tx_count']} vs mempool={m['tx_count']}"
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_brk_extras(brk, addr):
"""Brk-only extras must be coherent: known addr_type, non-negative type_index/realized_price."""
b = brk.get_address(addr)
assert b["addr_type"] in KNOWN_ADDR_TYPES, (
f"unknown addr_type {b['addr_type']!r} for {addr}"
)
cs = b["chain_stats"]
assert cs["type_index"] >= 0, f"negative type_index for {addr}: {cs['type_index']}"
assert cs["realized_price"] >= 0, (
f"negative realized_price for {addr}: {cs['realized_price']}"
)
if cs["tx_count"] == 0:
assert cs["realized_price"] == 0, (
f"unfunded address {addr} must have realized_price=0, got {cs['realized_price']}"
)
def test_address_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_address_pubkey_as_address(brk):
"""Brk-only: hex-encoded pubkey is accepted as a P2PK address."""
b = brk.get_address(SATOSHI_GENESIS_PUBKEY)
assert b["addr_type"] == "p2pk", f"expected p2pk, got {b['addr_type']!r}"
assert b["chain_stats"]["funded_txo_count"] >= 1, (
f"genesis pubkey must have at least one funded output, got "
f"{b['chain_stats']['funded_txo_count']}"
)

View File

@@ -2,33 +2,44 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
@pytest.fixture(params=[
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r",
], ids=["p2pkh", "p2sh"])
def static_addr(request):
return request.param
# Heavy address (recently active) — stresses the 50-cap path; cannot be ordered
# exactly against mempool.space because the two indexers drift at the chain tip.
ACTIVE_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
# Inactive historical addresses — both indexers agree exactly on first-page
# ordering and on pagination.
STABLE_ADDRS = [
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # p2pkh, ~125 txs
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # p2sh, ~5700 txs (heavy pagination)
]
STATIC_ADDRS = [ACTIVE_ADDR] + STABLE_ADDRS
def test_address_txs_static(brk, mempool, static_addr):
"""Confirmed+mempool tx list structure must match for well-known addresses."""
path = f"/api/address/{static_addr}/txs"
b = brk.get_json(path)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_shape(brk, mempool, addr):
"""Typed list response must structurally match mempool; brk's `index` extra is allowed."""
path = f"/api/address/{addr}/txs"
b = brk.get_address_txs(addr)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list)
if b and m:
assert_same_structure(b[0], m[0])
assert "index" in b[0], "brk-only `index` field missing"
def test_address_txs_discovered(brk, mempool, live_addrs):
"""Confirmed+mempool tx list structure must match for each discovered type."""
def test_address_txs_shape_dynamic(brk, mempool, live_addrs):
"""Same shape contract over each live-discovered scriptpubkey type."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs:
path = f"/api/address/{addr}/txs"
b = brk.get_json(path)
b = brk.get_address_txs(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list)
@@ -36,14 +47,76 @@ def test_address_txs_discovered(brk, mempool, live_addrs):
assert_same_structure(b[0], m[0])
def test_address_txs_fields(brk, mempool, live):
"""Every tx in the list must carry the core mempool.space fields."""
path = f"/api/address/{live.sample_address}/txs"
b = brk.get_json(path)
show("GET", path, f"({len(b)} txs)", "")
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_ordering(brk, addr):
"""All entries must be confirmed and heights monotonically non-increasing."""
b = brk.get_address_txs(addr)
if not b:
pytest.skip("address has no txs in brk")
required = {"txid", "version", "locktime", "vin", "vout", "size", "weight", "fee", "status"}
for tx in b[:5]:
missing = required - set(tx.keys())
assert not missing, f"tx {tx.get('txid', '?')} missing fields: {missing}"
pytest.skip(f"{addr} has no txs in brk")
for tx in b:
assert tx["status"]["confirmed"] is True, (
f"{addr} returned unconfirmed tx {tx['txid']} (this endpoint is chain-only on brk)"
)
heights = [tx["status"]["block_height"] for tx in b]
assert heights == sorted(heights, reverse=True), (
f"{addr} not newest-first by height: {heights[:5]}..."
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_limit(brk, addr):
"""Hard cap of 50 confirmed txs per call."""
b = brk.get_address_txs(addr)
assert len(b) <= 50, f"{addr} returned {len(b)} txs, exceeds 50-cap"
@pytest.mark.parametrize("addr", STABLE_ADDRS)
def test_address_txs_top_match_stable(brk, mempool, addr):
"""For inactive historical addresses, brk and mempool agree on first-page order."""
b_txids = [t["txid"] for t in brk.get_address_txs(addr)]
m_txids = [t["txid"] for t in mempool.get_json(f"/api/address/{addr}/txs")]
assert b_txids == m_txids, (
f"{addr} first-page txid order diverges:\n"
f" brk: {b_txids[:5]}...\n"
f" mempool: {m_txids[:5]}..."
)
def test_address_txs_pagination(brk, mempool):
"""`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]}..."
)
def test_address_txs_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_txs("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)

View File

@@ -1,34 +1,43 @@
"""GET /api/address/{address}/txs/chain"""
"""GET /api/address/{address}/txs/chain (and /txs/chain/{after_txid})"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
@pytest.fixture(params=[
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r",
], ids=["p2pkh", "p2sh"])
def static_addr(request):
return request.param
# Heavy active address (chain-tip drift expected, no exact-order assertion)
ACTIVE_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
# Inactive historical addresses — both indexers agree exactly on first-page ordering
STABLE_ADDRS = [
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # p2pkh, ~125 txs
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # p2sh, ~5700 txs (heavy pagination)
]
STATIC_ADDRS = [ACTIVE_ADDR] + STABLE_ADDRS
def test_address_txs_chain_static(brk, mempool, static_addr):
"""Confirmed-only tx list structure must match for well-known addresses."""
path = f"/api/address/{static_addr}/txs/chain"
b = brk.get_json(path)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_chain_shape(brk, mempool, addr):
"""Typed list response must structurally match mempool; brk's `index` extra is allowed."""
path = f"/api/address/{addr}/txs/chain"
b = brk.get_address_confirmed_txs(addr)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list)
if b and m:
assert_same_structure(b[0], m[0])
assert "index" in b[0], "brk-only `index` field missing"
def test_address_txs_chain_discovered(brk, mempool, live_addrs):
"""Confirmed-only tx list structure must match for each discovered type."""
def test_address_txs_chain_shape_dynamic(brk, mempool, live_addrs):
"""Same shape contract over each live-discovered scriptpubkey type."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs:
path = f"/api/address/{addr}/txs/chain"
b = brk.get_json(path)
b = brk.get_address_confirmed_txs(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list)
@@ -36,14 +45,88 @@ def test_address_txs_chain_discovered(brk, mempool, live_addrs):
assert_same_structure(b[0], m[0])
def test_address_txs_chain_all_confirmed(brk, live):
"""Every tx returned by /txs/chain must have confirmed=True in its status."""
path = f"/api/address/{live.sample_address}/txs/chain"
b = brk.get_json(path)
show("GET", path, f"({len(b)} txs)", "")
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_chain_all_confirmed(brk, addr):
"""Every entry must have `status.confirmed == True`."""
b = brk.get_address_confirmed_txs(addr)
if not b:
pytest.skip("address has no confirmed txs in brk")
unconfirmed = [t for t in b if not t.get("status", {}).get("confirmed", False)]
pytest.skip(f"{addr} has no confirmed txs in brk")
unconfirmed = [t for t in b if not t["status"]["confirmed"]]
assert not unconfirmed, (
f"{len(unconfirmed)} unconfirmed tx(s) returned by /txs/chain"
f"{addr}: {len(unconfirmed)} unconfirmed tx(s) returned: "
f"{[t['txid'] for t in unconfirmed[:3]]}"
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_chain_ordering(brk, addr):
"""Heights must be monotonically non-increasing (newest first)."""
b = brk.get_address_confirmed_txs(addr)
if not b:
pytest.skip(f"{addr} has no confirmed txs in brk")
heights = [t["status"]["block_height"] for t in b]
assert heights == sorted(heights, reverse=True), (
f"{addr} not newest-first by height: {heights[:5]}..."
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_chain_limit(brk, addr):
"""Hard cap of 25 confirmed txs per call."""
b = brk.get_address_confirmed_txs(addr)
assert len(b) <= 25, f"{addr} returned {len(b)} txs, exceeds 25-cap"
@pytest.mark.parametrize("addr", STABLE_ADDRS)
def test_address_txs_chain_top_match_stable(brk, mempool, addr):
"""For inactive historical addresses, brk and mempool agree on first-page order."""
b_txids = [t["txid"] for t in brk.get_address_confirmed_txs(addr)]
m_txids = [t["txid"] for t in mempool.get_json(f"/api/address/{addr}/txs/chain")]
assert b_txids == m_txids, (
f"{addr} first-page txid order diverges:\n"
f" brk: {b_txids[:5]}...\n"
f" mempool: {m_txids[:5]}..."
)
def test_address_txs_chain_pagination(brk, mempool):
"""Path-style pagination must match mempool.space's Esplora-canonical form exactly."""
addr = "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r"
first = brk.get_address_confirmed_txs(addr)
assert len(first) == 25, f"expected full first page (25), got {len(first)}"
last_txid = first[-1]["txid"]
last_height = first[-1]["status"]["block_height"]
second = brk.get_address_confirmed_txs_after(addr, last_txid)
assert second, "second page must be non-empty for a 5700-tx address"
assert len(second) <= 25, f"page 2 exceeds 25-cap: {len(second)}"
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"]["confirmed"] is True, f"page 2 has unconfirmed tx {tx['txid']}"
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}"
)
# Cross-check against mempool.space's path-style form.
m_second = mempool.get_json(f"/api/address/{addr}/txs/chain/{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 path-style:\n"
f" brk: {b_ids[:5]}...\n"
f" mempool: {m_ids[:5]}..."
)
def test_address_txs_chain_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_confirmed_txs("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)

View File

@@ -1,33 +1,55 @@
"""GET /api/address/{address}/txs/mempool"""
from _lib import show
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
def test_address_txs_mempool_sample(brk, mempool, live):
"""Mempool tx list must be an array (contents are volatile)."""
path = f"/api/address/{live.sample_address}/txs/mempool"
b = brk.get_json(path)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list)
def test_address_txs_mempool_discovered(brk, mempool, live_addrs):
"""Mempool tx list must be a (possibly empty) array for each discovered type."""
def test_address_txs_mempool_shape_dynamic(brk, mempool, live_addrs):
"""Shape contract over each live-discovered scriptpubkey type."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs:
path = f"/api/address/{addr}/txs/mempool"
b = brk.get_json(path)
b = brk.get_address_mempool_txs(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list)
if b and m:
assert_same_structure(b[0], m[0])
def test_address_txs_mempool_all_unconfirmed(brk, live):
"""Every tx returned by /txs/mempool must have confirmed=False (if any)."""
path = f"/api/address/{live.sample_address}/txs/mempool"
b = brk.get_json(path)
show("GET", path, f"({len(b)} txs)", "")
confirmed = [t for t in b if t.get("status", {}).get("confirmed", False)]
assert not confirmed, (
f"{len(confirmed)} confirmed tx(s) returned by /txs/mempool"
def test_address_txs_mempool_limit(brk, live_addrs):
"""Hard cap of 50 mempool txs per call."""
for _atype, addr in live_addrs:
b = brk.get_address_mempool_txs(addr)
assert len(b) <= 50, f"{addr} returned {len(b)} txs, exceeds 50-cap"
def test_address_txs_mempool_all_unconfirmed(brk, live_addrs):
"""Every entry must have status.confirmed == False."""
for _atype, addr in live_addrs:
b = brk.get_address_mempool_txs(addr)
confirmed = [t for t in b if t["status"]["confirmed"]]
assert not confirmed, (
f"{addr}: {len(confirmed)} confirmed tx(s) returned by /txs/mempool: "
f"{[t['txid'] for t in confirmed[:3]]}"
)
def test_address_txs_mempool_unique_txids(brk, live_addrs):
"""No duplicate txids within a single response."""
for _atype, addr in live_addrs:
b = brk.get_address_mempool_txs(addr)
txids = [t["txid"] for t in b]
assert len(txids) == len(set(txids)), f"{addr}: duplicate txids in response"
def test_address_txs_mempool_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_mempool_txs("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)

View File

@@ -2,51 +2,70 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
@pytest.fixture(params=[
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r",
], ids=["p2pkh", "p2sh"])
def static_addr(request):
return request.param
# Inactive historical addresses with stable, comparable UTXO sets.
STABLE_ADDRS = [
("p2pkh", "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S"),
("p2sh", "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r"),
]
# Genesis pubkey-hash address: tens of thousands of dust UTXOs — exceeds both
# brk's 1000-cap and mempool.space's 500-cap, so both indexers must 400.
HEAVY_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
def test_address_utxo_static(brk, mempool, static_addr):
"""UTXO list must match — same txids, values, and statuses."""
path = f"/api/address/{static_addr}/utxo"
b = brk.get_json(path)
@pytest.mark.parametrize("atype,addr", STABLE_ADDRS, ids=[a for a, _ in STABLE_ADDRS])
def test_address_utxo_static(brk, mempool, atype, addr):
"""Exact UTXO parity (txid+vout+value+status) for stable historical addresses."""
path = f"/api/address/{addr}/utxo"
b = brk.get_address_utxos(addr)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} utxos)", f"({len(m)} utxos)")
assert isinstance(b, list) and isinstance(m, list)
key = lambda u: (u.get("txid", ""), u.get("vout", 0))
b_sorted = sorted(b, key=key)
m_sorted = sorted(m, key=key)
assert_same_values(b_sorted, m_sorted)
show("GET", f"{path} [{atype}]", f"({len(b)} utxos)", f"({len(m)} utxos)")
key = lambda u: (u["txid"], u["vout"])
assert_same_values(sorted(b, key=key), sorted(m, key=key))
def test_address_utxo_discovered(brk, mempool, live_addrs):
"""UTXO list must match for each discovered address type — same txids, values, and statuses."""
"""Same exact-parity contract over each live-discovered scriptpubkey type."""
for atype, addr in live_addrs:
path = f"/api/address/{addr}/utxo"
b = brk.get_json(path)
b = brk.get_address_utxos(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} utxos)", f"({len(m)} utxos)")
assert isinstance(b, list) and isinstance(m, list)
key = lambda u: (u.get("txid", ""), u.get("vout", 0))
key = lambda u: (u["txid"], u["vout"])
assert_same_values(sorted(b, key=key), sorted(m, key=key))
def test_address_utxo_fields(brk, live):
"""Every utxo must carry the core mempool.space fields."""
path = f"/api/address/{live.sample_address}/utxo"
b = brk.get_json(path)
show("GET", path, f"({len(b)} utxos)", "")
@pytest.mark.parametrize("atype,addr", STABLE_ADDRS, ids=[a for a, _ in STABLE_ADDRS])
def test_address_utxo_all_confirmed(brk, atype, addr):
"""brk's /utxo only returns confirmed UTXOs (mempool-funded ones are excluded by design)."""
b = brk.get_address_utxos(addr)
if not b:
pytest.skip("address has no utxos in brk")
required = {"txid", "vout", "value", "status"}
for u in b[:5]:
missing = required - set(u.keys())
assert not missing, f"utxo {u.get('txid', '?')}:{u.get('vout', '?')} missing fields: {missing}"
assert isinstance(u["value"], int) and u["value"] > 0
pytest.skip(f"{addr} has no utxos in brk")
unconfirmed = [u for u in b if not u["status"]["confirmed"]]
assert not unconfirmed, (
f"{addr}: {len(unconfirmed)} unconfirmed UTXO(s) returned: "
f"{[(u['txid'], u['vout']) for u in unconfirmed[:3]]}"
)
def test_address_utxo_too_many(brk):
"""Heavy address (>1000 UTXOs) must produce BrkError(status=400, code=too_many_utxos)."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_utxos(HEAVY_ADDR)
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_address_utxo_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_utxos("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)

View File

@@ -5,49 +5,79 @@ import pytest
from _lib import assert_same_structure, assert_same_values, show
def test_validate_address_discovered(brk, mempool, live_addrs):
"""Validation of each discovered address type must match exactly."""
for atype, addr in live_addrs:
path = f"/api/v1/validate-address/{addr}"
b = brk.get_json(path)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", b, m)
assert_same_values(b, m)
assert b["isvalid"] is True
VALID_ADDRS = [
("p2pkh-genesis", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
("p2sh", "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"),
("p2wpkh", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
("p2wsh", "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"),
("p2tr", "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"),
]
INVALID_ADDRS = [
("garbage", "notanaddress123"),
("bad-checksum-p2pkh", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb"),
("bad-checksum-p2sh", "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLz"),
("bad-checksum-p2wpkh", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5"),
("wrong-network-bech32", "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
("mixed-case-bech32", "bc1QRP33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"),
]
# Satoshi's genesis-coinbase pubkey: brk validates this as p2pk; mempool.space
# rejects all raw-pubkey-hex inputs. Documents the intentional brk superset.
GENESIS_PUBKEY = (
"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb6"
"49f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f"
)
@pytest.mark.parametrize("addr,kind", [
("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "p2pkh-genesis"),
("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", "p2sh"),
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", "p2wpkh"),
("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", "p2tr"),
])
def test_validate_address_static_valid(brk, mempool, addr, kind):
@pytest.mark.parametrize("kind,addr", VALID_ADDRS, ids=[k for k, _ in VALID_ADDRS])
def test_validate_address_static_valid(brk, mempool, kind, addr):
"""Well-known addresses across all script types must validate identically."""
path = f"/api/v1/validate-address/{addr}"
b = brk.get_json(path)
b = brk.validate_address(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{kind}]", b, m)
assert_same_values(b, m)
assert b["isvalid"] is True
assert_same_values(b, m)
@pytest.mark.parametrize("addr,kind", [
("notanaddress123", "garbage"),
("", "empty"),
("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb", "bad-checksum-p2pkh"),
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5", "bad-checksum-p2wpkh"),
("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLz", "bad-checksum-p2sh"),
])
def test_validate_address_invalid(brk, mempool, addr, kind):
"""Invalid addresses must produce the same rejection structure."""
def test_validate_address_discovered(brk, mempool, live_addrs):
"""Validation of each live-discovered scriptpubkey type must match exactly."""
for atype, addr in live_addrs:
path = f"/api/v1/validate-address/{addr}"
b = brk.validate_address(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", b, m)
assert b["isvalid"] is True
assert_same_values(b, m)
@pytest.mark.parametrize("kind,addr", INVALID_ADDRS, ids=[k for k, _ in INVALID_ADDRS])
def test_validate_address_invalid(brk, mempool, kind, addr):
"""Invalid addresses produce isvalid=false; structure must match (error strings differ by impl)."""
path = f"/api/v1/validate-address/{addr}"
if kind == "empty":
# An empty path segment routes to a different endpoint — skip.
pytest.skip("empty address routes to a different endpoint")
b = brk.get_json(path)
b = brk.validate_address(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{kind}]", b, m)
assert b["isvalid"] is False
assert m["isvalid"] is False
assert_same_structure(b, m)
def test_validate_address_pubkey_hex_brk_only(brk, mempool):
"""Raw pubkey hex: brk accepts as p2pk (superset); mempool.space rejects (non-2xx or no isvalid:true)."""
path = f"/api/v1/validate-address/{GENESIS_PUBKEY}"
b = brk.validate_address(GENESIS_PUBKEY)
m_resp = mempool.get_raw(path)
show("GET", path, b, f"<HTTP {m_resp.status_code}> {m_resp.text[:200]}")
assert b["isvalid"] is True, "brk must accept raw pubkey hex as p2pk"
assert b.get("isscript") is False
assert b.get("iswitness") is False
if 200 <= m_resp.status_code < 300:
try:
m = m_resp.json()
except ValueError:
m = None
assert not (isinstance(m, dict) and m.get("isvalid") is True), (
"mempool.space must not validate raw pubkey hex as a real address"
)

View File

@@ -1,12 +1,52 @@
"""GET /api/block/{hash}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_block_by_hash(brk, mempool, block):
"""Confirmed block info must be identical."""
"""Confirmed block info must be byte-identical for every height in the fixture."""
path = f"/api/block/{block.hash}"
b = brk.get_json(path)
b = brk.get_block(block.hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_block_genesis(brk, mempool):
"""Genesis (h=0): all fields match except previousblockhash.
Known divergence: brk returns the all-zero hash, mempool.space returns null.
Excluded from value comparison so this test surfaces if any *other* genesis
field drifts, without blocking on the known nullability gap.
"""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}"
b = brk.get_block(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b["height"] == 0
assert b["id"] == genesis_hash
assert_same_values(b, m, exclude={"previousblockhash"})
def test_block_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,52 @@
"""GET /api/block/{hash}/header"""
import re
import pytest
from brk_client import BrkError
from _lib import show
HEX_RE = re.compile(r"^[0-9a-f]{160}$")
def test_block_header(brk, mempool, block):
"""80-byte hex block header must be identical."""
"""80-byte hex block header must be identical for every height in the fixture."""
path = f"/api/block/{block.hash}/header"
b = brk.get_text(path)
b = brk.get_block_header(block.hash)
m = mempool.get_text(path)
show("GET", path, b, m)
assert len(b) == 160, f"Expected 160 hex chars (80 bytes), got {len(b)}"
assert HEX_RE.match(b), f"brk header is not 160 lowercase hex chars: {b!r}"
assert b == m
def test_block_header_genesis(brk, mempool):
"""Genesis header is byte-deterministic — must match mempool.space exactly."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/header"
b = brk.get_block_header(genesis_hash)
m = mempool.get_text(path)
show("GET", path, b, m)
assert HEX_RE.match(b)
assert b == m
def test_block_header_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_header("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_header_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_header(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,62 @@
"""GET /api/block-height/{height}"""
import re
import pytest
from brk_client import BrkError
from _lib import show
def test_block_height_to_hash(brk, mempool, block):
"""Block hash at a given height must match."""
HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
def test_block_height(brk, mempool, block):
"""Hash at the given height must match mempool.space and round-trip via /block/{hash}."""
path = f"/api/block-height/{block.height}"
b = brk.get_text(path)
b = brk.get_block_by_height(block.height)
m = mempool.get_text(path)
show("GET", path, b, m)
assert HEX_HASH_RE.match(b), f"hash is not 64 lowercase hex chars: {b!r}"
assert b == m
assert b == block.hash
assert brk.get_block(b)["height"] == block.height, "round-trip /block/{hash}.height must match"
def test_block_height_genesis(brk, mempool):
"""Height 0 returns the deterministic genesis hash."""
path = "/api/block-height/0"
b = brk.get_block_by_height(0)
m = mempool.get_text(path)
show("GET", path, b, m)
assert b == m
assert b == block.hash
assert b == "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
def test_block_height_tip(brk, mempool):
"""Height = tip/height returns tip/hash."""
tip_height = int(mempool.get_text("/api/blocks/tip/height"))
tip_hash = mempool.get_text("/api/blocks/tip/hash")
b = brk.get_block_by_height(tip_height)
show("GET", f"/api/block-height/{tip_height}", b, tip_hash)
assert b == tip_hash, f"tip mismatch: brk={b!r} mempool={tip_hash!r}"
def test_block_height_out_of_range(brk):
"""Height past the tip must produce BrkError(status=404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_by_height(99_999_999)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)
@pytest.mark.parametrize("bad", ["-1", "abc"])
def test_block_height_malformed(brk, bad):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/block-height/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,49 @@
"""GET /api/block/{hash}/raw"""
import pytest
from brk_client import BrkError
from _lib import show
def test_block_raw(brk, mempool, block):
"""Raw block bytes must be identical and start with the 80-byte header."""
"""Raw block bytes must be byte-identical to mempool.space and start with the /header bytes."""
path = f"/api/block/{block.hash}/raw"
b = brk.get_bytes(path)
b = brk.get_block_raw(block.hash)
m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m
assert len(b) >= 80
assert b[:80].hex() == brk.get_block_header(block.hash), (
"first 80 bytes of /raw must match /header response"
)
def test_block_raw_genesis(brk, mempool):
"""Genesis raw block is byte-deterministic — must match exactly."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/raw"
b = brk.get_block_raw(genesis_hash)
m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m
assert b[:80].hex() == brk.get_block_header(genesis_hash)
def test_block_raw_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_raw("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_raw_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_raw(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,12 +1,61 @@
"""GET /api/block/{hash}/status"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_block_status(brk, mempool, block):
"""Block status must be identical for a confirmed block."""
"""Block status must be identical for every height in the fixture."""
path = f"/api/block/{block.hash}/status"
b = brk.get_json(path)
b = brk.get_block_status(block.hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_block_status_genesis(brk, mempool):
"""Genesis: in_best_chain=true, height=0, next_best is block 1."""
genesis_hash = mempool.get_text("/api/block-height/0")
h1_hash = mempool.get_text("/api/block-height/1")
path = f"/api/block/{genesis_hash}/status"
b = brk.get_block_status(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b["in_best_chain"] is True
assert b["height"] == 0
assert b["next_best"] == h1_hash
assert_same_values(b, m)
def test_block_status_tip(brk, mempool):
"""Tip: next_best must be null (only block with no successor)."""
tip_hash = mempool.get_text("/api/blocks/tip/hash")
path = f"/api/block/{tip_hash}/status"
b = brk.get_block_status(tip_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b["in_best_chain"] is True
assert b["next_best"] is None, f"tip next_best must be null, got {b['next_best']!r}"
assert_same_values(b, m)
def test_block_status_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_status("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_status_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_status(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -2,36 +2,70 @@
import pytest
from brk_client import BrkError
from _lib import show
def test_block_txid_at_index_0(brk, mempool, block):
"""Txid at position 0 (coinbase) must match."""
def test_block_txid_coinbase(brk, mempool, block):
"""Position 0 is the coinbase txid; must match mempool.space byte-for-byte."""
path = f"/api/block/{block.hash}/txid/0"
b = brk.get_text(path)
b = brk.get_block_txid(block.hash, 0)
m = mempool.get_text(path)
show("GET", path, b, m)
assert b == m
def test_block_txid_at_index_1(brk, mempool, block):
"""Txid at position 1 (first non-coinbase) must match."""
def test_block_txid_positions(brk, mempool, block):
"""First, middle, and last positions in the block must all match."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
if len(txids) <= 1:
pytest.skip("block has only coinbase")
path = f"/api/block/{block.hash}/txid/1"
b = brk.get_text(path)
n = len(txids)
indices = sorted({0, 1, n // 2, n - 1})
indices = [i for i in indices if 0 <= i < n]
for i in indices:
path = f"/api/block/{block.hash}/txid/{i}"
b = brk.get_block_txid(block.hash, i)
m = mempool.get_text(path)
show("GET", path, b, m)
assert b == m, f"index {i} differs: brk={b!r} mempool={m!r}"
def test_block_txid_genesis(brk, mempool):
"""Genesis: only one tx (coinbase) at index 0, byte-deterministic."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/txid/0"
b = brk.get_block_txid(genesis_hash, 0)
m = mempool.get_text(path)
show("GET", path, b, m)
assert b == m
assert b == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
def test_block_txid_at_last_index(brk, mempool, block):
"""Txid at last position must match."""
def test_block_txid_out_of_range(brk, mempool, block):
"""Index past the last tx in the block must produce BrkError(status=404) on both."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
last = len(txids) - 1
path = f"/api/block/{block.hash}/txid/{last}"
b = brk.get_text(path)
m = mempool.get_text(path)
show("GET", path, b, m)
assert b == m
bad_index = len(txids) + 1000
with pytest.raises(BrkError) as exc_info:
brk.get_block_txid(block.hash, bad_index)
assert exc_info.value.status == 404, (
f"expected status=404 for out-of-range index, got {exc_info.value.status}"
)
def test_block_txid_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txid("notavalidhash", 0)
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txid_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txid(unknown, 0)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,12 +1,55 @@
"""GET /api/block/{hash}/txids"""
import re
import pytest
from brk_client import BrkError
from _lib import show
HEX_TXID_RE = re.compile(r"^[0-9a-f]{64}$")
def test_block_txids(brk, mempool, block):
"""Ordered txid list must be identical."""
"""Ordered txid list must match mempool.space byte-for-byte."""
path = f"/api/block/{block.hash}/txids"
b = brk.get_json(path)
b = brk.get_block_txids(block.hash)
m = mempool.get_json(path)
show("GET", path, b[:3], m[:3])
assert b == m
assert all(HEX_TXID_RE.match(t) for t in b), "every txid must be 64 lowercase hex chars"
assert b[0] == brk.get_block_txid(block.hash, 0), (
"txids[0] must equal /txid/0 (split-brain check)"
)
def test_block_txids_genesis(brk, mempool):
"""Genesis: single-element list with the deterministic coinbase txid."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/txids"
b = brk.get_block_txids(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b == m
assert b == ["4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"]
def test_block_txids_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txids("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txids_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txids(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,14 +1,73 @@
"""GET /api/block/{hash}/txs"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_block_txs_page0(brk, mempool, block):
"""First page of block transactions must match."""
# brk and mempool's sigop counting diverges (different rules for redeemscript/witness).
# Documented divergence — same source data, different aggregation.
SIGOPS_DIFF = {"sigops"}
PAGE_SIZE = 25
def test_block_txs(brk, mempool, block):
"""First page (up to 25 txs) must match mempool.space tx-for-tx, in order."""
path = f"/api/block/{block.hash}/txs"
b = brk.get_json(path)
b = brk.get_block_txs(block.hash)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)", max_lines=4)
assert len(b) == len(m), f"Page size: brk={len(b)} vs mempool={len(m)}"
if b and m:
assert_same_values(b[0], m[0], exclude={"sigops"})
assert_same_values(b, m, exclude=SIGOPS_DIFF)
def test_block_txs_page_size(brk, block):
"""Page size invariant: 25 if block has ≥25 txs, else exactly tx_count."""
txids = brk.get_block_txids(block.hash)
b = brk.get_block_txs(block.hash)
expected = min(PAGE_SIZE, len(txids))
assert len(b) == expected, (
f"page size: got {len(b)}, expected min({PAGE_SIZE}, {len(txids)})={expected}"
)
def test_block_txs_order_and_coinbase(brk, block):
"""Page order matches /txids and tx[0] is the coinbase."""
txids = brk.get_block_txids(block.hash)
b = brk.get_block_txs(block.hash)
assert [t["txid"] for t in b] == txids[: len(b)], "order must match /txids"
assert b[0]["vin"][0]["is_coinbase"] is True, "tx[0] must be coinbase"
def test_block_txs_genesis(brk, mempool):
"""Genesis: single coinbase tx with the well-known scriptsig."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/txs"
b = brk.get_block_txs(genesis_hash)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)", max_lines=4)
assert len(b) == 1
assert_same_values(b, m, exclude=SIGOPS_DIFF)
assert b[0]["txid"] == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
def test_block_txs_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txs_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -2,67 +2,97 @@
import pytest
from _lib import assert_same_structure, show
from brk_client import BrkError
from _lib import assert_same_values, show
def test_block_txs_start_index_25(brk, mempool, block):
"""Paginated txs from index 25 must match (skip small blocks)."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
if len(txids) <= 25:
pytest.skip(f"block has only {len(txids)} txs")
path = f"/api/block/{block.hash}/txs/25"
b = brk.get_json(path)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
assert len(b) == len(m)
if b and m:
assert_same_structure(b[0], m[0])
SIGOPS_DIFF = {"sigops"}
PAGE_SIZE = 25
def test_block_txs_start_index_zero(brk, mempool, block):
"""`/txs/0` must mirror `/txs` (the default page) in length and structure."""
path0 = f"/api/block/{block.hash}/txs/0"
pathx = f"/api/block/{block.hash}/txs"
b0 = brk.get_json(path0)
bx = brk.get_json(pathx)
show("GET", path0, f"({len(b0)} txs)", f"vs /txs ({len(bx)} txs)")
assert len(b0) == len(bx)
if b0 and bx:
assert b0[0]["txid"] == bx[0]["txid"]
def test_block_txs_start_default(brk, block):
"""/txs/0 must equal /txs (the default page)."""
b0 = brk.get_block_txs_from_index(block.hash, 0)
bx = brk.get_block_txs(block.hash)
show("GET", f"/api/block/{block.hash}/txs/0", f"({len(b0)} txs)", f"vs /txs ({len(bx)} txs)")
assert b0 == bx
def test_block_txs_start_aligned_pagination(brk, mempool, block):
"""Pages at 0, 25, 50 must each be aligned slices of the full txid list."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
if len(txids) <= 50:
pytest.skip(f"block has only {len(txids)} txs")
# mempool.space orders txids tip-first inside the block payload, but
# /txids returns them in block order (coinbase-first). Paged /txs follows
# the same coinbase-first order — so page N starts at offset N.
page0 = brk.get_json(f"/api/block/{block.hash}/txs/0")
page25 = brk.get_json(f"/api/block/{block.hash}/txs/25")
page50 = brk.get_json(f"/api/block/{block.hash}/txs/50")
show("GET", f"/api/block/{block.hash}/txs/{{0,25,50}}",
f"page0={len(page0)} page25={len(page25)} page50={len(page50)}", "")
# The paging origin is what mempool.space does; verify against the live
# /txids list rather than re-deriving the order ourselves.
assert page0 and page0[0]["txid"] == txids[0]
assert page25 and page25[0]["txid"] == txids[25]
assert page50 and page50[0]["txid"] == txids[50]
def test_block_txs_start_past_end(brk, mempool, block):
"""A start index past the last tx must produce the same response on both servers."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
past = len(txids) + 1000
path = f"/api/block/{block.hash}/txs/{past}"
b_resp = brk.get_raw(path)
m_resp = mempool.get_raw(path)
show("GET", path, f"brk={b_resp.status_code}", f"mempool={m_resp.status_code}")
assert b_resp.status_code == m_resp.status_code, (
f"past-end status differs: brk={b_resp.status_code} vs mempool={m_resp.status_code}"
)
if b_resp.status_code == 200:
assert b_resp.json() == m_resp.json(), (
f"past-end body differs: brk={b_resp.json()} vs mempool={m_resp.json()}"
def test_block_txs_start_aligned(brk, block):
"""Every aligned page is the matching slice of /txids; no overlap, no gaps."""
txids = brk.get_block_txids(block.hash)
n = len(txids)
for start in range(0, n, PAGE_SIZE):
page = brk.get_block_txs_from_index(block.hash, start)
end = min(start + PAGE_SIZE, n)
assert [t["txid"] for t in page] == txids[start:end], (
f"page at start={start} txids do not match /txids[{start}:{end}]"
)
def test_block_txs_start_last_partial_page(brk, block):
"""The final page returns exactly the trailing remainder."""
txids = brk.get_block_txids(block.hash)
n = len(txids)
last_start = ((n - 1) // PAGE_SIZE) * PAGE_SIZE
expected = n - last_start
page = brk.get_block_txs_from_index(block.hash, last_start)
assert len(page) == expected, (
f"last page from start={last_start}: got {len(page)}, expected {expected}"
)
def test_block_txs_start_against_mempool(brk, mempool, block):
"""Mid-block page: full body must match mempool tx-for-tx."""
txids = brk.get_block_txids(block.hash)
if len(txids) <= PAGE_SIZE:
pytest.skip(f"block has only {len(txids)} txs (<= page size)")
path = f"/api/block/{block.hash}/txs/{PAGE_SIZE}"
b = brk.get_block_txs_from_index(block.hash, PAGE_SIZE)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)", max_lines=4)
assert_same_values(b, m, exclude=SIGOPS_DIFF)
def test_block_txs_start_genesis(brk, mempool):
"""Genesis: /txs/0 returns the 1 coinbase tx; /txs/1 must 404."""
genesis_hash = mempool.get_text("/api/block-height/0")
page0 = brk.get_block_txs_from_index(genesis_hash, 0)
assert len(page0) == 1
assert page0[0]["txid"] == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs_from_index(genesis_hash, 1)
assert exc_info.value.status == 404, (
f"expected status=404 for past-end on genesis, got {exc_info.value.status}"
)
def test_block_txs_start_past_end(brk, block):
"""Start past the last tx must produce BrkError(status=404)."""
txids = brk.get_block_txids(block.hash)
past = len(txids) + 1000
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs_from_index(block.hash, past)
assert exc_info.value.status == 404, (
f"expected status=404 for past-end, got {exc_info.value.status}"
)
def test_block_txs_start_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs_from_index("notavalidhash", 0)
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txs_start_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs_from_index(unknown, 0)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,37 +1,80 @@
"""GET /api/v1/block/{hash}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show
def test_block_v1_extras_all_values(brk, mempool, block):
"""Every shared extras field must match - exposes computation differences.
# Fee-distribution fields where mempool uses positional/cut-based percentiles
# and brk uses a single vsize-weighted percentile distribution. Same source
# data, different aggregation — diverges anywhere tx sizes vary.
FEE_ALGO_DIFF = {"medianFee", "medianFeeAmt", "feeRange", "feePercentiles"}
Excluded fields:
- medianFee, feeRange, feePercentiles: mempool computes each entry with
a different algorithm (1st/99th percentile + first/last 2% of block
order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90
for the inner feeRange entries and for feePercentiles, and a vsize-
weighted middle-0.25%-of-block-weight slice for medianFee). brk
computes them all from a single vsize-weighted percentile distribution,
so they diverge anywhere tx sizes vary widely.
- avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate
(integer sat/vB), brk returns the float version. Same formula, brk
keeps decimal precision.
"""
# avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate (integer
# sat/vB), brk returns the float version. Same formula, brk preserves precision.
ROUNDING_DIFF = {"avgFeeRate"}
EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF
def test_block_v1_envelope(brk, mempool, block):
"""Top-level v1 envelope: id matches, brk-only `stale` and `extras.price` are present."""
path = f"/api/v1/block/{block.hash}"
b = brk.get_json(path)["extras"]
b = brk.get_block_v1(block.hash)
m = mempool.get_json(path)
show("GET", path, b, m, max_lines=30)
assert b["id"] == block.hash
assert b["stale"] is False, f"confirmed block must have stale=False, got {b['stale']!r}"
assert isinstance(b["extras"]["price"], (int, float))
assert b["extras"]["price"] >= 0
def test_block_v1_extras(brk, mempool, block):
"""Every shared extras field must match (excluding documented algorithm divergences)."""
path = f"/api/v1/block/{block.hash}"
b = brk.get_block_v1(block.hash)["extras"]
m = mempool.get_json(path)["extras"]
show("GET", f"{path} [extras]", b, m, max_lines=50)
assert_same_structure(b, m)
assert_same_values(
b, m, exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"}
assert_same_values(b, m, exclude=EXTRAS_EXCLUDE)
# Genesis-only divergence: Bitcoin Core treats the genesis coinbase output as
# unspendable and excludes it from the UTXO set (Satoshi quirk). brk counts
# it like any other output, so genesis utxoSetChange is 1 on brk vs 0 on
# mempool.space. Documented test-only exclude.
GENESIS_EXTRAS_EXCLUDE = EXTRAS_EXCLUDE | {"utxoSetChange"}
def test_block_v1_genesis(brk, mempool):
"""Genesis: extras must match (excluding fee-algo divergence and the genesis utxoSetChange quirk)."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/v1/block/{genesis_hash}"
b = brk.get_block_v1(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m, max_lines=30)
assert b["height"] == 0
assert b["stale"] is False
assert_same_structure(b["extras"], m["extras"])
assert_same_values(b["extras"], m["extras"], exclude=GENESIS_EXTRAS_EXCLUDE)
def test_block_v1_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_v1("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_v1_extras_pool(brk, mempool, block):
"""Pool identification structure must match."""
path = f"/api/v1/block/{block.hash}"
bp = brk.get_json(path)["extras"]["pool"]
mp = mempool.get_json(path)["extras"]["pool"]
show("GET", f"{path} [extras.pool]", bp, mp)
assert_same_structure(bp, mp)
def test_block_v1_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_v1(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,14 +1,75 @@
"""GET /api/blocks/{height}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
PAGE_SIZE = 10
def test_blocks_from_height(brk, mempool, block):
"""Confirmed blocks from a fixed height must match exactly."""
"""Up to 10 blocks descending from `block.height` must match mempool tx-for-tx."""
path = f"/api/blocks/{block.height}"
b = brk.get_json(path)
b = brk.get_blocks_from_height(block.height)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert len(b) == len(m)
if b and m:
assert_same_values(b[0], m[0])
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=3)
assert len(b) == min(PAGE_SIZE, block.height + 1)
assert_same_values(b, m)
def test_blocks_from_height_chain(brk, block):
"""Heights strictly descending; previousblockhash links the page."""
b = brk.get_blocks_from_height(block.height)
heights = [blk["height"] for blk in b]
assert heights == list(range(block.height, block.height - len(b), -1)), (
f"page is not contiguous descending: {heights}"
)
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}"
)
def test_blocks_from_height_genesis(brk, mempool):
"""height=0 returns exactly the genesis block."""
path = "/api/blocks/0"
b = brk.get_blocks_from_height(0)
m = mempool.get_json(path)
show("GET", path, b, m, max_lines=4)
assert len(b) == 1
assert b[0]["id"] == "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
assert_same_values(b, m)
def test_blocks_from_height_small(brk, mempool):
"""height=5 returns 6 blocks (5,4,3,2,1,0), byte-deterministic against mempool."""
path = "/api/blocks/5"
b = brk.get_blocks_from_height(5)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=3)
assert len(b) == 6
assert [blk["height"] for blk in b] == [5, 4, 3, 2, 1, 0]
assert_same_values(b, m)
def test_blocks_from_height_clamp_to_tip(brk):
"""height past the tip clamps to 10 tip blocks."""
b = brk.get_blocks_from_height(99_999_999)
show("GET", "/api/blocks/99999999", f"({len(b)} blocks)", "-")
assert len(b) == PAGE_SIZE
assert b[0]["id"] == brk.get_block_tip_hash(), (
"head of clamped page must equal /api/blocks/tip/hash"
)
@pytest.mark.parametrize("bad", ["-1", "abc"])
def test_blocks_from_height_malformed(brk, bad):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/blocks/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,35 +1,60 @@
"""GET /api/blocks (most recent confirmed blocks, no height)"""
"""GET /api/blocks (most recent confirmed blocks)"""
from _lib import assert_same_structure, show
import re
from _lib import assert_same_structure, assert_same_values, show
def test_blocks_recent_structure(brk, mempool):
"""Recent blocks list must have the same element structure."""
HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
EXPECTED_COUNT = 10
def test_blocks_recent_shape(brk, mempool):
"""Recent blocks list must have the same length and element structure as mempool.space."""
path = "/api/blocks"
b = brk.get_json(path)
b = brk.get_blocks()
m = mempool.get_json(path)
show(
"GET", path,
f"({len(b)} blocks, {b[-1]['height']}-{b[0]['height']})" if b else "[]",
f"({len(m)} blocks, {m[-1]['height']}-{m[0]['height']})" if m else "[]",
f"({len(b)} blocks, {b[-1]['height']}-{b[0]['height']})",
f"({len(m)} blocks, {m[-1]['height']}-{m[0]['height']})",
)
assert len(b) > 0
assert len(b) == EXPECTED_COUNT, f"expected {EXPECTED_COUNT}, got {len(b)}"
assert len(b) == len(m), f"length mismatch: brk={len(b)} vs mempool={len(m)}"
assert_same_structure(b, m)
def test_blocks_recent_ordering(brk):
"""Returned blocks must be ordered tip-first by strictly decreasing height."""
b = brk.get_json("/api/blocks")
def test_blocks_recent_chain(brk):
"""Tip-first order, no duplicates, and previousblockhash links each block to its successor."""
b = brk.get_blocks()
heights = [blk["height"] for blk in b]
show("GET", "/api/blocks", f"heights={heights[:5]}...", "")
assert heights == sorted(heights, reverse=True), (
f"blocks are not strictly tip-first: {heights}"
)
assert len(set(heights)) == len(heights), "duplicate heights in /api/blocks"
show("GET", "/api/blocks", f"heights={heights}", "-")
assert heights == sorted(heights, reverse=True), f"not tip-first: {heights}"
assert len(set(heights)) == len(heights), "duplicate heights"
for blk in b:
assert HEX_HASH_RE.match(blk["id"]), f"id is not 64 lowercase hex: {blk['id']!r}"
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}: prev={b[i]['previousblockhash']!r} "
f"vs next.id={b[i + 1]['id']!r}"
)
def test_blocks_recent_count(brk):
"""mempool.space returns up to 15 blocks; brk should match that contract."""
b = brk.get_json("/api/blocks")
show("GET", "/api/blocks", f"({len(b)} blocks)", "")
assert 1 <= len(b) <= 15, f"unexpected block count: {len(b)}"
def test_blocks_recent_tip(brk):
"""The first element of /api/blocks must be the tip."""
b = brk.get_blocks()
tip_hash = brk.get_block_tip_hash()
tip_height = brk.get_block_tip_height()
show("GET", "/api/blocks[0]", b[0], f"tip={tip_hash} h={tip_height}")
assert b[0]["id"] == tip_hash, f"head mismatch: {b[0]['id']!r} vs tip={tip_hash!r}"
assert b[0]["height"] == tip_height
def test_blocks_recent_canonical(brk, mempool):
"""The floor block (least likely to race vs mempool's tip) must value-match mempool."""
b = brk.get_blocks()
floor = b[-1]
path = f"/api/block/{floor['id']}"
m = mempool.get_json(path)
show("GET", path, floor, m, max_lines=20)
assert_same_values(floor, m)

View File

@@ -1,37 +1,48 @@
"""GET /api/blocks/tip/hash"""
import re
from _lib import show
HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
def test_blocks_tip_hash_format(brk, mempool):
"""Tip hash must be a valid 64-char hex string on both servers."""
"""Tip hash on both servers must be a 64-char lowercase hex string."""
path = "/api/blocks/tip/hash"
b = brk.get_text(path)
b = brk.get_block_tip_hash()
m = mempool.get_text(path)
show("GET", path, b, m)
assert len(b) == 64 and all(c in "0123456789abcdef" for c in b.lower())
assert len(m) == 64 and all(c in "0123456789abcdef" for c in m.lower())
assert HEX_HASH_RE.match(b), f"brk tip hash not 64-char hex: {b!r}"
assert HEX_HASH_RE.match(m), f"mempool tip hash not 64-char hex: {m!r}"
def test_blocks_tip_hash_resolves(brk):
"""tip/hash must resolve to a real block whose .id matches."""
tip_hash = brk.get_block_tip_hash()
blk = brk.get_block(tip_hash)
show("GET", "/api/blocks/tip/hash", tip_hash, f"block.id={blk['id']} h={blk['height']}")
assert blk["id"] == tip_hash, f"round-trip mismatch: {blk['id']!r} vs {tip_hash!r}"
assert blk["height"] >= 0
def test_blocks_tip_hash_matches_height(brk):
"""`tip/hash` must equal `block-height/{tip_height}`."""
h = int(brk.get_text("/api/blocks/tip/height"))
by_height = brk.get_text(f"/api/block-height/{h}")
tip_hash = brk.get_text("/api/blocks/tip/hash")
show("GET", "/api/blocks/tip/hash", tip_hash, by_height)
# Allow a one-block race if a new block landed between the two fetches.
if tip_hash != by_height:
h2 = int(brk.get_text("/api/blocks/tip/height"))
assert h2 != h or tip_hash == by_height, (
f"tip/hash={tip_hash} but block-height/{h}={by_height}"
)
"""tip/hash and tip/height must point to the same block (race-free direction)."""
tip_hash = brk.get_block_tip_hash()
blk = brk.get_block(tip_hash)
tip_height = brk.get_block_tip_height()
show("GET", "/api/blocks/tip/hash", tip_hash, f"block.height={blk['height']} tip/height={tip_height}")
assert tip_height - blk["height"] in (0, 1), (
f"tip/hash@{blk['height']} not within 1 block of tip/height={tip_height}"
)
def test_blocks_tip_hash_matches_recent(brk):
"""`tip/hash` must equal the first hash in `/api/blocks`."""
tip_hash = brk.get_text("/api/blocks/tip/hash")
blocks = brk.get_json("/api/blocks")
"""tip/hash must equal /api/blocks[0].id."""
tip_hash = brk.get_block_tip_hash()
blocks = brk.get_blocks()
show("GET", "/api/blocks/tip/hash", tip_hash, blocks[0]["id"])
assert blocks and blocks[0]["id"] == tip_hash, (
f"tip/hash={tip_hash} but /api/blocks[0].id={blocks[0].get('id')}"
assert blocks[0]["id"] == tip_hash, (
f"tip/hash={tip_hash} but /api/blocks[0].id={blocks[0]['id']}"
)

View File

@@ -1,32 +1,47 @@
"""GET /api/blocks/tip/height"""
import re
from _lib import show
HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
def test_blocks_tip_height_close(brk, mempool):
"""Tip heights must be within a few blocks of each other."""
"""brk and mempool tips must be within 3 blocks (live-race tolerance)."""
path = "/api/blocks/tip/height"
b = int(brk.get_text(path))
b = brk.get_block_tip_height()
m = int(mempool.get_text(path))
show("GET", path, b, m)
assert abs(b - m) <= 3, f"Tip heights differ by {abs(b - m)}: brk={b}, mempool={m}"
assert isinstance(b, int) and b >= 0, f"tip height not a non-negative int: {b!r}"
assert abs(b - m) <= 3, f"tip heights differ by {abs(b - m)}: brk={b} mempool={m}"
def test_blocks_tip_height_resolves_to_hash(brk):
"""`tip/height` must resolve to a valid hash via `block-height/{tip}`."""
h = int(brk.get_text("/api/blocks/tip/height"))
bh = brk.get_text(f"/api/block-height/{h}")
"""tip/height must resolve to a 64-char hex hash via /api/block-height/{tip}."""
h = brk.get_block_tip_height()
bh = brk.get_block_by_height(h)
show("GET", "/api/blocks/tip/height", h, bh)
assert len(bh) == 64 and all(c in "0123456789abcdef" for c in bh.lower()), (
f"block-height/{h} returned non-hash: {bh!r}"
assert HEX_HASH_RE.match(bh), f"block-height/{h} returned non-hash: {bh!r}"
def test_blocks_tip_height_matches_tip_hash(brk):
"""tip/height and tip/hash must point to the same block."""
h = brk.get_block_tip_height()
tip_hash = brk.get_block_tip_hash()
blk = brk.get_block(tip_hash)
show("GET", "/api/blocks/tip/height", h, f"tip_hash={tip_hash} block.height={blk['height']}")
assert blk["height"] == h, (
f"tip/height={h} but /block/{tip_hash}.height={blk['height']}"
)
def test_blocks_tip_height_matches_recent(brk):
"""`tip/height` must equal the first element's height in `/api/blocks`."""
h = int(brk.get_text("/api/blocks/tip/height"))
blocks = brk.get_json("/api/blocks")
"""tip/height must equal /api/blocks[0].height."""
h = brk.get_block_tip_height()
blocks = brk.get_blocks()
show("GET", "/api/blocks/tip/height", h, blocks[0]["height"])
assert blocks and blocks[0]["height"] == h, (
assert blocks[0]["height"] == h, (
f"tip/height={h} but /api/blocks[0].height={blocks[0]['height']}"
)

View File

@@ -1,31 +1,87 @@
"""GET /api/v1/blocks/{height}"""
"""GET /api/v1/blocks/{height} (paginated descending v1 blocks with extras)"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_blocks_v1_from_height(brk, mempool, block):
"""v1 blocks from a confirmed height - all values must match.
PAGE_SIZE = 15
Excluded fields:
- medianFee, feeRange, feePercentiles: mempool computes each entry with
a different algorithm (1st/99th percentile + first/last 2% of block
order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90
for the inner feeRange entries and for feePercentiles, and a vsize-
weighted middle-0.25%-of-block-weight slice for medianFee). brk
computes them all from a single vsize-weighted percentile distribution,
so they diverge anywhere tx sizes vary widely.
- avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate
(integer sat/vB), brk returns the float version. Same formula, brk
keeps decimal precision.
"""
# Same fee-algo / rounding divergences as /api/v1/block/{hash} and /api/v1/blocks.
FEE_ALGO_DIFF = {"medianFee", "medianFeeAmt", "feeRange", "feePercentiles"}
ROUNDING_DIFF = {"avgFeeRate"}
EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF
# Genesis: Bitcoin Core's Satoshi quirk - the genesis coinbase is not in the UTXO set.
GENESIS_EXTRAS_EXCLUDE = EXTRAS_EXCLUDE | {"utxoSetChange"}
def test_blocks_v1_from_height(brk, mempool, block):
"""Up to 15 v1 blocks descending from `block.height`, full-page value match."""
path = f"/api/v1/blocks/{block.height}"
b = brk.get_json(path)
b = brk.get_blocks_v1_from_height(block.height)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert len(b) == len(m)
if b and m:
assert_same_values(
b[0],
m[0],
exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"},
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4)
assert len(b) == min(PAGE_SIZE, block.height + 1)
assert len(b) == len(m), f"length mismatch: brk={len(b)} vs mempool={len(m)}"
assert_same_values(b, m, exclude=EXTRAS_EXCLUDE)
def test_blocks_v1_from_height_chain(brk, block):
"""Heights strictly descending; prev-hash chain; stale=False; extras.price set."""
b = brk.get_blocks_v1_from_height(block.height)
heights = [blk["height"] for blk in b]
assert heights == list(range(block.height, block.height - len(b), -1)), (
f"page is not contiguous descending: {heights}"
)
for blk in b:
assert blk["stale"] is False, f"confirmed block stale=True: {blk['id']}"
assert isinstance(blk["extras"]["price"], (int, float))
assert blk["extras"]["price"] >= 0
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}"
)
def test_blocks_v1_from_height_genesis(brk, mempool):
"""height=0 returns exactly the genesis block (with utxoSetChange divergence)."""
path = "/api/v1/blocks/0"
b = brk.get_blocks_v1_from_height(0)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4)
assert len(b) == 1
assert b[0]["id"] == "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
assert_same_values(b, m, exclude=GENESIS_EXTRAS_EXCLUDE)
def test_blocks_v1_from_height_small(brk, mempool):
"""height=5 returns 6 blocks (5,4,3,2,1,0) with full-page value match."""
path = "/api/v1/blocks/5"
b = brk.get_blocks_v1_from_height(5)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=3)
assert len(b) == 6
assert [blk["height"] for blk in b] == [5, 4, 3, 2, 1, 0]
assert_same_values(b, m, exclude=GENESIS_EXTRAS_EXCLUDE)
def test_blocks_v1_from_height_clamp_to_tip(brk):
"""Height past the tip clamps to a 15-block tip page."""
b = brk.get_blocks_v1_from_height(99_999_999)
show("GET", "/api/v1/blocks/99999999", f"({len(b)} blocks)", "-")
assert len(b) == PAGE_SIZE
assert b[0]["id"] == brk.get_block_tip_hash(), (
"head of clamped page must equal /api/blocks/tip/hash"
)
@pytest.mark.parametrize("bad", ["-1", "abc"])
def test_blocks_v1_from_height_malformed(brk, bad):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/blocks/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,31 +1,63 @@
"""GET /api/v1/blocks (with extras, no height)"""
"""GET /api/v1/blocks (most recent confirmed blocks with extras)"""
from _lib import assert_same_structure, show
import re
from _lib import assert_same_structure, assert_same_values, show
def test_blocks_v1_recent_structure(brk, mempool):
"""Recent v1 blocks (with extras) must have the same structure."""
HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
EXPECTED_COUNT = 15
# Same fee-algo / rounding divergences as /api/v1/block/{hash}.
FEE_ALGO_DIFF = {"medianFee", "medianFeeAmt", "feeRange", "feePercentiles"}
ROUNDING_DIFF = {"avgFeeRate"}
EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF
def test_blocks_v1_recent_shape(brk, mempool):
"""v1 list must have the same length and element structure as mempool.space."""
path = "/api/v1/blocks"
b = brk.get_json(path)
b = brk.get_blocks_v1()
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert len(b) > 0
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4)
assert len(b) == EXPECTED_COUNT, f"expected {EXPECTED_COUNT}, got {len(b)}"
assert len(b) == len(m), f"length mismatch: brk={len(b)} vs mempool={len(m)}"
assert_same_structure(b, m)
def test_blocks_v1_recent_ordering(brk):
"""v1 blocks must also be tip-first."""
b = brk.get_json("/api/v1/blocks")
def test_blocks_v1_recent_chain(brk):
"""Tip-first order, no duplicates, valid previousblockhash chain, stale=False, extras.price set."""
b = brk.get_blocks_v1()
heights = [blk["height"] for blk in b]
show("GET", "/api/v1/blocks", f"heights={heights[:5]}...", "")
assert heights == sorted(heights, reverse=True), (
f"v1 blocks are not strictly tip-first: {heights}"
)
show("GET", "/api/v1/blocks", f"heights={heights}", "-")
assert heights == sorted(heights, reverse=True), f"not tip-first: {heights}"
assert len(set(heights)) == len(heights), "duplicate heights"
for blk in b:
assert HEX_HASH_RE.match(blk["id"]), f"id is not 64 lowercase hex: {blk['id']!r}"
assert blk["stale"] is False, f"confirmed block stale=True: {blk['id']}"
assert isinstance(blk["extras"]["price"], (int, float))
assert blk["extras"]["price"] >= 0
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}"
)
def test_blocks_v1_recent_has_extras(brk):
"""Each v1 block must carry the extras envelope (v1 distinguishes itself from /api/blocks)."""
b = brk.get_json("/api/v1/blocks")
show("GET", "/api/v1/blocks", f"({len(b)} blocks)", "")
assert b
assert "extras" in b[0], f"v1 blocks element missing 'extras': {list(b[0].keys())}"
def test_blocks_v1_recent_tip(brk):
"""The first element must be the tip."""
b = brk.get_blocks_v1()
tip_hash = brk.get_block_tip_hash()
tip_height = brk.get_block_tip_height()
show("GET", "/api/v1/blocks[0]", b[0]["id"], f"tip={tip_hash} h={tip_height}")
assert b[0]["id"] == tip_hash
assert b[0]["height"] == tip_height
def test_blocks_v1_recent_canonical(brk, mempool):
"""The floor block must value-match mempool (modulo fee-algo + rounding divergences)."""
b = brk.get_blocks_v1()
floor = b[-1]
path = f"/api/v1/block/{floor['id']}"
m = mempool.get_json(path)
show("GET", path, floor["extras"], m["extras"], max_lines=15)
assert_same_values(floor, m, exclude=EXTRAS_EXCLUDE)

View File

@@ -3,25 +3,43 @@
from _lib import assert_same_structure, show
def test_fees_mempool_blocks(brk, mempool):
"""Projected mempool blocks must have the same element structure."""
MAX_PROJECTED_BLOCKS = 8
BRK_FEE_RANGE_LEN = 7
def test_fees_mempool_blocks_structure(brk, mempool):
"""Projected mempool blocks envelope must match across the full list."""
path = "/api/v1/fees/mempool-blocks"
b = brk.get_json(path)
b = brk.get_mempool_blocks()
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert isinstance(b, list) and isinstance(m, list)
assert len(b) > 0
if b and m:
assert_same_structure(b[0], m[0])
assert len(b) > 0, "expected non-empty projected blocks"
assert_same_structure(b, m)
def test_fees_mempool_blocks_fee_range(brk, mempool):
"""Each projected block must have a 7-element feeRange."""
path = "/api/v1/fees/mempool-blocks"
for label, client in [("brk", brk), ("mempool", mempool)]:
blocks = client.get_json(path)
for i, block in enumerate(blocks[:3]):
assert "feeRange" in block, f"{label} block {i} missing feeRange"
assert len(block["feeRange"]) == 7, (
f"{label} block {i} feeRange has {len(block['feeRange'])} items, expected 7"
)
def test_fees_mempool_blocks_invariants(brk):
"""Block counts, sizes, fees, medianFee in feeRange, ordering by descending medianFee."""
b = brk.get_mempool_blocks()
show("GET", "/api/v1/fees/mempool-blocks", f"({len(b)} blocks)", "-")
assert 1 <= len(b) <= MAX_PROJECTED_BLOCKS, (
f"projected block count out of range: {len(b)}"
)
medians = [block["medianFee"] for block in b]
assert medians == sorted(medians, reverse=True), (
f"blocks not ordered by descending medianFee: {medians}"
)
for i, block in enumerate(b):
assert block["blockSize"] > 0, f"block {i} has non-positive blockSize"
assert block["blockVSize"] > 0, f"block {i} has non-positive blockVSize"
assert block["nTx"] > 0, f"block {i} has non-positive nTx"
assert block["totalFees"] >= 0, f"block {i} has negative totalFees"
assert block["medianFee"] > 0, f"block {i} has non-positive medianFee"
fr = block["feeRange"]
assert len(fr) == BRK_FEE_RANGE_LEN, (
f"block {i} feeRange has {len(fr)} items, expected {BRK_FEE_RANGE_LEN}"
)
assert fr == sorted(fr), f"block {i} feeRange not ascending: {fr}"
assert fr[0] <= block["medianFee"] <= fr[-1], (
f"block {i} medianFee {block['medianFee']} outside feeRange [{fr[0]}, {fr[-1]}]"
)

View File

@@ -3,40 +3,37 @@
from _lib import assert_same_structure, show
EXPECTED_FEE_KEYS = [
"fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee",
]
EXPECTED_FEE_KEYS = ["fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee"]
def test_fees_precise_structure(brk, mempool):
"""Precise fees must have the same structure as recommended."""
"""Precise fees envelope must match mempool's keys and numeric types."""
path = "/api/v1/fees/precise"
b = brk.get_json(path)
b = brk.get_precise_fees()
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
def test_fees_precise_invariants(brk):
"""All tiers numeric, positive, and monotonically non-increasing."""
b = brk.get_precise_fees()
show("GET", "/api/v1/fees/precise", b, "-")
for key in EXPECTED_FEE_KEYS:
assert key in b
assert key in b, f"missing '{key}'"
assert isinstance(b[key], (int, float)), f"'{key}' not numeric: {type(b[key])}"
assert b[key] > 0, f"'{key}' must be positive, got {b[key]}"
assert b["fastestFee"] >= b["halfHourFee"] >= b["hourFee"], (
f"fast tiers not ordered: {b}"
)
assert b["hourFee"] >= b["economyFee"] >= b["minimumFee"], (
f"slow tiers not ordered: {b}"
)
def test_fees_precise_ordering(brk, mempool):
"""Precise fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum."""
path = "/api/v1/fees/precise"
for label, client in [("brk", brk), ("mempool", mempool)]:
d = client.get_json(path)
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"], (
f"{label}: precise fee ordering violated {d}"
)
assert d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
f"{label}: precise fee ordering violated {d}"
)
def test_fees_precise_numeric(brk):
"""Each tier in /precise must be a non-negative number."""
d = brk.get_json("/api/v1/fees/precise")
show("GET", "/api/v1/fees/precise", d, "")
for key in EXPECTED_FEE_KEYS:
v = d[key]
assert isinstance(v, (int, float)), f"{key} not numeric: {type(v).__name__}"
assert v >= 0, f"{key} is negative: {v}"
def test_fees_precise_mempool_ordering_sanity(mempool):
"""Sanity: mempool itself follows the documented ordering."""
d = mempool.get_json("/api/v1/fees/precise")
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
f"mempool tiers not ordered: {d}"
)

View File

@@ -3,31 +3,37 @@
from _lib import assert_same_structure, show
EXPECTED_FEE_KEYS = [
"fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee",
]
EXPECTED_FEE_KEYS = ["fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee"]
def test_fees_recommended(brk, mempool):
"""Recommended fees must have the same keys and numeric types."""
def test_fees_recommended_structure(brk, mempool):
"""Recommended fees envelope must match mempool's keys and numeric types."""
path = "/api/v1/fees/recommended"
b = brk.get_json(path)
b = brk.get_recommended_fees()
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
def test_fees_recommended_invariants(brk):
"""All tiers numeric, positive, and monotonically non-increasing."""
b = brk.get_recommended_fees()
show("GET", "/api/v1/fees/recommended", b, "-")
for key in EXPECTED_FEE_KEYS:
assert key in b, f"brk missing '{key}'"
assert isinstance(b[key], (int, float)), f"'{key}' is not numeric: {type(b[key])}"
assert key in b, f"missing '{key}'"
assert isinstance(b[key], (int, float)), f"'{key}' not numeric: {type(b[key])}"
assert b[key] > 0, f"'{key}' must be positive, got {b[key]}"
assert b["fastestFee"] >= b["halfHourFee"] >= b["hourFee"], (
f"fast tiers not ordered: {b}"
)
assert b["hourFee"] >= b["economyFee"] >= b["minimumFee"], (
f"slow tiers not ordered: {b}"
)
def test_fees_recommended_ordering(brk, mempool):
"""Fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum."""
path = "/api/v1/fees/recommended"
for label, client in [("brk", brk), ("mempool", mempool)]:
d = client.get_json(path)
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"], (
f"{label}: fee ordering violated {d}"
)
assert d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
f"{label}: fee ordering violated {d}"
)
def test_fees_recommended_mempool_ordering_sanity(mempool):
"""Sanity: mempool itself follows the documented ordering (pins our reading of the contract)."""
d = mempool.get_json("/api/v1/fees/recommended")
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
f"mempool tiers not ordered: {d}"
)

View File

@@ -7,13 +7,49 @@ predecessor was non-signaling (full-RBF).
from _lib import assert_same_structure, show
def test_fullrbf_replacements_shape(brk, mempool):
"""Full-RBF replacement-tree structure must match for the first element if both lists are non-empty."""
HEX = set("0123456789abcdef")
MAX_REPLACEMENTS = 25
def _validate_node(node, path):
"""Recursively validate a ReplacementNode and its replaces children."""
assert "tx" in node and "replaces" in node, f"{path}: missing tx/replaces"
assert node["time"] > 0, f"{path}: non-positive time {node['time']}"
tx = node["tx"]
txid = tx["txid"]
assert isinstance(txid, str) and len(txid) == 64 and set(txid) <= HEX, (
f"{path}.tx.txid malformed: {txid!r}"
)
assert int(tx["fee"]) >= 0, f"{path}.tx.fee negative: {tx['fee']}"
assert int(tx["vsize"]) > 0, f"{path}.tx.vsize non-positive: {tx['vsize']}"
assert int(tx["value"]) >= 0, f"{path}.tx.value negative: {tx['value']}"
assert tx["rate"] >= 0, f"{path}.tx.rate negative: {tx['rate']}"
assert tx["time"] > 0, f"{path}.tx.time non-positive: {tx['time']}"
replaces = node["replaces"]
assert isinstance(replaces, list), f"{path}.replaces not a list"
for i, child in enumerate(replaces):
_validate_node(child, f"{path}.replaces[{i}]")
def test_fullrbf_replacements_structure(brk, mempool):
"""Full-RBF replacement-tree envelope must match across the full list."""
path = "/api/v1/fullrbf/replacements"
b = brk.get_json(path)
b = brk.get_fullrbf_replacements()
m = mempool.get_json(path)
show("GET", path, b, m)
assert isinstance(b, list) and isinstance(m, list)
assert len(b) <= 25 and len(m) <= 25
assert len(b) <= MAX_REPLACEMENTS and len(m) <= MAX_REPLACEMENTS
if b and m:
assert_same_structure(b[0], m[0])
assert_same_structure(b, m)
def test_fullrbf_replacements_invariants(brk):
"""Length cap, recursive node validation, every root must be full-RBF."""
b = brk.get_fullrbf_replacements()
show("GET", "/api/v1/fullrbf/replacements", f"({len(b)} trees)", "-")
assert 0 <= len(b) <= MAX_REPLACEMENTS, f"unexpected length: {len(b)}"
for i, root in enumerate(b):
assert root["fullRbf"] is True, (
f"root[{i}] is not fullRbf - endpoint contract violated: {root['tx']['txid']}"
)
_validate_node(root, f"root[{i}]")

View File

@@ -3,21 +3,40 @@
from _lib import assert_same_structure, show
def test_mempool_info(brk, mempool):
"""Mempool stats must have the same keys and types."""
def test_mempool_info_structure(brk, mempool):
"""Mempool stats envelope must match mempool's keys and types."""
path = "/api/mempool"
b = brk.get_json(path)
b = brk.get_mempool()
m = mempool.get_json(path)
show("GET", path, b, m, max_lines=15)
assert_same_structure(b, m)
assert isinstance(b["count"], int)
assert isinstance(b["vsize"], int)
def test_mempool_info_positive(brk, mempool):
"""Both servers must report a non-empty mempool."""
path = "/api/mempool"
for label, client in [("brk", brk), ("mempool", mempool)]:
d = client.get_json(path)
assert d["count"] > 0, f"{label} mempool count is 0"
assert d["vsize"] > 0, f"{label} mempool vsize is 0"
def test_mempool_info_invariants(brk):
"""Counts positive, fee histogram descending and accounting-exact (sum bin_vsizes == vsize)."""
b = brk.get_mempool()
show("GET", "/api/mempool", b, "-", max_lines=15)
assert isinstance(b["count"], int) and b["count"] > 0
assert isinstance(b["vsize"], int) and b["vsize"] > 0
assert b["total_fee"] >= 0, f"negative total_fee: {b['total_fee']}"
fh = b["fee_histogram"]
assert isinstance(fh, list) and len(fh) > 0, "fee_histogram must be non-empty list"
rates = []
bin_vsize_sum = 0
for i, entry in enumerate(fh):
assert isinstance(entry, list) and len(entry) == 2, (
f"histogram entry {i} not a 2-element list: {entry}"
)
rate, bvs = entry
assert isinstance(rate, (int, float)) and rate > 0, (
f"non-positive rate at bin {i}: {rate}"
)
assert isinstance(bvs, int) and bvs > 0, f"non-positive vsize at bin {i}: {bvs}"
rates.append(rate)
bin_vsize_sum += bvs
assert rates == sorted(rates, reverse=True), (
f"fee_histogram not descending by rate: {rates[:5]}..."
)
assert bin_vsize_sum == b["vsize"], (
f"sum(bin_vsizes)={bin_vsize_sum} != vsize={b['vsize']}"
)

View File

@@ -3,23 +3,34 @@
from _lib import assert_same_structure, show
def test_mempool_recent(brk, mempool):
"""Recent mempool txs must have the same element structure."""
HEX = set("0123456789abcdef")
MAX_RECENT = 10
def test_mempool_recent_structure(brk, mempool):
"""Recent mempool txs envelope must match across the full list."""
path = "/api/mempool/recent"
b = brk.get_json(path)
b = brk.get_mempool_recent()
m = mempool.get_json(path)
show("GET", path, b, m)
assert isinstance(b, list) and isinstance(m, list)
assert len(b) > 0
if b and m:
assert_same_structure(b[0], m[0])
assert len(b) > 0, "brk recent list is empty"
assert_same_structure(b, m)
def test_mempool_recent_fields(brk, mempool):
"""Each recent tx must have txid, fee, vsize, value."""
path = "/api/mempool/recent"
for label, client in [("brk", brk), ("mempool", mempool)]:
txs = client.get_json(path)
for tx in txs[:3]:
for key in ["txid", "fee", "vsize", "value"]:
assert key in tx, f"{label} recent tx missing '{key}': {tx}"
def test_mempool_recent_invariants(brk):
"""Length cap, txid format, positive fee/vsize/value, unique txids."""
b = brk.get_mempool_recent()
show("GET", "/api/mempool/recent", b, "-")
assert 1 <= len(b) <= MAX_RECENT, f"recent length out of range: {len(b)}"
txids = []
for i, tx in enumerate(b):
txid = tx["txid"]
assert isinstance(txid, str) and len(txid) == 64 and set(txid) <= HEX, (
f"entry {i} txid malformed: {txid!r}"
)
assert int(tx["fee"]) >= 0, f"entry {i} negative fee: {tx['fee']}"
assert int(tx["vsize"]) > 0, f"entry {i} non-positive vsize: {tx['vsize']}"
assert int(tx["value"]) > 0, f"entry {i} non-positive value: {tx['value']}"
txids.append(txid)
assert len(txids) == len(set(txids)), f"duplicate txids in recent: {txids}"

View File

@@ -8,13 +8,46 @@ load-bearing.
from _lib import assert_same_structure, show
def test_replacements_shape(brk, mempool):
"""Replacement-tree structure must match for the first element if both lists are non-empty."""
HEX = set("0123456789abcdef")
MAX_REPLACEMENTS = 25
def _validate_node(node, path):
"""Recursively validate a ReplacementNode and its replaces children."""
assert "tx" in node and "replaces" in node, f"{path}: missing tx/replaces"
assert node["time"] > 0, f"{path}: non-positive time {node['time']}"
tx = node["tx"]
txid = tx["txid"]
assert isinstance(txid, str) and len(txid) == 64 and set(txid) <= HEX, (
f"{path}.tx.txid malformed: {txid!r}"
)
assert int(tx["fee"]) >= 0, f"{path}.tx.fee negative: {tx['fee']}"
assert int(tx["vsize"]) > 0, f"{path}.tx.vsize non-positive: {tx['vsize']}"
assert int(tx["value"]) >= 0, f"{path}.tx.value negative: {tx['value']}"
assert tx["rate"] >= 0, f"{path}.tx.rate negative: {tx['rate']}"
assert tx["time"] > 0, f"{path}.tx.time non-positive: {tx['time']}"
replaces = node["replaces"]
assert isinstance(replaces, list), f"{path}.replaces not a list"
for i, child in enumerate(replaces):
_validate_node(child, f"{path}.replaces[{i}]")
def test_replacements_structure(brk, mempool):
"""Replacement-tree envelope must match across the full list."""
path = "/api/v1/replacements"
b = brk.get_json(path)
b = brk.get_replacements()
m = mempool.get_json(path)
show("GET", path, b, m)
assert isinstance(b, list) and isinstance(m, list)
assert len(b) <= 25 and len(m) <= 25
assert len(b) <= MAX_REPLACEMENTS and len(m) <= MAX_REPLACEMENTS
if b and m:
assert_same_structure(b[0], m[0])
assert_same_structure(b, m)
def test_replacements_invariants(brk):
"""Length cap, recursive node validation."""
b = brk.get_replacements()
show("GET", "/api/v1/replacements", f"({len(b)} trees)", "-")
assert 0 <= len(b) <= MAX_REPLACEMENTS, f"unexpected length: {len(b)}"
for i, root in enumerate(b):
_validate_node(root, f"root[{i}]")

View File

@@ -6,47 +6,44 @@ from _lib import show
HEX = set("0123456789abcdef")
def test_mempool_txids_basic(brk, mempool):
"""Txid list must be a non-empty array of strings on both servers."""
def test_mempool_txids_structure(brk, mempool):
"""Txid list must be a non-empty array on both servers."""
path = "/api/mempool/txids"
b = brk.get_json(path)
b = brk.get_mempool_txids()
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txids)", f"({len(m)} txids)")
assert isinstance(b, list) and isinstance(m, list)
assert len(b) > 0, "brk mempool has no txids"
assert isinstance(b[0], str) and len(b[0]) == 64
assert len(b) > 0, "brk mempool txids list is empty"
def test_mempool_txids_format(brk):
"""Every txid in brk's mempool list must be a 64-char lowercase hex string."""
b = brk.get_json("/api/mempool/txids")
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "")
bad = [t for t in b if not (isinstance(t, str) and len(t) == 64 and set(t.lower()) <= HEX)]
"""Every txid must be a 64-char strict-lowercase hex string."""
b = brk.get_mempool_txids()
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "-")
bad = [t for t in b if not (isinstance(t, str) and len(t) == 64 and set(t) <= HEX)]
assert not bad, f"{len(bad)} malformed txid(s), e.g. {bad[0] if bad else None!r}"
def test_mempool_txids_unique(brk):
"""Brk's mempool txid list must not contain duplicates."""
b = brk.get_json("/api/mempool/txids")
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "")
"""No duplicates."""
b = brk.get_mempool_txids()
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "-")
assert len(b) == len(set(b)), (
f"duplicate txids: {len(b) - len(set(b))} duplicates out of {len(b)}"
)
def test_mempool_txids_count_matches_summary(brk):
"""`/api/mempool/txids` length must roughly track `/api/mempool`'s `count`.
"""`/api/mempool/txids` length must roughly track `/api/mempool`.count.
The two endpoints are independent reads against a live mempool, so
arrivals / evictions between fetches cause drift. We only assert the
counts are in the same ballpark - exact equality would be flaky.
arrivals / evictions between fetches cause drift. We assert within
max(50, count/100) tolerance to absorb normal churn.
"""
txids = brk.get_json("/api/mempool/txids")
summary = brk.get_json("/api/mempool")
show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary.get('count')}")
assert isinstance(summary["count"], int) and summary["count"] > 0
assert len(txids) > 0
# 1% tolerance covers normal mempool churn between the two fetches.
txids = brk.get_mempool_txids()
summary = brk.get_mempool()
show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary['count']}")
assert summary["count"] > 0 and len(txids) > 0
drift = abs(len(txids) - summary["count"])
assert drift <= max(50, summary["count"] // 100), (
f"txids={len(txids)} vs /api/mempool.count={summary['count']} (drift={drift})"

View File

@@ -2,14 +2,51 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
def test_mining_blocks_fee_rates(brk, mempool, period):
"""Block fee-rate percentiles must have the same element structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
PERCENTILES = ["avgFee_0", "avgFee_10", "avgFee_25", "avgFee_50", "avgFee_75", "avgFee_90", "avgFee_100"]
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_fee_rates_structure(brk, mempool, period):
"""Block fee-rate percentiles envelope must match across all periods."""
path = f"/api/v1/mining/blocks/fee-rates/{period}"
b = brk.get_json(path)
b = brk.get_block_fee_rates(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m)
def test_mining_blocks_fee_rates_invariants(brk):
"""Series ordering, percentile monotonicity, non-negative rates (period=1m)."""
period = "1m"
b = brk.get_block_fee_rates(period)
show("GET", f"/api/v1/mining/blocks/fee-rates/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty fee-rates series for 1m"
heights = [entry["avgHeight"] for entry in b]
timestamps = [entry["timestamp"] for entry in b]
assert heights == sorted(heights), "avgHeight not ascending"
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(heights)) == len(heights), "duplicate avgHeight in series"
for entry in b:
values = [entry[k] for k in PERCENTILES]
assert values == sorted(values), (
f"percentiles not monotonically non-decreasing at height {entry['avgHeight']}: {values}"
)
for k in PERCENTILES:
assert entry[k] >= 0, f"negative fee rate {k}={entry[k]} at {entry['avgHeight']}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_fee_rates_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/fee-rates/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,46 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
def test_mining_blocks_fees(brk, mempool, period):
"""Average block fees must have the same element structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_fees_structure(brk, mempool, period):
"""Average block fees envelope must match across all periods."""
path = f"/api/v1/mining/blocks/fees/{period}"
b = brk.get_json(path)
b = brk.get_block_fees(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m)
def test_mining_blocks_fees_invariants(brk):
"""Series ascending by height and timestamp, fees and USD non-negative (period=1m)."""
period = "1m"
b = brk.get_block_fees(period)
show("GET", f"/api/v1/mining/blocks/fees/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty fees series for 1m"
heights = [entry["avgHeight"] for entry in b]
timestamps = [entry["timestamp"] for entry in b]
assert heights == sorted(heights), "avgHeight not ascending"
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(heights)) == len(heights), "duplicate avgHeight in series"
for entry in b:
assert entry["avgFees"] >= 0, f"negative avgFees: {entry}"
assert entry["USD"] >= 0, f"negative USD: {entry}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_fees_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/fees/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,46 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
def test_mining_blocks_rewards(brk, mempool, period):
"""Average block rewards must have the same element structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_rewards_structure(brk, mempool, period):
"""Average block rewards envelope must match across all periods."""
path = f"/api/v1/mining/blocks/rewards/{period}"
b = brk.get_json(path)
b = brk.get_block_rewards(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m)
def test_mining_blocks_rewards_invariants(brk):
"""Series ascending by height and timestamp, rewards positive, USD non-negative (period=1m)."""
period = "1m"
b = brk.get_block_rewards(period)
show("GET", f"/api/v1/mining/blocks/rewards/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty rewards series for 1m"
heights = [entry["avgHeight"] for entry in b]
timestamps = [entry["timestamp"] for entry in b]
assert heights == sorted(heights), "avgHeight not ascending"
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(heights)) == len(heights), "duplicate avgHeight in series"
for entry in b:
assert entry["avgRewards"] > 0, f"non-positive avgRewards: {entry}"
assert entry["USD"] >= 0, f"negative USD: {entry}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_rewards_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/rewards/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,60 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
def test_mining_blocks_sizes_weights(brk, mempool, period):
"""Block sizes and weights must have the same structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
MAX_BLOCK_WEIGHT = 4_000_000
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_sizes_weights_structure(brk, mempool, period):
"""Combined sizes/weights envelope must match across all periods."""
path = f"/api/v1/mining/blocks/sizes-weights/{period}"
b = brk.get_json(path)
b = brk.get_block_sizes_weights(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert isinstance(b, dict) and isinstance(m, dict)
assert_same_structure(b, m)
def test_mining_blocks_sizes_weights_invariants(brk):
"""Parallel arrays, ascending order, positive size, weight in (0, 4M] (period=1m)."""
period = "1m"
b = brk.get_block_sizes_weights(period)
sizes = b["sizes"]
weights = b["weights"]
show("GET", f"/api/v1/mining/blocks/sizes-weights/{period}", summary(b), "-")
assert len(sizes) > 0, "expected non-empty sizes series for 1m"
assert len(sizes) == len(weights), (
f"sizes/weights array lengths diverge: {len(sizes)} vs {len(weights)}"
)
size_heights = [e["avgHeight"] for e in sizes]
size_ts = [e["timestamp"] for e in sizes]
assert size_heights == sorted(size_heights), "size avgHeights not ascending"
assert size_ts == sorted(size_ts), "size timestamps not ascending"
assert len(set(size_heights)) == len(size_heights), "duplicate avgHeight in sizes"
for s, w in zip(sizes, weights):
assert s["avgHeight"] == w["avgHeight"], (
f"size/weight height misalignment: {s['avgHeight']} vs {w['avgHeight']}"
)
assert s["timestamp"] == w["timestamp"], (
f"size/weight timestamp misalignment at height {s['avgHeight']}"
)
assert s["avgSize"] > 0, f"non-positive avgSize at {s['avgHeight']}: {s['avgSize']}"
assert 0 < w["avgWeight"] <= MAX_BLOCK_WEIGHT, (
f"avgWeight out of range at {w['avgHeight']}: {w['avgWeight']}"
)
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_sizes_weights_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/sizes-weights/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,15 +1,52 @@
"""GET /api/v1/mining/blocks/timestamp/{timestamp}"""
from _lib import assert_same_structure, show
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show
def test_mining_blocks_timestamp(brk, mempool, live):
"""Block lookup by timestamp must have the same structure for various eras."""
GENESIS_TIMESTAMP = 1231006505
def test_mining_blocks_timestamp_structure_and_parity(brk, mempool, live):
"""For each live era, brk and mempool must resolve the same block."""
for block in live.blocks:
info = brk.get_json(f"/api/block/{block.hash}")
ts = info["timestamp"]
path = f"/api/v1/mining/blocks/timestamp/{ts}"
b = brk.get_json(path)
b = brk.get_block_by_timestamp(ts)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
assert_same_values(b, m)
def test_mining_blocks_timestamp_round_trip(brk, live):
"""Looking up a block's own timestamp must return that block (or an earlier one with same ts)."""
for block in live.blocks:
info = brk.get_json(f"/api/block/{block.hash}")
ts = info["timestamp"]
b = brk.get_block_by_timestamp(ts)
show("GET", f"/api/v1/mining/blocks/timestamp/{ts}", b, "-")
assert b["height"] <= block.height, (
f"resolved height {b['height']} > requested block height {block.height}"
)
def test_mining_blocks_timestamp_genesis(brk):
"""Genesis Unix timestamp must resolve to genesis (height 0)."""
b = brk.get_block_by_timestamp(GENESIS_TIMESTAMP)
show("GET", f"/api/v1/mining/blocks/timestamp/{GENESIS_TIMESTAMP}", b, "-")
assert b["height"] == 0, f"genesis ts must resolve to height 0, got {b['height']}"
@pytest.mark.parametrize("bad", ["abc", "-1"])
def test_mining_blocks_timestamp_malformed(brk, bad):
"""Non-numeric or negative timestamp must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/timestamp/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,53 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"])
def test_mining_difficulty_adjustments(brk, mempool, period):
"""Historical difficulty adjustments must have the same structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
RETARGET_INTERVAL = 2016
@pytest.mark.parametrize("period", PERIODS)
def test_mining_difficulty_adjustments_structure(brk, mempool, period):
"""Historical difficulty adjustments envelope must match across all periods."""
path = f"/api/v1/mining/difficulty-adjustments/{period}"
b = brk.get_json(path)
b = brk.get_difficulty_adjustments_by_period(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m)
def test_mining_difficulty_adjustments_invariants(brk):
"""Tip-first ordering, retarget-aligned heights, genesis sentinel (period=all)."""
period = "all"
b = brk.get_difficulty_adjustments_by_period(period)
show("GET", f"/api/v1/mining/difficulty-adjustments/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty difficulty adjustments for period=all"
heights = [entry[1] for entry in b]
assert heights == sorted(heights, reverse=True), "entries not descending by height"
assert len(set(heights)) == len(heights), "duplicate heights in series"
assert heights[-1] == 0, f"last entry must be genesis (height 0), got {heights[-1]}"
assert heights.count(0) == 1, "expected exactly one genesis entry"
for entry in b[:-1]:
timestamp, height, difficulty, change_ratio = entry
assert height % RETARGET_INTERVAL == 0, (
f"non-genesis height {height} not on retarget boundary"
)
assert difficulty > 0, f"non-positive difficulty: {difficulty} at height {height}"
assert change_ratio > 0, f"non-positive change ratio: {change_ratio} at height {height}"
genesis = b[-1]
assert genesis[2] == 1.0, f"genesis difficulty must be 1.0, got {genesis[2]}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_difficulty_adjustments_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/difficulty-adjustments/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,52 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"])
def test_mining_hashrate(brk, mempool, period):
"""Network hashrate + difficulty must have the same structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
@pytest.mark.parametrize("period", PERIODS)
def test_mining_hashrate_structure(brk, mempool, period):
"""Network hashrate envelope must match across all periods."""
path = f"/api/v1/mining/hashrate/{period}"
b = brk.get_json(path)
b = brk.get_hashrate_by_period(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert_same_structure(b, m)
def test_mining_hashrate_invariants(brk):
"""Series ascending, values positive, current* fields populated (period=1m)."""
period = "1m"
b = brk.get_hashrate_by_period(period)
show("GET", f"/api/v1/mining/hashrate/{period}", summary(b), "-")
assert isinstance(b["currentHashrate"], int) and b["currentHashrate"] > 0
assert isinstance(b["currentDifficulty"], (int, float)) and b["currentDifficulty"] > 0
hashrates = b["hashrates"]
assert len(hashrates) > 0, "expected non-empty hashrates list for 1m"
timestamps = [h["timestamp"] for h in hashrates]
assert timestamps == sorted(timestamps), "hashrate timestamps not ascending"
assert len(set(timestamps)) == len(timestamps), "duplicate hashrate timestamps"
for h in hashrates:
assert isinstance(h["avgHashrate"], int) and h["avgHashrate"] > 0
difficulty = b["difficulty"]
times = [d["time"] for d in difficulty]
heights = [d["height"] for d in difficulty]
assert times == sorted(times), "difficulty entries not ascending by time"
assert heights == sorted(heights), "difficulty entries not ascending by height"
for d in difficulty:
assert d["difficulty"] > 0, f"non-positive difficulty: {d}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_hashrate_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/hashrate/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,48 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "1y"])
def test_mining_hashrate_pools(brk, mempool, period):
"""Per-pool hashrate must have the same structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
@pytest.mark.parametrize("period", PERIODS)
def test_mining_hashrate_pools_structure(brk, mempool, period):
"""Per-pool hashrate snapshot envelope must match across all periods."""
path = f"/api/v1/mining/hashrate/pools/{period}"
b = brk.get_json(path)
b = brk.get_pools_hashrate_by_period(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m)
def test_mining_hashrate_pools_invariants(brk):
"""Snapshot has single timestamp, valid shares summing to <=1, unique pool names (period=1w)."""
period = "1w"
b = brk.get_pools_hashrate_by_period(period)
show("GET", f"/api/v1/mining/hashrate/pools/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty per-pool hashrate snapshot for 1w"
timestamps = {entry["timestamp"] for entry in b}
assert len(timestamps) == 1, f"expected single snapshot timestamp, got {timestamps}"
pool_names = [entry["poolName"] for entry in b]
assert len(set(pool_names)) == len(pool_names), "duplicate poolName in snapshot"
for entry in b:
assert entry["poolName"], "empty poolName"
assert isinstance(entry["avgHashrate"], int) and entry["avgHashrate"] >= 0
assert isinstance(entry["share"], (int, float)) and 0.0 <= entry["share"] <= 1.0
total_share = sum(entry["share"] for entry in b)
assert total_share <= 1.0001, f"share sum > 1: {total_share}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_hashrate_pools_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/hashrate/pools/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,73 @@
"""GET /api/v1/mining/pool/{slug}"""
from _lib import assert_same_structure, show, summary
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show, summary
def test_mining_pool_detail(brk, mempool, pool_slugs):
"""Pool detail must have the same structure for top pools."""
# Tip-race / mempool-only / int-vs-str fields excluded from value equality.
VOLATILE = {
"blockCount", "blockShare", "estimatedHashrate", "reportedHashrate",
"totalReward", "avgBlockHealth", "avgMatchRate", "avgFeeDelta",
}
# Digit/punctuation slugs that previously diverged between brk and mempool.
# Pinning them here lets the slug rename fixes regress loudly if reverted.
SLUG_RENAME_REGRESSION_GUARD = ["1thash", "175btc", "21inc", "1hash", "58coin", "7pool"]
def test_mining_pool_detail_structure(brk, mempool, pool_slugs):
"""Pool detail envelope must match mempool for the top active pools."""
for slug in pool_slugs:
path = f"/api/v1/mining/pool/{slug}"
b = brk.get_json(path)
b = brk.get_pool(slug)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert_same_structure(b, m)
def test_mining_pool_detail_static_fields(brk, mempool, pool_slug):
"""The pool registry fields (id, name, link, slug, unique_id) must value-match."""
path = f"/api/v1/mining/pool/{pool_slug}"
b = brk.get_pool(pool_slug)
m = mempool.get_json(path)
show("GET", path, b["pool"], m["pool"])
assert_same_values(b["pool"], m["pool"], path=f"{path}.pool")
def test_mining_pool_detail_invariants(brk, pool_slug):
"""blockCount monotonic by window; blockShare in [0,1]; pool.slug round-trips."""
b = brk.get_pool(pool_slug)
show("GET", f"/api/v1/mining/pool/{pool_slug}", summary(b), "-")
assert b["pool"]["slug"] == pool_slug, (
f"response.pool.slug={b['pool']['slug']!r} vs URL slug={pool_slug!r}"
)
bc = b["blockCount"]
assert bc["all"] >= bc["1w"] >= bc["24h"] >= 0, f"blockCount not monotonic: {bc}"
bs = b["blockShare"]
for window, value in bs.items():
assert 0.0 <= value <= 1.0, f"blockShare[{window}]={value} out of [0,1]"
assert isinstance(b["estimatedHashrate"], int) and b["estimatedHashrate"] >= 0
@pytest.mark.parametrize("slug", SLUG_RENAME_REGRESSION_GUARD)
def test_mining_pool_detail_slug_renames(brk, mempool, slug):
"""Pools whose slugs were renamed to match mempool must remain reachable."""
path = f"/api/v1/mining/pool/{slug}"
b = brk.get_pool(slug)
m = mempool.get_json(path)
show("GET", path, b["pool"], m["pool"])
assert b["pool"]["slug"] == slug
assert_same_values(b["pool"], m["pool"], path=f"{path}.pool")
@pytest.mark.parametrize("bad", ["notapool", "FoundryUSA", ""])
def test_mining_pool_detail_malformed(brk, bad):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad}")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,15 +1,47 @@
"""GET /api/v1/mining/pool/{slug}/blocks"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
def test_mining_pool_blocks(brk, mempool, pool_slugs):
"""Recent blocks by pool must have the same element structure."""
PAGE_SIZE = 100
def test_mining_pool_blocks_structure(brk, mempool, pool_slugs):
"""Per-pool block list element schema must match for top active pools."""
for slug in pool_slugs:
path = f"/api/v1/mining/pool/{slug}/blocks"
b = brk.get_json(path)
b = brk.get_pool_blocks(slug)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert isinstance(b, list) and isinstance(m, list)
if b and m:
assert_same_structure(b[0], m[0])
assert_same_structure(b, m)
def test_mining_pool_blocks_invariants(brk, pool_slug):
"""Page is descending, capped at 100, all blocks attributed to the requested pool."""
b = brk.get_pool_blocks(pool_slug)
show("GET", f"/api/v1/mining/pool/{pool_slug}/blocks", f"({len(b)} blocks)", "-")
assert 0 < len(b) <= PAGE_SIZE, f"unexpected length: {len(b)}"
heights = [blk["height"] for blk in b]
assert heights == sorted(heights, reverse=True), f"not tip-first: {heights[:5]}..."
assert len(set(heights)) == len(heights), "duplicate heights in page"
for blk in b:
assert blk["stale"] is False, f"stale block in page: {blk['id']}"
assert blk["extras"]["pool"]["slug"] == pool_slug, (
f"block {blk['id']} attributed to {blk['extras']['pool']['slug']}, "
f"expected {pool_slug}"
)
@pytest.mark.parametrize("bad", ["notapool", "FoundryUSA"])
def test_mining_pool_blocks_malformed(brk, bad):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad}/blocks")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,15 +1,61 @@
"""GET /api/v1/mining/pool/{slug}/blocks/{height}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
def test_mining_pool_blocks_at_height(brk, mempool, pool_slug, live):
"""Pool blocks before various heights must have the same element structure."""
for block in live.blocks[::2]: # every other block, to keep run-time bounded
path = f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}"
b = brk.get_json(path)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert isinstance(b, list) and isinstance(m, list)
if b and m:
assert_same_structure(b[0], m[0])
PAGE_SIZE = 100
def test_mining_pool_blocks_from_height_structure(brk, mempool, pool_slug, block):
"""Per-pool block list before a height must match mempool's element schema."""
path = f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}"
b = brk.get_pool_blocks_from(pool_slug, block.height)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m)
def test_mining_pool_blocks_from_height_invariants(brk, pool_slug, block):
"""Page is descending, capped at 100, height-bounded, attributed to the pool."""
b = brk.get_pool_blocks_from(pool_slug, block.height)
show("GET", f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}", f"({len(b)} blocks)", "-")
assert 0 <= len(b) <= PAGE_SIZE, f"unexpected length: {len(b)}"
if not b:
return
heights = [blk["height"] for blk in b]
assert heights == sorted(heights, reverse=True), f"not descending: {heights[:5]}..."
assert max(heights) <= block.height, (
f"page contains height > requested {block.height}: max={max(heights)}"
)
assert len(set(heights)) == len(heights), "duplicate heights in page"
for blk in b:
assert blk["stale"] is False, f"stale block in page: {blk['id']}"
assert blk["extras"]["pool"]["slug"] == pool_slug, (
f"block {blk['id']} attributed to {blk['extras']['pool']['slug']}, "
f"expected {pool_slug}"
)
@pytest.mark.parametrize("bad_slug", ["notapool", "FoundryUSA"])
def test_mining_pool_blocks_from_height_malformed_slug(brk, bad_slug):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad_slug}/blocks/100000")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for slug {bad_slug!r}, got {exc_info.value.status}"
)
@pytest.mark.parametrize("bad_height", ["-1", "abc"])
def test_mining_pool_blocks_from_height_malformed_height(brk, pool_slug, bad_height):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{pool_slug}/blocks/{bad_height}")
assert exc_info.value.status == 400, (
f"expected 400 for height {bad_height!r}, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,43 @@
"""GET /api/v1/mining/pool/{slug}/hashrate"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
def test_mining_pool_hashrate(brk, mempool, pool_slugs):
"""Pool hashrate history must have the same structure for top pools."""
def test_mining_pool_hashrate_structure(brk, mempool, pool_slugs):
"""Pool hashrate history element schema must match for top active pools."""
for slug in pool_slugs:
path = f"/api/v1/mining/pool/{slug}/hashrate"
b = brk.get_json(path)
b = brk.get_pool_hashrate(slug)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m)
def test_mining_pool_hashrate_invariants(brk, pool_slug):
"""Series must be non-empty, ascending in time, with valid hashrate/share/poolName."""
b = brk.get_pool_hashrate(pool_slug)
show("GET", f"/api/v1/mining/pool/{pool_slug}/hashrate", summary(b), "-")
assert len(b) > 0, f"empty hashrate history for {pool_slug}"
timestamps = [entry["timestamp"] for entry in b]
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(timestamps)) == len(timestamps), "duplicate timestamps"
pool_names = {entry["poolName"] for entry in b}
assert len(pool_names) == 1, f"poolName not consistent across series: {pool_names}"
for entry in b:
assert isinstance(entry["avgHashrate"], int) and entry["avgHashrate"] >= 0
assert isinstance(entry["share"], (int, float)) and 0.0 <= entry["share"] <= 1.0
@pytest.mark.parametrize("bad", ["notapool", "FoundryUSA"])
def test_mining_pool_hashrate_malformed(brk, bad):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad}/hashrate")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -3,43 +3,78 @@
from _lib import assert_same_structure, show
# Slugs present in brk's vendored pools-v2.json but reported under a different
# slug (or missing) by mempool.space. Currently the duplicate-pool collision
# case where brk preserves both `bitcoinindia` (variant 80) and
# `bitcoinindiapool` (variant 134), while mempool emits both as `bitcoinindia`.
KNOWN_BRK_ONLY_SLUGS = {"bitcoinindiapool"}
# Pools added upstream after brk's vendored pools-v2.json snapshot. Refresh
# the vendored file (and update this set) when bumping the snapshot.
KNOWN_MEMPOOL_ONLY_SLUGS = {
"drdetroit", "emzy", "knorrium", "mononaut", "nymkappa", "rijndael",
}
EXPECTED_MIN_POOLS = 165
def test_mining_pools_list_structure(brk, mempool):
"""Pool list must have the same element structure."""
"""Pool list element schema must match (flat list, {name, slug, unique_id})."""
path = "/api/v1/mining/pools"
b = brk.get_json(path)
b = brk.get_pools()
m = mempool.get_json(path)
show(
"GET", path,
b[:3] if isinstance(b, list) else b,
m[:3] if isinstance(m, list) else m,
)
show("GET", path, f"({len(b)} pools)", f"({len(m)} pools)", max_lines=4)
assert isinstance(b, list) and isinstance(m, list), "both must be flat lists"
assert_same_structure(b, m)
def _pools(data):
"""`pools` may live at the root or inside an envelope across versions."""
if isinstance(data, list):
return data
return data.get("pools", []) if isinstance(data, dict) else []
def test_mining_pools_list_fields(brk):
"""Each pool entry must carry slug and name (period-less endpoint omits stats)."""
b = _pools(brk.get_json("/api/v1/mining/pools"))
show("GET", "/api/v1/mining/pools", f"({len(b)} pools)", "")
assert b, "no pools in brk's response"
required = {"slug", "name"}
for p in b[:5]:
missing = required - set(p.keys())
assert not missing, f"pool {p.get('slug', '?')} missing fields: {missing}"
assert isinstance(p["name"], str) and p["name"]
"""Every pool entry must carry a non-empty slug + name + non-negative unique_id."""
b = brk.get_pools()
show("GET", "/api/v1/mining/pools", f"({len(b)} pools)", "-")
assert len(b) >= EXPECTED_MIN_POOLS, f"expected >= {EXPECTED_MIN_POOLS} pools, got {len(b)}"
for p in b:
assert isinstance(p["slug"], str) and p["slug"], f"bad slug: {p!r}"
assert isinstance(p["name"], str) and p["name"], f"bad name: {p!r}"
assert isinstance(p["unique_id"], int) and p["unique_id"] >= 0, (
f"bad unique_id: {p!r}"
)
def test_mining_pools_slugs_unique(brk):
"""Pool slugs must be unique across the response."""
b = _pools(brk.get_json("/api/v1/mining/pools"))
b = brk.get_pools()
slugs = [p["slug"] for p in b]
show("GET", "/api/v1/mining/pools", f"({len(slugs)} slugs)", "")
show("GET", "/api/v1/mining/pools", f"({len(slugs)} slugs)", "-")
assert len(slugs) == len(set(slugs)), (
f"duplicate slugs: {len(slugs) - len(set(slugs))}"
)
def test_mining_pools_unique_ids_unique(brk):
"""Pool unique_ids must be unique across the response."""
b = brk.get_pools()
ids = [p["unique_id"] for p in b]
show("GET", "/api/v1/mining/pools", f"({len(ids)} unique_ids)", "-")
assert len(ids) == len(set(ids)), (
f"duplicate unique_ids: {len(ids) - len(set(ids))}"
)
def test_mining_pools_slugs_match_mempool(brk, mempool):
"""brk's slug set must equal mempool's, modulo documented exceptions."""
b_slugs = {p["slug"] for p in brk.get_pools()}
m_slugs = {p["slug"] for p in mempool.get_json("/api/v1/mining/pools")}
show(
"GET", "/api/v1/mining/pools",
f"brk-only={sorted(b_slugs - m_slugs)}",
f"mempool-only={sorted(m_slugs - b_slugs)}",
)
unexpected_brk_only = (b_slugs - m_slugs) - KNOWN_BRK_ONLY_SLUGS
unexpected_mempool_only = (m_slugs - b_slugs) - KNOWN_MEMPOOL_ONLY_SLUGS
assert not unexpected_brk_only, (
f"undocumented brk-only slugs (likely format divergence): {unexpected_brk_only}"
)
assert not unexpected_mempool_only, (
f"undocumented mempool-only slugs (refresh pools-v2.json?): {unexpected_mempool_only}"
)

View File

@@ -2,14 +2,53 @@
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"])
def test_mining_pools_by_period(brk, mempool, period):
"""Pool stats for a time period must have the same structure."""
PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
@pytest.mark.parametrize("period", PERIODS)
def test_mining_pools_by_period_structure(brk, mempool, period):
"""Pool stats envelope must structurally match mempool across all periods."""
path = f"/api/v1/mining/pools/{period}"
b = brk.get_json(path)
b = brk.get_pool_stats(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert_same_structure(b, m)
def test_mining_pools_by_period_invariants(brk):
"""A single deep-period sanity pass on `1w`."""
period = "1w"
b = brk.get_pool_stats(period)
show("GET", f"/api/v1/mining/pools/{period}", summary(b), "-")
assert isinstance(b["blockCount"], int) and b["blockCount"] > 0
assert isinstance(b["lastEstimatedHashrate"], int) and b["lastEstimatedHashrate"] > 0
pools = b["pools"]
assert pools, "expected non-empty pools list for 1w"
slugs = [p["slug"] for p in pools]
assert len(slugs) == len(set(slugs)), "duplicate slugs in pools list"
ranks = [p["rank"] for p in pools]
assert ranks == list(range(1, len(pools) + 1)), f"ranks not 1..N: {ranks}"
block_total = 0
for p in pools:
assert isinstance(p["blockCount"], int) and p["blockCount"] >= 0
assert isinstance(p["emptyBlocks"], int) and p["emptyBlocks"] >= 0
assert 0.0 <= p["share"] <= 1.0, f"share out of range for {p['slug']}: {p['share']}"
block_total += p["blockCount"]
assert block_total <= b["blockCount"], (
f"sum(pool.blockCount)={block_total} exceeds envelope.blockCount={b['blockCount']}"
)
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_pools_by_period_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pools/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,62 @@
import pytest
from _lib import assert_same_structure, show
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show
@pytest.mark.parametrize("block_count", [10, 100, 500])
def test_mining_reward_stats(brk, mempool, block_count):
"""Reward stats must have the same structure."""
path = f"/api/v1/mining/reward-stats/{block_count}"
b = brk.get_json(path)
COUNTS = [1, 10, 100, 500, 1000]
@pytest.mark.parametrize("count", COUNTS)
def test_mining_reward_stats_structure(brk, mempool, count):
"""Reward stats envelope must match across counts."""
path = f"/api/v1/mining/reward-stats/{count}"
b = brk.get_reward_stats(count)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
@pytest.mark.parametrize("count", [100, 1000])
def test_mining_reward_stats_values_match(brk, mempool, count):
"""brk and mempool must agree exactly on aggregated stats."""
path = f"/api/v1/mining/reward-stats/{count}"
b = brk.get_reward_stats(count)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_mining_reward_stats_invariants(brk):
"""Range alignment, reward >= fee, totalTx >= block count (count=1000)."""
count = 1000
b = brk.get_reward_stats(count)
show("GET", f"/api/v1/mining/reward-stats/{count}", b, "-")
start = int(b["startBlock"])
end = int(b["endBlock"])
total_reward = int(b["totalReward"])
total_fee = int(b["totalFee"])
total_tx = int(b["totalTx"])
assert start <= end, f"startBlock {start} > endBlock {end}"
assert end - start + 1 == count, (
f"range mismatch: {end} - {start} + 1 = {end - start + 1}, expected {count}"
)
assert total_fee >= 0, f"negative totalFee: {total_fee}"
assert total_reward >= total_fee, (
f"totalReward {total_reward} < totalFee {total_fee} (subsidy must be non-negative)"
)
assert total_tx >= count, (
f"totalTx {total_tx} < block_count {count} (each block has >=1 coinbase tx)"
)
@pytest.mark.parametrize("bad", ["abc", "-1"])
def test_mining_reward_stats_malformed(brk, bad):
"""Non-numeric or negative block_count must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/reward-stats/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,12 +1,57 @@
"""GET /api/v1/cpfp/{txid}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
def test_cpfp(brk, mempool, block):
"""CPFP info structure must match for a confirmed tx."""
def test_cpfp_structure(brk, mempool, block):
"""CPFP structure must match for a confirmed regular tx (multi-era)."""
path = f"/api/v1/cpfp/{block.txid}"
b = brk.get_json(path)
b = brk.get_cpfp(block.txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
def test_cpfp_coinbase_structure(brk, mempool, block):
"""CPFP structure must match for a coinbase tx (multi-era)."""
path = f"/api/v1/cpfp/{block.coinbase_txid}"
b = brk.get_cpfp(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
def test_cpfp_invariants(brk, live):
"""Recent confirmed tx: ancestors empty, any brk-computed extras non-negative."""
sample = live.blocks[-1]
c = brk.get_cpfp(sample.txid)
show("GET", f"/api/v1/cpfp/{sample.txid}", c, "-")
assert c["ancestors"] == [], "confirmed tx must have empty ancestors"
if "fee" in c:
assert int(c["fee"]) >= 0
if "effectiveFeePerVsize" in c:
assert c["effectiveFeePerVsize"] >= 0
if "adjustedVsize" in c:
assert int(c["adjustedVsize"]) > 0
def test_cpfp_unknown_tx_returns_empty(brk, mempool):
"""Both servers return {ancestors: []} for any 64-char hex (no 404)."""
bad = "0" * 64
path = f"/api/v1/cpfp/{bad}"
b = brk.get_cpfp(bad)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b.get("ancestors") == []
assert m.get("ancestors") == []
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_cpfp_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400) on brk (mempool returns 501)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/cpfp/{bad}")
assert exc_info.value.status == 400

View File

@@ -1,40 +1,55 @@
"""POST /api/tx (broadcast)
We can't actually broadcast a real transaction in a test, so we send a
clearly malformed payload and verify both servers reject it with 4xx. The
goal is to confirm the endpoint exists and behaves like a transaction
broadcaster — not to push live transactions.
Live broadcast can't be tested in CI — instead we feed every form of
*invalid* payload and verify both servers reject it identically with 400.
"""
import pytest
from brk_client import BrkError
from _lib import show
def test_post_tx_invalid_hex(brk, mempool):
"""Both servers must reject an obviously invalid hex payload with 4xx."""
@pytest.mark.parametrize("label,body", [
("empty", ""),
("whitespace", " "),
("padded_garbage", " deadbeef "),
("garbage_short", "deadbeef"),
("non_hex", "not-hex-zzzz"),
("single_byte", "00"),
])
def test_post_tx_invalid_body_rejected(brk, mempool, label, body):
"""Invalid body must be rejected with 400 on both servers."""
path = "/api/tx"
bad_hex = "deadbeef" # too short to be a valid serialized transaction
b = brk.session.post(f"{brk.base_url}{path}", data=bad_hex, timeout=15)
with pytest.raises(BrkError) as ei:
brk.post_tx(body)
assert ei.value.status == 400, label
mempool._wait()
m = mempool.session.post(f"{mempool.base_url}{path}", data=bad_hex, timeout=15)
show("POST", path, f"brk={b.status_code}", f"mempool={m.status_code}")
assert 400 <= b.status_code < 500, (
f"brk POST /api/tx with garbage should 4xx, got {b.status_code}: {b.text!r}"
)
assert 400 <= m.status_code < 500, (
f"mempool POST /api/tx with garbage should 4xx, got {m.status_code}: {m.text!r}"
)
m = mempool.session.post(f"{mempool.base_url}{path}", data=body, timeout=15)
show("POST", f"{path} ({label})", "brk=400", f"mempool={m.status_code}")
assert m.status_code == 400, f"{label}: mempool={m.status_code}"
def test_post_tx_empty_body(brk, mempool):
"""Both servers must reject an empty body with 4xx."""
path = "/api/tx"
b = brk.session.post(f"{brk.base_url}{path}", data="", timeout=15)
def test_post_tx_coinbase_rejected(brk, mempool, block):
"""Re-broadcasting a coinbase tx is rejected with 400 on both servers (multi-era)."""
coinbase_hex = mempool.get_text(f"/api/tx/{block.coinbase_txid}/hex")
with pytest.raises(BrkError) as ei:
brk.post_tx(coinbase_hex)
assert ei.value.status == 400
mempool._wait()
m = mempool.session.post(f"{mempool.base_url}{path}", data="", timeout=15)
show("POST", path, f"brk={b.status_code}", f"mempool={m.status_code}")
m = mempool.session.post(f"{mempool.base_url}/api/tx", data=coinbase_hex, timeout=15)
show("POST", f"/api/tx (coinbase h={block.height})", "brk=400", f"mempool={m.status_code}")
assert m.status_code == 400
assert 400 <= b.status_code < 500
assert 400 <= m.status_code < 500
def test_post_tx_already_confirmed_rejected(brk, mempool, live):
"""Re-broadcasting an already-confirmed regular tx is rejected with 400 on both."""
sample = live.blocks[-1]
tx_hex = mempool.get_text(f"/api/tx/{sample.txid}/hex")
with pytest.raises(BrkError) as ei:
brk.post_tx(tx_hex)
assert ei.value.status == 400
mempool._wait()
m = mempool.session.post(f"{mempool.base_url}/api/tx", data=tx_hex, timeout=15)
show("POST", f"/api/tx (confirmed h={sample.height})", "brk=400", f"mempool={m.status_code}")
assert m.status_code == 400

View File

@@ -1,56 +1,67 @@
"""GET /api/v1/transaction-times?txId[]=..."""
import pytest
from brk_client import BrkError
from _lib import show
def test_transaction_times_few(brk, mempool, live):
"""First-seen timestamps must match for a few txids."""
"""First-seen timestamps must match for a few txids (confirmed → all 0)."""
txids = [b.txid for b in live.blocks[:3]]
params = [("txId[]", t) for t in txids]
path = "/api/v1/transaction-times"
b = brk.get_json(path, params=params)
m = mempool.get_json(path, params=params)
show("GET", f"{path}?txId[]={{{len(txids)} txids}}", b, m)
b = brk.get_transaction_times(txids)
m = mempool.get_json("/api/v1/transaction-times", params=params)
show("GET", f"/api/v1/transaction-times?txId[]={{{len(txids)} txids}}", b, m)
assert isinstance(b, list) and isinstance(m, list)
assert len(b) == len(m) == len(txids)
assert b == m, f"timestamps differ: brk={b} vs mempool={m}"
assert b == m
def test_transaction_times_many(brk, mempool, live):
"""A larger batch (covering all sample blocks + coinbases) must match exactly."""
"""A larger batch (all sample blocks + coinbases) must match exactly."""
txids = [b.txid for b in live.blocks] + [b.coinbase_txid for b in live.blocks]
params = [("txId[]", t) for t in txids]
path = "/api/v1/transaction-times"
b = brk.get_json(path, params=params)
m = mempool.get_json(path, params=params)
show("GET", f"{path}?txId[]={{{len(txids)} txids}}", f"({len(b)})", f"({len(m)})")
b = brk.get_transaction_times(txids)
m = mempool.get_json("/api/v1/transaction-times", params=params)
show("GET", f"/api/v1/transaction-times?txId[]={{{len(txids)} txids}}",
f"({len(b)})", f"({len(m)})")
assert len(b) == len(m) == len(txids)
assert b == m, f"timestamps differ: brk={b} vs mempool={m}"
assert b == m
def test_transaction_times_single(brk, mempool, live):
"""A single-element batch must return a 1-element list with the same value."""
txid = live.sample_txid
params = [("txId[]", txid)]
path = "/api/v1/transaction-times"
b = brk.get_json(path, params=params)
m = mempool.get_json(path, params=params)
show("GET", f"{path}?txId[]={txid[:16]}...", b, m)
b = brk.get_transaction_times([txid])
m = mempool.get_json("/api/v1/transaction-times", params=params)
show("GET", f"/api/v1/transaction-times?txId[]={txid[:16]}...", b, m)
assert isinstance(b, list) and isinstance(m, list)
assert len(b) == len(m) == 1
assert b == m, f"single timestamp differs: brk={b} vs mempool={m}"
assert b == m
def test_transaction_times_empty(brk, mempool):
"""An empty batch must be rejected (any non-2xx) on both servers.
def test_transaction_times_unknown_txid_returns_zero(brk, mempool):
"""Unknown 64-char hex must return [0] on both servers."""
bad = "0" * 64
params = [("txId[]", bad)]
b = brk.get_transaction_times([bad])
m = mempool.get_json("/api/v1/transaction-times", params=params)
show("GET", f"/api/v1/transaction-times?txId[]={bad[:16]}...", b, m)
assert b == [0]
assert m == [0]
mempool.space returns 500 — technically a server-side bug (it should be a
4xx since the request itself is malformed) — so we don't insist on exact
status parity, only that neither server silently treats it as valid input.
"""
path = "/api/v1/transaction-times"
b_resp = brk.get_raw(path)
m_resp = mempool.get_raw(path)
show("GET", path, f"brk={b_resp.status_code}", f"mempool={m_resp.status_code}")
assert not b_resp.ok, f"brk accepted empty batch with {b_resp.status_code}: {b_resp.text!r}"
assert not m_resp.ok, f"mempool accepted empty batch with {m_resp.status_code}"
def test_transaction_times_empty_batch_rejected(brk):
"""Empty batch must produce BrkError(status=400) (mempool returns 500, brk-only check)."""
with pytest.raises(BrkError) as exc_info:
brk.get_transaction_times([])
assert exc_info.value.status == 400
def test_transaction_times_malformed_short(brk):
"""Short txid in batch must produce BrkError(status=400) (mempool silently returns [])."""
with pytest.raises(BrkError) as exc_info:
brk.get_transaction_times(["abc"])
assert exc_info.value.status == 400

View File

@@ -1,21 +1,71 @@
"""GET /api/tx/{txid}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_tx_by_id(brk, mempool, block):
"""Full transaction data must match for a confirmed tx."""
def test_tx_by_id_value_parity(brk, mempool, block):
"""Full transaction data must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}"
b = brk.get_json(path)
b = brk.get_tx(block.txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m, exclude={"sigops"})
def test_tx_coinbase(brk, mempool, block):
"""Coinbase transaction must match."""
def test_tx_coinbase_value_parity(brk, mempool, block):
"""Coinbase transaction must match (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}"
b = brk.get_json(path)
b = brk.get_tx(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m, exclude={"sigops"})
def test_tx_invariants_regular(brk, live):
"""Recent regular tx: fee accounting, weight <= 4*size, status confirmed."""
sample = live.blocks[-1]
if sample.txid == sample.coinbase_txid:
pytest.skip("recent block has only coinbase")
tx = brk.get_tx(sample.txid)
show("GET", f"/api/tx/{sample.txid}", tx, "-")
assert tx["txid"] == sample.txid
assert len(tx["vin"]) > 0 and len(tx["vout"]) > 0
assert int(tx["size"]) > 0
assert 0 < int(tx["weight"]) <= 4 * int(tx["size"])
sum_in = sum(int(v["prevout"]["value"]) for v in tx["vin"])
sum_out = sum(int(o["value"]) for o in tx["vout"])
assert sum_in - sum_out == int(tx["fee"])
assert tx["status"]["confirmed"] is True
def test_tx_invariants_coinbase(brk, live):
"""Recent coinbase: single vin, is_coinbase, no prevout, status confirmed."""
sample = live.blocks[-1]
tx = brk.get_tx(sample.coinbase_txid)
show("GET", f"/api/tx/{sample.coinbase_txid}", tx, "-")
assert tx["txid"] == sample.coinbase_txid
assert len(tx["vin"]) == 1
cbin = tx["vin"][0]
assert cbin["is_coinbase"] is True
assert cbin["prevout"] is None
assert int(tx["fee"]) == 0
assert tx["status"]["confirmed"] is True
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}")
assert exc_info.value.status == 400
def test_tx_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,53 @@
"""GET /api/tx/{txid}/hex"""
import pytest
from brk_client import BrkError
from _lib import show
def test_tx_hex(brk, mempool, block):
"""Raw transaction hex must be identical."""
HEX = set("0123456789abcdef")
def test_tx_hex_value_parity(brk, mempool, block):
"""Raw tx hex must be byte-identical for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/hex"
b = brk.get_text(path)
b = brk.get_tx_hex(block.txid)
m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m
def test_tx_hex_coinbase_value_parity(brk, mempool, block):
"""Coinbase tx hex must be byte-identical (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/hex"
b = brk.get_tx_hex(block.coinbase_txid)
m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m
def test_tx_hex_invariants(brk, live):
"""Recent tx hex: non-empty, even length, strict lowercase hex."""
sample = live.blocks[-1]
h = brk.get_tx_hex(sample.txid)
show("GET", f"/api/tx/{sample.txid}/hex", f"({len(h)} chars)", "-")
assert isinstance(h, str) and len(h) > 0
assert len(h) % 2 == 0, f"odd hex length: {len(h)}"
assert set(h) <= HEX, f"non-hex chars present: {set(h) - HEX}"
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_hex_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/hex")
assert exc_info.value.status == 400
def test_tx_hex_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/hex")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,70 @@
"""GET /api/tx/{txid}/merkle-proof"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_tx_merkle_proof(brk, mempool, block):
"""Merkle inclusion proof must match."""
HEX = set("0123456789abcdef")
def test_tx_merkle_proof_value_parity(brk, mempool, block):
"""Merkle proof must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/merkle-proof"
b = brk.get_json(path)
b = brk.get_tx_merkle_proof(block.txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_merkle_proof_coinbase_value_parity(brk, mempool, block):
"""Merkle proof must match for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/merkle-proof"
b = brk.get_tx_merkle_proof(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_merkle_proof_invariants_regular(brk, live):
"""Recent regular tx: block_height matches, pos > 0, all siblings 64-char hex."""
sample = live.blocks[-1]
if sample.txid == sample.coinbase_txid:
pytest.skip("recent block has only coinbase")
p = brk.get_tx_merkle_proof(sample.txid)
show("GET", f"/api/tx/{sample.txid}/merkle-proof", p, "-")
assert int(p["block_height"]) == sample.height
assert int(p["pos"]) > 0, "regular tx pos must be > 0 (coinbase is at 0)"
assert isinstance(p["merkle"], list)
for i, sib in enumerate(p["merkle"]):
assert isinstance(sib, str) and len(sib) == 64 and set(sib) <= HEX, (
f"merkle[{i}] malformed: {sib!r}"
)
def test_tx_merkle_proof_invariants_coinbase(brk, live):
"""Recent coinbase: pos == 0, block_height matches."""
sample = live.blocks[-1]
p = brk.get_tx_merkle_proof(sample.coinbase_txid)
show("GET", f"/api/tx/{sample.coinbase_txid}/merkle-proof", p, "-")
assert int(p["block_height"]) == sample.height
assert int(p["pos"]) == 0
for sib in p["merkle"]:
assert isinstance(sib, str) and len(sib) == 64 and set(sib) <= HEX
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_merkle_proof_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkle-proof")
assert exc_info.value.status == 400
def test_tx_merkle_proof_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkle-proof")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,58 @@
"""GET /api/tx/{txid}/merkleblock-proof"""
import pytest
from brk_client import BrkError
from _lib import show
def test_tx_merkleblock_proof(brk, mempool, block):
"""BIP37 merkleblock proof hex must be identical."""
HEX = set("0123456789abcdef")
HEADER_HEX_LEN = 160 # 80-byte BIP37 block header prefix
def test_tx_merkleblock_proof_value_parity(brk, mempool, block):
"""Merkleblock proof hex must be byte-identical for a regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/merkleblock-proof"
b = brk.get_text(path)
b = brk.get_tx_merkleblock_proof(block.txid)
m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m
def test_tx_merkleblock_proof_coinbase_value_parity(brk, mempool, block):
"""Merkleblock proof hex must be byte-identical for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/merkleblock-proof"
b = brk.get_tx_merkleblock_proof(block.coinbase_txid)
m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m
def test_tx_merkleblock_proof_invariants(brk, live):
"""Recent tx: even hex, lowercase, header prefix matches /block/{hash}/header."""
sample = live.blocks[-1]
proof = brk.get_tx_merkleblock_proof(sample.txid)
show("GET", f"/api/tx/{sample.txid}/merkleblock-proof", f"({len(proof)} chars)", "-")
assert isinstance(proof, str) and len(proof) > HEADER_HEX_LEN
assert len(proof) % 2 == 0, f"odd hex length: {len(proof)}"
assert set(proof) <= HEX, f"non-hex chars: {set(proof) - HEX}"
header = brk.get_block_header(sample.hash)
assert proof[:HEADER_HEX_LEN] == header, (
"merkleblock-proof header prefix must match /block/{hash}/header"
)
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_merkleblock_proof_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkleblock-proof")
assert exc_info.value.status == 400
def test_tx_merkleblock_proof_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkleblock-proof")
assert exc_info.value.status == 404

View File

@@ -1,38 +1,79 @@
"""GET /api/tx/{txid}/outspend/{vout}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_tx_outspend_first(brk, mempool, block):
"""Spending status of vout 0 must match exactly."""
HEX = set("0123456789abcdef")
def test_tx_outspend_first_value_parity(brk, mempool, block):
"""Spending status of vout 0 must match (multi-era)."""
path = f"/api/tx/{block.txid}/outspend/0"
b = brk.get_json(path)
b = brk.get_tx_outspend(block.txid, 0)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_outspend_last(brk, mempool, block):
"""Spending status of the last vout must also match exactly."""
def test_tx_outspend_last_value_parity(brk, mempool, block):
"""Spending status of the last vout must match (multi-era)."""
tx = mempool.get_json(f"/api/tx/{block.txid}")
last_vout = len(tx["vout"]) - 1
path = f"/api/tx/{block.txid}/outspend/{last_vout}"
b = brk.get_json(path)
b = brk.get_tx_outspend(block.txid, last_vout)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_outspend_coinbase_value_parity(brk, mempool, block):
"""Coinbase vout 0 spending status must match (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/outspend/0"
b = brk.get_tx_outspend(block.coinbase_txid, 0)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_outspend_out_of_range(brk, mempool, block):
"""A vout index past the last output must produce the same response on both servers.
Both servers return `{"spent": false}` rather than 4xx — they don't bound-check
the vout index. The compat property is that they agree.
"""
"""Past-the-end vout returns {spent: false} on both servers (no 404)."""
tx = mempool.get_json(f"/api/tx/{block.txid}")
bad_vout = len(tx["vout"]) + 100
path = f"/api/tx/{block.txid}/outspend/{bad_vout}"
b = brk.get_json(path)
b = brk.get_tx_outspend(block.txid, bad_vout)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b == m, f"out-of-range outspend disagrees: brk={b} vs mempool={m}"
assert b == m
def test_tx_outspend_invariants_spent(brk, live):
"""An old (h100) tx output: if spent, validate the spending-tx envelope."""
h100 = next((b for b in live.blocks if b.height == 100), None)
if h100 is None:
pytest.skip("h100 not discovered")
o = brk.get_tx_outspend(h100.txid, 0)
show("GET", f"/api/tx/{h100.txid}/outspend/0", o, "-")
if o["spent"] is True:
assert isinstance(o["txid"], str) and len(o["txid"]) == 64 and set(o["txid"]) <= HEX
assert int(o["vin"]) >= 0
assert o["status"]["confirmed"] is True
assert int(o["status"]["block_height"]) >= h100.height
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_outspend_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspend/0")
assert exc_info.value.status == 400
def test_tx_outspend_malformed_unknown_tx(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspend/0")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,59 @@
"""GET /api/tx/{txid}/outspends"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_tx_outspends(brk, mempool, block):
"""Spending status of all outputs must match exactly."""
def test_tx_outspends_value_parity(brk, mempool, block):
"""Outspends list must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/outspends"
b = brk.get_json(path)
b = brk.get_tx_outspends(block.txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_outspends_coinbase_value_parity(brk, mempool, block):
"""Outspends list must match for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/outspends"
b = brk.get_tx_outspends(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_outspends_length_matches_vout(brk, live):
"""Recent tx: len(outspends) must equal len(tx.vout)."""
sample = live.blocks[-1]
tx = brk.get_tx(sample.txid)
o = brk.get_tx_outspends(sample.txid)
show("GET", f"/api/tx/{sample.txid}/outspends", f"({len(o)} entries)", "-")
assert len(o) == len(tx["vout"]), f"outspends={len(o)} vs vout={len(tx['vout'])}"
def test_tx_outspends_matches_per_vout(brk, live):
"""Recent tx: each outspends[i] equals /outspend/{i}."""
sample = live.blocks[-1]
o = brk.get_tx_outspends(sample.txid)
show("GET", f"/api/tx/{sample.txid}/outspends", f"({len(o)} entries)", "-")
for i, expected in enumerate(o):
single = brk.get_tx_outspend(sample.txid, i)
assert single == expected, f"outspends[{i}] != /outspend/{i}"
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_outspends_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspends")
assert exc_info.value.status == 400
def test_tx_outspends_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspends")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,50 @@
"""GET /api/tx/{txid}/raw"""
import pytest
from brk_client import BrkError
from _lib import show
def test_tx_raw(brk, mempool, block):
"""Raw transaction bytes must be identical."""
def test_tx_raw_value_parity(brk, mempool, block):
"""Raw tx bytes must be byte-identical for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/raw"
b = brk.get_bytes(path)
b = brk.get_tx_raw(block.txid)
m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m
def test_tx_raw_coinbase_value_parity(brk, mempool, block):
"""Coinbase tx bytes must be byte-identical (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/raw"
b = brk.get_tx_raw(block.coinbase_txid)
m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m
def test_tx_raw_matches_hex(brk, live):
"""Recent tx: raw bytes' hex must equal /hex endpoint output exactly."""
sample = live.blocks[-1]
raw = brk.get_tx_raw(sample.txid)
hex_str = brk.get_tx_hex(sample.txid)
show("GET", f"/api/tx/{sample.txid}/raw", f"<{len(raw)} bytes>", "-")
assert isinstance(raw, bytes) and len(raw) > 0
assert raw.hex() == hex_str, "raw.hex() != /hex"
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_raw_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get(f"/api/tx/{bad}/raw")
assert exc_info.value.status == 400
def test_tx_raw_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get(f"/api/tx/{bad}/raw")
assert exc_info.value.status == 404

View File

@@ -1,16 +1,82 @@
"""GET /api/v1/tx/{txid}/rbf
For confirmed transactions both servers return an empty/null replacement
set; the structure is what's load-bearing here.
brk's `tx_graveyard` retains RBF tree data for 1 hour after a tx leaves the
live mempool (whether mined or replaced). Past that window, brk returns
`{replacements: null, replaces: null}`. mempool.space retains RBF history
for longer, so the cross-server response can diverge for txs older than
brk's window but newer than mempool's. The tests verify:
- brk's contract (always-null) holds for txs deeply past its retention window;
- value parity holds when mempool also reports null (steady state);
- within retention, brk and mempool agree structurally on the tree shape.
"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
def test_tx_rbf_for_confirmed(brk, mempool, block):
"""RBF replacement timeline structure must match for a confirmed tx."""
NULL_RBF = {"replacements": None, "replaces": None}
def test_tx_rbf_brk_null_for_confirmed(brk, block):
"""brk contract: confirmed regular tx always has null replacements/replaces."""
r = brk.get_tx_rbf(block.txid)
show("GET", f"/api/v1/tx/{block.txid}/rbf", r, "-")
assert r == NULL_RBF
def test_tx_rbf_brk_null_for_coinbase(brk, block):
"""brk contract: coinbase tx always has null replacements/replaces."""
r = brk.get_tx_rbf(block.coinbase_txid)
show("GET", f"/api/v1/tx/{block.coinbase_txid}/rbf", r, "-")
assert r == NULL_RBF
def test_tx_rbf_value_parity_when_mempool_null(brk, mempool, block):
"""When mempool also reports null, brk and mempool must agree exactly."""
path = f"/api/v1/tx/{block.txid}/rbf"
b = brk.get_json(path)
m = mempool.get_json(path)
if m != NULL_RBF:
pytest.skip("mempool retained RBF history (recently-confirmed); brk doesn't")
b = brk.get_tx_rbf(block.txid)
show("GET", path, b, m)
assert b == m
def test_tx_rbf_within_retention_window(brk, mempool):
"""A root from brk's /replacements list is within the 1h retention window;
brk must return its tree, and mempool (longer retention) must agree on shape."""
trees = brk.get_replacements()
if not trees:
pytest.skip("no recent RBF replacements observed by brk")
root_txid = trees[0]["tx"]["txid"]
path = f"/api/v1/tx/{root_txid}/rbf"
b = brk.get_tx_rbf(root_txid)
show("GET", path, b, "-")
assert b["replacements"] is not None, (
"brk evicted RBF tree it just listed in /replacements"
)
m = mempool.get_json(path)
if m["replacements"] is None:
pytest.skip("mempool has no RBF history for brk's recent root")
assert_same_structure(b, m)
def test_tx_rbf_unknown_tx_returns_null(brk, mempool):
"""Both servers return null replacements/replaces for any 64-char hex (no 404)."""
bad = "0" * 64
path = f"/api/v1/tx/{bad}/rbf"
b = brk.get_tx_rbf(bad)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
assert b == NULL_RBF
assert m == NULL_RBF
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_rbf_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400) on brk (mempool returns 501)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/tx/{bad}/rbf")
assert exc_info.value.status == 400

View File

@@ -1,12 +1,51 @@
"""GET /api/tx/{txid}/status"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show
def test_tx_status(brk, mempool, block):
"""Confirmation status must match for a confirmed tx."""
def test_tx_status_value_parity(brk, mempool, block):
"""Status must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/status"
b = brk.get_json(path)
b = brk.get_tx_status(block.txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_status_coinbase_value_parity(brk, mempool, block):
"""Status must match for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/status"
b = brk.get_tx_status(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_status_invariants(brk, live):
"""Recent confirmed tx: confirmed=True, height/hash/time match the block."""
sample = live.blocks[-1]
s = brk.get_tx_status(sample.txid)
show("GET", f"/api/tx/{sample.txid}/status", s, "-")
assert s["confirmed"] is True
assert int(s["block_height"]) == sample.height
assert s["block_hash"] == sample.hash
assert int(s["block_time"]) > 0
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_status_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/status")
assert exc_info.value.status == 400
def test_tx_status_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/status")
assert exc_info.value.status == 404