diff --git a/Cargo.lock b/Cargo.lock index 7f241dfbf..3af61f7a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,15 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blk" version = "0.3.0-beta.7" +dependencies = [ + "bitcoin", + "brk_error", + "brk_reader", + "brk_rpc", + "brk_types", + "owo-colors", + "serde_json", +] [[package]] name = "brk" diff --git a/crates/blk/Cargo.toml b/crates/blk/Cargo.toml index 3d4927598..bd7c8320f 100644 --- a/crates/blk/Cargo.toml +++ b/crates/blk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "blk" -description = "A command line tool to inspect Bitcoin Core blocks" +description = "A CLI to inspect Bitcoin Core blocks" version.workspace = true edition.workspace = true license.workspace = true @@ -8,6 +8,13 @@ homepage.workspace = true repository.workspace = true [dependencies] +bitcoin = { workspace = true } +brk_error = { workspace = true } +brk_reader = { workspace = true } +brk_rpc = { workspace = true } +brk_types = { workspace = true } +owo-colors = { workspace = true } +serde_json = { workspace = true } [[bin]] name = "blk" diff --git a/crates/blk/README.md b/crates/blk/README.md new file mode 100644 index 000000000..0dcfd787a --- /dev/null +++ b/crates/blk/README.md @@ -0,0 +1,27 @@ +# blk + +A CLI to inspect Bitcoin Core blocks. + +Reads `blk*.dat` files directly via [`brk_reader`](../brk_reader) and resolves +the chain tip / heights via the Bitcoin Core RPC. Output is shell-friendly: +bare values, NDJSON, pretty JSON, or TSV. + +## Install + +```sh +cargo install --path crates/blk +``` + +## Quick start + +```sh +blk 800000 hash # bare hash +blk 800000 height hash time # one compact JSON line +blk 800000 tx.0.vout.0.value # coinbase output 0 sats +blk 0..2 hash tx.0.txid # 3 NDJSON lines +blk tip tx.0 # whole coinbase tx as JSON +``` + +## Reference + +Run `blk --help` for the full field/selector/option reference. diff --git a/crates/blk/src/args.rs b/crates/blk/src/args.rs new file mode 100644 index 000000000..cd9b79cdf --- /dev/null +++ b/crates/blk/src/args.rs @@ -0,0 +1,131 @@ +use std::path::PathBuf; + +use brk_error::{Error, Result}; +use brk_rpc::{Auth, Client}; + +use crate::path::Path; + +pub struct Args { + pub selector: String, + pub paths: Vec, + pub pretty: bool, + pub compact: bool, + bitcoindir: Option, + blocksdir: Option, + rpcconnect: Option, + rpcport: Option, + rpccookiefile: Option, + rpcuser: Option, + rpcpassword: Option, +} + +impl Args { + pub fn parse(raw: Vec) -> Result { + let mut pretty = false; + let mut compact = false; + let mut bitcoindir = None; + let mut blocksdir = None; + let mut rpcconnect = None; + let mut rpcport = None; + let mut rpccookiefile = None; + let mut rpcuser = None; + let mut rpcpassword = None; + let mut positional: Vec = Vec::new(); + let mut iter = raw.into_iter(); + while let Some(a) = iter.next() { + if a == "-p" || a == "--pretty" { + pretty = true; + continue; + } + if a == "-c" || a == "--compact" { + compact = true; + continue; + } + if let Some(rest) = a.strip_prefix("--") { + 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)), + "blocksdir" => blocksdir = 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}"))), + } + continue; + } + positional.push(a); + } + + let mut iter = positional.into_iter(); + let selector = iter + .next() + .ok_or_else(|| Error::Parse("missing selector".into()))?; + let paths: Vec = iter.map(|f| Path::parse(&f)).collect::>()?; + if paths.is_empty() { + return Err(Error::Parse( + "missing field. ask for at least one (e.g. `blk 0 hash`)".into(), + )); + } + Ok(Self { + selector, + paths, + pretty, + compact, + bitcoindir, + blocksdir, + rpcconnect, + rpcport, + rpccookiefile, + rpcuser, + rpcpassword, + }) + } + + pub fn bitcoin_dir(&self) -> PathBuf { + self.bitcoindir + .clone() + .unwrap_or_else(Client::default_bitcoin_path) + } + + pub fn blocks_dir(&self) -> PathBuf { + self.blocksdir + .clone() + .unwrap_or_else(|| self.bitcoin_dir().join("blocks")) + } + + pub fn rpc(&self) -> Result { + let host = self.rpcconnect.as_deref().unwrap_or("localhost"); + let port = self.rpcport.unwrap_or(8332); + let url = format!("http://{host}:{port}"); + let cookie = self + .rpccookiefile + .clone() + .unwrap_or_else(|| self.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) + } +} diff --git a/crates/blk/src/fields.rs b/crates/blk/src/fields.rs new file mode 100644 index 000000000..a0c2fa4d2 --- /dev/null +++ b/crates/blk/src/fields.rs @@ -0,0 +1,309 @@ +use std::cell::OnceCell; + +use bitcoin::{ + Address, Block, Network, ScriptBuf, Transaction, TxIn, TxOut, consensus::encode::serialize_hex, + hex::DisplayHex, +}; +use brk_error::{Error, Result}; +use brk_types::ReadBlock; +use serde_json::{Value, json}; + +use crate::path::{Path, Step}; + +pub struct Ctx<'a> { + block: &'a ReadBlock, + size_weight: OnceCell<(usize, usize)>, +} + +impl<'a> Ctx<'a> { + pub fn new(block: &'a ReadBlock) -> Self { + Self { + block, + size_weight: OnceCell::new(), + } + } + + pub fn resolve(&self, path: &Path) -> Result { + let (step, rest) = pop(&path.steps)?; + let b = self.block; + let raw: &Block = b; + let scalar = |v| scalar_leaf(v, step, rest); + match step.name.as_str() { + "height" => scalar(json!(*b.height())), + "hash" => scalar(json!(b.hash().to_string())), + "time" => scalar(json!(b.header.time)), + "version" => scalar(json!(b.header.version.to_consensus())), + "version_hex" => scalar(json!(format!( + "{:08x}", + b.header.version.to_consensus() as u32 + ))), + "bits" => scalar(json!(b.header.bits.to_consensus())), + "nonce" => scalar(json!(b.header.nonce)), + "prev" => scalar(json!(b.header.prev_blockhash.to_string())), + "merkle" => scalar(json!(b.header.merkle_root.to_string())), + "difficulty" => scalar(json!(b.header.difficulty_float())), + "txs" => scalar(json!(b.txdata.len())), + "n_inputs" => scalar(json!( + b.txdata.iter().map(|tx| tx.input.len()).sum::() + )), + "n_outputs" => scalar(json!( + b.txdata.iter().map(|tx| tx.output.len()).sum::() + )), + "witness_txs" => scalar(json!( + b.txdata.iter().filter(|tx| tx_has_witness(tx)).count() + )), + "size" => scalar(json!(self.size_and_weight().0)), + "weight" => scalar(json!(self.size_and_weight().1)), + "strippedsize" => { + let (size, weight) = self.size_and_weight(); + scalar(json!((weight - size) / 3)) + } + "subsidy" => scalar(json!(subsidy_sats(*b.height()))), + "header_hex" => scalar(json!(serialize_hex(&b.header))), + "hex" => scalar(json!(serialize_hex(raw))), + "coinbase" => scalar(json!(b.coinbase_tag().as_str())), + "tx" => pick(&b.txdata, step, rest, |i, tx| resolve_tx(tx, i == 0, rest)), + other => Err(unknown("block", other)), + } + } + + pub fn resolve_str(&self, path: &Path) -> Result { + Ok(match self.resolve(path)? { + Value::String(s) => s, + other => other.to_string(), + }) + } + + 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 { + if steps.is_empty() { + return Ok(tx_to_value(tx, is_coinbase)); + } + let (step, rest) = pop(steps)?; + let scalar = |v| scalar_leaf(v, step, rest); + match step.name.as_str() { + "txid" => scalar(json!(tx.compute_txid().to_string())), + "wtxid" => scalar(json!(tx.compute_wtxid().to_string())), + "version" => scalar(json!(tx.version.0)), + "locktime" => scalar(json!(tx.lock_time.to_consensus_u32())), + "size" => scalar(json!(tx.total_size())), + "base_size" => scalar(json!(tx.base_size())), + "vsize" => scalar(json!(tx.vsize())), + "weight" => scalar(json!(tx.weight().to_wu())), + "inputs" => scalar(json!(tx.input.len())), + "outputs" => scalar(json!(tx.output.len())), + "is_coinbase" => scalar(json!(is_coinbase)), + "has_witness" => scalar(json!(tx_has_witness(tx))), + "is_rbf" => scalar(json!(tx_is_rbf(tx))), + "total_out" => scalar(json!(tx_total_out(tx))), + "hex" => scalar(json!(serialize_hex(tx))), + "vin" => pick(&tx.input, step, rest, |j, vin| { + resolve_vin(vin, is_coinbase && j == 0, rest) + }), + "vout" => pick(&tx.output, step, rest, |_, vout| resolve_vout(vout, rest)), + other => Err(unknown("tx", other)), + } +} + +fn resolve_vin(vin: &TxIn, is_coinbase: bool, steps: &[Step]) -> Result { + if steps.is_empty() { + return Ok(vin_to_value(vin, is_coinbase)); + } + let (step, rest) = pop(steps)?; + let scalar = |v| scalar_leaf(v, step, rest); + match step.name.as_str() { + "prev_txid" => scalar(json!(vin.previous_output.txid.to_string())), + "prev_vout" => scalar(json!(vin.previous_output.vout)), + "sequence" => scalar(json!(vin.sequence.0)), + "script_sig" => scalar(json!(vin.script_sig.to_hex_string())), + "script_sig_asm" => scalar(json!(vin.script_sig.to_asm_string())), + "witness" => scalar(witness_to_value(vin)), + "has_witness" => scalar(json!(!vin.witness.is_empty())), + "is_rbf" => scalar(json!(vin.sequence.is_rbf())), + "coinbase" => scalar(json!(is_coinbase)), + other => Err(unknown("vin", other)), + } +} + +fn resolve_vout(vout: &TxOut, steps: &[Step]) -> Result { + 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( + items: &[T], + step: &Step, + _rest: &[Step], + mut resolve: impl FnMut(usize, &T) -> Result, +) -> Result { + match step.index { + Some(i) => { + let item = items + .get(i) + .ok_or_else(|| out_of_range(&step.name, i, items.len()))?; + resolve(i, item) + } + None => Ok(Value::Array( + items + .iter() + .enumerate() + .map(|(i, item)| resolve(i, item)) + .collect::>()?, + )), + } +} + +fn pop(steps: &[Step]) -> Result<(&Step, &[Step])> { + steps + .split_first() + .ok_or_else(|| Error::Parse("empty path segment".into())) +} + +fn scalar_leaf(v: Value, step: &Step, rest: &[Step]) -> Result { + if step.index.is_some() { + return Err(Error::Parse(format!("'{}' is not an array", step.name))); + } + if !rest.is_empty() { + return Err(Error::Parse(format!( + "'{}' is a scalar; nothing to drill into", + step.name + ))); + } + Ok(v) +} + +fn out_of_range(name: &str, i: usize, len: usize) -> Error { + Error::Parse(format!("{name}.{i} out of range (len {len})")) +} + +fn unknown(level: &str, name: &str) -> Error { + Error::Parse(format!( + "unknown {level} field '{name}' (run `blk --help` for the list)" + )) +} + +fn tx_to_value(tx: &Transaction, is_coinbase: bool) -> Value { + let vin: Vec = tx + .input + .iter() + .enumerate() + .map(|(j, v)| vin_to_value(v, is_coinbase && j == 0)) + .collect(); + let vout: Vec = 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 { + tx.input.iter().any(|i| !i.witness.is_empty()) +} + +fn tx_is_rbf(tx: &Transaction) -> bool { + tx.input.iter().any(|i| i.sequence.is_rbf()) +} + +fn tx_total_out(tx: &Transaction) -> u64 { + tx.output.iter().map(|o| o.value.to_sat()).sum() +} + +fn subsidy_sats(height: u32) -> u64 { + let halvings = height / 210_000; + if halvings >= 64 { + 0 + } else { + (50 * 100_000_000u64) >> halvings + } +} + +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 { + if s.is_p2pkh() { + "p2pkh" + } else if s.is_p2sh() { + "p2sh" + } else if s.is_p2wpkh() { + "p2wpkh" + } else if s.is_p2wsh() { + "p2wsh" + } else if s.is_p2tr() { + "p2tr" + } else if s.is_op_return() { + "op_return" + } else if s.is_p2pk() { + "p2pk" + } else { + "unknown" + } +} + +fn address_value(s: &ScriptBuf) -> Value { + Address::from_script(s, Network::Bitcoin) + .map(|a| Value::String(a.to_string())) + .unwrap_or(Value::Null) +} diff --git a/crates/blk/src/formatter.rs b/crates/blk/src/formatter.rs new file mode 100644 index 000000000..925430511 --- /dev/null +++ b/crates/blk/src/formatter.rs @@ -0,0 +1,66 @@ +use brk_error::Result; +use serde_json::{Map, Value}; + +use crate::{fields::Ctx, mode::Mode, path::Path}; + +pub struct Formatter { + mode: Mode, + fields: Vec, +} + +impl Formatter { + pub fn new(mode: Mode, fields: Vec) -> Self { + Self { mode, fields } + } + + pub fn format(&self, ctx: &Ctx) -> Result { + match self.mode { + Mode::Bare => self.bare(ctx), + Mode::Tsv => self.tsv(ctx), + Mode::Json => Ok(serde_json::to_string(&self.object(ctx)?)?), + Mode::Pretty => Ok(serde_json::to_string_pretty(&self.object(ctx)?)?), + } + } + + fn bare(&self, ctx: &Ctx) -> Result { + let mut out = String::new(); + flatten(&ctx.resolve(&self.fields[0])?, &mut out); + Ok(out) + } + + fn tsv(&self, ctx: &Ctx) -> Result { + let mut row = String::new(); + for (i, path) in self.fields.iter().enumerate() { + if i > 0 { + row.push('\t'); + } + for c in ctx.resolve_str(path)?.chars() { + row.push(if matches!(c, '\t' | '\n' | '\r') { ' ' } else { c }); + } + } + Ok(row) + } + + fn object(&self, ctx: &Ctx) -> Result { + let mut obj = Map::with_capacity(self.fields.len()); + for path in &self.fields { + obj.insert(path.raw.clone(), ctx.resolve(path)?); + } + Ok(Value::Object(obj)) + } +} + +fn flatten(v: &Value, out: &mut String) { + match v { + Value::Array(arr) => arr.iter().for_each(|item| flatten(item, out)), + Value::String(s) => push_line(out, s), + other => push_line(out, &other.to_string()), + } +} + +fn push_line(out: &mut String, s: &str) { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(s); +} diff --git a/crates/blk/src/main.rs b/crates/blk/src/main.rs index f328e4d9d..15b485c2b 100644 --- a/crates/blk/src/main.rs +++ b/crates/blk/src/main.rs @@ -1 +1,52 @@ -fn main() {} +mod args; +mod fields; +mod formatter; +mod mode; +mod path; +mod selector; +mod usage; + +use std::process::ExitCode; + +use brk_error::Result; +use brk_reader::Reader; + +use args::Args; +use fields::Ctx; +use formatter::Formatter; +use mode::Mode; +use selector::Selector; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("blk: {e}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<()> { + let raw: Vec = std::env::args().skip(1).collect(); + if raw.is_empty() || raw.iter().any(|a| matches!(a.as_str(), "-h" | "--help")) { + usage::print(); + return Ok(()); + } + let args = Args::parse(raw)?; + + let client = args.rpc()?; + let (start, end) = Selector::parse(&args.selector, &client)?; + + let mode = Mode::pick(args.pretty, args.compact, args.paths.len()); + let reader = Reader::new(args.blocks_dir(), &client); + let formatter = Formatter::new(mode, args.paths); + for block in reader.range(start, end)?.iter() { + let block = block?; + let line = formatter.format(&Ctx::new(&block))?; + if !line.is_empty() { + println!("{line}"); + } + } + Ok(()) +} diff --git a/crates/blk/src/mode.rs b/crates/blk/src/mode.rs new file mode 100644 index 000000000..1d44addaf --- /dev/null +++ b/crates/blk/src/mode.rs @@ -0,0 +1,21 @@ +#[derive(Clone, Copy)] +pub enum Mode { + Bare, + Tsv, + Json, + Pretty, +} + +impl Mode { + pub fn pick(pretty: bool, compact: bool, n_fields: usize) -> Self { + if pretty { + Self::Pretty + } else if n_fields == 1 { + Self::Bare + } else if compact { + Self::Tsv + } else { + Self::Json + } + } +} diff --git a/crates/blk/src/path.rs b/crates/blk/src/path.rs new file mode 100644 index 000000000..976539c44 --- /dev/null +++ b/crates/blk/src/path.rs @@ -0,0 +1,40 @@ +use brk_error::{Error, Result}; + +pub struct Step { + pub name: String, + pub index: Option, +} + +pub struct Path { + pub raw: String, + pub steps: Vec, +} + +impl Path { + pub fn parse(s: &str) -> Result { + let parts: Vec<&str> = s.split('.').collect(); + let mut steps = Vec::new(); + let mut i = 0; + while i < parts.len() { + let name = parts[i]; + if name.is_empty() { + return Err(Error::Parse(format!("bad path '{s}': empty segment"))); + } + if name.parse::().is_ok() { + return Err(Error::Parse(format!( + "bad path '{s}': '{name}' must follow a field name" + ))); + } + let index = parts.get(i + 1).and_then(|p| p.parse::().ok()); + steps.push(Step { + name: name.to_string(), + index, + }); + i += if index.is_some() { 2 } else { 1 }; + } + Ok(Self { + raw: s.to_string(), + steps, + }) + } +} diff --git a/crates/blk/src/selector.rs b/crates/blk/src/selector.rs new file mode 100644 index 000000000..a620c49e4 --- /dev/null +++ b/crates/blk/src/selector.rs @@ -0,0 +1,40 @@ +use brk_error::{Error, Result}; +use brk_rpc::Client; +use brk_types::{CheckedSub, Height}; + +pub struct Selector; + +impl Selector { + pub fn parse(s: &str, client: &Client) -> Result<(Height, Height)> { + let (start, end) = match s.split_once("..") { + Some((a, b)) => (Self::endpoint(a, client)?, Self::endpoint(b, client)?), + None => { + let h = Self::endpoint(s, client)?; + (h, h) + } + }; + if end < start { + return Err(Error::Parse(format!("range end {end} before start {start}"))); + } + Ok((start, end)) + } + + fn endpoint(s: &str, client: &Client) -> Result { + if s == "tip" { + return client.get_last_height(); + } + if let Some(rest) = s.strip_prefix("tip-") { + let n: u32 = rest + .parse() + .map_err(|_| Error::Parse(format!("bad tip offset: {s}")))?; + let tip = client.get_last_height()?; + return tip + .checked_sub(n) + .ok_or_else(|| Error::Parse(format!("tip-{n} underflows genesis"))); + } + let n: u32 = s + .parse() + .map_err(|_| Error::Parse(format!("bad height: {s}")))?; + Ok(Height::new(n)) + } +} diff --git a/crates/blk/src/usage.rs b/crates/blk/src/usage.rs new file mode 100644 index 000000000..606fdb5ce --- /dev/null +++ b/crates/blk/src/usage.rs @@ -0,0 +1,155 @@ +use owo_colors::OwoColorize; + +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 FLAG_W: usize = 15; // longest flag: "--rpccookiefile" +const PH_W: usize = LABEL_W - FLAG_W - 1; // placeholder column width so flag+ph total = LABEL_W +const GAP: usize = 4; + +pub fn print() { + println!("{} - inspect a Bitcoin Core block", "blk".bold()); + println!(); + + section("USAGE"); + println!( + " blk {} {} [field ...] [OPTIONS]", + "".bright_black(), + "".bright_black() + ); + println!(); + + section("SELECTOR"); + sel("", "single height (e.g. 800000)"); + sel("tip", "current chain tip"); + sel("tip-N", "tip minus N"); + sel("a..b", "inclusive range, endpoints can be height/tip/tip-N"); + println!(); + + section("FIELDS"); + println!( + " {}", + "dotted paths drill into nested data; omit an index for arrays" + .bright_black() + ); + println!(); + group("block"); + fields(&[ + "height, hash, time, version, version_hex, bits, nonce,", + "prev, merkle, difficulty, txs, n_inputs, n_outputs,", + "witness_txs, size, strippedsize, weight, subsidy, coinbase,", + "header_hex, hex", + ]); + println!(); + group_note("tx.i", "omit i for all txs"); + fields(&[ + "txid, wtxid, version, locktime, size, base_size, vsize,", + "weight, inputs, outputs, is_coinbase, has_witness, is_rbf,", + "total_out, hex", + ]); + println!(); + group_note("tx.i.vin.j", "omit j for all inputs"); + fields(&[ + "prev_txid, prev_vout, sequence, script_sig, script_sig_asm,", + "witness, has_witness, is_rbf, coinbase", + ]); + println!(); + group_note("tx.i.vout.j", "omit j for all outputs"); + fields(&["value, script_pubkey, script_pubkey_asm, type, address"]); + println!(); + println!( + " {}", + "Naked tx / tx.i / vin / vout returns the whole sub-object as JSON." + .bright_black() + ); + println!(); + + section("OUTPUT"); + out("1 field", "bare value, one per line"); + out("2+ fields", "compact JSON object, one per line (NDJSON)"); + out("-p, --pretty", "pretty JSON object instead"); + out("-c, --compact", "tab-separated values, no field names (TSV)"); + println!(); + + section("OPTIONS"); + opt("--bitcoindir", "", "Bitcoin directory", Some("[OS default]")); + opt("--blocksdir", "", "Blocks directory", Some("[/blocks]")); + opt("--rpcconnect", "", "RPC host", Some("[localhost]")); + opt("--rpcport", "", "RPC port", Some("[8332]")); + opt("--rpccookiefile", "", "RPC cookie file", Some("[/.cookie]")); + opt("--rpcuser", "", "RPC username", None); + opt("--rpcpassword", "", "RPC password", None); + println!(); + + section("EXAMPLES"); + ex("blk 800000 hash", "bare hash"); + ex("blk 800000 height hash time", "one compact JSON line"); + ex("blk 800000 tx.0.txid", "coinbase txid"); + ex("blk 800000 tx.txid", "all txids in block (array)"); + ex("blk 800000 tx.0.vout.0.value", "coinbase output 0 sats"); + ex("blk 800000 tx.0.vout.value", "all output sats for tx 0"); + ex("blk 800000 tx.vout.value", "array of arrays (per tx)"); + ex("blk 0..2 hash tx.0.txid", "3 NDJSON lines"); + ex("blk tip tx.0", "whole coinbase tx as JSON"); +} + +fn section(name: &str) { + println!("{}", format!("{name}:").bold()); +} + +fn group(name: &str) { + println!(" {}", format!("{name}:").bold()); +} + +fn group_note(name: &str, note: &str) { + println!( + " {} {}", + format!("{name}:").bold(), + format!("({note})").bright_black() + ); +} + +fn fields(lines: &[&str]) { + for line in lines { + println!(" {line}"); + } +} + +fn pad(s: &str, width: usize) -> String { + " ".repeat(width.saturating_sub(s.len())) +} + +fn sel(token: &str, desc: &str) { + println!( + " {}{}{}{desc}", + token.bright_black(), + pad(token, SEL_W), + " ".repeat(GAP), + ); +} + +fn out(label: &str, desc: &str) { + println!(" {label}{}{}{desc}", pad(label, LABEL_W), " ".repeat(GAP)); +} + +fn opt(flag: &str, ph: &str, desc: &str, default: Option<&str>) { + let head = format!( + " {flag}{} {}{}{}", + pad(flag, FLAG_W), + ph.bright_black(), + pad(ph, PH_W), + " ".repeat(GAP), + ); + match default { + Some(d) => println!("{head}{desc} {}", d.bright_black()), + None => println!("{head}{desc}"), + } +} + +fn ex(cmd: &str, note: &str) { + println!( + " {cmd}{}{}{}", + pad(cmd, LABEL_W), + " ".repeat(GAP), + format!("# {note}").bright_black() + ); +} diff --git a/crates/brk_indexer/src/lengths.rs b/crates/brk_indexer/src/lengths.rs index 5b094b1ad..0f621d426 100644 --- a/crates/brk_indexer/src/lengths.rs +++ b/crates/brk_indexer/src/lengths.rs @@ -138,7 +138,7 @@ impl Lengths { } /// Read current local lengths. `None` pre-genesis. - pub fn from_local(vecs: &mut Vecs, stores: &Stores) -> Option { + pub fn from_local(vecs: &Vecs, stores: &Stores) -> Option { let height = vecs.next_height().min(stores.next_height()); Self::collect_at(height, vecs) } @@ -146,7 +146,7 @@ impl Lengths { /// Read lengths to resume at `required_height`. Reorg-aware: /// - if local is ahead, clamp down to `required_height`; /// - if local is behind, return `None` (caller must full-reset). - pub fn resume_at(required_height: Height, vecs: &mut Vecs, stores: &Stores) -> Option { + pub fn resume_at(required_height: Height, vecs: &Vecs, stores: &Stores) -> Option { let local = vecs.next_height().min(stores.next_height()); if local < required_height { return None; @@ -163,7 +163,7 @@ impl Lengths { Self::collect_at(height, vecs) } - fn collect_at(height: Height, vecs: &mut Vecs) -> Option { + fn collect_at(height: Height, vecs: &Vecs) -> Option { Some(Self { empty_output_index: next_index( &vecs.scripts.empty.first_index, diff --git a/crates/brk_indexer/src/lib.rs b/crates/brk_indexer/src/lib.rs index 0919ddc8d..e4690f72e 100644 --- a/crates/brk_indexer/src/lib.rs +++ b/crates/brk_indexer/src/lib.rs @@ -85,12 +85,17 @@ impl Indexer { let tip_blockhash = vecs.blocks.blockhash.collect_last().unwrap_or_default(); + let safe_lengths = SafeLengths::new(); + if let Some(lengths) = Lengths::from_local(&vecs, &stores) { + safe_lengths.advance(lengths); + } + Ok(Self { path: indexed_path.clone(), vecs, stores, tip_blockhash: Arc::new(RwLock::new(tip_blockhash)), - safe_lengths: SafeLengths::new(), + safe_lengths, }) }; @@ -157,7 +162,7 @@ impl Indexer { let (starting_lengths, prev_hash) = if let Some(hash) = last_blockhash { let (height, hash) = client.get_closest_valid_height(hash)?; - match Lengths::resume_at(height.incremented(), &mut self.vecs, &self.stores) { + match Lengths::resume_at(height.incremented(), &self.vecs, &self.stores) { Some(starting_lengths) => { if starting_lengths.height > client.get_last_height()? { info!("Up to date, nothing to index."); @@ -368,7 +373,7 @@ impl Indexer { /// bg ingest first so stores are queryable at the new bound. pub fn advance_safe_lengths(&mut self) -> Result<()> { self.vecs.db.sync_bg_tasks()?; - if let Some(lengths) = Lengths::from_local(&mut self.vecs, &self.stores) { + if let Some(lengths) = Lengths::from_local(&self.vecs, &self.stores) { self.safe_lengths.advance(lengths); } Ok(()) diff --git a/crates/brk_indexer/src/vecs/addrs.rs b/crates/brk_indexer/src/vecs/addrs.rs index 464b28d06..aacd535ed 100644 --- a/crates/brk_indexer/src/vecs/addrs.rs +++ b/crates/brk_indexer/src/vecs/addrs.rs @@ -199,6 +199,28 @@ impl AddrsVecs { .into_par_iter() } + pub fn iter_any(&self) -> impl Iterator { + [ + &self.p2pk65.first_index as &dyn AnyStoredVec, + &self.p2pk33.first_index, + &self.p2pkh.first_index, + &self.p2sh.first_index, + &self.p2wpkh.first_index, + &self.p2wsh.first_index, + &self.p2tr.first_index, + &self.p2a.first_index, + &self.p2pk65.bytes, + &self.p2pk33.bytes, + &self.p2pkh.bytes, + &self.p2sh.bytes, + &self.p2wpkh.bytes, + &self.p2wsh.bytes, + &self.p2tr.bytes, + &self.p2a.bytes, + ] + .into_iter() + } + /// Get address bytes by output type, using the cached VecReader for the specific address type. /// Returns None if the index doesn't exist yet. pub fn get_bytes_by_type( diff --git a/crates/brk_indexer/src/vecs/blocks.rs b/crates/brk_indexer/src/vecs/blocks.rs index c4df740a9..7b4c405ee 100644 --- a/crates/brk_indexer/src/vecs/blocks.rs +++ b/crates/brk_indexer/src/vecs/blocks.rs @@ -109,4 +109,20 @@ impl BlocksVecs { ] .into_par_iter() } + + pub fn iter_any(&self) -> impl Iterator { + [ + &self.blockhash.inner as &dyn AnyStoredVec, + &self.coinbase_tag, + &self.difficulty, + &self.timestamp.inner, + &self.total, + &self.weight, + &self.position, + &self.segwit_txs, + &self.segwit_size, + &self.segwit_weight, + ] + .into_iter() + } } diff --git a/crates/brk_indexer/src/vecs/inputs.rs b/crates/brk_indexer/src/vecs/inputs.rs index 8058f3da5..c07048c25 100644 --- a/crates/brk_indexer/src/vecs/inputs.rs +++ b/crates/brk_indexer/src/vecs/inputs.rs @@ -57,4 +57,15 @@ impl InputsVecs { ] .into_par_iter() } + + pub fn iter_any(&self) -> impl Iterator { + [ + &self.first_txin_index as &dyn AnyStoredVec, + &self.outpoint, + &self.tx_index, + &self.output_type, + &self.type_index, + ] + .into_iter() + } } diff --git a/crates/brk_indexer/src/vecs/mod.rs b/crates/brk_indexer/src/vecs/mod.rs index 52134a787..1ae97ee7e 100644 --- a/crates/brk_indexer/src/vecs/mod.rs +++ b/crates/brk_indexer/src/vecs/mod.rs @@ -126,8 +126,8 @@ impl Vecs { Ok(()) } - pub fn next_height(&mut self) -> Height { - self.par_iter_mut_any_stored_vec() + pub fn next_height(&self) -> Height { + self.iter_any_stored_vec() .map(|vec| { let h = Height::from(vec.stamp()); if h > Height::ZERO { h.incremented() } else { h } @@ -172,4 +172,14 @@ impl Vecs { .chain(self.addrs.par_iter_mut_any()) .chain(self.scripts.par_iter_mut_any()) } + + fn iter_any_stored_vec(&self) -> impl Iterator { + self.blocks + .iter_any() + .chain(self.transactions.iter_any()) + .chain(self.inputs.iter_any()) + .chain(self.outputs.iter_any()) + .chain(self.addrs.iter_any()) + .chain(self.scripts.iter_any()) + } } diff --git a/crates/brk_indexer/src/vecs/outputs.rs b/crates/brk_indexer/src/vecs/outputs.rs index 46d8510f9..cd56915e6 100644 --- a/crates/brk_indexer/src/vecs/outputs.rs +++ b/crates/brk_indexer/src/vecs/outputs.rs @@ -64,4 +64,15 @@ impl OutputsVecs { ] .into_par_iter() } + + pub fn iter_any(&self) -> impl Iterator { + [ + &self.first_txout_index as &dyn AnyStoredVec, + &self.value, + &self.output_type, + &self.type_index, + &self.tx_index, + ] + .into_iter() + } } diff --git a/crates/brk_indexer/src/vecs/scripts.rs b/crates/brk_indexer/src/vecs/scripts.rs index f013d1a0e..7459ec097 100644 --- a/crates/brk_indexer/src/vecs/scripts.rs +++ b/crates/brk_indexer/src/vecs/scripts.rs @@ -120,4 +120,18 @@ impl ScriptsVecs { ] .into_par_iter() } + + pub fn iter_any(&self) -> impl Iterator { + [ + &self.empty.first_index as &dyn AnyStoredVec, + &self.op_return.first_index, + &self.p2ms.first_index, + &self.unknown.first_index, + &self.empty.to_tx_index, + &self.op_return.to_tx_index, + &self.p2ms.to_tx_index, + &self.unknown.to_tx_index, + ] + .into_iter() + } } diff --git a/crates/brk_indexer/src/vecs/transactions.rs b/crates/brk_indexer/src/vecs/transactions.rs index 6f18362af..07e35db5a 100644 --- a/crates/brk_indexer/src/vecs/transactions.rs +++ b/crates/brk_indexer/src/vecs/transactions.rs @@ -142,4 +142,21 @@ impl TransactionsVecs { ] .into_par_iter() } + + pub fn iter_any(&self) -> impl Iterator { + [ + &self.first_tx_index as &dyn AnyStoredVec, + &self.txid, + &self.tx_version, + &self.raw_locktime, + &self.base_size, + &self.total_size, + &self.total_sigop_cost, + &self.is_explicitly_rbf, + &self.first_txin_index, + &self.first_txout_index, + &self.position, + ] + .into_iter() + } } diff --git a/crates/brk_mempool/.gitignore b/crates/brk_mempool/.gitignore new file mode 100644 index 000000000..88b06327a --- /dev/null +++ b/crates/brk_mempool/.gitignore @@ -0,0 +1,2 @@ +*.md +!README.md diff --git a/crates/brk_mempool/src/lib.rs b/crates/brk_mempool/src/lib.rs index 71c43a534..76dfd5ae0 100644 --- a/crates/brk_mempool/src/lib.rs +++ b/crates/brk_mempool/src/lib.rs @@ -96,14 +96,12 @@ impl Mempool { self.0.state.addrs.read().stats_hash(addr) } - /// Look up the mempool tx that spends `(txid, vout)`. Returns - /// `(spender_txid, vin)` if the outpoint is spent in the mempool, - /// `None` otherwise. The spender's input list is walked to rule - /// out a `TxidPrefix` collision before returning a match. + /// Mempool tx spending `(txid, vout)`, or `None`. The spender's + /// input list is walked to rule out `TxidPrefix` collisions. pub fn lookup_spender(&self, txid: &Txid, vout: Vout) -> Option<(Txid, Vin)> { let key = OutpointPrefix::new(TxidPrefix::from(txid), vout); - let txs = self.0.state.txs.read(); - let entries = self.0.state.entries.read(); + let txs = self.txs(); + let entries = self.entries(); let outpoint_spends = self.0.state.outpoint_spends.read(); let idx = outpoint_spends.get(&key)?; let spender_txid = entries.slot(idx)?.txid; @@ -168,9 +166,7 @@ impl Mempool { } /// Live mempool txs touching `addr`, newest first by `first_seen`, - /// capped at `limit`. Acquires `txs`, `addrs`, `entries` in canonical - /// order; returns owned `Transaction`s so the lock is released - /// before the caller does anything else with them. + /// capped at `limit`. Returns owned `Transaction`s. pub fn addr_txs(&self, addr: &AddrBytes, limit: usize) -> Vec { let txs = self.txs(); let addrs = self.addrs(); @@ -211,9 +207,8 @@ impl Mempool { f(&mut iter) } - /// Effective fee rate for a live mempool tx: the seed's chunk rate from - /// the latest snapshot, with fall-back to the entry's simple `fee/vsize` - /// when the snapshot doesn't yet contain it. + /// Effective fee rate for a live tx: seed's snapshot chunk rate, + /// falling back to the entry's `fee/vsize` if not yet in the snapshot. pub fn live_effective_fee_rate(&self, prefix: &TxidPrefix) -> Option { let entries = self.entries(); if let Some(seed_idx) = entries.idx_of(prefix) @@ -232,9 +227,9 @@ impl Mempool { } /// `first_seen` Unix-second timestamps for `txids`, in input order. - /// Returns 0 for unknown txids. `Vanished` graveyard tombstones fall - /// back to the buried entry's `first_seen` so a tx doesn't flicker - /// to 0 in the brief window between mempool drop and indexer catch-up. + /// Returns 0 for unknown txids. `Vanished` tombstones fall back to + /// the buried entry's `first_seen` to avoid flicker between drop + /// and indexer catch-up. pub fn transaction_times(&self, txids: &[Txid]) -> Vec { let entries = self.entries(); let graveyard = self.graveyard(); @@ -260,11 +255,8 @@ impl Mempool { } /// Variant of `start` that runs `after_update` after every cycle. - /// - /// `update` and `after_update` are wrapped in `catch_unwind` so an - /// unexpected panic in either step doesn't kill the loop and freeze - /// the mempool snapshot. `parking_lot` locks don't poison, so state - /// remains usable after a panic. + /// Both steps are wrapped in `catch_unwind` so a panic doesn't + /// freeze the snapshot; `parking_lot` locks don't poison. pub fn start_with(&self, mut after_update: impl FnMut()) { loop { let outcome = catch_unwind(AssertUnwindSafe(|| { @@ -288,9 +280,8 @@ impl Mempool { } /// Fill remaining `prevout == None` inputs via an external - /// resolver (typically the brk indexer for confirmed parents). - /// Same-cycle in-mempool parents are filled automatically by - /// `Resolver::resolve_in_mempool` after each `Applier::apply`. + /// resolver (typically the indexer for confirmed parents). + /// In-mempool parents are filled automatically each cycle. pub fn fill_prevouts(&self, resolver: F) -> bool where F: Fn(&Txid, Vout) -> Option, diff --git a/crates/brk_mempool/src/rbf.rs b/crates/brk_mempool/src/rbf.rs index 43d93b54b..d1ee77bb3 100644 --- a/crates/brk_mempool/src/rbf.rs +++ b/crates/brk_mempool/src/rbf.rs @@ -1,13 +1,7 @@ -//! RBF (Replace-By-Fee) tree extraction from the live mempool + -//! graveyard. -//! -//! Both methods return owned, lock-free `RbfNode` trees so the caller -//! (typically `brk_query`) can enrich each node with indexer-resident -//! data (`mined`, effective fee rate) after the mempool lock window -//! closes. Doing the enrichment under the lock would re-enter -//! `Mempool` indirectly via `effective_fee_rate` and recursively -//! acquire the same `entries`/`graveyard` read locks, which can -//! deadlock against a queued writer in `parking_lot`. +//! RBF tree extraction. Returns owned trees so the caller can enrich +//! with indexer data (`mined`, effective fee rate) after the lock +//! drops: enriching under the lock re-enters `Mempool` and would +//! recursively acquire the same read locks. use brk_types::{Sats, Timestamp, Transaction, Txid, TxidPrefix, VSize}; use rustc_hash::FxHashSet; @@ -17,15 +11,11 @@ use crate::{ stores::{EntryPool, TxGraveyard}, }; -/// One node in an RBF replacement tree, populated entirely from -/// mempool state. The caller layers on `mined` and effective fee rate -/// after the lock has been released. #[derive(Debug, Clone)] pub struct RbfNode { pub txid: Txid, pub fee: Sats, pub vsize: VSize, - /// Sum of the tx's output amounts. pub value: Sats, pub first_seen: Timestamp, /// BIP-125 signaling: at least one input has sequence < 0xffffffff-1. @@ -35,21 +25,18 @@ pub struct RbfNode { pub replaces: Vec, } -/// Result of [`Mempool::rbf_for_tx`]. #[derive(Debug, Clone, Default)] pub struct RbfForTx { - /// Tree rooted at the requested tx's terminal replacer. `None` if - /// the tx is unknown to both the live pool and the graveyard. + /// Tree rooted at the terminal replacer. `None` if `txid` is unknown. pub root: Option, /// Direct predecessors of the requested tx (txids only). pub replaces: Vec, } impl Mempool { - /// Build the RBF tree relevant to `txid`: walk forward through - /// `Replaced { by }` links to the terminal replacer, return its - /// full predecessor tree, plus the requested tx's own direct - /// predecessors. Single read-lock window in canonical order. + /// Walk forward through `Replaced { by }` to the terminal replacer + /// and return its full predecessor tree, plus the requested tx's + /// direct predecessors. Single read-lock window in canonical order. pub fn rbf_for_tx(&self, txid: &Txid) -> RbfForTx { let txs = self.txs(); let entries = self.entries(); @@ -61,10 +48,9 @@ impl Mempool { RbfForTx { root, replaces } } - /// Recent terminal-replacer trees, most-recent replacement first, - /// deduplicated by tree root, capped at `limit`. When - /// `full_rbf_only` is true, drops trees with no non-signaling - /// predecessor anywhere. + /// Recent terminal-replacer trees, most-recent first, deduplicated + /// by root, capped at `limit`. `full_rbf_only` drops trees with no + /// non-signaling predecessor. pub fn recent_rbf_trees(&self, full_rbf_only: bool, limit: usize) -> Vec { let txs = self.txs(); let entries = self.entries(); diff --git a/crates/brk_mempool/src/stats.rs b/crates/brk_mempool/src/stats.rs index c73fec71a..7838aa95c 100644 --- a/crates/brk_mempool/src/stats.rs +++ b/crates/brk_mempool/src/stats.rs @@ -17,8 +17,6 @@ pub struct MempoolStats { } impl From<&Mempool> for MempoolStats { - /// Acquires every sub-lock in canonical order to build a coherent - /// snapshot. Cheap; locks are released as soon as the counts are read. fn from(mempool: &Mempool) -> Self { let state = mempool.state(); let info = state.info.read(); diff --git a/crates/brk_mempool/src/steps/applier.rs b/crates/brk_mempool/src/steps/applier.rs index 2655b7e65..58bdffa1d 100644 --- a/crates/brk_mempool/src/steps/applier.rs +++ b/crates/brk_mempool/src/steps/applier.rs @@ -37,17 +37,10 @@ impl Applier { return; }; if !s.txs.contains(&txid) { - // entries had this prefix but txs didn't — a state divergence - // that should be impossible: publish/bury both touch them - // together under one write_all guard. Reaching this branch - // means a prior cycle left the two stores out of sync (e.g. - // panic mid-publish before `txs.extend` ran). Skip the bury - // entirely: freeing the entries slot here would let - // outpoint_spends point at a slot the next insert recycles - // for an unrelated tx. - warn!( - "mempool bury: entry present but tx missing for txid={txid} - skipping bury to preserve outpoint_spends integrity" - ); + // Skip bury on entries/txs divergence: freeing the slot here + // would let outpoint_spends point at a slot the next insert + // recycles for an unrelated tx. + warn!("mempool bury: entry present but tx missing for txid={txid}"); return; } let (idx, entry) = s.entries.remove(prefix).expect("entry present"); diff --git a/crates/brk_mempool/src/steps/rebuilder/mod.rs b/crates/brk_mempool/src/steps/rebuilder/mod.rs index 3e12613cf..d4a5770d8 100644 --- a/crates/brk_mempool/src/steps/rebuilder/mod.rs +++ b/crates/brk_mempool/src/steps/rebuilder/mod.rs @@ -89,10 +89,9 @@ impl Rebuilder { } } - /// Returns true iff dirty and the throttle window has elapsed. On - /// success, starts a new throttle window. The dirty bit is cleared - /// by `tick` only after `publish` returns, so a panic in - /// `build_snapshot` leaves dirty set and the next cycle retries. + /// True iff dirty and the throttle window has elapsed. The dirty + /// bit is cleared in `tick` only after `publish` returns, so a + /// panic in `build_snapshot` retries on the next cycle. fn try_claim_rebuild(&self) -> bool { if !self.dirty.load(Ordering::Acquire) { self.skip_clean.fetch_add(1, Ordering::Relaxed); diff --git a/crates/brk_mempool/src/stores/state.rs b/crates/brk_mempool/src/stores/state.rs index a783ed96b..78a2953c0 100644 --- a/crates/brk_mempool/src/stores/state.rs +++ b/crates/brk_mempool/src/stores/state.rs @@ -3,23 +3,11 @@ use parking_lot::{RwLock, RwLockWriteGuard}; use super::{AddrTracker, EntryPool, OutpointSpends, TxGraveyard, TxStore}; -/// The six buckets making up live mempool state. -/// -/// Each bucket has its own `RwLock` so readers of different buckets -/// don't contend with each other. Any code that takes more than one -/// lock must follow the canonical partial order -/// `info → txs → addrs → entries → outpoint_spends → graveyard`, -/// otherwise a reader-holds-A-wants-B / writer-holds-B-wants-A -/// circular wait can deadlock. The Applier takes all six write locks -/// in this order for a brief window once per cycle via -/// [`MempoolState::write_all`]; multi-lock readers inside the crate -/// take a (canonical-order) subset inline. -/// -/// This discipline is *internal* to `brk_mempool`: external crates -/// only see `Mempool` methods that bundle each multi-lock operation -/// behind a single call (e.g. `Mempool::lookup_spender`, -/// `Mempool::addr_txs`, `Mempool::rbf_for_tx`), so callers can never -/// take the order wrong because they don't get to choose. +/// The six buckets making up live mempool state. Each has its own +/// `RwLock`. Multi-lock code must follow the canonical order +/// `info → txs → addrs → entries → outpoint_spends → graveyard` to +/// avoid circular waits. External callers go through bundled +/// `Mempool` methods so they can't take the order wrong. #[derive(Default)] pub struct MempoolState { pub(crate) info: RwLock, diff --git a/crates/brk_query/src/impl/block/timestamp.rs b/crates/brk_query/src/impl/block/timestamp.rs index ed1cda422..74a5624da 100644 --- a/crates/brk_query/src/impl/block/timestamp.rs +++ b/crates/brk_query/src/impl/block/timestamp.rs @@ -48,7 +48,9 @@ impl Query { for h in start..=tip { let block_ts = ts_cursor.get(h).data()?; if block_ts <= target { - best = Some((h, block_ts)); + if best.is_none_or(|(_, bts)| block_ts > bts) { + best = Some((h, block_ts)); + } above_streak = 0; } else { above_streak += 1; @@ -63,12 +65,15 @@ impl Query { for h in (0..start).rev() { let block_ts = ts_cursor.get(h).data()?; if block_ts <= target { - best = Some((h, block_ts)); - break; - } - above_streak += 1; - if above_streak >= MTP_TERMINAL_STREAK { - break; + if best.is_none_or(|(_, bts)| block_ts > bts) { + best = Some((h, block_ts)); + } + above_streak = 0; + } else { + above_streak += 1; + if above_streak >= MTP_TERMINAL_STREAK { + break; + } } } } diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index 3dd6f6aea..898bb21bb 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -50,14 +50,8 @@ impl Query { } /// Fill any `prevout == None` inputs on live mempool txs from the - /// indexer, mutating them in place. Cheap when the unresolved set - /// is empty (the steady-state with `-txindex` on); otherwise resolves - /// each missing prevout via the same lookup chain used for confirmed - /// txs: `txid → tx_index → first_txout_index + vout → output_type - /// / type_index / value → script_pubkey`. - /// - /// Driver calls this once per cycle, right after `mempool.update()`. - /// Returns true if at least one prevout was filled. + /// indexer. Driver calls this once per cycle right after + /// `mempool.update()`. Returns true if at least one was filled. pub fn fill_mempool_prevouts(&self) -> bool { let Some(mempool) = self.mempool() else { return false; @@ -101,12 +95,10 @@ impl Query { Ok(self.require_mempool()?.recent_txs()) } - /// RBF history for a tx, matching mempool.space's - /// `GET /api/v1/tx/:txid/rbf`. The mempool builds the owned - /// replacement tree (terminal replacer + recursive predecessors) - /// under one read-lock window; this method then enriches each node - /// with `mined` + effective fee rate, both of which need the - /// indexer/computer. + /// RBF history for a tx. Matches mempool.space's + /// `GET /api/v1/tx/:txid/rbf`. Mempool builds the owned tree under + /// one read-lock window; this then layers on `mined` + effective + /// fee rate from the indexer/computer. pub fn tx_rbf(&self, txid: &Txid) -> Result { let RbfForTx { root, replaces } = self.require_mempool()?.rbf_for_tx(txid); let replacements = root.map(|n| self.enrich_rbf_node(n, None)); @@ -117,14 +109,10 @@ impl Query { }) } - /// Recent RBF replacements across the whole mempool, matching - /// mempool.space's `GET /api/v1/replacements` and - /// `GET /api/v1/fullrbf/replacements`. Each entry is a complete - /// replacement tree rooted at the terminal replacer; same shape as - /// `tx_rbf().replacements`. Ordered by most-recent replacement - /// event first and capped at 25 entries. When `full_rbf_only` is - /// true, only trees with at least one non-signaling predecessor - /// are returned. + /// Recent RBF replacements. Matches mempool.space's + /// `GET /api/v1/replacements` and `GET /api/v1/fullrbf/replacements`. + /// Most-recent first, capped at 25. `full_rbf_only` keeps only + /// trees with at least one non-signaling predecessor. pub fn recent_replacements(&self, full_rbf_only: bool) -> Result> { Ok(self .require_mempool()? @@ -134,10 +122,9 @@ impl Query { .collect()) } - /// Layer indexer-resident data (`mined`, effective fee rate) onto - /// a `RbfNode` tree. Runs after the mempool lock window has closed - /// because `effective_fee_rate` re-enters `Mempool` and would - /// recursively acquire the same read locks otherwise. + /// Layer `mined` + effective fee rate onto an `RbfNode` tree. + /// Must run after the mempool lock has dropped (effective_fee_rate + /// re-enters Mempool). fn enrich_rbf_node( &self, node: RbfNode, @@ -176,18 +163,14 @@ impl Query { } } - /// `first_seen` Unix-second timestamps for each txid, matching - /// mempool.space's `POST /api/v1/transaction-times`. Returns 0 for - /// unknown txids, in input order. + /// `first_seen` Unix-second timestamps. Matches mempool.space's + /// `POST /api/v1/transaction-times`. Returns 0 for unknowns. pub fn transaction_times(&self, txids: &[Txid]) -> Result> { Ok(self.require_mempool()?.transaction_times(txids)) } - /// Opaque content hash that changes whenever the projected next - /// block changes. Same value used as the mempool ETag, surfaced as - /// JSON so external monitors can detect a frozen update loop by - /// polling: if the value doesn't change for tens of seconds on a - /// live network, the mempool sync has stalled. + /// Content hash of the projected next block. Same value as the + /// mempool ETag. Polling lets monitors detect a stalled sync. pub fn mempool_hash(&self) -> Result { Ok(self.require_mempool()?.next_block_hash()) } diff --git a/crates/brk_query/src/impl/tx.rs b/crates/brk_query/src/impl/tx.rs index c8dc68179..69b9e9ac4 100644 --- a/crates/brk_query/src/impl/tx.rs +++ b/crates/brk_query/src/impl/tx.rs @@ -97,13 +97,10 @@ impl Query { // ── Transaction queries ──────────────────────────────────────── - /// Resolve a tx body across the three sources in order: live mempool, - /// indexer (via `indexed`), then `Vanished` graveyard tombstone. - /// The graveyard fallback only fires when the indexer reports - /// `UnknownTxid`, covering the brief race where a mined tx has been - /// buried by `Applier` but `safe_lengths.tx_index` has not yet - /// advanced to cover it. `Replaced` tombstones are excluded — those - /// txs will never confirm. + /// Resolve a tx body: live mempool → indexer → `Vanished` tombstone. + /// The tombstone fallback covers the race where a mined tx has been + /// buried but `safe_lengths.tx_index` hasn't caught up. `Replaced` + /// tombstones are excluded since they will never confirm. fn lookup_tx( &self, txid: &Txid,