mmpl: new, mempool + rpc: fixes

This commit is contained in:
nym21
2026-05-14 13:59:15 +02:00
parent 528c134f26
commit 90aca2e048
36 changed files with 1269 additions and 453 deletions

91
Cargo.lock generated
View File

@@ -769,9 +769,9 @@ checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.61" version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -969,9 +969,9 @@ dependencies = [
[[package]] [[package]]
name = "corepc-types" name = "corepc-types"
version = "0.12.0" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1583872320eb2ac629c36753023fd072f1ca1b3b74b20cc62bab055b54278789" checksum = "b96c7869aa8234d10a41cbe3a1697bcb3a2482c48d9eb3541b3a4014a81afdad"
dependencies = [ dependencies = [
"bitcoin", "bitcoin",
"serde", "serde",
@@ -1501,9 +1501,9 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.0" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "heck" name = "heck"
@@ -1511,6 +1511,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hex-conservative" name = "hex-conservative"
version = "0.2.2" version = "0.2.2"
@@ -1796,7 +1802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.0", "hashbrown 0.17.1",
"serde", "serde",
"serde_core", "serde_core",
] ]
@@ -1810,6 +1816,23 @@ dependencies = [
"compare", "compare",
] ]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -2019,9 +2042,9 @@ dependencies = [
[[package]] [[package]]
name = "lz4_flex" name = "lz4_flex"
version = "0.13.0" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
[[package]] [[package]]
name = "matchit" name = "matchit"
@@ -2086,6 +2109,19 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "mmpl"
version = "0.3.0-beta.9"
dependencies = [
"brk_error",
"brk_mempool",
"brk_rpc",
"brk_types",
"rustc-hash",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -2155,6 +2191,10 @@ name = "owo-colors"
version = "4.3.0" version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
dependencies = [
"supports-color 2.1.0",
"supports-color 3.0.2",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
@@ -2200,9 +2240,9 @@ dependencies = [
[[package]] [[package]]
name = "pco" name = "pco"
version = "1.0.1" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89d71ab3c07ed898defa4915bdc2a963131d811a1eab0eeacfac65c94cdeae8" checksum = "553ccdc7f6999785559af4998c79712a5ab820e26b68bad9146609c19587ec82"
dependencies = [ dependencies = [
"better_io", "better_io",
"dtype_dispatch", "dtype_dispatch",
@@ -2887,6 +2927,25 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "supports-color"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89"
dependencies = [
"is-terminal",
"is_ci",
]
[[package]]
name = "supports-color"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
dependencies = [
"is_ci",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -3000,9 +3059,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.2" version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [ dependencies = [
"libc", "libc",
"mio", "mio",
@@ -3302,7 +3361,7 @@ dependencies = [
"itoa", "itoa",
"libc", "libc",
"log", "log",
"lz4_flex 0.13.0", "lz4_flex 0.13.1",
"parking_lot", "parking_lot",
"pco", "pco",
"rawdb", "rawdb",
@@ -3819,9 +3878,9 @@ dependencies = [
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [ dependencies = [
"zerofrom-derive", "zerofrom-derive",
] ]

View File

@@ -65,14 +65,14 @@ brk_website = { version = "0.3.0-beta.9", path = "crates/brk_website" }
byteview = "0.10.1" byteview = "0.10.1"
color-eyre = "0.6.5" color-eyre = "0.6.5"
corepc-jsonrpc = { package = "jsonrpc", version = "0.19.0", features = ["simple_http"], default-features = false } corepc-jsonrpc = { package = "jsonrpc", version = "0.19.0", features = ["simple_http"], default-features = false }
corepc-types = { version = "0.12.0", features = ["std"], default-features = false } corepc-types = { version = "0.13.0", features = ["std"], default-features = false }
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] } derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
fjall = "=3.0.4" fjall = "=3.0.4"
indexmap = { version = "2.14.0", features = ["serde"] } indexmap = { version = "2.14.0", features = ["serde"] }
jiff = { version = "0.2.24", features = ["perf-inline", "tz-system"], default-features = false } jiff = { version = "0.2.24", features = ["perf-inline", "tz-system"], default-features = false }
owo-colors = "4.3.0" owo-colors = "4.3.0"
parking_lot = "0.12.5" parking_lot = "0.12.5"
pco = "1.0.1" pco = "1.0.2"
rayon = "1.12.0" rayon = "1.12.0"
rustc-hash = "2.1.2" rustc-hash = "2.1.2"
schemars = { version = "1.2.1", features = ["indexmap2"] } schemars = { version = "1.2.1", features = ["indexmap2"] }
@@ -81,7 +81,7 @@ serde_bytes = "0.11.19"
serde_derive = "1.0.228" serde_derive = "1.0.228"
serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order"] } serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order"] }
smallvec = "1.15.1" smallvec = "1.15.1"
tokio = { version = "1.52.2", features = ["rt-multi-thread"] } tokio = { version = "1.52.3", features = ["rt-multi-thread"] }
tower-http = { version = "0.6.10", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] } tower-http = { version = "0.6.10", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] }
tower-layer = "0.3" tower-layer = "0.3"
tracing = { version = "0.1", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false, features = ["std"] }

View File

@@ -13,7 +13,7 @@ brk_error = { workspace = true }
brk_reader = { workspace = true } brk_reader = { workspace = true }
brk_rpc = { workspace = true } brk_rpc = { workspace = true }
brk_types = { workspace = true } brk_types = { workspace = true }
owo-colors = { workspace = true } owo-colors = { workspace = true, features = ["supports-colors"] }
serde_json = { workspace = true } serde_json = { workspace = true }
[[bin]] [[bin]]

View File

@@ -1,4 +1,4 @@
use std::path::PathBuf; use std::{collections::HashSet, path::PathBuf};
use brk_error::{Error, Result}; use brk_error::{Error, Result};
use brk_rpc::{Auth, Client}; use brk_rpc::{Auth, Client};
@@ -66,6 +66,9 @@ impl Args {
} }
continue; continue;
} }
if a.starts_with('-') {
return Err(Error::Parse(format!("unknown flag {a}")));
}
positional.push(a); positional.push(a);
} }
@@ -74,6 +77,12 @@ impl Args {
.next() .next()
.ok_or_else(|| Error::Parse("missing selector".into()))?; .ok_or_else(|| Error::Parse("missing selector".into()))?;
let paths: Vec<Path> = iter.map(|f| Path::parse(&f)).collect::<Result<_>>()?; let paths: Vec<Path> = iter.map(|f| Path::parse(&f)).collect::<Result<_>>()?;
let mut seen = HashSet::with_capacity(paths.len());
for p in &paths {
if !seen.insert(p.raw.as_str()) {
return Err(Error::Parse(format!("duplicate field '{}'", p.raw)));
}
}
Ok(Self { Ok(Self {
selector, selector,
paths, paths,

View File

@@ -6,29 +6,126 @@ use bitcoin::{
}; };
use brk_error::{Error, Result}; use brk_error::{Error, Result};
use brk_types::ReadBlock; use brk_types::ReadBlock;
use serde_json::{Value, json}; use serde_json::{Map, Value, json};
use crate::path::{Path, Step}; use crate::path::{Path, Step};
// `hex` is intentionally absent: matches `bitcoin-cli getblock <hash> 2`
// and keeps NDJSON dumps tractable. Still reachable explicitly via `blk N hex`.
const BLOCK_FIELDS: &[&str] = &[
"height",
"hash",
"version",
"version_hex",
"merkle",
"time",
"nonce",
"bits",
"difficulty",
"prev",
"txs",
"n_inputs",
"n_outputs",
"witness_txs",
"size",
"strippedsize",
"weight",
"subsidy",
"coinbase",
"coinbase_hex",
"header_hex",
"tx",
];
const TX_FIELDS: &[&str] = &[
"txid",
"wtxid",
"version",
"locktime",
"size",
"base_size",
"vsize",
"weight",
"inputs",
"outputs",
"is_coinbase",
"has_witness",
"is_rbf",
"total_out",
"hex",
"vin",
"vout",
];
const VIN_FIELDS: &[&str] = &[
"prev_txid",
"prev_vout",
"sequence",
"script_sig",
"script_sig_asm",
"witness",
"has_witness",
"is_rbf",
"coinbase",
];
const VOUT_FIELDS: &[&str] = &[
"value",
"script_pubkey",
"script_pubkey_asm",
"type",
"address",
];
pub struct Ctx<'a> { pub struct Ctx<'a> {
block: &'a ReadBlock, block: &'a ReadBlock,
network: Network,
size_weight: OnceCell<(usize, usize)>, size_weight: OnceCell<(usize, usize)>,
} }
impl<'a> Ctx<'a> { impl<'a> Ctx<'a> {
pub fn new(block: &'a ReadBlock) -> Self { pub fn new(block: &'a ReadBlock, network: Network) -> Self {
Self { Self {
block, block,
network,
size_weight: OnceCell::new(), size_weight: OnceCell::new(),
} }
} }
pub fn resolve(&self, path: &Path) -> Result<Value> { pub fn resolve(&self, path: &Path) -> Result<Value> {
let (step, rest) = pop(&path.steps)?; let (step, rest) = pop(&path.steps)?;
self.block_field(&step.name, step.index, rest)
}
pub fn resolve_str(&self, path: &Path) -> Result<String> {
Ok(match self.resolve(path)? {
Value::String(s) => s,
other => other.to_string(),
})
}
pub fn full(&self) -> Value {
let mut obj = Map::with_capacity(BLOCK_FIELDS.len());
for &name in BLOCK_FIELDS {
obj.insert(
name.into(),
self.block_field(name, None, &[]).expect("known block field"),
);
}
Value::Object(obj)
}
fn size_and_weight(&self) -> (usize, usize) {
*self
.size_weight
.get_or_init(|| self.block.total_size_and_weight())
}
fn block_field(&self, name: &str, index: Option<usize>, rest: &[Step]) -> Result<Value> {
let b = self.block; let b = self.block;
let raw: &Block = b; let raw: &Block = b;
let scalar = |v| scalar_leaf(v, step, rest); let scalar = |v| scalar_leaf(v, name, index, rest);
match step.name.as_str() { match name {
"height" => scalar(json!(*b.height())), "height" => scalar(json!(*b.height())),
"hash" => scalar(json!(b.hash().to_string())), "hash" => scalar(json!(b.hash().to_string())),
"time" => scalar(json!(b.header.time)), "time" => scalar(json!(b.header.time)),
@@ -37,7 +134,7 @@ impl<'a> Ctx<'a> {
"{:08x}", "{:08x}",
b.header.version.to_consensus() as u32 b.header.version.to_consensus() as u32
))), ))),
"bits" => scalar(json!(b.header.bits.to_consensus())), "bits" => scalar(json!(format!("{:08x}", b.header.bits.to_consensus()))),
"nonce" => scalar(json!(b.header.nonce)), "nonce" => scalar(json!(b.header.nonce)),
"prev" => scalar(json!(b.header.prev_blockhash.to_string())), "prev" => scalar(json!(b.header.prev_blockhash.to_string())),
"merkle" => scalar(json!(b.header.merkle_root.to_string())), "merkle" => scalar(json!(b.header.merkle_root.to_string())),
@@ -62,66 +159,46 @@ impl<'a> Ctx<'a> {
"header_hex" => scalar(json!(serialize_hex(&b.header))), "header_hex" => scalar(json!(serialize_hex(&b.header))),
"hex" => scalar(json!(serialize_hex(raw))), "hex" => scalar(json!(serialize_hex(raw))),
"coinbase" => scalar(json!(b.coinbase_tag().as_str())), "coinbase" => scalar(json!(b.coinbase_tag().as_str())),
"tx" => pick(&b.txdata, step, rest, |i, tx| resolve_tx(tx, i == 0, rest)), "coinbase_hex" => {
debug_assert!(
!b.txdata.is_empty() && !b.txdata[0].input.is_empty(),
"consensus-valid block has a coinbase tx with at least one input"
);
scalar(json!(b.txdata[0].input[0].script_sig.to_hex_string()))
}
"tx" => pick(&b.txdata, name, index, |i, tx| {
self.resolve_tx(tx, i == 0, rest)
}),
other => Err(unknown("block", other)), other => Err(unknown("block", other)),
} }
} }
pub fn resolve_str(&self, path: &Path) -> Result<String> { fn resolve_tx(&self, tx: &Transaction, is_coinbase: bool, steps: &[Step]) -> Result<Value> {
Ok(match self.resolve(path)? {
Value::String(s) => s,
other => other.to_string(),
})
}
pub fn full(&self) -> Value {
let b = self.block;
let (size, weight) = self.size_and_weight();
let tx: Vec<Value> = b
.txdata
.iter()
.enumerate()
.map(|(i, tx)| tx_to_value(tx, i == 0))
.collect();
json!({
"height": *b.height(),
"hash": b.hash().to_string(),
"version": b.header.version.to_consensus(),
"version_hex": format!("{:08x}", b.header.version.to_consensus() as u32),
"merkle": b.header.merkle_root.to_string(),
"time": b.header.time,
"nonce": b.header.nonce,
"bits": b.header.bits.to_consensus(),
"difficulty": b.header.difficulty_float(),
"prev": b.header.prev_blockhash.to_string(),
"txs": b.txdata.len(),
"n_inputs": b.txdata.iter().map(|t| t.input.len()).sum::<usize>(),
"n_outputs": b.txdata.iter().map(|t| t.output.len()).sum::<usize>(),
"witness_txs": b.txdata.iter().filter(|t| tx_has_witness(t)).count(),
"size": size,
"strippedsize": (weight - size) / 3,
"weight": weight,
"subsidy": subsidy_sats(*b.height()),
"coinbase": b.coinbase_tag().as_str(),
"header_hex": serialize_hex(&b.header),
"tx": tx,
})
}
fn size_and_weight(&self) -> (usize, usize) {
*self
.size_weight
.get_or_init(|| self.block.total_size_and_weight())
}
}
fn resolve_tx(tx: &Transaction, is_coinbase: bool, steps: &[Step]) -> Result<Value> {
if steps.is_empty() { if steps.is_empty() {
return Ok(tx_to_value(tx, is_coinbase)); let mut obj = Map::with_capacity(TX_FIELDS.len());
for &name in TX_FIELDS {
obj.insert(
name.into(),
self.tx_field(tx, is_coinbase, name, None, &[])
.expect("known tx field"),
);
}
return Ok(Value::Object(obj));
} }
let (step, rest) = pop(steps)?; let (step, rest) = pop(steps)?;
let scalar = |v| scalar_leaf(v, step, rest); self.tx_field(tx, is_coinbase, &step.name, step.index, rest)
match step.name.as_str() { }
fn tx_field(
&self,
tx: &Transaction,
is_coinbase: bool,
name: &str,
index: Option<usize>,
rest: &[Step],
) -> Result<Value> {
let scalar = |v| scalar_leaf(v, name, index, rest);
match name {
"txid" => scalar(json!(tx.compute_txid().to_string())), "txid" => scalar(json!(tx.compute_txid().to_string())),
"wtxid" => scalar(json!(tx.compute_wtxid().to_string())), "wtxid" => scalar(json!(tx.compute_wtxid().to_string())),
"version" => scalar(json!(tx.version.0)), "version" => scalar(json!(tx.version.0)),
@@ -137,27 +214,99 @@ fn resolve_tx(tx: &Transaction, is_coinbase: bool, steps: &[Step]) -> Result<Val
"is_rbf" => scalar(json!(tx_is_rbf(tx))), "is_rbf" => scalar(json!(tx_is_rbf(tx))),
"total_out" => scalar(json!(tx_total_out(tx))), "total_out" => scalar(json!(tx_total_out(tx))),
"hex" => scalar(json!(serialize_hex(tx))), "hex" => scalar(json!(serialize_hex(tx))),
"vin" => pick(&tx.input, step, rest, |j, vin| { "vin" => pick(&tx.input, name, index, |j, vin| {
resolve_vin(vin, is_coinbase && j == 0, rest) resolve_vin(vin, is_coinbase && j == 0, rest)
}), }),
"vout" => pick(&tx.output, step, rest, |_, vout| resolve_vout(vout, rest)), "vout" => pick(&tx.output, name, index, |_, vout| {
self.resolve_vout(vout, rest)
}),
other => Err(unknown("tx", other)), other => Err(unknown("tx", other)),
} }
}
fn resolve_vout(&self, vout: &TxOut, steps: &[Step]) -> Result<Value> {
if steps.is_empty() {
let mut obj = Map::with_capacity(VOUT_FIELDS.len());
for &name in VOUT_FIELDS {
obj.insert(
name.into(),
self.vout_field(vout, name, None, &[])
.expect("known vout field"),
);
}
return Ok(Value::Object(obj));
}
let (step, rest) = pop(steps)?;
self.vout_field(vout, &step.name, step.index, rest)
}
fn vout_field(
&self,
vout: &TxOut,
name: &str,
index: Option<usize>,
rest: &[Step],
) -> Result<Value> {
let scalar = |v| scalar_leaf(v, name, index, rest);
match name {
"value" => scalar(json!(vout.value.to_sat())),
"script_pubkey" => scalar(json!(vout.script_pubkey.to_hex_string())),
"script_pubkey_asm" => scalar(json!(vout.script_pubkey.to_asm_string())),
"type" => scalar(json!(script_type(&vout.script_pubkey))),
"address" => scalar(self.address_value(&vout.script_pubkey)),
other => Err(unknown("vout", other)),
}
}
fn address_value(&self, s: &ScriptBuf) -> Value {
Address::from_script(s, self.network)
.map(|a| Value::String(a.to_string()))
.unwrap_or(Value::Null)
}
} }
fn resolve_vin(vin: &TxIn, is_coinbase: bool, steps: &[Step]) -> Result<Value> { fn resolve_vin(vin: &TxIn, is_coinbase: bool, steps: &[Step]) -> Result<Value> {
if steps.is_empty() { if steps.is_empty() {
return Ok(vin_to_value(vin, is_coinbase)); let mut obj = Map::with_capacity(VIN_FIELDS.len());
for &name in VIN_FIELDS {
obj.insert(
name.into(),
vin_field(vin, is_coinbase, name, None, &[]).expect("known vin field"),
);
}
return Ok(Value::Object(obj));
} }
let (step, rest) = pop(steps)?; let (step, rest) = pop(steps)?;
let scalar = |v| scalar_leaf(v, step, rest); vin_field(vin, is_coinbase, &step.name, step.index, rest)
match step.name.as_str() { }
fn vin_field(
vin: &TxIn,
is_coinbase: bool,
name: &str,
index: Option<usize>,
rest: &[Step],
) -> Result<Value> {
let scalar = |v| scalar_leaf(v, name, index, rest);
match name {
"prev_txid" => scalar(json!(vin.previous_output.txid.to_string())), "prev_txid" => scalar(json!(vin.previous_output.txid.to_string())),
"prev_vout" => scalar(json!(vin.previous_output.vout)), "prev_vout" => scalar(json!(vin.previous_output.vout)),
"sequence" => scalar(json!(vin.sequence.0)), "sequence" => scalar(json!(vin.sequence.0)),
"script_sig" => scalar(json!(vin.script_sig.to_hex_string())), "script_sig" => scalar(json!(vin.script_sig.to_hex_string())),
"script_sig_asm" => scalar(json!(vin.script_sig.to_asm_string())), "script_sig_asm" => scalar(json!(vin.script_sig.to_asm_string())),
"witness" => scalar(witness_to_value(vin)), "witness" => {
if !rest.is_empty() {
return Err(Error::Parse(
"'witness' element has no fields to drill into".into(),
));
}
let items: Vec<String> = vin
.witness
.iter()
.map(|w| w.to_lower_hex_string())
.collect();
pick(&items, name, index, |_, hex| Ok(Value::String(hex.clone())))
}
"has_witness" => scalar(json!(!vin.witness.is_empty())), "has_witness" => scalar(json!(!vin.witness.is_empty())),
"is_rbf" => scalar(json!(vin.sequence.is_rbf())), "is_rbf" => scalar(json!(vin.sequence.is_rbf())),
"coinbase" => scalar(json!(is_coinbase)), "coinbase" => scalar(json!(is_coinbase)),
@@ -165,33 +314,17 @@ fn resolve_vin(vin: &TxIn, is_coinbase: bool, steps: &[Step]) -> Result<Value> {
} }
} }
fn resolve_vout(vout: &TxOut, steps: &[Step]) -> Result<Value> {
if steps.is_empty() {
return Ok(vout_to_value(vout));
}
let (step, rest) = pop(steps)?;
let scalar = |v| scalar_leaf(v, step, rest);
match step.name.as_str() {
"value" => scalar(json!(vout.value.to_sat())),
"script_pubkey" => scalar(json!(vout.script_pubkey.to_hex_string())),
"script_pubkey_asm" => scalar(json!(vout.script_pubkey.to_asm_string())),
"type" => scalar(json!(script_type(&vout.script_pubkey))),
"address" => scalar(address_value(&vout.script_pubkey)),
other => Err(unknown("vout", other)),
}
}
fn pick<T>( fn pick<T>(
items: &[T], items: &[T],
step: &Step, name: &str,
_rest: &[Step], index: Option<usize>,
mut resolve: impl FnMut(usize, &T) -> Result<Value>, mut resolve: impl FnMut(usize, &T) -> Result<Value>,
) -> Result<Value> { ) -> Result<Value> {
match step.index { match index {
Some(i) => { Some(i) => {
let item = items let item = items
.get(i) .get(i)
.ok_or_else(|| out_of_range(&step.name, i, items.len()))?; .ok_or_else(|| out_of_range(name, i, items.len()))?;
resolve(i, item) resolve(i, item)
} }
None => Ok(Value::Array( None => Ok(Value::Array(
@@ -210,14 +343,13 @@ fn pop(steps: &[Step]) -> Result<(&Step, &[Step])> {
.ok_or_else(|| Error::Parse("empty path segment".into())) .ok_or_else(|| Error::Parse("empty path segment".into()))
} }
fn scalar_leaf(v: Value, step: &Step, rest: &[Step]) -> Result<Value> { fn scalar_leaf(v: Value, name: &str, index: Option<usize>, rest: &[Step]) -> Result<Value> {
if step.index.is_some() { if index.is_some() {
return Err(Error::Parse(format!("'{}' is not an array", step.name))); return Err(Error::Parse(format!("'{name}' is not an array")));
} }
if !rest.is_empty() { if !rest.is_empty() {
return Err(Error::Parse(format!( return Err(Error::Parse(format!(
"'{}' is a scalar; nothing to drill into", "'{name}' has no fields to drill into"
step.name
))); )));
} }
Ok(v) Ok(v)
@@ -233,59 +365,6 @@ fn unknown(level: &str, name: &str) -> Error {
)) ))
} }
fn tx_to_value(tx: &Transaction, is_coinbase: bool) -> Value {
let vin: Vec<Value> = tx
.input
.iter()
.enumerate()
.map(|(j, v)| vin_to_value(v, is_coinbase && j == 0))
.collect();
let vout: Vec<Value> = tx.output.iter().map(vout_to_value).collect();
json!({
"txid": tx.compute_txid().to_string(),
"wtxid": tx.compute_wtxid().to_string(),
"version": tx.version.0,
"locktime": tx.lock_time.to_consensus_u32(),
"size": tx.total_size(),
"base_size": tx.base_size(),
"vsize": tx.vsize(),
"weight": tx.weight().to_wu(),
"inputs": tx.input.len(),
"outputs": tx.output.len(),
"is_coinbase": is_coinbase,
"has_witness": tx_has_witness(tx),
"is_rbf": tx_is_rbf(tx),
"total_out": tx_total_out(tx),
"hex": serialize_hex(tx),
"vin": vin,
"vout": vout,
})
}
fn vin_to_value(vin: &TxIn, is_coinbase: bool) -> Value {
json!({
"prev_txid": vin.previous_output.txid.to_string(),
"prev_vout": vin.previous_output.vout,
"sequence": vin.sequence.0,
"script_sig": vin.script_sig.to_hex_string(),
"script_sig_asm": vin.script_sig.to_asm_string(),
"witness": witness_to_value(vin),
"has_witness": !vin.witness.is_empty(),
"is_rbf": vin.sequence.is_rbf(),
"coinbase": is_coinbase,
})
}
fn vout_to_value(vout: &TxOut) -> Value {
json!({
"value": vout.value.to_sat(),
"script_pubkey": vout.script_pubkey.to_hex_string(),
"script_pubkey_asm": vout.script_pubkey.to_asm_string(),
"type": script_type(&vout.script_pubkey),
"address": address_value(&vout.script_pubkey),
})
}
fn tx_has_witness(tx: &Transaction) -> bool { fn tx_has_witness(tx: &Transaction) -> bool {
tx.input.iter().any(|i| !i.witness.is_empty()) tx.input.iter().any(|i| !i.witness.is_empty())
} }
@@ -307,15 +386,6 @@ fn subsidy_sats(height: u32) -> u64 {
} }
} }
fn witness_to_value(vin: &TxIn) -> Value {
Value::Array(
vin.witness
.iter()
.map(|w| Value::String(w.to_lower_hex_string()))
.collect(),
)
}
fn script_type(s: &ScriptBuf) -> &'static str { fn script_type(s: &ScriptBuf) -> &'static str {
if s.is_p2pkh() { if s.is_p2pkh() {
"p2pkh" "p2pkh"
@@ -335,9 +405,3 @@ fn script_type(s: &ScriptBuf) -> &'static str {
"unknown" "unknown"
} }
} }
fn address_value(s: &ScriptBuf) -> Value {
Address::from_script(s, Network::Bitcoin)
.map(|a| Value::String(a.to_string()))
.unwrap_or(Value::Null)
}

View File

@@ -15,16 +15,18 @@ impl Formatter {
pub fn format(&self, ctx: &Ctx) -> Result<String> { pub fn format(&self, ctx: &Ctx) -> Result<String> {
match self.mode { match self.mode {
Mode::Bare => self.bare(ctx), Mode::Bare => self.bare(ctx, false),
Mode::Tsv => self.tsv(ctx), Mode::Tsv => self.tsv(ctx),
Mode::Json => Ok(serde_json::to_string(&self.object(ctx)?)?), Mode::Json => Ok(serde_json::to_string(&self.object(ctx)?)?),
Mode::Pretty if self.fields.len() == 1 => self.bare(ctx, true),
Mode::Pretty => Ok(serde_json::to_string_pretty(&self.object(ctx)?)?), Mode::Pretty => Ok(serde_json::to_string_pretty(&self.object(ctx)?)?),
} }
} }
fn bare(&self, ctx: &Ctx) -> Result<String> { fn bare(&self, ctx: &Ctx, pretty: bool) -> Result<String> {
Ok(match ctx.resolve(&self.fields[0])? { Ok(match ctx.resolve(&self.fields[0])? {
Value::String(s) => s, Value::String(s) => s,
other if pretty => serde_json::to_string_pretty(&other)?,
other => other.to_string(), other => other.to_string(),
}) })
} }

View File

@@ -37,17 +37,19 @@ fn run() -> Result<()> {
let client = args.rpc()?; let client = args.rpc()?;
let (start, end) = Selector::parse(&args.selector, &client)?; let (start, end) = Selector::parse(&args.selector, &client)?;
let network = client.get_network()?;
let mode = Mode::pick(args.pretty, args.compact, args.paths.len()); let mode = Mode::pick(args.pretty, args.compact, args.paths.len())?;
let reader = Reader::new(args.blocks_dir(), &client); let reader = Reader::new(args.blocks_dir(), &client);
let formatter = Formatter::new(mode, args.paths); let formatter = Formatter::new(mode, args.paths);
let parser_threads = std::thread::available_parallelism() let parser_threads = (std::thread::available_parallelism()
.map(|n| n.get()) .map(|n| n.get())
.unwrap_or(2) .unwrap_or(2)
/ 2; / 2)
.max(1);
for block in reader.range_with(start, end, parser_threads)?.iter() { for block in reader.range_with(start, end, parser_threads)?.iter() {
let block = block?; let block = block?;
let line = formatter.format(&Ctx::new(&block))?; let line = formatter.format(&Ctx::new(&block, network))?;
if !line.is_empty() { if !line.is_empty() {
println!("{line}"); println!("{line}");
} }

View File

@@ -1,3 +1,5 @@
use brk_error::{Error, Result};
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub enum Mode { pub enum Mode {
Bare, Bare,
@@ -7,8 +9,18 @@ pub enum Mode {
} }
impl Mode { impl Mode {
pub fn pick(pretty: bool, compact: bool, n_fields: usize) -> Self { pub fn pick(pretty: bool, compact: bool, n_fields: usize) -> Result<Self> {
if pretty { if pretty && compact {
return Err(Error::Parse(
"--pretty and --compact are mutually exclusive".into(),
));
}
if compact && n_fields == 0 {
return Err(Error::Parse(
"--compact requires at least one field".into(),
));
}
Ok(if pretty {
Self::Pretty Self::Pretty
} else if n_fields == 0 { } else if n_fields == 0 {
Self::Json Self::Json
@@ -18,6 +30,6 @@ impl Mode {
Self::Tsv Self::Tsv
} else { } else {
Self::Json Self::Json
} })
} }
} }

View File

@@ -6,13 +6,15 @@ pub struct Selector;
impl Selector { impl Selector {
pub fn parse(s: &str, client: &Client) -> Result<(Height, Height)> { pub fn parse(s: &str, client: &Client) -> Result<(Height, Height)> {
let (start, end) = match s.split_once("..") { let (a, b) = s.split_once("..").unwrap_or((s, s));
Some((a, b)) => (Self::endpoint(a, client)?, Self::endpoint(b, client)?), let needs_tip = |p: &str| p == "tip" || p.starts_with("tip-");
None => { let tip = if needs_tip(a) || needs_tip(b) {
let h = Self::endpoint(s, client)?; Some(client.get_last_height()?)
(h, h) } else {
} None
}; };
let start = Self::endpoint(a, tip)?;
let end = Self::endpoint(b, tip)?;
if end < start { if end < start {
return Err(Error::Parse(format!( return Err(Error::Parse(format!(
"range end {end} before start {start}" "range end {end} before start {start}"
@@ -21,15 +23,15 @@ impl Selector {
Ok((start, end)) Ok((start, end))
} }
fn endpoint(s: &str, client: &Client) -> Result<Height> { fn endpoint(s: &str, tip: Option<Height>) -> Result<Height> {
if s == "tip" { if s == "tip" {
return client.get_last_height(); return Ok(tip.expect("tip pre-resolved when input contains 'tip'"));
} }
if let Some(rest) = s.strip_prefix("tip-") { if let Some(rest) = s.strip_prefix("tip-") {
let n: u32 = rest let n: u32 = rest
.parse() .parse()
.map_err(|_| Error::Parse(format!("bad tip offset: {s}")))?; .map_err(|_| Error::Parse(format!("bad tip offset: {s}")))?;
let tip = client.get_last_height()?; let tip = tip.expect("tip pre-resolved when input contains 'tip'");
return tip return tip
.checked_sub(n) .checked_sub(n)
.ok_or_else(|| Error::Parse(format!("tip-{n} underflows genesis"))); .ok_or_else(|| Error::Parse(format!("tip-{n} underflows genesis")));

View File

@@ -1,4 +1,4 @@
use owo_colors::OwoColorize; use owo_colors::{OwoColorize, Stream};
const SEL_W: usize = 5; // longest selector token: "tip-N" const SEL_W: usize = 5; // longest selector token: "tip-N"
const LABEL_W: usize = 28; // longest label across OUTPUT/OPTIONS/EXAMPLES (= example cmd "blk 800000 tx.0.vout.0.value") const LABEL_W: usize = 28; // longest label across OUTPUT/OPTIONS/EXAMPLES (= example cmd "blk 800000 tx.0.vout.0.value")
@@ -7,18 +7,18 @@ const PH_W: usize = LABEL_W - FLAG_W - 1; // placeholder column width so flag+ph
const GAP: usize = 4; const GAP: usize = 4;
pub fn print() { pub fn print() {
println!("{} - inspect a Bitcoin Core block", "blk".bold()); println!("{} - inspect a Bitcoin Core block", bold("blk"));
println!(); println!();
section("USAGE"); section("USAGE");
println!( println!(
" blk {} [{} ...] [OPTIONS]", " blk {} [{} ...] [OPTIONS]",
"<selector>".bright_black(), dim("<selector>"),
"<field>".bright_black() dim("<field>")
); );
println!( println!(
" {}", " {}",
"no fields = full block as JSON (analog of `bitcoin-cli getblock <hash> 2`)".bright_black() dim("no fields = full block as JSON (analog of `bitcoin-cli getblock <hash> 2`)")
); );
println!(); println!();
@@ -32,15 +32,15 @@ pub fn print() {
section("FIELDS"); section("FIELDS");
println!( println!(
" {}", " {}",
"dotted paths drill into nested data; omit an index for arrays".bright_black() dim("dotted paths drill into nested data, omit an index for arrays")
); );
println!(); println!();
group("block"); group("block");
fields(&[ fields(&[
"height, hash, time, version, version_hex, bits, nonce,", "height, hash, time, version, version_hex, bits, nonce,",
"prev, merkle, difficulty, txs, n_inputs, n_outputs,", "prev, merkle, difficulty, txs, n_inputs, n_outputs,",
"witness_txs, size, strippedsize, weight, subsidy, coinbase,", "witness_txs, size, strippedsize, weight, subsidy,",
"header_hex, hex", "coinbase, coinbase_hex, header_hex, hex",
]); ]);
println!(); println!();
group_note("tx.i", "omit i for all txs"); group_note("tx.i", "omit i for all txs");
@@ -61,14 +61,14 @@ pub fn print() {
println!(); println!();
println!( println!(
" {}", " {}",
"Naked tx / tx.i / vin / vout returns the whole sub-object as JSON.".bright_black() dim("Naked tx / tx.i / vin / vout returns the whole sub-object as JSON.")
); );
println!(); println!();
section("OUTPUT"); section("OUTPUT");
out("no fields", "full block JSON object, one per line (NDJSON)"); out("no fields", "full block JSON object, one per line (NDJSON)");
out("1 field", "bare value, one per line"); out("1 field", "bare value, one per line");
out("2+ fields", "compact JSON object, one per line (NDJSON)"); out("2+ fields", "JSON object, one per line (NDJSON)");
out("-p, --pretty", "pretty JSON object instead"); out("-p, --pretty", "pretty JSON object instead");
out( out(
"-c, --compact", "-c, --compact",
@@ -115,18 +115,18 @@ pub fn print() {
} }
fn section(name: &str) { fn section(name: &str) {
println!("{}", format!("{name}:").bold()); println!("{}", bold(&format!("{name}:")));
} }
fn group(name: &str) { fn group(name: &str) {
println!(" {}", format!("{name}:").bold()); println!(" {}", bold(&format!("{name}:")));
} }
fn group_note(name: &str, note: &str) { fn group_note(name: &str, note: &str) {
println!( println!(
" {} {}", " {} {}",
format!("{name}:").bold(), bold(&format!("{name}:")),
format!("({note})").bright_black() dim(&format!("({note})"))
); );
} }
@@ -143,7 +143,7 @@ fn pad(s: &str, width: usize) -> String {
fn sel(token: &str, desc: &str) { fn sel(token: &str, desc: &str) {
println!( println!(
" {}{}{}{desc}", " {}{}{}{desc}",
token.bright_black(), dim(token),
pad(token, SEL_W), pad(token, SEL_W),
" ".repeat(GAP), " ".repeat(GAP),
); );
@@ -161,12 +161,12 @@ fn opt(flag: &str, ph: &str, desc: &str, default: Option<&str>) {
let head = format!( let head = format!(
" {flag}{} {}{}{}", " {flag}{} {}{}{}",
pad(flag, FLAG_W), pad(flag, FLAG_W),
ph.bright_black(), dim(ph),
pad(ph, PH_W), pad(ph, PH_W),
" ".repeat(GAP), " ".repeat(GAP),
); );
match default { match default {
Some(d) => println!("{head}{desc} {}", d.bright_black()), Some(d) => println!("{head}{desc} {}", dim(d)),
None => println!("{head}{desc}"), None => println!("{head}{desc}"),
} }
} }
@@ -176,6 +176,15 @@ fn ex(cmd: &str, note: &str) {
" {cmd}{}{}{}", " {cmd}{}{}{}",
pad(cmd, LABEL_W), pad(cmd, LABEL_W),
" ".repeat(GAP), " ".repeat(GAP),
format!("# {note}").bright_black() dim(&format!("# {note}"))
); );
} }
fn bold(s: &str) -> String {
s.if_supports_color(Stream::Stdout, |t| t.bold()).to_string()
}
fn dim(s: &str) -> String {
s.if_supports_color(Stream::Stdout, |t| t.bright_black())
.to_string()
}

View File

@@ -118,7 +118,7 @@ impl Blocks {
BlockRange::After { hash } => { BlockRange::After { hash } => {
let start = if let Some(hash) = hash.as_ref() { let start = if let Some(hash) = hash.as_ref() {
let block_info = client.get_block_header_info(hash)?; let block_info = client.get_block_header_info(hash)?;
(block_info.height + 1).into() Height::from((block_info.height + 1) as u64)
} else { } else {
Height::ZERO Height::ZERO
}; };

View File

@@ -0,0 +1,57 @@
//! Per-cycle event report returned by [`super::Mempool::tick`].
use std::{sync::Arc, time::Duration};
use brk_types::{AddrBytes, BlockHash, FeeRate, Height, MempoolInfo, Sats, Timestamp, Txid, VSize};
use crate::{Snapshot, TxRemoval};
/// One pull cycle's worth of changes. Produced by
/// [`super::Mempool::tick`] after fetch → prepare → apply → prevouts →
/// rebuild. The snapshot is always present (the rebuilder runs every
/// cycle); compare `next_block_hash` across cycles if you need to
/// detect whether the projection actually changed.
pub struct Cycle {
pub added: Vec<TxAdded>,
pub removed: Vec<TxRemoved>,
/// Addresses that went from 0 → 1+ live mempool txs this cycle.
/// Same-cycle enter-then-leave is collapsed (no event in either list).
pub addr_enters: Vec<AddrBytes>,
/// Addresses that went from 1+ → 0 live mempool txs this cycle.
pub addr_leaves: Vec<AddrBytes>,
/// Latest confirmed block. Compare to the prior cycle's `tip_hash`
/// to detect a new block.
pub tip_hash: BlockHash,
pub tip_height: Height,
pub info: MempoolInfo,
pub snapshot: Arc<Snapshot>,
pub took: Duration,
}
#[derive(Debug, Clone, Copy)]
pub struct TxAdded {
pub txid: Txid,
pub fee: Sats,
pub vsize: VSize,
pub fee_rate: FeeRate,
pub first_seen: Timestamp,
pub kind: AddedKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddedKind {
/// First time we've seen this txid.
Fresh,
/// Re-entered the pool after a prior removal still in the graveyard.
Revived,
}
#[derive(Debug, Clone, Copy)]
pub struct TxRemoved {
pub txid: Txid,
pub reason: TxRemoval,
/// Package-effective rate at burial. Same value the tx reported
/// while alive - RBF predecessors keep their package rate, not a
/// misleading isolated fee/vsize.
pub chunk_rate: FeeRate,
}

View File

@@ -0,0 +1,11 @@
//! Per-cycle accumulator threaded through the pipeline steps and
//! drained into the public [`crate::Cycle`] at end of cycle.
use crate::{TxAdded, TxRemoved, stores::AddrTransitions};
#[derive(Default)]
pub struct CycleDiff {
pub added: Vec<TxAdded>,
pub removed: Vec<TxRemoved>,
pub addrs: AddrTransitions,
}

View File

@@ -16,8 +16,8 @@
//! pass, using same-cycle in-mempool parents directly and the //! pass, using same-cycle in-mempool parents directly and the
//! caller-supplied resolver (default: `getrawtransaction`) for //! caller-supplied resolver (default: `getrawtransaction`) for
//! confirmed parents. //! confirmed parents.
//! 5. [`steps::rebuilder::Rebuilder`] - throttled rebuild of the //! 5. [`steps::rebuilder::Rebuilder`] - rebuild of the projected-blocks
//! projected-blocks `Snapshot` from the same-cycle GBT and min fee. //! `Snapshot` from the same-cycle GBT and min fee.
use std::{ use std::{
any::Any, any::Any,
@@ -45,20 +45,24 @@ use tracing::error;
mod cluster; mod cluster;
mod cpfp; mod cpfp;
mod cycle;
mod cycle_diff;
mod diagnostics; mod diagnostics;
mod rbf; mod rbf;
mod state; mod state;
pub(crate) mod steps; pub(crate) mod steps;
pub(crate) mod stores; pub(crate) mod stores;
pub use cycle::{AddedKind, Cycle, TxAdded, TxRemoved};
pub use diagnostics::MempoolStats; pub use diagnostics::MempoolStats;
pub use rbf::{RbfForTx, RbfNode}; pub use rbf::{RbfForTx, RbfNode};
pub use steps::Snapshot; pub use steps::{Snapshot, TxRemoval};
use steps::{Applier, Fetched, Fetcher, Preparer, Prevouts, Rebuilder}; use steps::{Applier, Fetched, Fetcher, Preparer, Prevouts, Rebuilder};
pub(crate) use steps::{BlockStats, RecommendedFees, TxEntry, TxRemoval}; pub(crate) use cycle_diff::CycleDiff;
pub(crate) use stores::{TxStore, TxTombstone}; pub(crate) use steps::{BlockStats, RecommendedFees, TxEntry};
pub(crate) use stores::{AddrTransitions, TxStore, TxTombstone};
/// Confirmed-parent prevout resolver passed to [`Mempool::update_with`] / /// Confirmed-parent prevout resolver passed to [`Mempool::tick_with`] /
/// [`Mempool::start_with`]. Receives a slice of `(parent_txid, vout)` /// [`Mempool::start_with`]. Receives a slice of `(parent_txid, vout)`
/// holes and returns the subset that resolved. Unresolved holes are /// holes and returns the subset that resolved. Unresolved holes are
/// simply omitted from the map; the next cycle retries automatically. /// simply omitted from the map; the next cycle retries automatically.
@@ -326,7 +330,9 @@ impl Mempool {
/// Infinite update loop with a 500ms interval. Resolves /// Infinite update loop with a 500ms interval. Resolves
/// confirmed-parent prevouts via the default `getrawtransaction` /// confirmed-parent prevouts via the default `getrawtransaction`
/// resolver; requires bitcoind started with `txindex=1`. /// resolver; requires bitcoind started with `txindex=1`. Drops
/// per-cycle [`Cycle`] events on the floor - use [`Mempool::tick`]
/// to consume them.
pub fn start(&self) { pub fn start(&self) {
self.start_with(Prevouts::rpc_resolver(self.0.client.clone())); self.start_with(Prevouts::rpc_resolver(self.0.client.clone()));
} }
@@ -355,7 +361,7 @@ impl Mempool {
loop { loop {
let started = Instant::now(); let started = Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(|| { let outcome = catch_unwind(AssertUnwindSafe(|| {
if let Err(e) = self.update_with(&resolver) { if let Err(e) = self.tick_with(&resolver) {
error!("update failed: {e}"); error!("update failed: {e}");
} }
})); }));
@@ -371,14 +377,23 @@ impl Mempool {
} }
} }
/// One sync cycle: fetch, prepare, apply, fill prevouts, maybe /// One sync cycle: fetch, prepare, apply, fill prevouts, rebuild.
/// rebuild. The resolver MUST resolve confirmed prevouts only; /// Returns a [`Cycle`] reporting everything that changed. Uses the
/// mempool-to-mempool chains are wired internally and the /// default `getrawtransaction` resolver for confirmed-parent
/// resolver is never called for them. /// prevouts (requires `txindex=1`).
fn update_with<F>(&self, resolver: F) -> Result<()> pub fn tick(&self) -> Result<Cycle> {
self.tick_with(Prevouts::rpc_resolver(self.0.client.clone()))
}
/// Variant of [`Mempool::tick`] with a caller-supplied resolver for
/// confirmed-parent prevouts. The resolver MUST resolve confirmed
/// prevouts only; mempool-to-mempool chains are wired internally
/// and the resolver is never called for them.
pub fn tick_with<F>(&self, resolver: F) -> Result<Cycle>
where where
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>, F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>,
{ {
let started = Instant::now();
let Inner { let Inner {
client, client,
state, state,
@@ -387,18 +402,30 @@ impl Mempool {
} = &*self.0; } = &*self.0;
let Fetched { let Fetched {
live_txids, state: rpc,
new_entries, new_entries,
new_txs, new_txs,
gbt_txids, block_template_txids,
min_fee,
} = Fetcher::fetch(client, state)?; } = Fetcher::fetch(client, state)?;
let pulled = Preparer::prepare(&live_txids, new_entries, new_txs, state); let pulled = Preparer::prepare(&rpc.live_txids, new_entries, new_txs, state);
Applier::apply(state, rebuilder, pulled); let mut diff = CycleDiff::default();
Prevouts::fill(state, resolver); Applier::apply(state, rebuilder, pulled, &mut diff);
rebuilder.tick(state, &gbt_txids, min_fee); Prevouts::fill(state, &mut diff, resolver);
rebuilder.tick(state, &block_template_txids, rpc.min_fee);
let CycleDiff { added, removed, addrs } = diff;
let (addr_enters, addr_leaves) = addrs.into_vecs();
Ok(()) Ok(Cycle {
added,
removed,
addr_enters,
addr_leaves,
tip_hash: rpc.tip_hash,
tip_height: rpc.tip_height,
info: self.info(),
snapshot: rebuilder.snapshot(),
took: started.elapsed(),
})
} }
} }

View File

@@ -2,7 +2,8 @@ use brk_types::{Transaction, TxidPrefix};
use parking_lot::RwLock; use parking_lot::RwLock;
use crate::{ use crate::{
State, TxEntry, TxRemoval, AddrTransitions, CycleDiff, State, TxEntry, TxRemoval,
cycle::{TxAdded, TxRemoved},
steps::{ steps::{
preparer::{TxAddition, TxsPulled}, preparer::{TxAddition, TxsPulled},
rebuilder::{Rebuilder, Snapshot}, rebuilder::{Rebuilder, Snapshot},
@@ -10,7 +11,8 @@ use crate::{
}; };
/// Applies a prepared diff to in-memory mempool state under one write /// Applies a prepared diff to in-memory mempool state under one write
/// guard. Body proceeds: bury removed → publish added → evict. /// guard. Body proceeds: bury removed → publish added → evict. Events
/// are pushed into the caller-supplied [`CycleDiff`] accumulator.
pub struct Applier; pub struct Applier;
impl Applier { impl Applier {
@@ -19,44 +21,75 @@ impl Applier {
/// package-aware via local linearization). The fallback to /// package-aware via local linearization). The fallback to
/// `entry.fee_rate()` is unreachable in steady state - every burial /// `entry.fee_rate()` is unreachable in steady state - every burial
/// target was alive at the previous tick, so the snapshot has it. /// target was alive at the previous tick, so the snapshot has it.
pub fn apply(lock: &RwLock<State>, rebuilder: &Rebuilder, pulled: TxsPulled) { pub fn apply(
lock: &RwLock<State>,
rebuilder: &Rebuilder,
pulled: TxsPulled,
diff: &mut CycleDiff,
) {
let TxsPulled { added, removed } = pulled; let TxsPulled { added, removed } = pulled;
let mut state = lock.write(); let mut state = lock.write();
Self::bury_removals(&mut state, rebuilder, removed); Self::bury_removals(&mut state, rebuilder, &mut diff.addrs, &mut diff.removed, removed);
Self::publish_additions(&mut state, added); Self::publish_additions(&mut state, &mut diff.addrs, &mut diff.added, added);
state.graveyard.evict_old(); state.graveyard.evict_old();
} }
fn bury_removals( fn bury_removals(
state: &mut State, state: &mut State,
rebuilder: &Rebuilder, rebuilder: &Rebuilder,
transitions: &mut AddrTransitions,
events: &mut Vec<TxRemoved>,
removed: Vec<(TxidPrefix, TxRemoval)>, removed: Vec<(TxidPrefix, TxRemoval)>,
) { ) {
let snapshot = rebuilder.snapshot(); let snapshot = rebuilder.snapshot();
events.reserve(removed.len());
for (prefix, reason) in removed { for (prefix, reason) in removed {
Self::bury_one(state, &snapshot, &prefix, reason); if let Some(ev) = Self::bury_one(state, &snapshot, transitions, &prefix, reason) {
events.push(ev);
}
} }
} }
fn bury_one(state: &mut State, snapshot: &Snapshot, prefix: &TxidPrefix, reason: TxRemoval) { fn bury_one(
let Some(record) = state.txs.remove_by_prefix(prefix) else { state: &mut State,
return; snapshot: &Snapshot,
}; transitions: &mut AddrTransitions,
prefix: &TxidPrefix,
reason: TxRemoval,
) -> Option<TxRemoved> {
let record = state.txs.remove_by_prefix(prefix)?;
let chunk_rate = snapshot let chunk_rate = snapshot
.chunk_rate_for(prefix) .chunk_rate_for(prefix)
.unwrap_or_else(|| record.entry.fee_rate()); .unwrap_or_else(|| record.entry.fee_rate());
let txid = record.entry.txid;
state.info.remove(&record.tx, record.entry.fee); state.info.remove(&record.tx, record.entry.fee);
state.addrs.remove_tx(&record.tx); state.addrs.remove_tx(transitions, &record.tx);
state.outpoint_spends.remove_spends(&record.tx, *prefix); state.outpoint_spends.remove_spends(&record.tx, *prefix);
state state
.graveyard .graveyard
.bury(record.tx, record.entry, chunk_rate, reason); .bury(record.tx, record.entry, chunk_rate, reason);
Some(TxRemoved { txid, reason, chunk_rate })
} }
fn publish_additions(state: &mut State, added: Vec<TxAddition>) { fn publish_additions(
state: &mut State,
transitions: &mut AddrTransitions,
events: &mut Vec<TxAdded>,
added: Vec<TxAddition>,
) {
events.reserve(added.len());
for addition in added { for addition in added {
let kind = addition.kind();
if let Some((tx, entry)) = Self::resolve_addition(state, addition) { if let Some((tx, entry)) = Self::resolve_addition(state, addition) {
Self::publish_one(state, tx, entry); events.push(TxAdded {
txid: entry.txid,
fee: entry.fee,
vsize: entry.vsize,
fee_rate: entry.fee_rate(),
first_seen: entry.first_seen,
kind,
});
Self::publish_one(state, transitions, tx, entry);
} }
} }
} }
@@ -71,10 +104,15 @@ impl Applier {
} }
} }
fn publish_one(state: &mut State, tx: Transaction, entry: TxEntry) { fn publish_one(
state: &mut State,
transitions: &mut AddrTransitions,
tx: Transaction,
entry: TxEntry,
) {
let prefix = entry.txid_prefix(); let prefix = entry.txid_prefix();
state.info.add(&tx, entry.fee); state.info.add(&tx, entry.fee);
state.addrs.add_tx(&tx); state.addrs.add_tx(transitions, &tx);
state.outpoint_spends.insert_spends(&tx, prefix); state.outpoint_spends.insert_spends(&tx, prefix);
state.txs.insert(tx, entry); state.txs.insert(tx, entry);
} }

View File

@@ -1,10 +1,13 @@
use brk_types::{FeeRate, MempoolEntryInfo, Txid}; use brk_rpc::MempoolState;
use brk_types::{MempoolEntryInfo, Txid};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
pub struct Fetched { pub struct Fetched {
/// Every txid currently in the mempool (from `getrawmempool false`). /// Passthrough fields from the batched RPC fetch: live txid set,
/// Used to derive the `live` set for removal classification. /// fee floor, chain tip. `live_txids` is the union of
pub live_txids: Vec<Txid>, /// `getrawmempool` and `getblocktemplate` (see [`super::Fetcher::fetch`]),
/// so downstream sees a single coherent "live" view.
pub state: MempoolState,
/// `MempoolEntryInfo` for newly-observed txids only (existing ones /// `MempoolEntryInfo` for newly-observed txids only (existing ones
/// keep their first-sight entry on the live store). /// keep their first-sight entry on the live store).
pub new_entries: Vec<MempoolEntryInfo>, pub new_entries: Vec<MempoolEntryInfo>,
@@ -13,6 +16,5 @@ pub struct Fetched {
/// already been folded into `new_entries`/`new_txs` (or were already /// already been folded into `new_entries`/`new_txs` (or were already
/// in the pool); the Rebuilder only needs the txid sequence to /// in the pool); the Rebuilder only needs the txid sequence to
/// project Core's exact selection. /// project Core's exact selection.
pub gbt_txids: Vec<Txid>, pub block_template_txids: Vec<Txid>,
pub min_fee: FeeRate,
} }

View File

@@ -3,7 +3,7 @@ mod fetched;
pub use fetched::Fetched; pub use fetched::Fetched;
use brk_error::Result; use brk_error::Result;
use brk_rpc::{Client, MempoolState}; use brk_rpc::Client;
use brk_types::{MempoolEntryInfo, Timestamp, Txid, VSize}; use brk_types::{MempoolEntryInfo, Timestamp, Txid, VSize};
use parking_lot::RwLock; use parking_lot::RwLock;
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
@@ -28,37 +28,31 @@ const MAX_TX_FETCHES_PER_CYCLE: usize = 10_000;
/// Core's exact selection because we never ask for that data twice. /// Core's exact selection because we never ask for that data twice.
/// ///
/// Confirmed prevouts are resolved post-apply by the caller-supplied /// Confirmed prevouts are resolved post-apply by the caller-supplied
/// resolver passed to `Mempool::update_with`, so the in-crate path no /// resolver passed to `Mempool::tick_with`, so the in-crate path no
/// longer issues a third batch for parents. /// longer issues a third batch for parents.
pub struct Fetcher; pub struct Fetcher;
impl Fetcher { impl Fetcher {
pub fn fetch(client: &Client, lock: &RwLock<State>) -> Result<Fetched> { pub fn fetch(client: &Client, lock: &RwLock<State>) -> Result<Fetched> {
let MempoolState { let (mut state, block_template) = client.fetch_mempool_state()?;
live_txids,
gbt,
min_fee,
} = client.fetch_mempool_state()?;
// One read snapshot decides both the RPC fetch list and the // One read snapshot decides both the RPC fetch list and the
// GBT-synthesis set, so they agree on what's "already known". // GBT-synthesis set, so they agree on what's "already known".
// Graveyard txs are treated as known so a re-broadcast still
// flows through `Preparer::classify_addition` and lands as
// [`crate::TxAddition::Revived`].
let (new_txids, gbt_synth_set) = { let (new_txids, gbt_synth_set) = {
let state = lock.read(); let mempool = lock.read();
let mut gbt_txids: FxHashSet<Txid> = let mut gbt_txids: FxHashSet<Txid> =
FxHashSet::with_capacity_and_hasher(gbt.len(), Default::default()); FxHashSet::with_capacity_and_hasher(block_template.len(), Default::default());
let mut gbt_synth_set: FxHashSet<Txid> = FxHashSet::default(); let mut gbt_synth_set: FxHashSet<Txid> = FxHashSet::default();
for g in &gbt { for g in &block_template {
gbt_txids.insert(g.txid); gbt_txids.insert(g.txid);
if !state.txs.contains(&g.txid) { if !mempool.txs.contains(&g.txid) {
gbt_synth_set.insert(g.txid); gbt_synth_set.insert(g.txid);
} }
} }
let new_txids: Vec<Txid> = live_txids let new_txids: Vec<Txid> = state
.live_txids
.iter() .iter()
.filter(|t| !state.txs.contains(t) && !gbt_txids.contains(t)) .filter(|t| !mempool.txs.contains(t) && !gbt_txids.contains(t))
.take(MAX_TX_FETCHES_PER_CYCLE) .take(MAX_TX_FETCHES_PER_CYCLE)
.copied() .copied()
.collect(); .collect();
@@ -69,17 +63,18 @@ impl Fetcher {
new_entries.reserve(gbt_synth_set.len()); new_entries.reserve(gbt_synth_set.len());
new_txs.reserve(gbt_synth_set.len()); new_txs.reserve(gbt_synth_set.len());
// Consume `gbt` by value: GBT-only txs move their body and // Consume `block_template` by value: GBT-only txs move their
// depends into the synthesis path (no clones), and the GBT // body and depends into the synthesis path (no clones), and
// ordering is captured as a `Vec<Txid>` for the Rebuilder, which // the GBT ordering is captured as a `Vec<Txid>` for the
// is the only downstream consumer and only reads txids. // Rebuilder, which is the only downstream consumer and only
// reads txids.
// //
// GBT carries no per-tx arrival timestamp. `now` is correct to // GBT carries no per-tx arrival timestamp. `now` is correct to
// within ~1 cycle for a tx that just entered Core's mempool // within ~1 cycle for a tx that just entered Core's mempool
// (the only kind that triggers synthesis: not in our pool yet // (the only kind that triggers synthesis: not in our pool yet
// means it just appeared this cycle). // means it just appeared this cycle).
let now = Timestamp::now(); let now = Timestamp::now();
let gbt_txids: Vec<Txid> = gbt let block_template_txids: Vec<Txid> = block_template
.into_iter() .into_iter()
.map(|g| { .map(|g| {
let txid = g.txid; let txid = g.txid;
@@ -98,12 +93,19 @@ impl Fetcher {
}) })
.collect(); .collect();
// Promote `live_txids` to the union of `getrawmempool` and GBT:
// the two RPC views can disagree by a cycle, so a tx visible to
// GBT but missing from `getrawmempool` (or vice versa) is still
// alive. Without the union, GBT-only txs would oscillate enter ↔
// leave every cycle as `Preparer::classify_removals` buried what
// GBT had just resurrected.
state.live_txids.extend(block_template_txids.iter().copied());
Ok(Fetched { Ok(Fetched {
live_txids, state,
new_entries, new_entries,
new_txs, new_txs,
gbt_txids, block_template_txids,
min_fee,
}) })
} }
} }

View File

@@ -9,7 +9,8 @@ mod rebuilder;
pub(crate) use applier::Applier; pub(crate) use applier::Applier;
pub(crate) use fetcher::{Fetched, Fetcher}; pub(crate) use fetcher::{Fetched, Fetcher};
pub(crate) use preparer::{Preparer, TxEntry, TxRemoval}; pub(crate) use preparer::{Preparer, TxEntry};
pub use preparer::TxRemoval;
pub(crate) use prevouts::Prevouts; pub(crate) use prevouts::Prevouts;
pub(crate) use rebuilder::{BlockStats, RecommendedFees, Rebuilder, SnapTx, TxIndex}; pub(crate) use rebuilder::{BlockStats, RecommendedFees, Rebuilder, SnapTx, TxIndex};
pub use rebuilder::Snapshot; pub use rebuilder::Snapshot;

View File

@@ -6,7 +6,7 @@
//! - **fresh** - decoded from `new_raws`, prevouts resolved against //! - **fresh** - decoded from `new_raws`, prevouts resolved against
//! the live mempool only. Confirmed-parent prevouts land as //! the live mempool only. Confirmed-parent prevouts land as
//! `prevout: None` and are filled post-apply by the resolver passed //! `prevout: None` and are filled post-apply by the resolver passed
//! to `Mempool::update_with`. //! to `Mempool::tick_with`.
//! //!
//! Existing entries are not re-classified - they keep their first-sight //! Existing entries are not re-classified - they keep their first-sight
//! state on the live store. Removals are inferred by cross-referencing //! state on the live store. Removals are inferred by cross-referencing

View File

@@ -4,14 +4,14 @@
//! prevouts against the live mempool (same-cycle parents), build a //! prevouts against the live mempool (same-cycle parents), build a
//! full `Transaction` + `Entry`. Confirmed parents land as //! full `Transaction` + `Entry`. Confirmed parents land as
//! `prevout: None` and are filled post-apply by the resolver passed //! `prevout: None` and are filled post-apply by the resolver passed
//! to `Mempool::update_with`. //! to `Mempool::tick_with`.
//! - **Revived** - tx in the graveyard. Rebuild the `Entry` only //! - **Revived** - tx in the graveyard. Rebuild the `Entry` only
//! (preserving `rbf`, `size`). The Applier exhumes the cached tx //! (preserving `rbf`, `size`). The Applier exhumes the cached tx
//! body. No raw decoding. //! body. No raw decoding.
use brk_types::{MempoolEntryInfo, SigOps, Transaction, TxIn, TxOut, TxStatus, Txid, Vout}; use brk_types::{MempoolEntryInfo, SigOps, Transaction, TxIn, TxOut, TxStatus, Txid, Vout};
use crate::{TxTombstone, stores::TxStore}; use crate::{TxTombstone, cycle::AddedKind, stores::TxStore};
use super::TxEntry; use super::TxEntry;
@@ -21,9 +21,16 @@ pub enum TxAddition {
} }
impl TxAddition { impl TxAddition {
pub fn kind(&self) -> AddedKind {
match self {
Self::Fresh { .. } => AddedKind::Fresh,
Self::Revived { .. } => AddedKind::Revived,
}
}
/// Resolves prevouts against the live mempool only. Confirmed /// Resolves prevouts against the live mempool only. Confirmed
/// parents land with `prevout: None` and are filled by the /// parents land with `prevout: None` and are filled by the
/// resolver supplied to `Mempool::update_with` in the same cycle. /// resolver supplied to `Mempool::tick_with` in the same cycle.
pub(super) fn fresh( pub(super) fn fresh(
info: &MempoolEntryInfo, info: &MempoolEntryInfo,
tx: bitcoin::Transaction, tx: bitcoin::Transaction,
@@ -64,8 +71,12 @@ impl TxAddition {
built built
} }
/// Preserves the tomb's original `first_seen`: bitcoind resets the
/// timestamp on re-acceptance (and GBT synthesis carries "now"), but
/// the consumer wants the first-ever sighting, not the latest one.
pub(super) fn revived(info: &MempoolEntryInfo, tomb: &TxTombstone) -> Self { pub(super) fn revived(info: &MempoolEntryInfo, tomb: &TxTombstone) -> Self {
let entry = TxEntry::new(info, tomb.entry.size, tomb.entry.rbf); let mut entry = TxEntry::new(info, tomb.entry.size, tomb.entry.rbf);
entry.first_seen = tomb.entry.first_seen;
Self::Revived { entry } Self::Revived { entry }
} }

View File

@@ -11,7 +11,7 @@ use brk_types::Txid;
/// `Vanished` = any other reason we can't distinguish from the data at /// `Vanished` = any other reason we can't distinguish from the data at
/// hand (mined, expired, evicted, or replaced by a tx we didn't fetch /// hand (mined, expired, evicted, or replaced by a tx we didn't fetch
/// due to the per-cycle fetch cap). /// due to the per-cycle fetch cap).
#[derive(Debug)] #[derive(Debug, Clone, Copy)]
pub enum TxRemoval { pub enum TxRemoval {
Replaced { by: Txid }, Replaced { by: Txid },
Vanished, Vanished,

View File

@@ -26,7 +26,7 @@ use parking_lot::RwLock;
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
use tracing::warn; use tracing::warn;
use crate::{State, stores::TxStore}; use crate::{CycleDiff, State, stores::TxStore};
pub struct Prevouts; pub struct Prevouts;
@@ -41,7 +41,7 @@ impl Prevouts {
/// in-mempool parents are filled lock-locally; the remainder go /// in-mempool parents are filled lock-locally; the remainder go
/// through `resolver` (one batched call) outside any lock. Returns /// through `resolver` (one batched call) outside any lock. Returns
/// true iff anything was written. /// true iff anything was written.
pub fn fill<F>(lock: &RwLock<State>, resolver: F) -> bool pub fn fill<F>(lock: &RwLock<State>, diff: &mut CycleDiff, resolver: F) -> bool
where where
F: Fn(&[(Txid, Vout)]) -> Resolved, F: Fn(&[(Txid, Vout)]) -> Resolved,
{ {
@@ -59,7 +59,7 @@ impl Prevouts {
for (txid, fills) in in_mempool.into_iter().chain(external) { for (txid, fills) in in_mempool.into_iter().chain(external) {
let prefix = TxidPrefix::from(&txid); let prefix = TxidPrefix::from(&txid);
for prevout in state.txs.apply_fills(&prefix, fills) { for prevout in state.txs.apply_fills(&prefix, fills) {
state.addrs.add_input(&txid, &prevout); state.addrs.add_input(&mut diff.addrs, &txid, &prevout);
} }
} }
true true

View File

@@ -0,0 +1,41 @@
//! Per-cycle 0↔1+ address transition buffer.
//!
//! Lives on the stack inside [`crate::Mempool::tick_with`], not on a
//! long-lived store, so the set naturally resets between cycles.
//! Same-cycle cancellation (enter→leave, leave→enter, and the 3-step
//! enter→leave→enter / leave→enter→leave variants) is encapsulated on
//! the recording methods so callers just announce raw 0↔1+ flips.
use brk_types::AddrBytes;
use rustc_hash::FxHashSet;
#[derive(Default)]
pub struct AddrTransitions {
enters: FxHashSet<AddrBytes>,
leaves: FxHashSet<AddrBytes>,
}
impl AddrTransitions {
/// Address just went 0 → 1+ live mempool txs. Cancels a pending
/// `leave` for the same address in this cycle.
pub fn record_enter(&mut self, bytes: AddrBytes) {
if !self.leaves.remove(&bytes) {
self.enters.insert(bytes);
}
}
/// Address just went 1+ → 0 live mempool txs. Cancels a pending
/// `enter` for the same address in this cycle.
pub fn record_leave(&mut self, bytes: AddrBytes) {
if !self.enters.remove(&bytes) {
self.leaves.insert(bytes);
}
}
pub fn into_vecs(self) -> (Vec<AddrBytes>, Vec<AddrBytes>) {
(
self.enters.into_iter().collect(),
self.leaves.into_iter().collect(),
)
}
}

View File

@@ -8,37 +8,39 @@ use derive_more::Deref;
use rustc_hash::{FxHashMap, FxHasher}; use rustc_hash::{FxHashMap, FxHasher};
mod addr_entry; mod addr_entry;
mod addr_transitions;
use addr_entry::AddrEntry; use addr_entry::AddrEntry;
pub use addr_transitions::AddrTransitions;
#[derive(Default, Deref)] #[derive(Default, Deref)]
pub struct AddrTracker(FxHashMap<AddrBytes, AddrEntry>); pub struct AddrTracker(FxHashMap<AddrBytes, AddrEntry>);
impl AddrTracker { impl AddrTracker {
pub fn add_tx(&mut self, tx: &Transaction) { pub fn add_tx(&mut self, transitions: &mut AddrTransitions, tx: &Transaction) {
let txid = &tx.txid; let txid = &tx.txid;
for txin in &tx.input { for txin in &tx.input {
if let Some(prevout) = txin.prevout.as_ref() { if let Some(prevout) = txin.prevout.as_ref() {
self.add_input(txid, prevout); self.add_input(transitions, txid, prevout);
} }
} }
for txout in &tx.output { for txout in &tx.output {
if let Some(bytes) = txout.addr_bytes() { if let Some(bytes) = txout.addr_bytes() {
self.apply_add(bytes, txid, |stats| stats.receiving(txout)); self.apply_add(transitions, bytes, txid, |stats| stats.receiving(txout));
} }
} }
} }
pub fn remove_tx(&mut self, tx: &Transaction) { pub fn remove_tx(&mut self, transitions: &mut AddrTransitions, tx: &Transaction) {
let txid = &tx.txid; let txid = &tx.txid;
for txin in &tx.input { for txin in &tx.input {
if let Some(prevout) = txin.prevout.as_ref() { if let Some(prevout) = txin.prevout.as_ref() {
self.remove_input(txid, prevout); self.remove_input(transitions, txid, prevout);
} }
} }
for txout in &tx.output { for txout in &tx.output {
if let Some(bytes) = txout.addr_bytes() { if let Some(bytes) = txout.addr_bytes() {
self.apply_remove(bytes, txid, |stats| stats.received(txout)); self.apply_remove(transitions, bytes, txid, |stats| stats.received(txout));
} }
} }
} }
@@ -62,34 +64,58 @@ impl AddrTracker {
/// previously `None` has been filled, and by `add_tx` for each /// previously `None` has been filled, and by `add_tx` for each
/// resolved input. Inputs whose prevout doesn't resolve to an addr /// resolved input. Inputs whose prevout doesn't resolve to an addr
/// are no-ops. /// are no-ops.
pub fn add_input(&mut self, txid: &Txid, prevout: &TxOut) { pub fn add_input(
&mut self,
transitions: &mut AddrTransitions,
txid: &Txid,
prevout: &TxOut,
) {
let Some(bytes) = prevout.addr_bytes() else { let Some(bytes) = prevout.addr_bytes() else {
return; return;
}; };
self.apply_add(bytes, txid, |stats| stats.sending(prevout)); self.apply_add(transitions, bytes, txid, |stats| stats.sending(prevout));
} }
fn remove_input(&mut self, txid: &Txid, prevout: &TxOut) { fn remove_input(
&mut self,
transitions: &mut AddrTransitions,
txid: &Txid,
prevout: &TxOut,
) {
let Some(bytes) = prevout.addr_bytes() else { let Some(bytes) = prevout.addr_bytes() else {
return; return;
}; };
self.apply_remove(bytes, txid, |stats| stats.sent(prevout)); self.apply_remove(transitions, bytes, txid, |stats| stats.sent(prevout));
} }
fn apply_add( fn apply_add(
&mut self, &mut self,
transitions: &mut AddrTransitions,
bytes: AddrBytes, bytes: AddrBytes,
txid: &Txid, txid: &Txid,
update_stats: impl FnOnce(&mut AddrMempoolStats), update_stats: impl FnOnce(&mut AddrMempoolStats),
) { ) {
let entry = self.0.entry(bytes).or_default(); match self.0.entry(bytes) {
MapEntry::Occupied(mut occupied) => {
let entry = occupied.get_mut();
entry.txids.insert(*txid); entry.txids.insert(*txid);
update_stats(&mut entry.stats); update_stats(&mut entry.stats);
entry.stats.update_tx_count(entry.txids.len() as u32); entry.stats.update_tx_count(entry.txids.len() as u32);
} }
MapEntry::Vacant(vacant) => {
let key = vacant.key().clone();
let entry = vacant.insert(AddrEntry::default());
entry.txids.insert(*txid);
update_stats(&mut entry.stats);
entry.stats.update_tx_count(entry.txids.len() as u32);
transitions.record_enter(key);
}
}
}
fn apply_remove( fn apply_remove(
&mut self, &mut self,
transitions: &mut AddrTransitions,
bytes: AddrBytes, bytes: AddrBytes,
txid: &Txid, txid: &Txid,
update_stats: impl FnOnce(&mut AddrMempoolStats), update_stats: impl FnOnce(&mut AddrMempoolStats),
@@ -102,7 +128,8 @@ impl AddrTracker {
update_stats(&mut entry.stats); update_stats(&mut entry.stats);
let len = entry.txids.len(); let len = entry.txids.len();
if len == 0 { if len == 0 {
occupied.remove(); let (bytes, _) = occupied.remove_entry();
transitions.record_leave(bytes);
} else { } else {
entry.stats.update_tx_count(len as u32); entry.stats.update_tx_count(len as u32);
} }

View File

@@ -9,7 +9,7 @@ pub(crate) mod output_bins;
pub(crate) mod tx_graveyard; pub(crate) mod tx_graveyard;
pub(crate) mod tx_store; pub(crate) mod tx_store;
pub(crate) use addr_tracker::AddrTracker; pub(crate) use addr_tracker::{AddrTracker, AddrTransitions};
pub(crate) use outpoint_spends::OutpointSpends; pub(crate) use outpoint_spends::OutpointSpends;
pub(crate) use output_bins::OutputBins; pub(crate) use output_bins::OutputBins;
pub(crate) use tx_graveyard::{TxGraveyard, TxTombstone}; pub(crate) use tx_graveyard::{TxGraveyard, TxTombstone};

View File

@@ -14,7 +14,7 @@ pub struct CanonicalRange {
impl CanonicalRange { impl CanonicalRange {
pub fn walk(client: &Client, anchor: Option<&BlockHash>, tip: Height) -> Result<Self> { pub fn walk(client: &Client, anchor: Option<&BlockHash>, tip: Height) -> Result<Self> {
let start = match anchor { let start = match anchor {
Some(hash) => Height::from(client.get_block_header_info(hash)?.height + 1), Some(hash) => Height::from((client.get_block_header_info(hash)?.height + 1) as u64),
None => Height::ZERO, None => Height::ZERO,
}; };
let mut range = Self::between(client, start, tip)?; let mut range = Self::between(client, start, tip)?;

View File

@@ -5,36 +5,16 @@ use std::{
time::Duration, time::Duration,
}; };
use bitcoin::ScriptBuf;
use brk_error::Result; use brk_error::Result;
use brk_types::{BlockHash, Sats, Txid, Weight}; use brk_types::{Sats, Txid, Weight};
mod client; mod client;
mod methods; mod methods;
use client::ClientInner; use client::ClientInner;
pub use corepc_types::v17::{GetBlockHeaderVerbose, GetBlockVerboseOne, GetTxOut};
pub use methods::MempoolState; pub use methods::MempoolState;
#[derive(Debug, Clone)]
pub struct BlockInfo {
pub height: usize,
pub confirmations: i64,
}
#[derive(Debug, Clone)]
pub struct BlockHeaderInfo {
pub height: usize,
pub confirmations: i64,
pub previous_block_hash: Option<BlockHash>,
}
#[derive(Debug, Clone)]
pub struct TxOutInfo {
pub coinbase: bool,
pub value: Sats,
pub script_pub_key: ScriptBuf,
}
/// One transaction from `getblocktemplate`. Carries the full decoded /// One transaction from `getblocktemplate`. Carries the full decoded
/// body and stats so block 0 can be projected without a follow-up /// body and stats so block 0 can be projected without a follow-up
/// `getmempoolentry`/`getrawtransaction` per tx; that follow-up was the /// `getmempoolentry`/`getrawtransaction` per tx; that follow-up was the

View File

@@ -9,13 +9,14 @@ use brk_types::{
use corepc_jsonrpc::error::Error as JsonRpcError; use corepc_jsonrpc::error::Error as JsonRpcError;
use corepc_types::{ use corepc_types::{
v17::{ v17::{
GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne, BlockTemplateTransaction, GetBlockCount, GetBlockHash, GetBlockHeader,
GetBlockVerboseZero, GetRawMempool, GetTxOut, GetBlockHeaderVerbose, GetBlockTemplate, GetBlockVerboseOne, GetBlockVerboseZero,
GetRawMempool, GetTxOut,
}, },
v24::GetMempoolInfo, v28::GetBlockchainInfo,
v24::{GetMempoolInfo, MempoolEntry},
}; };
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use tracing::{debug, info}; use tracing::{debug, info};
@@ -24,7 +25,7 @@ use tracing::{debug, info};
/// The mempool fetcher tolerates these per-item failures silently. /// The mempool fetcher tolerates these per-item failures silently.
const RPC_NOT_FOUND: i32 = -5; const RPC_NOT_FOUND: i32 = -5;
use crate::{BlockHeaderInfo, BlockInfo, BlockTemplateTx, Client, TxOutInfo}; use crate::{BlockTemplateTx, Client};
/// Per-batch request count for `get_block_hashes_range`, /// Per-batch request count for `get_block_hashes_range`,
/// `fetch_new_pool_data`, and `get_raw_transactions`. Sized so the JSON /// `fetch_new_pool_data`, and `get_raw_transactions`. Sized so the JSON
@@ -34,46 +35,21 @@ use crate::{BlockHeaderInfo, BlockInfo, BlockTemplateTx, Client, TxOutInfo};
/// the wire batch is twice that. /// the wire batch is twice that.
const BATCH_CHUNK: usize = 2000; const BATCH_CHUNK: usize = 2000;
/// Live mempool state fetched in one batched bitcoind round-trip: /// Mempool snapshot data that survives one fetch cycle: the live
/// `getblocktemplate` + `getrawmempool false` + `getmempoolinfo`. Each /// txid set, fee floor, and chain tip. Returned alongside the raw
/// `gbt` entry carries the full decoded tx and stats so block 0 is /// `block_template` (which Fetcher consumes for GBT synthesis) by
/// projected directly from Core's selection without a follow-up entry /// `Client::fetch_mempool_state`.
/// fetch that could race the eviction of one of those txs.
pub struct MempoolState { pub struct MempoolState {
pub live_txids: Vec<Txid>, pub live_txids: Vec<Txid>,
pub gbt: Vec<BlockTemplateTx>,
pub min_fee: FeeRate, pub min_fee: FeeRate,
/// Chain tip's hash (block-template's `previousblockhash`).
/// Compared between cycles to detect newly mined blocks.
pub tip_hash: BlockHash,
/// Chain tip's height (block-template's `height` minus one).
pub tip_height: Height,
} }
#[derive(Deserialize)] fn build_entry(txid: Txid, e: MempoolEntry) -> Result<MempoolEntryInfo> {
struct MempoolEntryRaw {
vsize: VSize,
weight: Weight,
time: Timestamp,
fees: MempoolEntryFeesRaw,
depends: Vec<String>,
}
#[derive(Deserialize)]
struct MempoolEntryFeesRaw {
base: Bitcoin,
}
#[derive(Deserialize)]
struct GbtResponseRaw {
transactions: Vec<GbtTxRaw>,
}
#[derive(Deserialize)]
struct GbtTxRaw {
data: String,
txid: bitcoin::Txid,
fee: u64,
weight: u64,
depends: Vec<u32>,
}
fn build_entry(txid: Txid, e: MempoolEntryRaw) -> Result<MempoolEntryInfo> {
let depends = e let depends = e
.depends .depends
.iter() .iter()
@@ -81,36 +57,47 @@ fn build_entry(txid: Txid, e: MempoolEntryRaw) -> Result<MempoolEntryInfo> {
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
Ok(MempoolEntryInfo { Ok(MempoolEntryInfo {
txid, txid,
vsize: e.vsize, vsize: VSize::from(e.vsize as u64),
weight: e.weight, weight: Weight::from(e.weight as u64),
fee: Sats::from(e.fees.base), fee: Sats::from(Bitcoin::from(e.fees.base)),
first_seen: e.time, first_seen: Timestamp::from(e.time),
depends, depends,
}) })
} }
fn build_gbt(raw: GbtResponseRaw) -> Result<Vec<BlockTemplateTx>> { fn build_gbt(raw: GetBlockTemplate) -> Result<Vec<BlockTemplateTx>> {
// Pass 1: decode bodies and stash the 1-based GBT-array indices // Pass 1: decode bodies and stash the 1-based GBT-array indices aside
// aside so we can drop each `data` hex string and `GbtTxRaw` as // so each `data` hex string and `BlockTemplateTransaction` drops as
// soon as the tx is pushed. // soon as the tx is pushed.
let n = raw.transactions.len(); let n = raw.transactions.len();
let mut depends_idx: Vec<Vec<u32>> = Vec::with_capacity(n); let mut depends_idx: Vec<Vec<i64>> = Vec::with_capacity(n);
let mut result: Vec<BlockTemplateTx> = Vec::with_capacity(n); let mut result: Vec<BlockTemplateTx> = Vec::with_capacity(n);
for t in raw.transactions { for t in raw.transactions {
depends_idx.push(t.depends); let BlockTemplateTransaction {
data,
txid,
depends,
fee,
weight,
..
} = t;
depends_idx.push(depends);
result.push(BlockTemplateTx { result.push(BlockTemplateTx {
txid: Txid::from(t.txid), txid: Client::parse_txid(&txid, "gbt txid")?,
fee: Sats::from(t.fee), fee: Sats::from(fee as u64),
weight: Weight::from(t.weight), weight: Weight::from(weight),
depends: Vec::new(), depends: Vec::new(),
tx: encode::deserialize_hex(&t.data)?, tx: encode::deserialize_hex(&data)?,
}); });
} }
// Pass 2: resolve indices to txids now that the array is complete. // Pass 2: resolve indices to txids now that the array is complete.
for (i, indices) in depends_idx.iter().enumerate() { for (i, indices) in depends_idx.iter().enumerate() {
let resolved: Vec<Txid> = indices let resolved: Vec<Txid> = indices
.iter() .iter()
.filter_map(|d| result.get((*d as usize).checked_sub(1)?).map(|t| t.txid)) .filter_map(|d| {
let idx = usize::try_from(*d).ok()?.checked_sub(1)?;
result.get(idx).map(|t| t.txid)
})
.collect(); .collect();
result[i].depends = resolved; result[i].depends = resolved;
} }
@@ -150,18 +137,13 @@ impl Client {
.map_err(|e| Error::Parse(format!("decode getblock: {e}"))) .map_err(|e| Error::Parse(format!("decode getblock: {e}")))
} }
pub fn get_block_info<'a, H>(&self, hash: &'a H) -> Result<BlockInfo> pub fn get_block_info<'a, H>(&self, hash: &'a H) -> Result<GetBlockVerboseOne>
where where
&'a H: Into<&'a bitcoin::BlockHash>, &'a H: Into<&'a bitcoin::BlockHash>,
{ {
let hash: &bitcoin::BlockHash = hash.into(); let hash: &bitcoin::BlockHash = hash.into();
let r: GetBlockVerboseOne = self self.0
.0 .call_with_retry("getblock", &[serde_json::to_value(hash)?, Value::from(1u8)])
.call_with_retry("getblock", &[serde_json::to_value(hash)?, Value::from(1u8)])?;
Ok(BlockInfo {
height: r.height as usize,
confirmations: r.confirmations,
})
} }
pub fn get_block_header<'a, H>(&self, hash: &'a H) -> Result<bitcoin::block::Header> pub fn get_block_header<'a, H>(&self, hash: &'a H) -> Result<bitcoin::block::Header>
@@ -177,23 +159,13 @@ impl Client {
bitcoin::consensus::deserialize::<bitcoin::block::Header>(&bytes).map_err(Error::from) bitcoin::consensus::deserialize::<bitcoin::block::Header>(&bytes).map_err(Error::from)
} }
pub fn get_block_header_info<'a, H>(&self, hash: &'a H) -> Result<BlockHeaderInfo> pub fn get_block_header_info<'a, H>(&self, hash: &'a H) -> Result<GetBlockHeaderVerbose>
where where
&'a H: Into<&'a bitcoin::BlockHash>, &'a H: Into<&'a bitcoin::BlockHash>,
{ {
let hash: &bitcoin::BlockHash = hash.into(); let hash: &bitcoin::BlockHash = hash.into();
let r: GetBlockHeaderVerbose = self self.0
.0 .call_with_retry("getblockheader", &[serde_json::to_value(hash)?])
.call_with_retry("getblockheader", &[serde_json::to_value(hash)?])?;
let previous_block_hash = r
.previous_block_hash
.map(|s| Self::parse_block_hash(&s, "previousblockhash"))
.transpose()?;
Ok(BlockHeaderInfo {
height: r.height as usize,
confirmations: r.confirmations,
previous_block_hash,
})
} }
pub fn get_block_hash<H>(&self, height: H) -> Result<BlockHash> pub fn get_block_hash<H>(&self, height: H) -> Result<BlockHash>
@@ -244,7 +216,7 @@ impl Client {
txid: &Txid, txid: &Txid,
vout: Vout, vout: Vout,
include_mempool: Option<bool>, include_mempool: Option<bool>,
) -> Result<Option<TxOutInfo>> { ) -> Result<Option<GetTxOut>> {
let txid: &bitcoin::Txid = txid.into(); let txid: &bitcoin::Txid = txid.into();
let mut args: Vec<Value> = vec![ let mut args: Vec<Value> = vec![
serde_json::to_value(txid)?, serde_json::to_value(txid)?,
@@ -253,19 +225,7 @@ impl Client {
if let Some(mempool) = include_mempool { if let Some(mempool) = include_mempool {
args.push(Value::Bool(mempool)); args.push(Value::Bool(mempool));
} }
let r: Option<GetTxOut> = self.0.call_with_retry("gettxout", &args)?; self.0.call_with_retry("gettxout", &args)
match r {
Some(r) => {
let script_pub_key = bitcoin::ScriptBuf::from_hex(&r.script_pubkey.hex)
.map_err(|e| Error::Parse(format!("script hex: {e}")))?;
Ok(Some(TxOutInfo {
coinbase: r.coinbase,
value: Sats::from(Bitcoin::from(r.value)),
script_pub_key,
}))
}
None => Ok(None),
}
} }
pub fn get_raw_mempool(&self) -> Result<Vec<Txid>> { pub fn get_raw_mempool(&self) -> Result<Vec<Txid>> {
@@ -394,7 +354,11 @@ impl Client {
/// carries each tx's full body and stats, so block 0 is exact even /// carries each tx's full body and stats, so block 0 is exact even
/// when a tx vanishes from the mempool listing between the GBT and /// when a tx vanishes from the mempool listing between the GBT and
/// `getrawmempool` calls; no follow-up entry fetch can race it. /// `getrawmempool` calls; no follow-up entry fetch can race it.
pub fn fetch_mempool_state(&self) -> Result<MempoolState> { /// Returns the passthrough `MempoolState` and the raw
/// `block_template` (consumed downstream by GBT synthesis), in one
/// batched round-trip: `getblocktemplate` + `getrawmempool false`
/// + `getmempoolinfo`.
pub fn fetch_mempool_state(&self) -> Result<(MempoolState, Vec<BlockTemplateTx>)> {
let requests: [(&str, Vec<Value>); 3] = [ let requests: [(&str, Vec<Value>); 3] = [
( (
"getblocktemplate", "getblocktemplate",
@@ -404,7 +368,7 @@ impl Client {
("getmempoolinfo", vec![]), ("getmempoolinfo", vec![]),
]; ];
let mut out = self.0.call_mixed_batch(&requests)?.into_iter(); let mut out = self.0.call_mixed_batch(&requests)?.into_iter();
let gbt_raw = out.next().ok_or(Error::Internal("missing gbt"))??; let template_raw = out.next().ok_or(Error::Internal("missing gbt"))??;
let txids_raw = out.next().ok_or(Error::Internal("missing rawmempool"))??; let txids_raw = out.next().ok_or(Error::Internal("missing rawmempool"))??;
let info_raw = out.next().ok_or(Error::Internal("missing mempoolinfo"))??; let info_raw = out.next().ok_or(Error::Internal("missing mempoolinfo"))??;
@@ -413,14 +377,23 @@ impl Client {
.iter() .iter()
.map(|s| Self::parse_txid(s, "mempool txid")) .map(|s| Self::parse_txid(s, "mempool txid"))
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
let gbt = build_gbt(serde_json::from_str(gbt_raw.get())?)?; let template: GetBlockTemplate = serde_json::from_str(template_raw.get())?;
let tip_hash = Self::parse_block_hash(&template.previous_block_hash, "previousblockhash")?;
let tip_height = Height::from(u64::try_from(template.height - 1).map_err(|_| {
Error::Parse(format!("gbt height out of range: {}", template.height))
})?);
let block_template = build_gbt(template)?;
let min_fee = build_min_fee(serde_json::from_str(info_raw.get())?); let min_fee = build_min_fee(serde_json::from_str(info_raw.get())?);
Ok(MempoolState { Ok((
MempoolState {
live_txids, live_txids,
gbt,
min_fee, min_fee,
}) tip_hash,
tip_height,
},
block_template,
))
} }
/// Mixed batch of `getmempoolentry` + `getrawtransaction` for the /// Mixed batch of `getmempoolentry` + `getrawtransaction` for the
@@ -453,7 +426,7 @@ impl Client {
let raw_res = iter.next().ok_or(Error::Internal("missing raw"))?; let raw_res = iter.next().ok_or(Error::Internal("missing raw"))?;
match entry_res.and_then(|raw| { match entry_res.and_then(|raw| {
let me: MempoolEntryRaw = serde_json::from_str(raw.get())?; let me: MempoolEntry = serde_json::from_str(raw.get())?;
build_entry(*txid, me) build_entry(*txid, me)
}) { }) {
Ok(info) => entries.push(info), Ok(info) => entries.push(info),
@@ -488,23 +461,31 @@ impl Client {
loop { loop {
let info = self.get_block_header_info(&current)?; let info = self.get_block_header_info(&current)?;
if info.confirmations > 0 { if info.confirmations > 0 {
return Ok((info.height.into(), current)); return Ok((Height::from(info.height as u64), current));
} }
current = info.previous_block_hash.ok_or(Error::NotFound( let prev = info.previous_block_hash.ok_or(Error::NotFound(
"Reached genesis without finding main chain".into(), "Reached genesis without finding main chain".into(),
))?; ))?;
current = Self::parse_block_hash(&prev, "previousblockhash")?;
} }
} }
pub fn wait_for_synced_node(&self) -> Result<()> { pub fn get_blockchain_info(&self) -> Result<GetBlockchainInfo> {
#[derive(Deserialize)] self.0.call_with_retry("getblockchaininfo", &[])
struct SyncProgress {
headers: u64,
blocks: u64,
} }
/// Bitcoin network the connected node is running on, derived from
/// `getblockchaininfo.chain`.
pub fn get_network(&self) -> Result<bitcoin::Network> {
let chain = self.get_blockchain_info()?.chain;
bitcoin::Network::from_core_arg(&chain)
.map_err(|e| Error::Parse(format!("getblockchaininfo.chain '{chain}': {e}")))
}
pub fn wait_for_synced_node(&self) -> Result<()> {
let is_synced = || -> Result<bool> { let is_synced = || -> Result<bool> {
let p: SyncProgress = self.0.call_with_retry("getblockchaininfo", &[])?; let info = self.get_blockchain_info()?;
Ok(p.headers == p.blocks) Ok(info.headers == info.blocks)
}; };
if !is_synced()? { if !is_synced()? {

View File

@@ -8,7 +8,7 @@ use super::{
P2WSHBytes, P2WSHBytes,
}; };
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AddrBytes { pub enum AddrBytes {
P2PK65(P2PK65Bytes), // 65 P2PK65(P2PK65Bytes), // 65
P2PK33(P2PK33Bytes), // 33 P2PK33(P2PK33Bytes), // 33

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::FeeRate; use crate::FeeRate;
/// Recommended fee rates in sat/vB /// Recommended fee rates in sat/vB
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RecommendedFees { pub struct RecommendedFees {
/// Fee rate for fastest confirmation (next block) /// Fee rate for fastest confirmation (next block)

21
crates/mmpl/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "mmpl"
description = "A CLI to stream Bitcoin mempool events as NDJSON"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[dependencies]
brk_error = { workspace = true }
brk_mempool = { workspace = true }
brk_rpc = { workspace = true }
brk_types = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[[bin]]
name = "mmpl"
path = "src/main.rs"

83
crates/mmpl/src/args.rs Normal file
View File

@@ -0,0 +1,83 @@
use std::path::PathBuf;
use brk_error::{Error, Result};
use brk_rpc::{Auth, Client};
pub struct Args {
bitcoindir: Option<PathBuf>,
rpcconnect: Option<String>,
rpcport: Option<u16>,
rpccookiefile: Option<PathBuf>,
rpcuser: Option<String>,
rpcpassword: Option<String>,
}
impl Args {
pub fn parse(raw: Vec<String>) -> Result<Self> {
let mut bitcoindir = None;
let mut rpcconnect = None;
let mut rpcport = None;
let mut rpccookiefile = None;
let mut rpcuser = None;
let mut rpcpassword = None;
let mut iter = raw.into_iter();
while let Some(a) = iter.next() {
let rest = a
.strip_prefix("--")
.ok_or_else(|| Error::Parse(format!("unexpected arg: '{a}'")))?;
let (key, value) = match rest.split_once('=') {
Some((k, v)) => (k.to_string(), v.to_string()),
None => (
rest.to_string(),
iter.next()
.ok_or_else(|| Error::Parse(format!("--{rest} requires a value")))?,
),
};
match key.as_str() {
"bitcoindir" => bitcoindir = Some(PathBuf::from(value)),
"rpcconnect" => rpcconnect = Some(value),
"rpcport" => {
rpcport = Some(value.parse().map_err(|_| {
Error::Parse(format!("--rpcport: '{value}' is not a valid port"))
})?);
}
"rpccookiefile" => rpccookiefile = Some(PathBuf::from(value)),
"rpcuser" => rpcuser = Some(value),
"rpcpassword" => rpcpassword = Some(value),
other => return Err(Error::Parse(format!("unknown flag --{other}"))),
}
}
Ok(Self {
bitcoindir,
rpcconnect,
rpcport,
rpccookiefile,
rpcuser,
rpcpassword,
})
}
pub fn rpc(&self) -> Result<Client> {
let host = self.rpcconnect.as_deref().unwrap_or("localhost");
let port = self.rpcport.unwrap_or(8332);
let url = format!("http://{host}:{port}");
let bitcoin_dir = self
.bitcoindir
.clone()
.unwrap_or_else(Client::default_bitcoin_path);
let cookie = self
.rpccookiefile
.clone()
.unwrap_or_else(|| bitcoin_dir.join(".cookie"));
let auth = if cookie.is_file() {
Auth::CookieFile(cookie)
} else if let (Some(u), Some(p)) = (self.rpcuser.as_deref(), self.rpcpassword.as_deref()) {
Auth::UserPass(u.to_string(), p.to_string())
} else {
return Err(Error::Parse(
"no RPC auth: cookie file missing and --rpcuser/--rpcpassword not set".into(),
));
};
Client::new(&url, auth)
}
}

105
crates/mmpl/src/emitter.rs Normal file
View File

@@ -0,0 +1,105 @@
//! Per-cycle NDJSON emitter. Owns the cycle-over-cycle memory used to
//! turn the always-fresh `Cycle` into change-only events for `tip`,
//! `block`, and `fees`.
use std::{
io::{self, Write},
time::{SystemTime, UNIX_EPOCH},
};
use brk_mempool::Cycle;
use brk_types::{Addr, AddrBytes, BlockHash, NextBlockHash, RecommendedFees, Txid};
use rustc_hash::FxHashSet;
use crate::event::Event;
/// Cycle-over-cycle memory for change-event detection. `None` on the
/// first cycle, so the very first `Tip` / `Block` / `Fees` always
/// fires - downstream consumers get a baseline without a special-case
/// "current state" RPC.
///
/// `prev_block0` is `None` on cold start so the first `block` event
/// reports the entire template as `added` (one big line, then small
/// deltas).
#[derive(Default)]
pub struct Emitter {
prev_tip_hash: Option<BlockHash>,
prev_next_block_hash: Option<NextBlockHash>,
prev_block0: Option<FxHashSet<Txid>>,
prev_fees: Option<RecommendedFees>,
}
impl Emitter {
/// Writes every event for one cycle and flushes once at the end.
/// Per-line flushes would cost one syscall per event on busy cycles;
/// the cycle period (~500ms) is the real "live" granularity.
pub fn emit<W: Write>(&mut self, out: &mut W, cycle: &Cycle) -> io::Result<()> {
let t = now_secs();
for tx in &cycle.added {
write_line(out, &Event::enter(t, tx))?;
}
for tx in &cycle.removed {
write_line(out, &Event::leave(t, tx))?;
}
for bytes in &cycle.addr_enters {
Self::emit_addr(out, t, bytes, Event::addr_enter)?;
}
for bytes in &cycle.addr_leaves {
Self::emit_addr(out, t, bytes, Event::addr_leave)?;
}
if self.prev_tip_hash != Some(cycle.tip_hash) {
self.prev_tip_hash = Some(cycle.tip_hash);
write_line(out, &Event::tip(t, cycle.tip_hash, cycle.tip_height))?;
}
let next_block_hash = cycle.snapshot.next_block_hash;
if self.prev_next_block_hash != Some(next_block_hash) {
self.prev_next_block_hash = Some(next_block_hash);
let current: FxHashSet<Txid> = cycle.snapshot.block0_txids().collect();
let (added, removed) = match &self.prev_block0 {
Some(prev) => (
current.difference(prev).copied().collect(),
prev.difference(&current).copied().collect(),
),
None => (current.iter().copied().collect(), Vec::new()),
};
write_line(out, &Event::block(t, next_block_hash, added, removed))?;
self.prev_block0 = Some(current);
}
if self.prev_fees.as_ref() != Some(&cycle.snapshot.fees) {
self.prev_fees = Some(cycle.snapshot.fees.clone());
write_line(out, &Event::fees(t, &cycle.snapshot.fees))?;
}
write_line(out, &Event::summary(t, cycle))?;
out.flush()
}
/// Render an `AddrBytes` and emit it via `make_event`. Unrenderable
/// bytes (e.g. exotic non-standard scripts) drop a one-line warning
/// to stderr - the event stream stays clean for downstream `jq`.
fn emit_addr<W: Write>(
out: &mut W,
t: f64,
bytes: &AddrBytes,
make_event: fn(f64, Addr) -> Event,
) -> io::Result<()> {
match Addr::try_from(bytes) {
Ok(addr) => write_line(out, &make_event(t, addr)),
Err(e) => {
eprintln!("mmpl: skipping addr event: {e}");
Ok(())
}
}
}
}
fn write_line<W: Write>(out: &mut W, ev: &Event) -> io::Result<()> {
serde_json::to_writer(&mut *out, ev).map_err(io::Error::other)?;
out.write_all(b"\n")
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64()
}

160
crates/mmpl/src/event.rs Normal file
View File

@@ -0,0 +1,160 @@
//! NDJSON event schema. One [`Event`] per line; consumers pipe to
//! `jq` / `grep` to filter. Per-event fields are flat (no nested
//! objects) so `jq -c 'select(...)'` works without `..` walks.
use brk_mempool::{Cycle, TxAdded, TxRemoval, TxRemoved};
use brk_types::{
Addr, BlockHash, FeeRate, Height, NextBlockHash, RecommendedFees, Sats, Timestamp, Txid, VSize,
};
use serde::Serialize;
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Event {
/// A tx entered the pool this cycle (either brand new or revived
/// from the graveyard - the stream collapses both to one event).
Enter {
t: f64,
txid: Txid,
vsize: VSize,
fee: Sats,
rate: FeeRate,
first_seen: Timestamp,
},
/// A tx left the pool this cycle. `rate` is the package-effective
/// rate at burial, not raw fee/vsize.
Leave {
t: f64,
txid: Txid,
#[serde(flatten)]
reason: LeaveReason,
rate: FeeRate,
},
/// An address went 0 → 1+ live mempool txs this cycle. Same-cycle
/// flip-flops are collapsed by the upstream tracker (no event).
AddrEnter { t: f64, addr: Addr },
/// An address went 1+ → 0 live mempool txs this cycle.
AddrLeave { t: f64, addr: Addr },
/// New confirmed block: bitcoind's chain tip moved since the last
/// cycle. `height` is the tip's own height (one less than the next
/// block being templated).
Tip {
t: f64,
hash: BlockHash,
height: Height,
},
/// The projected next block changed (different tx set or order).
/// `hash` is the same opaque content hash used as the mempool ETag.
/// `added`/`removed` is the txid-level diff against the previous
/// template; on the very first cycle `added` is the full template
/// and `removed` is empty.
Block {
t: f64,
hash: NextBlockHash,
added: Vec<Txid>,
removed: Vec<Txid>,
},
/// Recommended fee rates changed since the last cycle.
Fees {
t: f64,
fastest: FeeRate,
half_hour: FeeRate,
hour: FeeRate,
economy: FeeRate,
minimum: FeeRate,
},
/// Per-cycle heartbeat. Always emitted, even on idle cycles, so
/// downstream consumers see a steady pulse and can spot stalls.
/// `addr_enters`/`addr_leaves` count the post-cancellation 0↔1+
/// address transitions this cycle.
Cycle {
t: f64,
added: usize,
removed: usize,
addr_enters: usize,
addr_leaves: usize,
count: usize,
vsize: VSize,
fee: Sats,
took_ms: u64,
},
}
#[derive(Serialize)]
#[serde(tag = "reason", rename_all = "snake_case")]
pub enum LeaveReason {
Replaced { by: Txid },
Vanished,
}
impl Event {
pub fn enter(t: f64, tx: &TxAdded) -> Self {
Self::Enter {
t,
txid: tx.txid,
vsize: tx.vsize,
fee: tx.fee,
rate: tx.fee_rate,
first_seen: tx.first_seen,
}
}
pub fn leave(t: f64, tx: &TxRemoved) -> Self {
Self::Leave {
t,
txid: tx.txid,
reason: LeaveReason::from(tx.reason),
rate: tx.chunk_rate,
}
}
pub fn addr_enter(t: f64, addr: Addr) -> Self {
Self::AddrEnter { t, addr }
}
pub fn addr_leave(t: f64, addr: Addr) -> Self {
Self::AddrLeave { t, addr }
}
pub fn tip(t: f64, hash: BlockHash, height: Height) -> Self {
Self::Tip { t, hash, height }
}
pub fn block(t: f64, hash: NextBlockHash, added: Vec<Txid>, removed: Vec<Txid>) -> Self {
Self::Block { t, hash, added, removed }
}
pub fn fees(t: f64, fees: &RecommendedFees) -> Self {
Self::Fees {
t,
fastest: fees.fastest_fee,
half_hour: fees.half_hour_fee,
hour: fees.hour_fee,
economy: fees.economy_fee,
minimum: fees.minimum_fee,
}
}
pub fn summary(t: f64, cycle: &Cycle) -> Self {
Self::Cycle {
t,
added: cycle.added.len(),
removed: cycle.removed.len(),
addr_enters: cycle.addr_enters.len(),
addr_leaves: cycle.addr_leaves.len(),
count: cycle.info.count,
vsize: cycle.info.vsize,
fee: cycle.info.total_fee,
took_ms: cycle.took.as_millis() as u64,
}
}
}
impl From<TxRemoval> for LeaveReason {
fn from(reason: TxRemoval) -> Self {
match reason {
TxRemoval::Replaced { by } => Self::Replaced { by },
TxRemoval::Vanished => Self::Vanished,
}
}
}

61
crates/mmpl/src/main.rs Normal file
View File

@@ -0,0 +1,61 @@
mod args;
mod emitter;
mod event;
mod usage;
use std::{
io::{self, BufWriter},
process::ExitCode,
thread,
time::{Duration, Instant},
};
use brk_error::Result;
use brk_mempool::Mempool;
use args::Args;
use emitter::Emitter;
const PERIOD: Duration = Duration::from_millis(500);
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("mmpl: {e}");
ExitCode::from(1)
}
}
}
fn run() -> Result<()> {
let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.iter().any(|a| matches!(a.as_str(), "-h" | "--help")) {
usage::print();
return Ok(());
}
let args = Args::parse(raw)?;
let client = args.rpc()?;
let mempool = Mempool::new(&client);
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
let mut emitter = Emitter::default();
loop {
let started = Instant::now();
match mempool.tick() {
Ok(cycle) => match emitter.emit(&mut out, &cycle) {
Ok(()) => {}
// Broken pipe (e.g. `mmpl | head`) is a normal end-of-stream.
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e.into()),
},
// Transient RPC failure - log, then retry on the next tick.
Err(e) => eprintln!("mmpl: tick failed: {e}"),
}
if let Some(rest) = PERIOD.checked_sub(started.elapsed()) {
thread::sleep(rest);
}
}
}

49
crates/mmpl/src/usage.rs Normal file
View File

@@ -0,0 +1,49 @@
// Raw string contains `{`/`}` literals (JSON), so it can't be the
// format string of `print!`. Pass via positional arg.
#[allow(clippy::print_literal)]
pub fn print() {
print!(
"{}",
r#"mmpl - stream Bitcoin mempool events as NDJSON
Usage:
mmpl [options]
Options:
--bitcoindir <path> Bitcoin data dir (default: platform-specific)
--rpcconnect <host> RPC host (default: localhost)
--rpcport <port> RPC port (default: 8332)
--rpccookiefile <path> Cookie file (default: <bitcoindir>/.cookie)
--rpcuser <user> RPC username (if no cookie file)
--rpcpassword <pass> RPC password (if no cookie file)
-h, --help Show this help
Events (one JSON object per line):
Per-tx (one event per change):
{"kind":"enter","t":..,"txid":..,"vsize":..,"fee":..,"rate":..,"first_seen":..}
{"kind":"leave","t":..,"txid":..,"reason":"vanished","rate":..}
{"kind":"leave","t":..,"txid":..,"reason":"replaced","by":..,"rate":..}
Per-address (0 <-> 1+ live mempool txs):
{"kind":"addr_enter","t":..,"addr":..}
{"kind":"addr_leave","t":..,"addr":..}
State changes (fires only when the value changed):
{"kind":"tip","t":..,"hash":..,"height":..} (new confirmed block)
{"kind":"block","t":..,"hash":..,"added":[txid..],"removed":[txid..]}
(next-block template changed; first cycle
emits the full template as `added`)
{"kind":"fees","t":..,"fastest":..,"half_hour":..,"hour":..,"economy":..,"minimum":..}
Per-cycle heartbeat (always emitted):
{"kind":"cycle","t":..,"added":N,"removed":N,"addr_enters":N,"addr_leaves":N,
"count":N,"vsize":N,"fee":N,"took_ms":N}
Examples:
mmpl | jq -c 'select(.kind=="enter" and .rate>=50)'
mmpl | jq -c 'select(.kind=="tip")'
mmpl | grep -v '"kind":"cycle"'
mmpl | jq -c 'select(.reason=="replaced")'
"#
);
}