diff --git a/Cargo.lock b/Cargo.lock index 3af61f7a6..05ddf4d8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,9 +228,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.8" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" dependencies = [ "base58ck", "base64 0.21.7", @@ -788,9 +788,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" @@ -1290,6 +1290,12 @@ dependencies = [ "spin", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "font-kit" version = "0.14.3" @@ -1420,10 +1426,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.12.0" @@ -1463,6 +1482,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1682,6 +1710,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1832,9 +1866,9 @@ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1859,6 +1893,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lexopt" version = "0.3.2" @@ -2071,12 +2111,13 @@ dependencies = [ [[package]] name = "oas3" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ed0821ab10d7703415a06df039c2493f3a7667999d8b4e104731de0c53796f" +checksum = "da5a5aa72eddcc53edfd06f287a2c10f872d88e8b72c650234cd8a227572424a" dependencies = [ "derive_more", "http", + "indexmap", "log", "once_cell", "regex", @@ -2274,6 +2315,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2317,6 +2368,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand_core" version = "0.6.4" @@ -2863,7 +2920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3033,9 +3090,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.9" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "async-compression", "bitflags 2.11.1", @@ -3236,9 +3293,9 @@ checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" [[package]] name = "vecdb" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca57cedd42c0c7d8a343c06ab9c311be28a731e5d1e4101ef671d9a9af409a8" +checksum = "b66ff235ce524e97c0d2a8e386fe842b6939016b90ed732844cd91e0337bd5e1" dependencies = [ "itoa", "libc", @@ -3295,14 +3352,23 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -3313,9 +3379,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3323,9 +3389,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -3336,18 +3402,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] [[package]] -name = "web-sys" -version = "0.3.97" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -3555,12 +3655,100 @@ dependencies = [ "winapi", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index f37430f42..7a976bc9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ debug = true [workspace.dependencies] aide = { version = "0.16.0-alpha.4", features = ["axum-json", "axum-query"] } axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] } -bitcoin = { version = "0.32.8", features = ["serde"] } +bitcoin = { version = "0.32.9", features = ["serde"] } brk_alloc = { version = "0.3.0-beta.7", path = "crates/brk_alloc" } brk_bencher = { version = "0.3.0-beta.7", path = "crates/brk_bencher" } brk_bindgen = { version = "0.3.0-beta.7", path = "crates/brk_bindgen" } @@ -82,11 +82,11 @@ serde_derive = "1.0.228" serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order"] } smallvec = "1.15.1" tokio = { version = "1.52.2", features = ["rt-multi-thread"] } -tower-http = { version = "0.6.9", 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" tracing = { version = "0.1", default-features = false, features = ["std"] } ureq = { version = "3.3.0", features = ["json"] } -vecdb = { version = "=0.10.2", features = ["derive", "serde_json", "pco", "schemars"] } +vecdb = { version = "=0.10.3", features = ["derive", "serde_json", "pco", "schemars"] } # vecdb = { path = "../anydb/crates/vecdb", features = ["derive", "serde_json", "pco", "schemars"] } [workspace.metadata.release] diff --git a/crates/blk/src/args.rs b/crates/blk/src/args.rs index cd9b79cdf..1e5a47c9f 100644 --- a/crates/blk/src/args.rs +++ b/crates/blk/src/args.rs @@ -46,9 +46,8 @@ impl Args { 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")) - })?, + iter.next() + .ok_or_else(|| Error::Parse(format!("--{rest} requires a value")))?, ), }; match key.as_str() { @@ -75,11 +74,6 @@ impl Args { .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, @@ -117,9 +111,7 @@ impl Args { .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()) - { + } 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( diff --git a/crates/blk/src/fields.rs b/crates/blk/src/fields.rs index a0c2fa4d2..844dce093 100644 --- a/crates/blk/src/fields.rs +++ b/crates/blk/src/fields.rs @@ -74,6 +74,40 @@ impl<'a> Ctx<'a> { }) } + pub fn full(&self) -> Value { + let b = self.block; + let (size, weight) = self.size_and_weight(); + let tx: Vec = 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::(), + "n_outputs": b.txdata.iter().map(|t| t.output.len()).sum::(), + "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 diff --git a/crates/blk/src/formatter.rs b/crates/blk/src/formatter.rs index 73e7b289b..d21302640 100644 --- a/crates/blk/src/formatter.rs +++ b/crates/blk/src/formatter.rs @@ -36,13 +36,20 @@ impl Formatter { row.push('\t'); } for c in ctx.resolve_str(path)?.chars() { - row.push(if matches!(c, '\t' | '\n' | '\r') { ' ' } else { c }); + row.push(if matches!(c, '\t' | '\n' | '\r') { + ' ' + } else { + c + }); } } Ok(row) } fn object(&self, ctx: &Ctx) -> Result { + if self.fields.is_empty() { + return Ok(ctx.full()); + } let mut obj = Map::with_capacity(self.fields.len()); for path in &self.fields { obj.insert(path.raw.clone(), ctx.resolve(path)?); @@ -50,4 +57,3 @@ impl Formatter { Ok(Value::Object(obj)) } } - diff --git a/crates/blk/src/main.rs b/crates/blk/src/main.rs index 15b485c2b..973ed7437 100644 --- a/crates/blk/src/main.rs +++ b/crates/blk/src/main.rs @@ -41,7 +41,11 @@ fn run() -> Result<()> { 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 parser_threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2) + / 2; + for block in reader.range_with(start, end, parser_threads)?.iter() { let block = block?; let line = formatter.format(&Ctx::new(&block))?; if !line.is_empty() { diff --git a/crates/blk/src/mode.rs b/crates/blk/src/mode.rs index 1d44addaf..3c7c982ca 100644 --- a/crates/blk/src/mode.rs +++ b/crates/blk/src/mode.rs @@ -10,6 +10,8 @@ impl Mode { pub fn pick(pretty: bool, compact: bool, n_fields: usize) -> Self { if pretty { Self::Pretty + } else if n_fields == 0 { + Self::Json } else if n_fields == 1 { Self::Bare } else if compact { diff --git a/crates/blk/src/selector.rs b/crates/blk/src/selector.rs index a620c49e4..fa62f96b5 100644 --- a/crates/blk/src/selector.rs +++ b/crates/blk/src/selector.rs @@ -14,7 +14,9 @@ impl Selector { } }; if end < start { - return Err(Error::Parse(format!("range end {end} before start {start}"))); + return Err(Error::Parse(format!( + "range end {end} before start {start}" + ))); } Ok((start, end)) } diff --git a/crates/blk/src/usage.rs b/crates/blk/src/usage.rs index 606fdb5ce..c085a21ee 100644 --- a/crates/blk/src/usage.rs +++ b/crates/blk/src/usage.rs @@ -12,10 +12,14 @@ pub fn print() { section("USAGE"); println!( - " blk {} {} [field ...] [OPTIONS]", + " blk {} [{} ...] [OPTIONS]", "".bright_black(), "".bright_black() ); + println!( + " {}", + "no fields = full block as JSON (analog of `bitcoin-cli getblock 2`)".bright_black() + ); println!(); section("SELECTOR"); @@ -28,8 +32,7 @@ pub fn print() { section("FIELDS"); println!( " {}", - "dotted paths drill into nested data; omit an index for arrays" - .bright_black() + "dotted paths drill into nested data; omit an index for arrays".bright_black() ); println!(); group("block"); @@ -58,29 +61,48 @@ pub fn print() { println!(); println!( " {}", - "Naked tx / tx.i / vin / vout returns the whole sub-object as JSON." - .bright_black() + "Naked tx / tx.i / vin / vout returns the whole sub-object as JSON.".bright_black() ); println!(); section("OUTPUT"); + out("no fields", "full block JSON object, one per line (NDJSON)"); 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)"); + 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( + "--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( + "--rpccookiefile", + "", + "RPC cookie file", + Some("[/.cookie]"), + ); opt("--rpcuser", "", "RPC username", None); opt("--rpcpassword", "", "RPC password", None); println!(); section("EXAMPLES"); + ex("blk 800000", "full block as JSON"); ex("blk 800000 hash", "bare hash"); ex("blk 800000 height hash time", "one compact JSON line"); ex("blk 800000 tx.0.txid", "coinbase txid"); @@ -128,7 +150,11 @@ fn sel(token: &str, desc: &str) { } fn out(label: &str, desc: &str) { - println!(" {label}{}{}{desc}", pad(label, LABEL_W), " ".repeat(GAP)); + println!( + " {label}{}{}{desc}", + pad(label, LABEL_W), + " ".repeat(GAP) + ); } fn opt(flag: &str, ph: &str, desc: &str, default: Option<&str>) { diff --git a/crates/brk_bindgen/Cargo.toml b/crates/brk_bindgen/Cargo.toml index c69ef9cb8..2ea258f82 100644 --- a/crates/brk_bindgen/Cargo.toml +++ b/crates/brk_bindgen/Cargo.toml @@ -12,6 +12,6 @@ brk_cohort = { workspace = true } brk_query = { workspace = true } brk_types = { workspace = true } indexmap = { workspace = true } -oas3 = "0.21" +oas3 = "0.22" serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/brk_bindgen/src/openapi/mod.rs b/crates/brk_bindgen/src/openapi/mod.rs index 088e8856d..d5b1a0f63 100644 --- a/crates/brk_bindgen/src/openapi/mod.rs +++ b/crates/brk_bindgen/src/openapi/mod.rs @@ -218,12 +218,7 @@ fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec { - ref_to_type_name(ref_path).map(|s| s.to_string()) - } - ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema), - }) + .and_then(schema_type_from_schema) .unwrap_or_else(|| "string".to_string()); Some(Parameter { name: param.name.clone(), @@ -269,10 +264,7 @@ fn extract_response_kind(operation: &Operation, spec: &Spec) -> ResponseKind { } fn schema_name_from_content(content: &oas3::spec::MediaType) -> Option { - match content.schema.as_ref()? { - ObjectOrReference::Ref { ref_path, .. } => Some(ref_to_type_name(ref_path)?.to_string()), - ObjectOrReference::Object(schema) => schema_to_type_name(schema), - } + schema_type_from_schema(content.schema.as_ref()?) } /// Resolves `name` against `components.schemas` and reports whether the @@ -281,7 +273,10 @@ fn is_numeric_schema(spec: &Spec, name: &str) -> bool { let Some(components) = spec.components.as_ref() else { return false; }; - let Some(ObjectOrReference::Object(schema)) = components.schemas.get(name) else { + let Some(Schema::Object(obj_or_ref)) = components.schemas.get(name) else { + return false; + }; + let ObjectOrReference::Object(schema) = obj_or_ref.as_ref() else { return false; }; matches!( @@ -333,19 +328,21 @@ fn schema_to_type_name(schema: &ObjectSchema) -> Option { let types: Vec = variants .iter() .filter_map(|v| match v { - ObjectOrReference::Ref { ref_path, .. } => { - ref_to_type_name(ref_path).map(|s| s.to_string()) - } - ObjectOrReference::Object(obj) => { - // Skip null variants - if matches!( - obj.schema_type.as_ref(), - Some(SchemaTypeSet::Single(SchemaType::Null)) - ) { - return None; + Schema::Boolean(_) => None, + Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() { + ObjectOrReference::Ref { ref_path, .. } => { + ref_to_type_name(ref_path).map(|s| s.to_string()) } - schema_to_type_name(obj) - } + ObjectOrReference::Object(obj) => { + if matches!( + obj.schema_type.as_ref(), + Some(SchemaTypeSet::Single(SchemaType::Null)) + ) { + return None; + } + schema_to_type_name(obj) + } + }, }) .collect(); diff --git a/crates/brk_cli/src/main.rs b/crates/brk_cli/src/main.rs index 1bbda9d94..67e2da904 100644 --- a/crates/brk_cli/src/main.rs +++ b/crates/brk_cli/src/main.rs @@ -63,11 +63,9 @@ pub fn main() -> anyhow::Result<()> { let query = AsyncQuery::build(&reader, &indexer, &computer, Some(mempool.clone())); let mempool_clone = mempool.clone(); - let query_clone = query.clone(); + let resolver = query.sync(|q| q.indexer_prevout_resolver()); thread::spawn(move || { - mempool_clone.start_with(|| { - query_clone.sync(|q| q.fill_mempool_prevouts()); - }); + mempool_clone.start_with(resolver); }); let server_config = ServerConfig { diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index 1a32a3a82..ed468153b 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -9007,273 +9007,40 @@ impl BrkClient { )) } - /// Compact OpenAPI specification + /// Health check /// - /// Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. + /// Returns the health status of the API server, including uptime information. /// - /// Endpoint: `GET /api.json` - pub fn get_api(&self) -> Result { - self.base.get_json(&format!("/api.json")) + /// Endpoint: `GET /health` + pub fn get_health(&self) -> Result { + self.base.get_json(&format!("/health")) } - /// Address information + /// API version /// - /// Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + /// Returns the current version of the API server /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* - /// - /// Endpoint: `GET /api/address/{address}` - pub fn get_address(&self, address: Addr) -> Result { - self.base.get_json(&format!("/api/address/{address}")) + /// Endpoint: `GET /version` + pub fn get_version(&self) -> Result { + self.base.get_json(&format!("/version")) } - /// Address transactions + /// Sync status /// - /// Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. + /// Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* - /// - /// Endpoint: `GET /api/address/{address}/txs` - pub fn get_address_txs(&self, address: Addr) -> Result> { - self.base.get_json(&format!("/api/address/{address}/txs")) + /// Endpoint: `GET /api/server/sync` + pub fn get_sync_status(&self) -> Result { + self.base.get_json(&format!("/api/server/sync")) } - /// Address confirmed transactions + /// Disk usage /// - /// Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. + /// Returns the disk space used by BRK and Bitcoin data. /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* - /// - /// Endpoint: `GET /api/address/{address}/txs/chain` - pub fn get_address_confirmed_txs(&self, address: Addr) -> Result> { - self.base.get_json(&format!("/api/address/{address}/txs/chain")) - } - - /// Address confirmed transactions (paginated) - /// - /// Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* - /// - /// Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}` - pub fn get_address_confirmed_txs_after(&self, address: Addr, after_txid: Txid) -> Result> { - self.base.get_json(&format!("/api/address/{address}/txs/chain/{after_txid}")) - } - - /// Address mempool transactions - /// - /// Get unconfirmed transactions for an address from the mempool, newest first (up to 50). - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* - /// - /// Endpoint: `GET /api/address/{address}/txs/mempool` - pub fn get_address_mempool_txs(&self, address: Addr) -> Result> { - self.base.get_json(&format!("/api/address/{address}/txs/mempool")) - } - - /// Address UTXOs - /// - /// Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* - /// - /// Endpoint: `GET /api/address/{address}/utxo` - pub fn get_address_utxos(&self, address: Addr) -> Result> { - self.base.get_json(&format!("/api/address/{address}/utxo")) - } - - /// Block hash by height - /// - /// Retrieve the block hash at a given height. Returns the hash as plain text. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* - /// - /// Endpoint: `GET /api/block-height/{height}` - pub fn get_block_by_height(&self, height: Height) -> Result { - self.base.get_text(&format!("/api/block-height/{height}")) - } - - /// Block information - /// - /// Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* - /// - /// Endpoint: `GET /api/block/{hash}` - pub fn get_block(&self, hash: BlockHash) -> Result { - self.base.get_json(&format!("/api/block/{hash}")) - } - - /// Block header - /// - /// Returns the hex-encoded 80-byte block header. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* - /// - /// Endpoint: `GET /api/block/{hash}/header` - pub fn get_block_header(&self, hash: BlockHash) -> Result { - self.base.get_text(&format!("/api/block/{hash}/header")) - } - - /// Raw block - /// - /// Returns the raw block data in binary format. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* - /// - /// Endpoint: `GET /api/block/{hash}/raw` - pub fn get_block_raw(&self, hash: BlockHash) -> Result> { - self.base.get_bytes(&format!("/api/block/{hash}/raw")) - } - - /// Block status - /// - /// Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* - /// - /// Endpoint: `GET /api/block/{hash}/status` - pub fn get_block_status(&self, hash: BlockHash) -> Result { - self.base.get_json(&format!("/api/block/{hash}/status")) - } - - /// Transaction ID at index - /// - /// Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* - /// - /// Endpoint: `GET /api/block/{hash}/txid/{index}` - pub fn get_block_txid(&self, hash: BlockHash, index: BlockTxIndex) -> Result { - self.base.get_text(&format!("/api/block/{hash}/txid/{index}")) - } - - /// Block transaction IDs - /// - /// Retrieve all transaction IDs in a block. Returns an array of txids in block order. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* - /// - /// Endpoint: `GET /api/block/{hash}/txids` - pub fn get_block_txids(&self, hash: BlockHash) -> Result> { - self.base.get_json(&format!("/api/block/{hash}/txids")) - } - - /// Block transactions - /// - /// Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* - /// - /// Endpoint: `GET /api/block/{hash}/txs` - pub fn get_block_txs(&self, hash: BlockHash) -> Result> { - self.base.get_json(&format!("/api/block/{hash}/txs")) - } - - /// Block transactions (paginated) - /// - /// Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* - /// - /// Endpoint: `GET /api/block/{hash}/txs/{start_index}` - pub fn get_block_txs_from_index(&self, hash: BlockHash, start_index: BlockTxIndex) -> Result> { - self.base.get_json(&format!("/api/block/{hash}/txs/{start_index}")) - } - - /// Recent blocks - /// - /// Retrieve the last 10 blocks. Returns block metadata for each block. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* - /// - /// Endpoint: `GET /api/blocks` - pub fn get_blocks(&self) -> Result> { - self.base.get_json(&format!("/api/blocks")) - } - - /// Block tip hash - /// - /// Returns the hash of the last block. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* - /// - /// Endpoint: `GET /api/blocks/tip/hash` - pub fn get_block_tip_hash(&self) -> Result { - self.base.get_text(&format!("/api/blocks/tip/hash")) - } - - /// Block tip height - /// - /// Returns the height of the last block. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* - /// - /// Endpoint: `GET /api/blocks/tip/height` - pub fn get_block_tip_height(&self) -> Result { - self.base.get_text(&format!("/api/blocks/tip/height")) - } - - /// Blocks from height - /// - /// Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* - /// - /// Endpoint: `GET /api/blocks/{height}` - pub fn get_blocks_from_height(&self, height: Height) -> Result> { - self.base.get_json(&format!("/api/blocks/{height}")) - } - - /// Mempool statistics - /// - /// Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* - /// - /// Endpoint: `GET /api/mempool` - pub fn get_mempool(&self) -> Result { - self.base.get_json(&format!("/api/mempool")) - } - - /// Mempool content hash - /// - /// Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. - /// - /// Endpoint: `GET /api/mempool/hash` - pub fn get_mempool_hash(&self) -> Result { - self.base.get_json(&format!("/api/mempool/hash")) - } - - /// Live BTC/USD price - /// - /// Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. - /// - /// Endpoint: `GET /api/mempool/price` - pub fn get_live_price(&self) -> Result { - self.base.get_json(&format!("/api/mempool/price")) - } - - /// Recent mempool transactions - /// - /// Get the last 10 transactions to enter the mempool. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* - /// - /// Endpoint: `GET /api/mempool/recent` - pub fn get_mempool_recent(&self) -> Result> { - self.base.get_json(&format!("/api/mempool/recent")) - } - - /// Mempool transaction IDs - /// - /// Get all transaction IDs currently in the mempool. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* - /// - /// Endpoint: `GET /api/mempool/txids` - pub fn get_mempool_txids(&self) -> Result> { - self.base.get_json(&format!("/api/mempool/txids")) + /// Endpoint: `GET /api/server/disk` + pub fn get_disk_usage(&self) -> Result { + self.base.get_json(&format!("/api/server/disk")) } /// Series catalog @@ -9285,28 +9052,6 @@ impl BrkClient { self.base.get_json(&format!("/api/series")) } - /// Bulk series data - /// - /// Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. - /// - /// Endpoint: `GET /api/series/bulk` - pub fn get_series_bulk(&self, series: SeriesList, index: Index, start: Option, end: Option, limit: Option, format: Option) -> Result>> { - let mut query = Vec::new(); - query.push(format!("series={}", series)); - query.push(format!("index={}", index)); - if let Some(v) = start { query.push(format!("start={}", v)); } - if let Some(v) = end { query.push(format!("end={}", v)); } - if let Some(v) = limit { query.push(format!("limit={}", v)); } - if let Some(v) = format { query.push(format!("format={}", v)); } - let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; - let path = format!("/api/series/bulk{}", query_str); - if format == Some(Format::CSV) { - self.base.get_text(&path).map(FormatResponse::Csv) - } else { - self.base.get_json(&path).map(FormatResponse::Json) - } - } - /// Series count /// /// Returns the number of series available per index type. @@ -9429,33 +9174,668 @@ impl BrkClient { self.base.get_json(&format!("/api/series/{series}/{}/version", index.name())) } - /// Disk usage + /// Bulk series data /// - /// Returns the disk space used by BRK and Bitcoin data. + /// Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. /// - /// Endpoint: `GET /api/server/disk` - pub fn get_disk_usage(&self) -> Result { - self.base.get_json(&format!("/api/server/disk")) + /// Endpoint: `GET /api/series/bulk` + pub fn get_series_bulk(&self, series: SeriesList, index: Index, start: Option, end: Option, limit: Option, format: Option) -> Result>> { + let mut query = Vec::new(); + query.push(format!("series={}", series)); + query.push(format!("index={}", index)); + if let Some(v) = start { query.push(format!("start={}", v)); } + if let Some(v) = end { query.push(format!("end={}", v)); } + if let Some(v) = limit { query.push(format!("limit={}", v)); } + if let Some(v) = format { query.push(format!("format={}", v)); } + let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; + let path = format!("/api/series/bulk{}", query_str); + if format == Some(Format::CSV) { + self.base.get_text(&path).map(FormatResponse::Csv) + } else { + self.base.get_json(&path).map(FormatResponse::Json) + } } - /// Sync status + /// Available URPD cohorts /// - /// Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. + /// Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. /// - /// Endpoint: `GET /api/server/sync` - pub fn get_sync_status(&self) -> Result { - self.base.get_json(&format!("/api/server/sync")) + /// Endpoint: `GET /api/urpd` + pub fn list_urpd_cohorts(&self) -> Result> { + self.base.get_json(&format!("/api/urpd")) } - /// Broadcast transaction + /// Available URPD dates /// - /// Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. + /// Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* + /// Endpoint: `GET /api/urpd/{cohort}/dates` + pub fn list_urpd_dates(&self, cohort: Cohort) -> Result> { + self.base.get_json(&format!("/api/urpd/{cohort}/dates")) + } + + /// Latest URPD /// - /// Endpoint: `POST /api/tx` - pub fn post_tx(&self, body: &str) -> Result { - self.base.post_json(&format!("/api/tx"), body) + /// URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. + /// + /// See the URPD tag description for the response shape and `agg` options. + /// + /// Endpoint: `GET /api/urpd/{cohort}` + pub fn get_urpd(&self, cohort: Cohort, agg: Option) -> Result { + let mut query = Vec::new(); + if let Some(v) = agg { query.push(format!("agg={}", v)); } + let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; + let path = format!("/api/urpd/{cohort}{}", query_str); + self.base.get_json(&path) + } + + /// URPD at date + /// + /// URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. + /// + /// See the URPD tag description for unit conventions and `agg` options. + /// + /// Endpoint: `GET /api/urpd/{cohort}/{date}` + pub fn get_urpd_at(&self, cohort: Cohort, date: &str, agg: Option) -> Result { + let mut query = Vec::new(); + if let Some(v) = agg { query.push(format!("agg={}", v)); } + let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; + let path = format!("/api/urpd/{cohort}/{date}{}", query_str); + self.base.get_json(&path) + } + + /// Difficulty adjustment + /// + /// Get current difficulty adjustment progress and estimates. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* + /// + /// Endpoint: `GET /api/v1/difficulty-adjustment` + pub fn get_difficulty_adjustment(&self) -> Result { + self.base.get_json(&format!("/api/v1/difficulty-adjustment")) + } + + /// Current BTC price + /// + /// Returns bitcoin latest price (on-chain derived, USD only). + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* + /// + /// Endpoint: `GET /api/v1/prices` + pub fn get_prices(&self) -> Result { + self.base.get_json(&format!("/api/v1/prices")) + } + + /// Historical price + /// + /// Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* + /// + /// Endpoint: `GET /api/v1/historical-price` + pub fn get_historical_price(&self, timestamp: Option) -> Result { + let mut query = Vec::new(); + if let Some(v) = timestamp { query.push(format!("timestamp={}", v)); } + let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; + let path = format!("/api/v1/historical-price{}", query_str); + self.base.get_json(&path) + } + + /// Address information + /// + /// Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* + /// + /// Endpoint: `GET /api/address/{address}` + pub fn get_address(&self, address: Addr) -> Result { + self.base.get_json(&format!("/api/address/{address}")) + } + + /// Address transactions + /// + /// Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* + /// + /// Endpoint: `GET /api/address/{address}/txs` + pub fn get_address_txs(&self, address: Addr) -> Result> { + self.base.get_json(&format!("/api/address/{address}/txs")) + } + + /// Address confirmed transactions + /// + /// Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* + /// + /// Endpoint: `GET /api/address/{address}/txs/chain` + pub fn get_address_confirmed_txs(&self, address: Addr) -> Result> { + self.base.get_json(&format!("/api/address/{address}/txs/chain")) + } + + /// Address confirmed transactions (paginated) + /// + /// Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* + /// + /// Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}` + pub fn get_address_confirmed_txs_after(&self, address: Addr, after_txid: Txid) -> Result> { + self.base.get_json(&format!("/api/address/{address}/txs/chain/{after_txid}")) + } + + /// Address mempool transactions + /// + /// Get unconfirmed transactions for an address from the mempool, newest first (up to 50). + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* + /// + /// Endpoint: `GET /api/address/{address}/txs/mempool` + pub fn get_address_mempool_txs(&self, address: Addr) -> Result> { + self.base.get_json(&format!("/api/address/{address}/txs/mempool")) + } + + /// Address UTXOs + /// + /// Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* + /// + /// Endpoint: `GET /api/address/{address}/utxo` + pub fn get_address_utxos(&self, address: Addr) -> Result> { + self.base.get_json(&format!("/api/address/{address}/utxo")) + } + + /// Validate address + /// + /// Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* + /// + /// Endpoint: `GET /api/v1/validate-address/{address}` + pub fn validate_address(&self, address: &str) -> Result { + self.base.get_json(&format!("/api/v1/validate-address/{address}")) + } + + /// Block information + /// + /// Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* + /// + /// Endpoint: `GET /api/block/{hash}` + pub fn get_block(&self, hash: BlockHash) -> Result { + self.base.get_json(&format!("/api/block/{hash}")) + } + + /// Block (v1) + /// + /// Returns block details with extras by hash. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* + /// + /// Endpoint: `GET /api/v1/block/{hash}` + pub fn get_block_v1(&self, hash: BlockHash) -> Result { + self.base.get_json(&format!("/api/v1/block/{hash}")) + } + + /// Block header + /// + /// Returns the hex-encoded 80-byte block header. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* + /// + /// Endpoint: `GET /api/block/{hash}/header` + pub fn get_block_header(&self, hash: BlockHash) -> Result { + self.base.get_text(&format!("/api/block/{hash}/header")) + } + + /// Block hash by height + /// + /// Retrieve the block hash at a given height. Returns the hash as plain text. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* + /// + /// Endpoint: `GET /api/block-height/{height}` + pub fn get_block_by_height(&self, height: Height) -> Result { + self.base.get_text(&format!("/api/block-height/{height}")) + } + + /// Block by timestamp + /// + /// Find the block closest to a given UNIX timestamp. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* + /// + /// Endpoint: `GET /api/v1/mining/blocks/timestamp/{timestamp}` + pub fn get_block_by_timestamp(&self, timestamp: Timestamp) -> Result { + self.base.get_json(&format!("/api/v1/mining/blocks/timestamp/{timestamp}")) + } + + /// Raw block + /// + /// Returns the raw block data in binary format. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* + /// + /// Endpoint: `GET /api/block/{hash}/raw` + pub fn get_block_raw(&self, hash: BlockHash) -> Result> { + self.base.get_bytes(&format!("/api/block/{hash}/raw")) + } + + /// Block status + /// + /// Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* + /// + /// Endpoint: `GET /api/block/{hash}/status` + pub fn get_block_status(&self, hash: BlockHash) -> Result { + self.base.get_json(&format!("/api/block/{hash}/status")) + } + + /// Block tip height + /// + /// Returns the height of the last block. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* + /// + /// Endpoint: `GET /api/blocks/tip/height` + pub fn get_block_tip_height(&self) -> Result { + self.base.get_text(&format!("/api/blocks/tip/height")) + } + + /// Block tip hash + /// + /// Returns the hash of the last block. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* + /// + /// Endpoint: `GET /api/blocks/tip/hash` + pub fn get_block_tip_hash(&self) -> Result { + self.base.get_text(&format!("/api/blocks/tip/hash")) + } + + /// Transaction ID at index + /// + /// Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* + /// + /// Endpoint: `GET /api/block/{hash}/txid/{index}` + pub fn get_block_txid(&self, hash: BlockHash, index: BlockTxIndex) -> Result { + self.base.get_text(&format!("/api/block/{hash}/txid/{index}")) + } + + /// Block transaction IDs + /// + /// Retrieve all transaction IDs in a block. Returns an array of txids in block order. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* + /// + /// Endpoint: `GET /api/block/{hash}/txids` + pub fn get_block_txids(&self, hash: BlockHash) -> Result> { + self.base.get_json(&format!("/api/block/{hash}/txids")) + } + + /// Block transactions + /// + /// Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* + /// + /// Endpoint: `GET /api/block/{hash}/txs` + pub fn get_block_txs(&self, hash: BlockHash) -> Result> { + self.base.get_json(&format!("/api/block/{hash}/txs")) + } + + /// Block transactions (paginated) + /// + /// Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* + /// + /// Endpoint: `GET /api/block/{hash}/txs/{start_index}` + pub fn get_block_txs_from_index(&self, hash: BlockHash, start_index: BlockTxIndex) -> Result> { + self.base.get_json(&format!("/api/block/{hash}/txs/{start_index}")) + } + + /// Recent blocks + /// + /// Retrieve the last 10 blocks. Returns block metadata for each block. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* + /// + /// Endpoint: `GET /api/blocks` + pub fn get_blocks(&self) -> Result> { + self.base.get_json(&format!("/api/blocks")) + } + + /// Blocks from height + /// + /// Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* + /// + /// Endpoint: `GET /api/blocks/{height}` + pub fn get_blocks_from_height(&self, height: Height) -> Result> { + self.base.get_json(&format!("/api/blocks/{height}")) + } + + /// Recent blocks with extras + /// + /// Retrieve the last 15 blocks with extended data including pool identification and fee statistics. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* + /// + /// Endpoint: `GET /api/v1/blocks` + pub fn get_blocks_v1(&self) -> Result> { + self.base.get_json(&format!("/api/v1/blocks")) + } + + /// Blocks from height with extras + /// + /// Retrieve up to 15 blocks with extended data going backwards from the given height. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* + /// + /// Endpoint: `GET /api/v1/blocks/{height}` + pub fn get_blocks_v1_from_height(&self, height: Height) -> Result> { + self.base.get_json(&format!("/api/v1/blocks/{height}")) + } + + /// List all mining pools + /// + /// Get list of all known mining pools with their identifiers. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + /// + /// Endpoint: `GET /api/v1/mining/pools` + pub fn get_pools(&self) -> Result> { + self.base.get_json(&format!("/api/v1/mining/pools")) + } + + /// Mining pool statistics + /// + /// Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + /// + /// Endpoint: `GET /api/v1/mining/pools/{time_period}` + pub fn get_pool_stats(&self, time_period: TimePeriod) -> Result { + self.base.get_json(&format!("/api/v1/mining/pools/{time_period}")) + } + + /// Mining pool details + /// + /// Get detailed information about a specific mining pool including block counts and shares for different time periods. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* + /// + /// Endpoint: `GET /api/v1/mining/pool/{slug}` + pub fn get_pool(&self, slug: PoolSlug) -> Result { + self.base.get_json(&format!("/api/v1/mining/pool/{slug}")) + } + + /// All pools hashrate (all time) + /// + /// Get hashrate data for all mining pools. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + /// + /// Endpoint: `GET /api/v1/mining/hashrate/pools` + pub fn get_pools_hashrate(&self) -> Result> { + self.base.get_json(&format!("/api/v1/mining/hashrate/pools")) + } + + /// All pools hashrate + /// + /// Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + /// + /// Endpoint: `GET /api/v1/mining/hashrate/pools/{time_period}` + pub fn get_pools_hashrate_by_period(&self, time_period: TimePeriod) -> Result> { + self.base.get_json(&format!("/api/v1/mining/hashrate/pools/{time_period}")) + } + + /// Mining pool hashrate + /// + /// Get hashrate history for a specific mining pool. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* + /// + /// Endpoint: `GET /api/v1/mining/pool/{slug}/hashrate` + pub fn get_pool_hashrate(&self, slug: PoolSlug) -> Result> { + self.base.get_json(&format!("/api/v1/mining/pool/{slug}/hashrate")) + } + + /// Mining pool blocks + /// + /// Get the 10 most recent blocks mined by a specific pool. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + /// + /// Endpoint: `GET /api/v1/mining/pool/{slug}/blocks` + pub fn get_pool_blocks(&self, slug: PoolSlug) -> Result> { + self.base.get_json(&format!("/api/v1/mining/pool/{slug}/blocks")) + } + + /// Mining pool blocks from height + /// + /// Get 10 blocks mined by a specific pool before (and including) the given height. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + /// + /// Endpoint: `GET /api/v1/mining/pool/{slug}/blocks/{height}` + pub fn get_pool_blocks_from(&self, slug: PoolSlug, height: Height) -> Result> { + self.base.get_json(&format!("/api/v1/mining/pool/{slug}/blocks/{height}")) + } + + /// Network hashrate (all time) + /// + /// Get network hashrate and difficulty data for all time. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + /// + /// Endpoint: `GET /api/v1/mining/hashrate` + pub fn get_hashrate(&self) -> Result { + self.base.get_json(&format!("/api/v1/mining/hashrate")) + } + + /// Network hashrate + /// + /// Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + /// + /// Endpoint: `GET /api/v1/mining/hashrate/{time_period}` + pub fn get_hashrate_by_period(&self, time_period: TimePeriod) -> Result { + self.base.get_json(&format!("/api/v1/mining/hashrate/{time_period}")) + } + + /// Difficulty adjustments (all time) + /// + /// Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + /// + /// Endpoint: `GET /api/v1/mining/difficulty-adjustments` + pub fn get_difficulty_adjustments(&self) -> Result> { + self.base.get_json(&format!("/api/v1/mining/difficulty-adjustments")) + } + + /// Difficulty adjustments + /// + /// Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + /// + /// Endpoint: `GET /api/v1/mining/difficulty-adjustments/{time_period}` + pub fn get_difficulty_adjustments_by_period(&self, time_period: TimePeriod) -> Result> { + self.base.get_json(&format!("/api/v1/mining/difficulty-adjustments/{time_period}")) + } + + /// Mining reward statistics + /// + /// Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* + /// + /// Endpoint: `GET /api/v1/mining/reward-stats/{block_count}` + pub fn get_reward_stats(&self, block_count: i64) -> Result { + self.base.get_json(&format!("/api/v1/mining/reward-stats/{block_count}")) + } + + /// Block fees + /// + /// Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* + /// + /// Endpoint: `GET /api/v1/mining/blocks/fees/{time_period}` + pub fn get_block_fees(&self, time_period: TimePeriod) -> Result> { + self.base.get_json(&format!("/api/v1/mining/blocks/fees/{time_period}")) + } + + /// Block rewards + /// + /// Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* + /// + /// Endpoint: `GET /api/v1/mining/blocks/rewards/{time_period}` + pub fn get_block_rewards(&self, time_period: TimePeriod) -> Result> { + self.base.get_json(&format!("/api/v1/mining/blocks/rewards/{time_period}")) + } + + /// Block fee rates + /// + /// Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* + /// + /// Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}` + pub fn get_block_fee_rates(&self, time_period: TimePeriod) -> Result> { + self.base.get_json(&format!("/api/v1/mining/blocks/fee-rates/{time_period}")) + } + + /// Block sizes and weights + /// + /// Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* + /// + /// Endpoint: `GET /api/v1/mining/blocks/sizes-weights/{time_period}` + pub fn get_block_sizes_weights(&self, time_period: TimePeriod) -> Result { + self.base.get_json(&format!("/api/v1/mining/blocks/sizes-weights/{time_period}")) + } + + /// Projected mempool blocks + /// + /// Get projected blocks from the mempool for fee estimation. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* + /// + /// Endpoint: `GET /api/v1/fees/mempool-blocks` + pub fn get_mempool_blocks(&self) -> Result> { + self.base.get_json(&format!("/api/v1/fees/mempool-blocks")) + } + + /// Recommended fees + /// + /// Get recommended fee rates for different confirmation targets. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* + /// + /// Endpoint: `GET /api/v1/fees/recommended` + pub fn get_recommended_fees(&self) -> Result { + self.base.get_json(&format!("/api/v1/fees/recommended")) + } + + /// Precise recommended fees + /// + /// Get recommended fee rates with up to 3 decimal places. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* + /// + /// Endpoint: `GET /api/v1/fees/precise` + pub fn get_precise_fees(&self) -> Result { + self.base.get_json(&format!("/api/v1/fees/precise")) + } + + /// Mempool statistics + /// + /// Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* + /// + /// Endpoint: `GET /api/mempool` + pub fn get_mempool(&self) -> Result { + self.base.get_json(&format!("/api/mempool")) + } + + /// Mempool content hash + /// + /// Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. + /// + /// Endpoint: `GET /api/mempool/hash` + pub fn get_mempool_hash(&self) -> Result { + self.base.get_json(&format!("/api/mempool/hash")) + } + + /// Mempool transaction IDs + /// + /// Get all transaction IDs currently in the mempool. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* + /// + /// Endpoint: `GET /api/mempool/txids` + pub fn get_mempool_txids(&self) -> Result> { + self.base.get_json(&format!("/api/mempool/txids")) + } + + /// Recent mempool transactions + /// + /// Get the last 10 transactions to enter the mempool. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* + /// + /// Endpoint: `GET /api/mempool/recent` + pub fn get_mempool_recent(&self) -> Result> { + self.base.get_json(&format!("/api/mempool/recent")) + } + + /// Recent RBF replacements + /// + /// Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + /// + /// Endpoint: `GET /api/v1/replacements` + pub fn get_replacements(&self) -> Result> { + self.base.get_json(&format!("/api/v1/replacements")) + } + + /// Recent full-RBF replacements + /// + /// Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* + /// + /// Endpoint: `GET /api/v1/fullrbf/replacements` + pub fn get_fullrbf_replacements(&self) -> Result> { + self.base.get_json(&format!("/api/v1/fullrbf/replacements")) + } + + /// Live BTC/USD price + /// + /// Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. + /// + /// Endpoint: `GET /api/mempool/price` + pub fn get_live_price(&self) -> Result { + self.base.get_json(&format!("/api/mempool/price")) } /// Txid by index @@ -9467,6 +9847,28 @@ impl BrkClient { self.base.get_text(&format!("/api/tx-index/{index}")) } + /// CPFP info + /// + /// Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* + /// + /// Endpoint: `GET /api/v1/cpfp/{txid}` + pub fn get_cpfp(&self, txid: Txid) -> Result { + self.base.get_json(&format!("/api/v1/cpfp/{txid}")) + } + + /// RBF replacement history + /// + /// Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + /// + /// Endpoint: `GET /api/v1/tx/{txid}/rbf` + pub fn get_tx_rbf(&self, txid: Txid) -> Result { + self.base.get_json(&format!("/api/v1/tx/{txid}/rbf")) + } + /// Transaction information /// /// Retrieve complete transaction data by transaction ID (txid). Returns inputs, outputs, fee, size, and confirmation status. @@ -9489,17 +9891,6 @@ impl BrkClient { self.base.get_text(&format!("/api/tx/{txid}/hex")) } - /// Transaction merkle proof - /// - /// Get the merkle inclusion proof for a transaction. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* - /// - /// Endpoint: `GET /api/tx/{txid}/merkle-proof` - pub fn get_tx_merkle_proof(&self, txid: Txid) -> Result { - self.base.get_json(&format!("/api/tx/{txid}/merkle-proof")) - } - /// Transaction merkleblock proof /// /// Get the merkleblock proof for a transaction (BIP37 format, hex encoded). @@ -9511,6 +9902,17 @@ impl BrkClient { self.base.get_text(&format!("/api/tx/{txid}/merkleblock-proof")) } + /// Transaction merkle proof + /// + /// Get the merkle inclusion proof for a transaction. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* + /// + /// Endpoint: `GET /api/tx/{txid}/merkle-proof` + pub fn get_tx_merkle_proof(&self, txid: Txid) -> Result { + self.base.get_json(&format!("/api/tx/{txid}/merkle-proof")) + } + /// Output spend status /// /// Get the spending status of a transaction output. Returns whether the output has been spent and, if so, the spending transaction details. @@ -9555,388 +9957,6 @@ impl BrkClient { self.base.get_json(&format!("/api/tx/{txid}/status")) } - /// Available URPD cohorts - /// - /// Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. - /// - /// Endpoint: `GET /api/urpd` - pub fn list_urpd_cohorts(&self) -> Result> { - self.base.get_json(&format!("/api/urpd")) - } - - /// Latest URPD - /// - /// URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. - /// - /// See the URPD tag description for the response shape and `agg` options. - /// - /// Endpoint: `GET /api/urpd/{cohort}` - pub fn get_urpd(&self, cohort: Cohort, agg: Option) -> Result { - let mut query = Vec::new(); - if let Some(v) = agg { query.push(format!("agg={}", v)); } - let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; - let path = format!("/api/urpd/{cohort}{}", query_str); - self.base.get_json(&path) - } - - /// Available URPD dates - /// - /// Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. - /// - /// Endpoint: `GET /api/urpd/{cohort}/dates` - pub fn list_urpd_dates(&self, cohort: Cohort) -> Result> { - self.base.get_json(&format!("/api/urpd/{cohort}/dates")) - } - - /// URPD at date - /// - /// URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. - /// - /// See the URPD tag description for unit conventions and `agg` options. - /// - /// Endpoint: `GET /api/urpd/{cohort}/{date}` - pub fn get_urpd_at(&self, cohort: Cohort, date: &str, agg: Option) -> Result { - let mut query = Vec::new(); - if let Some(v) = agg { query.push(format!("agg={}", v)); } - let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; - let path = format!("/api/urpd/{cohort}/{date}{}", query_str); - self.base.get_json(&path) - } - - /// Block (v1) - /// - /// Returns block details with extras by hash. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* - /// - /// Endpoint: `GET /api/v1/block/{hash}` - pub fn get_block_v1(&self, hash: BlockHash) -> Result { - self.base.get_json(&format!("/api/v1/block/{hash}")) - } - - /// Recent blocks with extras - /// - /// Retrieve the last 15 blocks with extended data including pool identification and fee statistics. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* - /// - /// Endpoint: `GET /api/v1/blocks` - pub fn get_blocks_v1(&self) -> Result> { - self.base.get_json(&format!("/api/v1/blocks")) - } - - /// Blocks from height with extras - /// - /// Retrieve up to 15 blocks with extended data going backwards from the given height. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* - /// - /// Endpoint: `GET /api/v1/blocks/{height}` - pub fn get_blocks_v1_from_height(&self, height: Height) -> Result> { - self.base.get_json(&format!("/api/v1/blocks/{height}")) - } - - /// CPFP info - /// - /// Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* - /// - /// Endpoint: `GET /api/v1/cpfp/{txid}` - pub fn get_cpfp(&self, txid: Txid) -> Result { - self.base.get_json(&format!("/api/v1/cpfp/{txid}")) - } - - /// Difficulty adjustment - /// - /// Get current difficulty adjustment progress and estimates. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* - /// - /// Endpoint: `GET /api/v1/difficulty-adjustment` - pub fn get_difficulty_adjustment(&self) -> Result { - self.base.get_json(&format!("/api/v1/difficulty-adjustment")) - } - - /// Projected mempool blocks - /// - /// Get projected blocks from the mempool for fee estimation. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* - /// - /// Endpoint: `GET /api/v1/fees/mempool-blocks` - pub fn get_mempool_blocks(&self) -> Result> { - self.base.get_json(&format!("/api/v1/fees/mempool-blocks")) - } - - /// Precise recommended fees - /// - /// Get recommended fee rates with up to 3 decimal places. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* - /// - /// Endpoint: `GET /api/v1/fees/precise` - pub fn get_precise_fees(&self) -> Result { - self.base.get_json(&format!("/api/v1/fees/precise")) - } - - /// Recommended fees - /// - /// Get recommended fee rates for different confirmation targets. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* - /// - /// Endpoint: `GET /api/v1/fees/recommended` - pub fn get_recommended_fees(&self) -> Result { - self.base.get_json(&format!("/api/v1/fees/recommended")) - } - - /// Recent full-RBF replacements - /// - /// Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* - /// - /// Endpoint: `GET /api/v1/fullrbf/replacements` - pub fn get_fullrbf_replacements(&self) -> Result> { - self.base.get_json(&format!("/api/v1/fullrbf/replacements")) - } - - /// Historical price - /// - /// Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* - /// - /// Endpoint: `GET /api/v1/historical-price` - pub fn get_historical_price(&self, timestamp: Option) -> Result { - let mut query = Vec::new(); - if let Some(v) = timestamp { query.push(format!("timestamp={}", v)); } - let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; - let path = format!("/api/v1/historical-price{}", query_str); - self.base.get_json(&path) - } - - /// Block fee rates - /// - /// Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* - /// - /// Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}` - pub fn get_block_fee_rates(&self, time_period: TimePeriod) -> Result> { - self.base.get_json(&format!("/api/v1/mining/blocks/fee-rates/{time_period}")) - } - - /// Block fees - /// - /// Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* - /// - /// Endpoint: `GET /api/v1/mining/blocks/fees/{time_period}` - pub fn get_block_fees(&self, time_period: TimePeriod) -> Result> { - self.base.get_json(&format!("/api/v1/mining/blocks/fees/{time_period}")) - } - - /// Block rewards - /// - /// Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* - /// - /// Endpoint: `GET /api/v1/mining/blocks/rewards/{time_period}` - pub fn get_block_rewards(&self, time_period: TimePeriod) -> Result> { - self.base.get_json(&format!("/api/v1/mining/blocks/rewards/{time_period}")) - } - - /// Block sizes and weights - /// - /// Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* - /// - /// Endpoint: `GET /api/v1/mining/blocks/sizes-weights/{time_period}` - pub fn get_block_sizes_weights(&self, time_period: TimePeriod) -> Result { - self.base.get_json(&format!("/api/v1/mining/blocks/sizes-weights/{time_period}")) - } - - /// Block by timestamp - /// - /// Find the block closest to a given UNIX timestamp. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* - /// - /// Endpoint: `GET /api/v1/mining/blocks/timestamp/{timestamp}` - pub fn get_block_by_timestamp(&self, timestamp: Timestamp) -> Result { - self.base.get_json(&format!("/api/v1/mining/blocks/timestamp/{timestamp}")) - } - - /// Difficulty adjustments (all time) - /// - /// Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* - /// - /// Endpoint: `GET /api/v1/mining/difficulty-adjustments` - pub fn get_difficulty_adjustments(&self) -> Result> { - self.base.get_json(&format!("/api/v1/mining/difficulty-adjustments")) - } - - /// Difficulty adjustments - /// - /// Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* - /// - /// Endpoint: `GET /api/v1/mining/difficulty-adjustments/{time_period}` - pub fn get_difficulty_adjustments_by_period(&self, time_period: TimePeriod) -> Result> { - self.base.get_json(&format!("/api/v1/mining/difficulty-adjustments/{time_period}")) - } - - /// Network hashrate (all time) - /// - /// Get network hashrate and difficulty data for all time. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* - /// - /// Endpoint: `GET /api/v1/mining/hashrate` - pub fn get_hashrate(&self) -> Result { - self.base.get_json(&format!("/api/v1/mining/hashrate")) - } - - /// All pools hashrate (all time) - /// - /// Get hashrate data for all mining pools. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* - /// - /// Endpoint: `GET /api/v1/mining/hashrate/pools` - pub fn get_pools_hashrate(&self) -> Result> { - self.base.get_json(&format!("/api/v1/mining/hashrate/pools")) - } - - /// All pools hashrate - /// - /// Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* - /// - /// Endpoint: `GET /api/v1/mining/hashrate/pools/{time_period}` - pub fn get_pools_hashrate_by_period(&self, time_period: TimePeriod) -> Result> { - self.base.get_json(&format!("/api/v1/mining/hashrate/pools/{time_period}")) - } - - /// Network hashrate - /// - /// Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* - /// - /// Endpoint: `GET /api/v1/mining/hashrate/{time_period}` - pub fn get_hashrate_by_period(&self, time_period: TimePeriod) -> Result { - self.base.get_json(&format!("/api/v1/mining/hashrate/{time_period}")) - } - - /// Mining pool details - /// - /// Get detailed information about a specific mining pool including block counts and shares for different time periods. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* - /// - /// Endpoint: `GET /api/v1/mining/pool/{slug}` - pub fn get_pool(&self, slug: PoolSlug) -> Result { - self.base.get_json(&format!("/api/v1/mining/pool/{slug}")) - } - - /// Mining pool blocks - /// - /// Get the 10 most recent blocks mined by a specific pool. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* - /// - /// Endpoint: `GET /api/v1/mining/pool/{slug}/blocks` - pub fn get_pool_blocks(&self, slug: PoolSlug) -> Result> { - self.base.get_json(&format!("/api/v1/mining/pool/{slug}/blocks")) - } - - /// Mining pool blocks from height - /// - /// Get 10 blocks mined by a specific pool before (and including) the given height. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* - /// - /// Endpoint: `GET /api/v1/mining/pool/{slug}/blocks/{height}` - pub fn get_pool_blocks_from(&self, slug: PoolSlug, height: Height) -> Result> { - self.base.get_json(&format!("/api/v1/mining/pool/{slug}/blocks/{height}")) - } - - /// Mining pool hashrate - /// - /// Get hashrate history for a specific mining pool. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* - /// - /// Endpoint: `GET /api/v1/mining/pool/{slug}/hashrate` - pub fn get_pool_hashrate(&self, slug: PoolSlug) -> Result> { - self.base.get_json(&format!("/api/v1/mining/pool/{slug}/hashrate")) - } - - /// List all mining pools - /// - /// Get list of all known mining pools with their identifiers. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* - /// - /// Endpoint: `GET /api/v1/mining/pools` - pub fn get_pools(&self) -> Result> { - self.base.get_json(&format!("/api/v1/mining/pools")) - } - - /// Mining pool statistics - /// - /// Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* - /// - /// Endpoint: `GET /api/v1/mining/pools/{time_period}` - pub fn get_pool_stats(&self, time_period: TimePeriod) -> Result { - self.base.get_json(&format!("/api/v1/mining/pools/{time_period}")) - } - - /// Mining reward statistics - /// - /// Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* - /// - /// Endpoint: `GET /api/v1/mining/reward-stats/{block_count}` - pub fn get_reward_stats(&self, block_count: i64) -> Result { - self.base.get_json(&format!("/api/v1/mining/reward-stats/{block_count}")) - } - - /// Current BTC price - /// - /// Returns bitcoin latest price (on-chain derived, USD only). - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* - /// - /// Endpoint: `GET /api/v1/prices` - pub fn get_prices(&self) -> Result { - self.base.get_json(&format!("/api/v1/prices")) - } - - /// Recent RBF replacements - /// - /// Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* - /// - /// Endpoint: `GET /api/v1/replacements` - pub fn get_replacements(&self) -> Result> { - self.base.get_json(&format!("/api/v1/replacements")) - } - /// Transaction first-seen times /// /// Returns timestamps when transactions were first seen in the mempool. Returns 0 for mined or unknown transactions. @@ -9952,35 +9972,15 @@ impl BrkClient { self.base.get_json(&path) } - /// RBF replacement history + /// Broadcast transaction /// - /// Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. + /// Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* /// - /// Endpoint: `GET /api/v1/tx/{txid}/rbf` - pub fn get_tx_rbf(&self, txid: Txid) -> Result { - self.base.get_json(&format!("/api/v1/tx/{txid}/rbf")) - } - - /// Validate address - /// - /// Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. - /// - /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* - /// - /// Endpoint: `GET /api/v1/validate-address/{address}` - pub fn validate_address(&self, address: &str) -> Result { - self.base.get_json(&format!("/api/v1/validate-address/{address}")) - } - - /// Health check - /// - /// Returns the health status of the API server, including uptime information. - /// - /// Endpoint: `GET /health` - pub fn get_health(&self) -> Result { - self.base.get_json(&format!("/health")) + /// Endpoint: `POST /api/tx` + pub fn post_tx(&self, body: &str) -> Result { + self.base.post_json(&format!("/api/tx"), body) } /// OpenAPI specification @@ -9992,13 +9992,13 @@ impl BrkClient { self.base.get_text(&format!("/openapi.json")) } - /// API version + /// Compact OpenAPI specification /// - /// Returns the current version of the API server + /// Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. /// - /// Endpoint: `GET /version` - pub fn get_version(&self) -> Result { - self.base.get_json(&format!("/version")) + /// Endpoint: `GET /api.json` + pub fn get_api(&self) -> Result { + self.base.get_json(&format!("/api.json")) } } diff --git a/crates/brk_indexer/src/processor/sigops.rs b/crates/brk_indexer/src/processor/sigops.rs index 993b96e55..19e74c9be 100644 --- a/crates/brk_indexer/src/processor/sigops.rs +++ b/crates/brk_indexer/src/processor/sigops.rs @@ -54,8 +54,7 @@ impl BlockProcessor<'_> { let prev_kind = match source { InputSource::PreviousBlock { output_type, .. } => *output_type, InputSource::SameBlock { outpoint, .. } => { - let local = - (u32::from(outpoint.tx_index()) - base_tx_index) as usize; + let local = (u32::from(outpoint.tx_index()) - base_tx_index) as usize; let vout = u32::from(outpoint.vout()) as usize; txouts[tx_output_offsets[local] + vout].output_type } @@ -91,8 +90,8 @@ impl BlockProcessor<'_> { } else if rs.is_p2wsh() && let Some(last) = input.witness.last() { - witness = witness - .saturating_add(Script::from_bytes(last).count_sigops()); + witness = + witness.saturating_add(Script::from_bytes(last).count_sigops()); } } OutputType::P2WPKH => { @@ -100,14 +99,13 @@ impl BlockProcessor<'_> { } OutputType::P2WSH => { if let Some(last) = input.witness.last() { - witness = witness - .saturating_add(Script::from_bytes(last).count_sigops()); + witness = + witness.saturating_add(Script::from_bytes(last).count_sigops()); } } OutputType::P2TR => {} _ => { - legacy = legacy - .saturating_add(input.script_sig.count_sigops_legacy()); + legacy = legacy.saturating_add(input.script_sig.count_sigops_legacy()); } } } diff --git a/crates/brk_mempool/examples/mempool.rs b/crates/brk_mempool/examples/mempool.rs index 6107b3c34..ebf9c032d 100644 --- a/crates/brk_mempool/examples/mempool.rs +++ b/crates/brk_mempool/examples/mempool.rs @@ -1,9 +1,34 @@ use std::{thread, time::Duration}; use brk_error::Result; -use brk_mempool::{Mempool, MempoolStats}; +use brk_mempool::Mempool; use brk_rpc::{Auth, Client}; +#[derive(Debug, Clone)] +struct MempoolStats { + info_count: usize, + tx_count: usize, + unresolved_count: usize, + addr_count: usize, + outpoint_spend_count: usize, + graveyard_tombstone_count: usize, + graveyard_order_count: usize, +} + +impl From<&Mempool> for MempoolStats { + fn from(mempool: &Mempool) -> Self { + Self { + info_count: mempool.info().count, + tx_count: mempool.tx_count(), + unresolved_count: mempool.unresolved_count(), + addr_count: mempool.addr_count(), + outpoint_spend_count: mempool.outpoint_spend_count(), + graveyard_tombstone_count: mempool.graveyard_tombstone_count(), + graveyard_order_count: mempool.graveyard_order_count(), + } + } +} + fn main() -> Result<()> { brk_logger::init(None)?; @@ -26,36 +51,25 @@ fn main() -> Result<()> { let stats = MempoolStats::from(&mempool); let snapshot = mempool.snapshot(); - let cluster_nodes_total: usize = snapshot.clusters.iter().map(|c| c.nodes.len()).sum(); let blocks_tx_total: usize = snapshot.blocks.iter().map(|b| b.len()).sum(); - let (skip_clean, skip_throttled) = mempool.skip_counts(); println!( - "info.count={} entries.slots={} entries.active={} entries.free={} \ - txs={} unresolved={} addrs={} outpoints={} \ + "info.count={} txs={} unresolved={} addrs={} outpoints={} \ graveyard.tombstones={} graveyard.order={} \ - snap.clusters={} snap.cluster_nodes={} snap.cluster_of.len={} snap.cluster_of.active={} \ - snap.blocks={} snap.blocks_txs={} \ - rebuilds={} skip.clean={} skip.throttled={}", + snap.txs.len={} snap.blocks={} snap.blocks_txs={} \ + rebuilds={} skip.clean={}", stats.info_count, - stats.entry_slot_count, - stats.entry_active_count, - stats.entry_free_count, stats.tx_count, stats.unresolved_count, stats.addr_count, stats.outpoint_spend_count, stats.graveyard_tombstone_count, stats.graveyard_order_count, - snapshot.clusters.len(), - cluster_nodes_total, - snapshot.cluster_of_len(), - snapshot.cluster_of_active(), + snapshot.txs_len(), snapshot.blocks.len(), blocks_tx_total, mempool.rebuild_count(), - skip_clean, - skip_throttled, + mempool.skip_clean_count(), ); } } diff --git a/crates/brk_mempool/src/cluster/chunk.rs b/crates/brk_mempool/src/cluster/chunk.rs deleted file mode 100644 index 9cf278c1f..000000000 --- a/crates/brk_mempool/src/cluster/chunk.rs +++ /dev/null @@ -1,31 +0,0 @@ -use brk_types::{CpfpClusterChunk, CpfpClusterTxIndex, FeeRate, Sats, VSize}; -use smallvec::SmallVec; - -use super::LocalIdx; - -pub struct Chunk { - /// Cluster-local positions of the txs in this chunk, in topological - /// order (parents before children). Populated by `Cluster::new`. - pub txs: SmallVec<[LocalIdx; 4]>, - pub fee: Sats, - pub vsize: VSize, -} - -impl Chunk { - pub fn fee_rate(&self) -> FeeRate { - FeeRate::from((self.fee, self.vsize)) - } -} - -impl From<&Chunk> for CpfpClusterChunk { - fn from(chunk: &Chunk) -> Self { - Self { - txs: chunk - .txs - .iter() - .map(|&local| CpfpClusterTxIndex::from(local.inner())) - .collect(), - feerate: chunk.fee_rate(), - } - } -} diff --git a/crates/brk_mempool/src/cluster/chunk_id.rs b/crates/brk_mempool/src/cluster/chunk_id.rs deleted file mode 100644 index ed8bdb0d2..000000000 --- a/crates/brk_mempool/src/cluster/chunk_id.rs +++ /dev/null @@ -1,33 +0,0 @@ -/// Index of a `Chunk` inside a `Cluster.chunks`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[repr(transparent)] -pub struct ChunkId(u32); - -impl ChunkId { - pub const ZERO: Self = Self(0); - - #[inline] - pub fn as_usize(self) -> usize { - self.0 as usize - } - - #[inline] - pub fn inner(self) -> u32 { - self.0 - } -} - -impl From for ChunkId { - #[inline] - fn from(v: u32) -> Self { - Self(v) - } -} - -impl From for ChunkId { - #[inline] - fn from(v: usize) -> Self { - debug_assert!(v <= u32::MAX as usize, "ChunkId overflow: {v}"); - Self(v as u32) - } -} diff --git a/crates/brk_mempool/src/cluster/cluster_id.rs b/crates/brk_mempool/src/cluster/cluster_id.rs deleted file mode 100644 index 281b0a35b..000000000 --- a/crates/brk_mempool/src/cluster/cluster_id.rs +++ /dev/null @@ -1,31 +0,0 @@ -/// Index of a `Cluster` inside `Snapshot::clusters`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[repr(transparent)] -pub struct ClusterId(u32); - -impl ClusterId { - #[inline] - pub fn as_usize(self) -> usize { - self.0 as usize - } - - #[inline] - pub fn inner(self) -> u32 { - self.0 - } -} - -impl From for ClusterId { - #[inline] - fn from(v: u32) -> Self { - Self(v) - } -} - -impl From for ClusterId { - #[inline] - fn from(v: usize) -> Self { - debug_assert!(v <= u32::MAX as usize, "ClusterId overflow: {v}"); - Self(v as u32) - } -} diff --git a/crates/brk_mempool/src/cluster/cluster_node.rs b/crates/brk_mempool/src/cluster/cluster_node.rs deleted file mode 100644 index f1c47e8f1..000000000 --- a/crates/brk_mempool/src/cluster/cluster_node.rs +++ /dev/null @@ -1,48 +0,0 @@ -use brk_types::{CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, Sats, Txid, VSize, Weight}; -use smallvec::SmallVec; - -use super::LocalIdx; - -/// A node inside a `Cluster`. The `id` carries whatever the caller -/// uses to refer back to the source tx: `brk_mempool::stores::TxIndex` -/// (live pool slot) on the mempool path, `brk_types::TxIndex` (global -/// indexer position) on the confirmed path. `Cluster::new` and the SFL -/// algorithm don't read it. -/// -/// All fields are `pub` and callers construct directly with struct -/// literals; `parents` are always supplied at construction (no -/// post-init mutation pattern). -pub struct ClusterNode { - pub id: I, - pub txid: Txid, - pub fee: Sats, - pub vsize: VSize, - pub weight: Weight, - /// Direct parents in the cluster. Caller-supplied. - pub parents: SmallVec<[LocalIdx; 2]>, -} - -impl From<&ClusterNode> for CpfpEntry { - fn from(node: &ClusterNode) -> Self { - Self { - txid: node.txid, - weight: node.weight, - fee: node.fee, - } - } -} - -impl From<&ClusterNode> for CpfpClusterTx { - fn from(node: &ClusterNode) -> Self { - Self { - txid: node.txid, - weight: node.weight, - fee: node.fee, - parents: node - .parents - .iter() - .map(|&p| CpfpClusterTxIndex::from(p.inner())) - .collect(), - } - } -} diff --git a/crates/brk_mempool/src/cluster/cluster_ref.rs b/crates/brk_mempool/src/cluster/cluster_ref.rs deleted file mode 100644 index 3f88630c6..000000000 --- a/crates/brk_mempool/src/cluster/cluster_ref.rs +++ /dev/null @@ -1,9 +0,0 @@ -use super::{ClusterId, LocalIdx}; - -/// Locates a node within the cluster forest: which cluster it lives in, -/// and its `LocalIdx` inside that cluster. -#[derive(Debug, Clone, Copy)] -pub struct ClusterRef { - pub cluster_id: ClusterId, - pub local: LocalIdx, -} diff --git a/crates/brk_mempool/src/cluster/local_idx.rs b/crates/brk_mempool/src/cluster/local_idx.rs deleted file mode 100644 index 0ec5ffa27..000000000 --- a/crates/brk_mempool/src/cluster/local_idx.rs +++ /dev/null @@ -1,34 +0,0 @@ -/// Index of a node within a single `Cluster`. Cluster-local; meaningless -/// across clusters. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[repr(transparent)] -pub struct LocalIdx(u32); - -impl LocalIdx { - pub const ZERO: Self = Self(0); - - #[inline] - pub fn as_usize(self) -> usize { - self.0 as usize - } - - #[inline] - pub fn inner(self) -> u32 { - self.0 - } -} - -impl From for LocalIdx { - #[inline] - fn from(v: u32) -> Self { - Self(v) - } -} - -impl From for LocalIdx { - #[inline] - fn from(v: usize) -> Self { - debug_assert!(v <= u32::MAX as usize, "LocalIdx overflow: {v}"); - Self(v as u32) - } -} diff --git a/crates/brk_mempool/src/cluster/mod.rs b/crates/brk_mempool/src/cluster/mod.rs deleted file mode 100644 index 39f203b82..000000000 --- a/crates/brk_mempool/src/cluster/mod.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Cluster primitive shared by the live mempool snapshot rebuilder -//! and the per-request CPFP path. A `Cluster` is a connected component -//! of the mempool dependency graph, locally re-indexed in topological -//! order and SFL-linearized into chunks ordered by descending feerate. -//! -//! Callers supply `ClusterNode`s with parent edges only; `Cluster::new` -//! permutes them into Kahn topological order (so `LocalIdx == position -//! in `nodes` == topological position`), then runs SFL. - -mod chunk; -mod chunk_id; -mod cluster_id; -mod cluster_node; -mod cluster_ref; -mod local_idx; -mod sfl; - -pub use chunk::Chunk; -pub use chunk_id::ChunkId; -pub use cluster_id::ClusterId; -pub use cluster_node::ClusterNode; -pub use cluster_ref::ClusterRef; -pub use local_idx::LocalIdx; - -use smallvec::SmallVec; -use tracing::warn; - -/// A connected component of the mempool graph, stored in topological -/// order (parents before children) and SFL-linearized into chunks. -/// -/// `I` is the caller's identifier for each node: `brk_mempool::stores::TxIndex` -/// (live pool slot) on the mempool path, `brk_types::TxIndex` (global indexer -/// position) on the confirmed path. The SFL algorithm doesn't touch it; only -/// consumers that need to map a `LocalIdx` back to source-tx state read it. -/// -/// Because nodes are stored topologically, every `LocalIdx` is also -/// its topological position: parent edges always point to lower -/// indices, and a forward iteration over `nodes` is a valid topo -/// sweep. -pub struct Cluster { - pub nodes: Vec>, - /// SFL-emitted chunks, ordered by descending feerate. - pub chunks: Vec, - /// `node_to_chunk[local]` is the `ChunkId` that contains the node. - pub node_to_chunk: Vec, -} - -impl Cluster { - pub fn new(nodes: Vec>) -> Self { - let nodes = Self::permute_to_topo_order(nodes); - let (chunks, node_to_chunk) = if nodes.len() < sfl::BITMASK_LIMIT { - let chunk_masks = sfl::linearize(&nodes); - Self::materialize_chunks(&chunk_masks, nodes.len()) - } else { - // Bitcoin Core 30+ caps clusters at 100, but pre-BIP431 nodes - // (or relay-policy edge cases) can produce larger connected - // components. Fall back to a trivial linearization so the - // mempool loop survives instead of panicking. - warn!( - "cluster size {} >= u128 capacity, using trivial linearization", - nodes.len() - ); - Self::trivial_chunks(&nodes) - }; - Self { - nodes, - chunks, - node_to_chunk, - } - } - - /// Fallback linearization for oversized clusters: emit each node as - /// its own chunk (topo-ordered), then run the same stack-merge as - /// `sfl::canonicalize` to restore the non-increasing fee_rate - /// invariant the partitioner relies on. Suboptimal partitioning - /// for that one cluster, but topology is preserved (merges only - /// join consecutive topo-ordered runs). - fn trivial_chunks(nodes: &[ClusterNode]) -> (Vec, Vec) { - let mut out: Vec = Vec::with_capacity(nodes.len()); - for (i, node) in nodes.iter().enumerate() { - let mut txs: SmallVec<[LocalIdx; 4]> = SmallVec::new(); - txs.push(LocalIdx::from(i)); - let mut cur = Chunk { - txs, - fee: node.fee, - vsize: node.vsize, - }; - while let Some(top) = out.last() { - if cur.fee_rate() <= top.fee_rate() { - break; - } - let prev = out.pop().unwrap(); - let mut merged_txs = prev.txs; - merged_txs.extend(cur.txs.iter().copied()); - cur = Chunk { - txs: merged_txs, - fee: prev.fee + cur.fee, - vsize: prev.vsize + cur.vsize, - }; - } - out.push(cur); - } - let mut node_to_chunk = vec![ChunkId::ZERO; nodes.len()]; - for (cid, chunk) in out.iter().enumerate() { - let chunk_id = ChunkId::from(cid); - for &local in &chunk.txs { - node_to_chunk[local.as_usize()] = chunk_id; - } - } - (out, node_to_chunk) - } - - /// O(1) chunk lookup for a node. - #[inline] - pub fn chunk_of(&self, local: LocalIdx) -> &Chunk { - &self.chunks[self.node_to_chunk[local.as_usize()].as_usize()] - } - - /// Reorder `nodes` into Kahn topological order and remap every - /// parent edge into the new index space. Single pass: build the - /// child adjacency and in-degrees, then Kahn-pop directly into the - /// output Vec while remapping each node's parents through the - /// `new_pos[old] -> new` map populated as we pop. Post-condition: - /// for every `i`, every parent of `nodes[i]` has a `LocalIdx` - /// strictly less than `i`. - fn permute_to_topo_order(mut nodes: Vec>) -> Vec> { - let n = nodes.len(); - let mut children: Vec> = (0..n).map(|_| SmallVec::new()).collect(); - let mut indegree: Vec = vec![0; n]; - for (i, node) in nodes.iter().enumerate() { - indegree[i] = node.parents.len() as u32; - for &p in &node.parents { - children[p.as_usize()].push(LocalIdx::from(i)); - } - } - - // Sources (in-degree 0) seed the queue. We hold them as `LocalIdx` - // pointing at the *old* slot; `out` drains nodes out as it pops. - let mut queue: Vec = (0..n) - .filter(|&i| indegree[i] == 0) - .map(LocalIdx::from) - .collect(); - let mut new_pos = vec![LocalIdx::ZERO; n]; - let mut out: Vec> = Vec::with_capacity(n); - let mut taken: Vec>> = nodes.drain(..).map(Some).collect(); - - let mut head = 0; - while head < queue.len() { - let v = queue[head]; - head += 1; - new_pos[v.as_usize()] = LocalIdx::from(out.len()); - let mut node = taken[v.as_usize()].take().unwrap(); - for p in node.parents.iter_mut() { - *p = new_pos[p.as_usize()]; - } - out.push(node); - for &c in &children[v.as_usize()] { - indegree[c.as_usize()] -= 1; - if indegree[c.as_usize()] == 0 { - queue.push(c); - } - } - } - - assert_eq!(out.len(), n, "cluster contained a cycle"); - out - } - - /// Convert SFL's raw bit-masks into final `Chunk`s with topo-ordered - /// `txs` and a `tx → ChunkId` reverse map. Bit iteration via - /// `trailing_zeros` visits each chunk's bits in ascending order, and - /// nodes are stored in topo order (`LocalIdx == position`), so each - /// pushed `LocalIdx` lands parents-first in `chunk.txs`. - fn materialize_chunks(chunk_masks: &[sfl::ChunkMask], n: usize) -> (Vec, Vec) { - let mut chunks: Vec = Vec::with_capacity(chunk_masks.len()); - let mut node_to_chunk = vec![ChunkId::ZERO; n]; - for (cid, cm) in chunk_masks.iter().enumerate() { - let chunk_id = ChunkId::from(cid); - let mut chunk = Chunk { - txs: SmallVec::new(), - fee: cm.fee, - vsize: cm.vsize, - }; - let mut bits = cm.mask; - while bits != 0 { - let i = bits.trailing_zeros() as usize; - node_to_chunk[i] = chunk_id; - chunk.txs.push(LocalIdx::from(i)); - bits &= bits - 1; - } - chunks.push(chunk); - } - (chunks, node_to_chunk) - } -} diff --git a/crates/brk_mempool/src/cluster/sfl.rs b/crates/brk_mempool/src/cluster/sfl.rs deleted file mode 100644 index 67b52a10d..000000000 --- a/crates/brk_mempool/src/cluster/sfl.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Cluster linearizer. -//! -//! Two-branch dispatch by cluster size: -//! - **n ≤ 18**: recursive enumeration of topologically-closed subsets. -//! Provably optimal. Visits only valid subsets (skips non-closed ones -//! without filtering) and maintains running fee/vsize incrementally. -//! - **n > 18**: "greedy-union" ancestor-set search. Seeds with each -//! node's ancestor closure, then greedily adds any other ancestor -//! closure whose inclusion raises the combined feerate. Strict -//! superset of ancestor-set-sort's candidate space, catching the -//! sibling-union shapes that pure ASS misses. -//! -//! A final stack-based `canonicalize` pass merges adjacent chunks when -//! the later one's feerate beats the earlier's, restoring the -//! non-increasing-rate invariant. -//! -//! Everything runs on `u128` bitmasks (covers Bitcoin Core 31's cluster -//! cap of 100). Rate comparisons go through `FeeRate`. The caller is -//! `Cluster::new`, which has already permuted nodes into topological -//! order — so `LocalIdx == position == topological rank`, and this -//! module never has to take a `topo_order` permutation. - -use brk_types::{FeeRate, Sats, VSize}; - -use super::ClusterNode; - -const BRUTE_FORCE_LIMIT: usize = 18; -/// Cluster nodes are indexed by `u128` bitmask, so `n < 128`. Bitcoin -/// Core's cluster cap is 100, so this leaves comfortable margin. -pub(super) const BITMASK_LIMIT: usize = 128; - -/// Raw SFL output: a chunk's bitmask plus its totals. `Cluster::new` -/// converts these into final `Chunk`s with topo-ordered `txs`, so the -/// algorithm doesn't have to materialize them itself. -pub(super) struct ChunkMask { - pub mask: u128, - pub fee: Sats, - pub vsize: VSize, -} - -impl ChunkMask { - fn fee_rate(&self) -> FeeRate { - FeeRate::from((self.fee, self.vsize)) - } -} - -/// Linearize a cluster into SFL chunks. -/// -/// Precondition: `nodes.len() < BITMASK_LIMIT`. `Cluster::new` enforces -/// this by dispatching oversized clusters to a trivial fallback before -/// reaching here, so the check is `debug_assert!` rather than runtime. -pub(super) fn linearize(nodes: &[ClusterNode]) -> Vec { - debug_assert!( - nodes.len() < BITMASK_LIMIT, - "cluster size {} exceeds u128 capacity", - nodes.len() - ); - let tables = Tables::build(nodes); - let chunks = extract_chunks(&tables); - canonicalize(chunks) -} - -/// Peel the cluster one chunk at a time. Each iteration picks the -/// highest-feerate topologically-closed subset of `remaining` and -/// removes it. Loop terminates because every iteration removes at -/// least one node. -fn extract_chunks(t: &Tables) -> Vec { - let pick: fn(&Tables, u128) -> (u128, Sats, VSize) = if t.n <= BRUTE_FORCE_LIMIT { - best_subset - } else { - best_ancestor_union - }; - let mut chunks: Vec = Vec::new(); - let mut remaining: u128 = t.all; - while remaining != 0 { - let (mask, fee, vsize) = pick(t, remaining); - chunks.push(ChunkMask { mask, fee, vsize }); - remaining &= !mask; - } - chunks -} - -/// Recursive enumeration of topologically-closed subsets of -/// `remaining`. Returns the (mask, fee, vsize) with the highest rate; -/// when `remaining` is all zero-fee (e.g. a CPFP-parent leftover after -/// the paying chunk was extracted), the first non-empty subset wins so -/// `extract_chunks` always makes progress. Iterates nodes by index -/// `0..n`; since the cluster is stored in topological order, that *is* -/// a topological sweep. -fn best_subset(t: &Tables, remaining: u128) -> (u128, Sats, VSize) { - let ctx = Ctx { - tables: t, - remaining, - }; - let mut best = (0u128, Sats::ZERO, VSize::default()); - recurse(&ctx, 0, 0, Sats::ZERO, VSize::default(), &mut best); - best -} - -fn recurse( - ctx: &Ctx, - idx: usize, - included: u128, - f: Sats, - v: VSize, - best: &mut (u128, Sats, VSize), -) { - if idx == ctx.tables.n { - if included != 0 && (best.0 == 0 || FeeRate::from((f, v)) > FeeRate::from((best.1, best.2))) - { - *best = (included, f, v); - } - return; - } - let bit = 1u128 << idx; - - // Not in remaining, or a parent (within remaining) is excluded: - // this node is forced-excluded, no branching. - if (bit & ctx.remaining) == 0 || (ctx.tables.parents_mask[idx] & ctx.remaining & !included) != 0 - { - recurse(ctx, idx + 1, included, f, v, best); - return; - } - - recurse(ctx, idx + 1, included, f, v, best); - recurse( - ctx, - idx + 1, - included | bit, - f + ctx.tables.fee_of[idx], - v + ctx.tables.vsize_of[idx], - best, - ); -} - -/// For each node v in `remaining`, seed with anc(v) ∩ remaining, then -/// greedily extend by adding any anc(u) whose inclusion raises the -/// feerate. Pick the best result across all seeds; when every seed has -/// rate 0 (e.g. a CPFP-parent leftover after the paying chunk was -/// extracted), the first seed wins so `extract_chunks` always makes -/// progress. -/// -/// Every candidate evaluated is a union of ancestor closures, so it -/// is topologically closed by construction. Strictly explores more -/// candidates than pure ancestor-set-sort, at O(n³) per chunk step. -fn best_ancestor_union(t: &Tables, remaining: u128) -> (u128, Sats, VSize) { - let mut best = (0u128, Sats::ZERO, VSize::default()); - let mut best_rate = FeeRate::default(); - let mut seeds = remaining; - while seeds != 0 { - let i = seeds.trailing_zeros() as usize; - seeds &= seeds - 1; - - let mut s = t.ancestor_incl[i] & remaining; - let (mut f, mut v) = totals(s, &t.fee_of, &t.vsize_of); - let mut rate = FeeRate::from((f, v)); - - // Greedy extension to fixed point: pick the ancestor-closure - // addition that yields the highest resulting feerate, if any. - loop { - let mut picked: Option<(u128, Sats, VSize, FeeRate)> = None; - let mut cands = remaining & !s; - while cands != 0 { - let j = cands.trailing_zeros() as usize; - cands &= cands - 1; - let add = t.ancestor_incl[j] & remaining & !s; - if add == 0 { - continue; - } - let (df, dv) = totals(add, &t.fee_of, &t.vsize_of); - let nf = f + df; - let nv = v + dv; - let nrate = FeeRate::from((nf, nv)); - if nrate <= rate { - continue; - } - if picked.is_none_or(|(_, _, _, prate)| nrate > prate) { - picked = Some((add, nf, nv, nrate)); - } - } - match picked { - Some((add, nf, nv, nrate)) => { - s |= add; - f = nf; - v = nv; - rate = nrate; - } - None => break, - } - } - - if best.0 == 0 || rate > best_rate { - best = (s, f, v); - best_rate = rate; - } - } - best -} - -/// Single-pass stack merge: for each incoming chunk, merge it into -/// the stack top while the merge would raise the top's feerate, then -/// push. O(n) total regardless of how many merges cascade. -fn canonicalize(chunks: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(chunks.len()); - for mut cur in chunks { - while let Some(top) = out.last() { - if cur.fee_rate() <= top.fee_rate() { - break; - } - let prev = out.pop().unwrap(); - cur = ChunkMask { - mask: prev.mask | cur.mask, - fee: prev.fee + cur.fee, - vsize: prev.vsize + cur.vsize, - }; - } - out.push(cur); - } - out -} - -#[inline] -fn totals(mask: u128, fee_of: &[Sats], vsize_of: &[VSize]) -> (Sats, VSize) { - let mut f = Sats::ZERO; - let mut v = VSize::default(); - let mut bits = mask; - while bits != 0 { - let i = bits.trailing_zeros() as usize; - f += fee_of[i]; - v += vsize_of[i]; - bits &= bits - 1; - } - (f, v) -} - -/// Per-cluster precomputed bitmasks and lookups, shared across every -/// chunk-extraction iteration. Built once in `linearize`. -struct Tables { - n: usize, - /// Bitmask with one bit set per node (i.e. `(1 << n) - 1`). - all: u128, - /// `parents_mask[i]` = bits set for direct parents of node `i`. - parents_mask: Vec, - /// `ancestor_incl[i]` = bits set for `i` and all ancestors. - ancestor_incl: Vec, - fee_of: Vec, - vsize_of: Vec, -} - -impl Tables { - /// Single pass over nodes (in topological order, so each parent's - /// `ancestor_incl` is ready before the child reads it): build - /// parent-bit masks, ancestor closures, and pick out fee/vsize. - fn build(nodes: &[ClusterNode]) -> Self { - let n = nodes.len(); - let mut parents_mask: Vec = vec![0; n]; - let mut ancestor_incl: Vec = vec![0; n]; - let mut fee_of: Vec = Vec::with_capacity(n); - let mut vsize_of: Vec = Vec::with_capacity(n); - for (vi, node) in nodes.iter().enumerate() { - let mut par = 0u128; - let mut acc = 1u128 << vi; - for &p in &node.parents { - par |= 1u128 << p.inner(); - acc |= ancestor_incl[p.as_usize()]; - } - parents_mask[vi] = par; - ancestor_incl[vi] = acc; - fee_of.push(node.fee); - vsize_of.push(node.vsize); - } - Self { - n, - all: (1u128 << n) - 1, - parents_mask, - ancestor_incl, - fee_of, - vsize_of, - } - } -} - -/// Per-iteration immutable bundle for the brute-force recursion. -/// Keeping it small lets `recurse` stay at four moving args. -struct Ctx<'a> { - tables: &'a Tables, - remaining: u128, -} diff --git a/crates/brk_mempool/src/cpfp.rs b/crates/brk_mempool/src/cpfp.rs index 5c105ad7b..d86e77a0e 100644 --- a/crates/brk_mempool/src/cpfp.rs +++ b/crates/brk_mempool/src/cpfp.rs @@ -1,108 +1,23 @@ -//! CPFP (Child Pays For Parent) cluster reasoning. +//! CPFP (Child Pays For Parent) walk over a `Snapshot`'s adjacency. //! -//! Two consumers, one shared converter: -//! -//! - **Mempool path** (`Mempool::cpfp_info`): looks up the seed in the -//! `Snapshot.cluster_of` map, which already contains the SFL-linearized -//! connected component built once per snapshot cycle. No graph walk, -//! no SFL recomputation. -//! - **Confirmed path** (`brk_query::Query::confirmed_cpfp`): builds a -//! `Cluster` from same-block parent/child edges on demand. -//! -//! Both feed `Cluster::to_cpfp_info`, which walks the cluster from the -//! seed (parents → ancestors, topo-sweep → descendants), reads the seed's -//! chunk feerate as `effectiveFeePerVsize`, and emits the wire shape. -//! -//! The cluster spans the full connected component (matches mempool.space); -//! we don't scope to the seed's projected block, which would drop info -//! when a cluster crosses the projection floor. +//! The snapshot stores per-tx parent/child edges in `TxIndex` space and +//! a per-tx `chunk_rate` (Core's `fees.chunk` / `chunkweight` truth, or +//! the proxy fallback). The walk is a pair of capped DFSes, then the +//! cluster wire shape is materialized from the visited set. use brk_types::{ - CpfpCluster, CpfpClusterChunk, CpfpClusterTx, CpfpEntry, CpfpInfo, FeeRate, SigOps, TxidPrefix, - VSize, + CpfpCluster, CpfpClusterChunk, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate, + SigOps, TxidPrefix, VSize, }; +use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; +use smallvec::SmallVec; use crate::Mempool; -use crate::cluster::{Cluster, ClusterRef, LocalIdx}; +use crate::steps::{SnapTx, TxIndex}; -impl Cluster { - /// Wire-shape `CpfpInfo` for `seed` inside this cluster. `txid` and - /// `weight` come straight off each `ClusterNode`, so the converter - /// is self-contained — no parallel `members` slice required. - pub fn to_cpfp_info(&self, seed: LocalIdx, sigops: SigOps) -> CpfpInfo { - let descendants = self.walk_descendants(seed); - let best_descendant = descendants - .iter() - .max_by_key(|e| FeeRate::from((e.fee, e.weight))) - .cloned(); - let seed_node = &self.nodes[seed.as_usize()]; - - let vsize = VSize::from(seed_node.weight); - let adjusted_vsize = sigops.adjust_vsize(vsize); - - CpfpInfo { - ancestors: self.walk_ancestors(seed), - best_descendant, - descendants, - effective_fee_per_vsize: self.chunk_of(seed).fee_rate(), - sigops, - fee: seed_node.fee, - vsize, - adjusted_vsize, - cluster: self.cluster_view(seed), - } - } - - /// DFS up the parent edges from `seed`, exclusive. Cluster size is - /// capped at 128 by SFL, so a `u128` covers the visited set. - fn walk_ancestors(&self, seed: LocalIdx) -> Vec { - let mut visited = 1u128 << seed.inner(); - let mut out: Vec = Vec::new(); - let mut stack: Vec = self.nodes[seed.as_usize()].parents.to_vec(); - while let Some(idx) = stack.pop() { - let b = 1u128 << idx.inner(); - if visited & b != 0 { - continue; - } - visited |= b; - let node = &self.nodes[idx.as_usize()]; - out.push(CpfpEntry::from(node)); - stack.extend(node.parents.iter().copied()); - } - out - } - - /// Forward sweep over the topo-ordered tail after `seed`. A node is - /// a descendant iff any of its parents is `seed` or already-reached. - /// Nodes before `seed` can't reach it, so they're skipped entirely. - fn walk_descendants(&self, seed: LocalIdx) -> Vec { - let seed_pos = seed.as_usize(); - let mut reachable = 1u128 << seed.inner(); - let mut out: Vec = Vec::new(); - for (i, node) in self.nodes.iter().enumerate().skip(seed_pos + 1) { - if node - .parents - .iter() - .any(|&p| reachable & (1u128 << p.inner()) != 0) - { - reachable |= 1u128 << i; - out.push(CpfpEntry::from(node)); - } - } - out - } - - /// Wire-shape `CpfpCluster`. Cluster nodes are stored in topological - /// order, so `LocalIdx` maps directly onto `CpfpClusterTxIndex` - /// without a permutation lookup. - fn cluster_view(&self, seed: LocalIdx) -> CpfpCluster { - CpfpCluster { - txs: self.nodes.iter().map(CpfpClusterTx::from).collect(), - chunks: self.chunks.iter().map(CpfpClusterChunk::from).collect(), - chunk_index: self.node_to_chunk[seed.as_usize()].inner(), - } - } -} +/// Cap matches Bitcoin Core's default mempool ancestor/descendant +/// chain limits and mempool.space's truncation. +const MAX: usize = 25; impl Mempool { /// CPFP info for a live mempool tx. Returns `None` only when the @@ -110,20 +25,172 @@ impl Mempool { /// confirmed path. pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option { let snapshot = self.snapshot(); - let seed_idx = self.entries().idx_of(prefix)?; - let ClusterRef { - cluster_id, - local: seed_local, - } = snapshot.cluster_of(seed_idx)?; - let cluster = &snapshot.clusters[cluster_id.as_usize()]; - let seed_txid = &cluster.nodes[seed_local.as_usize()].txid; + let seed_idx = snapshot.idx_of(prefix)?; + let seed = snapshot.tx(seed_idx)?; let sigops = self - .txs() - .get(seed_txid) + .read() + .txs + .get(&seed.txid) .map(|tx| tx.total_sigop_cost) .unwrap_or(SigOps::ZERO); - Some(cluster.to_cpfp_info(seed_local, sigops)) + Some(build_cpfp_info(&snapshot.txs, seed_idx, seed, sigops)) } } + +pub(crate) fn build_cpfp_info( + txs: &[SnapTx], + seed_idx: TxIndex, + seed: &SnapTx, + sigops: SigOps, +) -> CpfpInfo { + let ancestors_idx = walk(txs, seed_idx, |t| &t.parents); + let descendants_idx = walk(txs, seed_idx, |t| &t.children); + + let ancestors: Vec = ancestors_idx + .iter() + .filter_map(|&i| txs.get(i.as_usize()).map(CpfpEntry::from)) + .collect(); + let descendants: Vec = descendants_idx + .iter() + .filter_map(|&i| txs.get(i.as_usize()).map(CpfpEntry::from)) + .collect(); + let best_descendant = descendants + .iter() + .max_by_key(|e| FeeRate::from((e.fee, e.weight))) + .cloned(); + + let cluster = build_cluster(txs, seed_idx, &ancestors_idx, &descendants_idx); + let vsize = VSize::from(seed.weight); + + CpfpInfo { + ancestors, + best_descendant, + descendants, + effective_fee_per_vsize: seed.chunk_rate, + sigops, + fee: seed.fee, + vsize, + adjusted_vsize: sigops.adjust_vsize(vsize), + cluster, + } +} + +/// Capped DFS from `seed` (exclusive), following the neighbors yielded +/// by `next`. Used for both the ancestor and descendant walks. +fn walk(txs: &[SnapTx], seed: TxIndex, next: impl Fn(&SnapTx) -> &[TxIndex]) -> Vec { + let mut visited: FxHashSet = + FxHashSet::with_capacity_and_hasher(MAX + 1, FxBuildHasher); + visited.insert(seed); + let mut out: Vec = Vec::with_capacity(MAX); + let mut stack: Vec = txs + .get(seed.as_usize()) + .map(|t| next(t).to_vec()) + .unwrap_or_default(); + while let Some(idx) = stack.pop() { + if out.len() >= MAX { + break; + } + if !visited.insert(idx) { + continue; + } + out.push(idx); + if let Some(t) = txs.get(idx.as_usize()) { + stack.extend(next(t).iter().copied()); + } + } + out +} + +/// Wire-shape `CpfpCluster`. Members are emitted in `[ancestors..., seed, +/// descendants...]` order so the seed's index inside the cluster is +/// `ancestors.len()`. Chunks group txs by exact `chunk_rate` value: under +/// Core 31 this matches Core's actual chunks; under proxy fallback it +/// produces a fine-grained but consistent breakdown. +fn build_cluster( + txs: &[SnapTx], + seed_idx: TxIndex, + ancestors: &[TxIndex], + descendants: &[TxIndex], +) -> CpfpCluster { + let members: Vec = ancestors + .iter() + .copied() + .chain(std::iter::once(seed_idx)) + .chain(descendants.iter().copied()) + .collect(); + + let local_of: FxHashMap = members + .iter() + .enumerate() + .map(|(i, &idx)| (idx, CpfpClusterTxIndex::from(i as u32))) + .collect(); + + let cluster_txs: Vec = members + .iter() + .filter_map(|&idx| { + let t = txs.get(idx.as_usize())?; + Some(CpfpClusterTx { + txid: t.txid, + weight: t.weight, + fee: t.fee, + parents: t + .parents + .iter() + .filter_map(|p| local_of.get(p).copied()) + .collect(), + }) + }) + .collect(); + + let chunks = chunk_groups(&members, txs, &local_of); + let seed_local = local_of[&seed_idx]; + let chunk_index = chunks + .iter() + .position(|ch| ch.txs.contains(&seed_local)) + .unwrap_or(0) as u32; + + CpfpCluster { + txs: cluster_txs, + chunks, + chunk_index, + } +} + +fn chunk_groups( + members: &[TxIndex], + txs: &[SnapTx], + local_of: &FxHashMap, +) -> Vec { + let mut groups: FxHashMap)> = + FxHashMap::with_capacity_and_hasher(members.len(), FxBuildHasher); + let mut order: Vec = Vec::new(); + for &idx in members { + let Some(t) = txs.get(idx.as_usize()) else { + continue; + }; + let key = f64::from(t.chunk_rate).to_bits(); + let local = local_of[&idx]; + groups + .entry(key) + .and_modify(|(_, v)| v.push(local)) + .or_insert_with(|| { + order.push(key); + let mut v: SmallVec<[CpfpClusterTxIndex; 4]> = SmallVec::new(); + v.push(local); + (t.chunk_rate, v) + }); + } + order.sort_by_key(|k| std::cmp::Reverse(groups[k].0)); + order + .into_iter() + .map(|k| { + let (rate, txs) = groups.remove(&k).unwrap(); + CpfpClusterChunk { + txs: txs.into_vec(), + feerate: rate, + } + }) + .collect() +} diff --git a/crates/brk_mempool/src/inner.rs b/crates/brk_mempool/src/inner.rs new file mode 100644 index 000000000..4f6f16a61 --- /dev/null +++ b/crates/brk_mempool/src/inner.rs @@ -0,0 +1,19 @@ +//! Single-locked container for the live mempool. +//! +//! All cycle steps and read-side accessors take a guard on this one +//! lock. The substructures are plain owned types — they used to each +//! own a RwLock, but the canonical lock-order discipline disappears +//! when there's nothing to order. + +use brk_types::MempoolInfo; + +use crate::stores::{AddrTracker, OutpointSpends, TxGraveyard, TxStore}; + +#[derive(Default)] +pub struct MempoolInner { + pub info: MempoolInfo, + pub txs: TxStore, + pub addrs: AddrTracker, + pub outpoint_spends: OutpointSpends, + pub graveyard: TxGraveyard, +} diff --git a/crates/brk_mempool/src/lib.rs b/crates/brk_mempool/src/lib.rs index 76dfd5ae0..fc44d4814 100644 --- a/crates/brk_mempool/src/lib.rs +++ b/crates/brk_mempool/src/lib.rs @@ -1,17 +1,22 @@ //! Live mempool monitor for the brk indexer. //! -//! One pull cycle, five pipeline steps: +//! One pull cycle, five steps: //! -//! 1. [`steps::fetcher::Fetcher`] - three batched RPCs (verbose -//! listing, raw txs for new entries, raw txs for confirmed parents). +//! 1. [`steps::fetcher::Fetcher`] - one mixed batched RPC for +//! `getrawmempool verbose` + `getblocktemplate` + `getmempoolinfo`, +//! then a second batch for `getrawtransaction` on new entries. The +//! GBT is validated to be a subset of the verbose listing; on +//! mismatch the cycle is skipped. //! 2. [`steps::preparer::Preparer`] - decode and classify into //! `TxsPulled { added, removed }`. Pure CPU. //! 3. [`steps::applier::Applier`] - apply the diff to -//! [`stores::state::MempoolState`] under brief write locks. -//! 4. [`steps::resolver::Resolver`] - fill prevouts from the live -//! mempool, or via a caller-supplied external resolver. +//! [`inner::MempoolInner`] under a single write lock. +//! 4. [`prevouts::fill`] - fills `prevout: None` inputs in one pass, +//! using same-cycle in-mempool parents directly and the +//! caller-supplied resolver (default: `getrawtransaction`) for +//! confirmed parents. //! 5. [`steps::rebuilder::Rebuilder`] - throttled rebuild of the -//! projected-blocks `Snapshot`. +//! projected-blocks `Snapshot` from the same-cycle GBT and min fee. use std::{ panic::{AssertUnwindSafe, catch_unwind}, @@ -26,24 +31,27 @@ use brk_types::{ AddrBytes, AddrMempoolStats, FeeRate, MempoolInfo, MempoolRecentTx, OutpointPrefix, OutputType, Sats, Timestamp, Transaction, TxOut, Txid, TxidPrefix, Vin, Vout, }; -use parking_lot::RwLockReadGuard; +use parking_lot::{RwLock, RwLockReadGuard}; use tracing::error; -pub mod cluster; mod cpfp; +mod inner; +mod prevouts; mod rbf; -mod stats; pub(crate) mod steps; pub(crate) mod stores; -#[cfg(test)] -mod tests; pub use rbf::{RbfForTx, RbfNode}; -pub use stats::MempoolStats; -use steps::{Applier, Fetcher, Preparer, Rebuilder, Resolver}; +use steps::{Applier, Fetched, Fetcher, Preparer, Rebuilder}; pub use steps::{BlockStats, RecommendedFees, Snapshot, TxEntry, TxRemoval}; -use stores::{AddrTracker, MempoolState}; -pub use stores::{EntryPool, TxGraveyard, TxStore, TxTombstone}; +pub use stores::{TxGraveyard, TxStore, TxTombstone}; + +/// Confirmed-parent prevout resolver passed to [`Mempool::update_with`] / +/// [`Mempool::start_with`]. Receives `(parent_txid, vout)`, returns the +/// `TxOut` if the parent is reachable, `None` otherwise. +pub type PrevoutResolver = Box Option + Send + Sync>; + +pub(crate) use inner::MempoolInner; /// Cheaply cloneable: clones share one live mempool via `Arc`. #[derive(Clone)] @@ -51,7 +59,7 @@ pub struct Mempool(Arc); struct Inner { client: Client, - state: MempoolState, + lock: RwLock, rebuilder: Rebuilder, } @@ -59,13 +67,13 @@ impl Mempool { pub fn new(client: &Client) -> Self { Self(Arc::new(Inner { client: client.clone(), - state: MempoolState::default(), + lock: RwLock::new(MempoolInner::default()), rebuilder: Rebuilder::default(), })) } pub fn info(&self) -> MempoolInfo { - self.0.state.info.read().clone() + self.read().info.clone() } pub fn snapshot(&self) -> Arc { @@ -76,8 +84,8 @@ impl Mempool { self.0.rebuilder.rebuild_count() } - pub fn skip_counts(&self) -> (u64, u64) { - self.0.rebuilder.skip_counts() + pub fn skip_clean_count(&self) -> u64 { + self.0.rebuilder.skip_clean_count() } pub fn fees(&self) -> RecommendedFees { @@ -93,93 +101,98 @@ impl Mempool { } pub fn addr_state_hash(&self, addr: &AddrBytes) -> u64 { - self.0.state.addrs.read().stats_hash(addr) + self.read().addrs.stats_hash(addr) } /// 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.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; - let spender_tx = txs.get(&spender_txid)?; - let vin_pos = spender_tx + let inner = self.read(); + let spender_prefix = inner.outpoint_spends.get(&key)?; + let spender = inner.txs.record_by_prefix(&spender_prefix)?; + let vin_pos = spender + .tx .input .iter() .position(|inp| inp.txid == *txid && inp.vout == vout)?; - Some((spender_txid, Vin::from(vin_pos))) + Some((spender.entry.txid, Vin::from(vin_pos))) } - pub(crate) fn txs(&self) -> RwLockReadGuard<'_, TxStore> { - self.0.state.txs.read() + pub(crate) fn read(&self) -> RwLockReadGuard<'_, MempoolInner> { + self.0.lock.read() } - pub(crate) fn entries(&self) -> RwLockReadGuard<'_, EntryPool> { - self.0.state.entries.read() + pub fn tx_count(&self) -> usize { + self.read().txs.len() } - pub(crate) fn addrs(&self) -> RwLockReadGuard<'_, AddrTracker> { - self.0.state.addrs.read() + pub fn unresolved_count(&self) -> usize { + self.read().txs.unresolved().len() } - pub(crate) fn graveyard(&self) -> RwLockReadGuard<'_, TxGraveyard> { - self.0.state.graveyard.read() + pub fn addr_count(&self) -> usize { + self.read().addrs.len() + } + + pub fn outpoint_spend_count(&self) -> usize { + self.read().outpoint_spends.len() + } + + pub fn graveyard_tombstone_count(&self) -> usize { + self.read().graveyard.tombstones_len() + } + + pub fn graveyard_order_count(&self) -> usize { + self.read().graveyard.order_len() } pub fn contains_txid(&self, txid: &Txid) -> bool { - self.txs().contains(txid) + self.read().txs.contains(txid) } /// Apply `f` to the live tx body if present. pub fn with_tx(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option { - self.txs().get(txid).map(f) + self.read().txs.get(txid).map(f) } /// Apply `f` to a `Vanished` tombstone's tx body if present. /// `Replaced` tombstones return `None` because the tx will not confirm. - pub fn with_vanished_tx( - &self, - txid: &Txid, - f: impl FnOnce(&Transaction) -> R, - ) -> Option { - let graveyard = self.graveyard(); - let tomb = graveyard.get(txid)?; + pub fn with_vanished_tx(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option { + let inner = self.read(); + let tomb = inner.graveyard.get(txid)?; matches!(tomb.reason(), TxRemoval::Vanished).then(|| f(&tomb.tx)) } /// Snapshot of all live mempool txids. pub fn txids(&self) -> Vec { - self.txs().keys().cloned().collect() + self.read().txs.txids().copied().collect() } /// Snapshot of recent live txs. pub fn recent_txs(&self) -> Vec { - self.txs().recent().to_vec() + self.read().txs.recent().to_vec() } /// Per-address mempool stats. `None` if the address has no live mempool activity. pub fn addr_stats(&self, addr: &AddrBytes) -> Option { - self.addrs().get(addr).map(|e| e.stats.clone()) + self.read().addrs.get(addr).map(|e| e.stats.clone()) } /// Live mempool txs touching `addr`, newest first by `first_seen`, /// 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(); - let entries = self.entries(); - let Some(entry) = addrs.get(addr) else { + let inner = self.read(); + let Some(entry) = inner.addrs.get(addr) else { return vec![]; }; let mut ordered: Vec<(Timestamp, &Txid)> = entry .txids .iter() .map(|txid| { - let first_seen = entries - .get(&TxidPrefix::from(txid)) + let first_seen = inner + .txs + .entry(txid) .map(|e| e.first_seen) .unwrap_or_default(); (first_seen, txid) @@ -188,7 +201,7 @@ impl Mempool { ordered.sort_unstable_by_key(|b| std::cmp::Reverse(b.0)); ordered .into_iter() - .filter_map(|(_, txid)| txs.get(txid).cloned()) + .filter_map(|(_, txid)| inner.txs.get(txid).cloned()) .take(limit) .collect() } @@ -199,29 +212,32 @@ impl Mempool { &self, f: impl FnOnce(&mut dyn Iterator) -> R, ) -> R { - let txs = self.txs(); - let mut iter = txs + let inner = self.read(); + let mut iter = inner + .txs .values() .flat_map(|tx| &tx.output) .map(|txout| (txout.value, txout.type_())); f(&mut iter) } - /// 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. + /// Effective fee rate for a live tx: snapshot's chunk rate when + /// the tx is in the latest snapshot, falling back to the entry's + /// `fee/vsize` if not yet ingested. pub fn live_effective_fee_rate(&self, prefix: &TxidPrefix) -> Option { - let entries = self.entries(); - if let Some(seed_idx) = entries.idx_of(prefix) - && let Some(rate) = self.snapshot().chunk_rate_of(seed_idx) - { + if let Some(rate) = self.snapshot().chunk_rate_for(prefix) { return Some(rate); } - entries.get(prefix).map(|e| e.fee_rate()) + self.read() + .txs + .entry_by_prefix(prefix) + .map(|e| e.fee_rate()) } /// Fee rate snapshotted into a graveyard tomb at burial. pub fn graveyard_fee_rate(&self, txid: &Txid) -> Option { - self.graveyard() + self.read() + .graveyard .get(txid) .map(|tomb| tomb.entry.fee_rate()) } @@ -231,15 +247,14 @@ impl Mempool { /// 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(); + let inner = self.read(); txids .iter() .map(|txid| { - if let Some(e) = entries.get(&TxidPrefix::from(txid)) { + if let Some(e) = inner.txs.entry(txid) { return u64::from(e.first_seen); } - if let Some(tomb) = graveyard.get(txid) + if let Some(tomb) = inner.graveyard.get(txid) && matches!(tomb.reason(), TxRemoval::Vanished) { return u64::from(tomb.entry.first_seen); @@ -249,21 +264,26 @@ impl Mempool { .collect() } - /// Infinite update loop with a 1 second interval. + /// Infinite update loop with a 1 second interval. Resolves + /// confirmed-parent prevouts via the default `getrawtransaction` + /// resolver; requires bitcoind started with `txindex=1`. pub fn start(&self) { - self.start_with(|| {}); + self.start_with(prevouts::rpc_resolver(self.0.client.clone())); } - /// Variant of `start` that runs `after_update` after every cycle. - /// Both steps are wrapped in `catch_unwind` so a panic doesn't + /// Variant of `start` that uses a caller-supplied resolver for + /// confirmed-parent prevouts (typically backed by an indexer). + /// Each cycle is 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()) { + pub fn start_with(&self, resolver: F) + where + F: Fn(&Txid, Vout) -> Option, + { loop { let outcome = catch_unwind(AssertUnwindSafe(|| { - if let Err(e) = self.update() { + if let Err(e) = self.update_with(&resolver) { error!("update failed: {e}"); } - after_update(); })); if let Err(payload) = outcome { let msg = if let Some(s) = payload.downcast_ref::<&'static str>() { @@ -279,34 +299,41 @@ impl Mempool { } } - /// Fill remaining `prevout == None` inputs via an external - /// resolver (typically the indexer for confirmed parents). - /// In-mempool parents are filled automatically each cycle. - pub fn fill_prevouts(&self, resolver: F) -> bool + /// One sync cycle with the default RPC resolver. Equivalent to + /// `update_with(rpc_resolver)`. Standalone consumers (Core + + /// `txindex=1`) get a one-line driver loop. + pub fn update(&self) -> Result<()> { + self.update_with(prevouts::rpc_resolver(self.0.client.clone())) + } + + /// One sync cycle: fetch, prepare, apply, fill prevouts, maybe + /// rebuild. The resolver MUST resolve confirmed prevouts only; + /// mempool-to-mempool chains are wired internally and the + /// resolver is never called for them. + pub fn update_with(&self, resolver: F) -> Result<()> where F: Fn(&Txid, Vout) -> Option, { - Resolver::resolve_external(&self.0.state, resolver) - } - - /// One sync cycle: fetch, prepare, apply, resolve, maybe rebuild. - pub fn update(&self) -> Result<()> { let Inner { client, - state, + lock, rebuilder, } = &*self.0; - let fetched = Fetcher::fetch(client, state)?; - let pulled = Preparer::prepare(fetched, state); - let changed = Applier::apply(state, pulled); - Resolver::resolve_in_mempool(state); - rebuilder.tick(client, state, changed); + let Some(Fetched { + entries_info, + new_raws, + gbt, + min_fee, + }) = Fetcher::fetch(client, lock)? + else { + return Ok(()); + }; + let pulled = Preparer::prepare(entries_info, new_raws, lock); + let changed = Applier::apply(lock, pulled); + prevouts::fill(lock, resolver); + rebuilder.tick(lock, changed, &gbt, min_fee); Ok(()) } - - pub(crate) fn state(&self) -> &MempoolState { - &self.0.state - } } diff --git a/crates/brk_mempool/src/prevouts.rs b/crates/brk_mempool/src/prevouts.rs new file mode 100644 index 000000000..4a6afb002 --- /dev/null +++ b/crates/brk_mempool/src/prevouts.rs @@ -0,0 +1,145 @@ +//! Prevout fill plumbing. +//! +//! A fresh tx can land in the store with `prevout: None` on some +//! inputs when the Preparer can't see the parent (parent arrived in +//! the same cycle as the child, or parent is confirmed and we don't +//! have an indexer hooked up). [`fill`] runs after each successful +//! `Applier::apply` and closes both gaps in one pass: +//! +//! 1. Snapshot under a read guard, walking `txs.unresolved()` once. +//! For each hole, if the parent is also in the live pool we record +//! a fill directly (cheap, lock-local). Otherwise we record the +//! hole for external resolution. +//! 2. Drop the read guard. Call `resolver` on the remaining holes +//! (typically `getrawtransaction` or an indexer lookup); failures +//! are simply skipped and retried next cycle. +//! 3. Take the write guard once and fold both fill batches into the +//! `TxStore` via `apply_fills` -> `add_input`. Idempotent: each +//! fill checks `prevout.is_none()` and bails if the tx was already +//! removed or filled between phases. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use brk_rpc::Client; +use brk_types::{TxOut, Txid, TxidPrefix, Vin, Vout}; +use parking_lot::RwLock; +use tracing::warn; + +use crate::{MempoolInner, stores::TxStore}; + +/// Default resolver: per-call `getrawtransaction` against the bitcoind +/// RPC client `Mempool` already holds. Requires `txindex=1`. On any +/// failure logs once with a hint, then returns `None`; the next cycle +/// retries automatically. +pub(crate) fn rpc_resolver(client: Client) -> impl Fn(&Txid, Vout) -> Option { + let warned = AtomicBool::new(false); + move |txid, vout| { + let bt: &bitcoin::Txid = txid.into(); + match client.get_raw_transaction(bt, None as Option<&bitcoin::BlockHash>) { + Ok(tx) => tx + .output + .get(usize::from(vout)) + .map(|o| TxOut::from((o.script_pubkey.clone(), o.value.into()))), + Err(_) => { + if !warned.swap(true, Ordering::Relaxed) { + warn!( + "mempool: getrawtransaction missed for {txid}; ensure bitcoind is running with txindex=1" + ); + } + None + } + } + } +} + +type Fills = Vec<(Vin, TxOut)>; +type Holes = Vec<(Vin, Txid, Vout)>; +type FillBatch = Vec<(TxidPrefix, Txid, Fills)>; +type HoleBatch = Vec<(TxidPrefix, Txid, Holes)>; + +/// Fill every unfilled prevout the cycle can resolve. Same-cycle +/// in-mempool parents are filled lock-locally; the remainder go +/// through `resolver` outside any lock. Returns true iff anything +/// was written. +pub(crate) fn fill(lock: &RwLock, resolver: F) -> bool +where + F: Fn(&Txid, Vout) -> Option, +{ + let (in_mempool, holes) = { + let inner = lock.read(); + gather(&inner.txs) + }; + let external = resolve_external(holes, resolver); + + if in_mempool.is_empty() && external.is_empty() { + return false; + } + + let mut inner = lock.write(); + write_fills(&mut inner, in_mempool); + write_fills(&mut inner, external); + true +} + +/// Single pass over `txs.unresolved()`: bucket each hole into a +/// same-cycle in-mempool fill (parent is live) or an external hole +/// (parent is confirmed or unknown). +fn gather(txs: &TxStore) -> (FillBatch, HoleBatch) { + if txs.unresolved().is_empty() { + return (Vec::new(), Vec::new()); + } + let mut filled: FillBatch = Vec::new(); + let mut holes: HoleBatch = Vec::new(); + for prefix in txs.unresolved() { + let Some(record) = txs.record_by_prefix(prefix) else { + continue; + }; + let mut tx_fills: Fills = Vec::new(); + let mut tx_holes: Holes = Vec::new(); + for (i, txin) in record.tx.input.iter().enumerate() { + if txin.prevout.is_some() { + continue; + } + let vin = Vin::from(i); + if let Some(parent) = txs.get(&txin.txid) + && let Some(out) = parent.output.get(usize::from(txin.vout)) + { + tx_fills.push((vin, out.clone())); + } else { + tx_holes.push((vin, txin.txid, txin.vout)); + } + } + let txid = record.entry.txid; + if !tx_fills.is_empty() { + filled.push((*prefix, txid, tx_fills)); + } + if !tx_holes.is_empty() { + holes.push((*prefix, txid, tx_holes)); + } + } + (filled, holes) +} + +fn resolve_external(holes: HoleBatch, resolver: F) -> FillBatch +where + F: Fn(&Txid, Vout) -> Option, +{ + holes + .into_iter() + .filter_map(|(prefix, txid, holes)| { + let fills: Fills = holes + .into_iter() + .filter_map(|(vin, prev_txid, vout)| resolver(&prev_txid, vout).map(|o| (vin, o))) + .collect(); + (!fills.is_empty()).then_some((prefix, txid, fills)) + }) + .collect() +} + +fn write_fills(inner: &mut MempoolInner, fills: FillBatch) { + for (prefix, txid, tx_fills) in fills { + for prevout in inner.txs.apply_fills(&prefix, tx_fills) { + inner.addrs.add_input(&txid, &prevout); + } + } +} diff --git a/crates/brk_mempool/src/rbf.rs b/crates/brk_mempool/src/rbf.rs index d1ee77bb3..d0066fdf4 100644 --- a/crates/brk_mempool/src/rbf.rs +++ b/crates/brk_mempool/src/rbf.rs @@ -1,15 +1,12 @@ //! 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. +//! recursively acquire the same read lock. use brk_types::{Sats, Timestamp, Transaction, Txid, TxidPrefix, VSize}; use rustc_hash::FxHashSet; -use crate::{ - Mempool, TxEntry, TxRemoval, TxStore, - stores::{EntryPool, TxGraveyard}, -}; +use crate::{Mempool, TxEntry, TxRemoval, TxStore, stores::TxGraveyard}; #[derive(Debug, Clone)] pub struct RbfNode { @@ -36,15 +33,17 @@ pub struct RbfForTx { impl Mempool { /// 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. + /// direct predecessors. Single read-lock window. pub fn rbf_for_tx(&self, txid: &Txid) -> RbfForTx { - let txs = self.txs(); - let entries = self.entries(); - let graveyard = self.graveyard(); + let inner = self.read(); - let root_txid = walk_to_replacement_root(&graveyard, *txid); - let replaces: Vec = graveyard.predecessors_of(txid).map(|(p, _)| *p).collect(); - let root = build_node(&root_txid, &txs, &entries, &graveyard); + let root_txid = walk_to_replacement_root(&inner.graveyard, *txid); + let replaces: Vec = inner + .graveyard + .predecessors_of(txid) + .map(|(p, _)| *p) + .collect(); + let root = build_node(&root_txid, &inner.txs, &inner.graveyard); RbfForTx { root, replaces } } @@ -52,18 +51,17 @@ impl Mempool { /// 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(); - let graveyard = self.graveyard(); + let inner = self.read(); let mut seen: FxHashSet = FxHashSet::default(); - graveyard + inner + .graveyard .replaced_iter_recent_first() .filter_map(|(_, by)| { - let root = walk_to_replacement_root(&graveyard, *by); + let root = walk_to_replacement_root(&inner.graveyard, *by); seen.insert(root).then_some(root) }) - .filter_map(|root| build_node(&root, &txs, &entries, &graveyard)) + .filter_map(|root| build_node(&root, &inner.txs, &inner.graveyard)) .filter(|n| !full_rbf_only || n.full_rbf) .take(limit) .collect() @@ -77,17 +75,12 @@ fn walk_to_replacement_root(graveyard: &TxGraveyard, mut root: Txid) -> Txid { root } -fn build_node( - txid: &Txid, - txs: &TxStore, - entries: &EntryPool, - graveyard: &TxGraveyard, -) -> Option { - let (tx, entry) = resolve_node(txid, txs, entries, graveyard)?; +fn build_node(txid: &Txid, txs: &TxStore, graveyard: &TxGraveyard) -> Option { + let (tx, entry) = resolve_node(txid, txs, graveyard)?; let replaces: Vec = graveyard .predecessors_of(txid) - .filter_map(|(pred, _)| build_node(pred, txs, entries, graveyard)) + .filter_map(|(pred, _)| build_node(pred, txs, graveyard)) .collect(); let full_rbf = replaces.iter().any(|c| !c.rbf || c.full_rbf); @@ -108,11 +101,10 @@ fn build_node( fn resolve_node<'a>( txid: &Txid, txs: &'a TxStore, - entries: &'a EntryPool, graveyard: &'a TxGraveyard, ) -> Option<(&'a Transaction, &'a TxEntry)> { - if let (Some(tx), Some(entry)) = (txs.get(txid), entries.get(&TxidPrefix::from(txid))) { - return Some((tx, entry)); + if let Some(record) = txs.record_by_prefix(&TxidPrefix::from(txid)) { + return Some((&record.tx, &record.entry)); } graveyard.get(txid).map(|tomb| (&tomb.tx, &tomb.entry)) } diff --git a/crates/brk_mempool/src/stats.rs b/crates/brk_mempool/src/stats.rs deleted file mode 100644 index 7838aa95c..000000000 --- a/crates/brk_mempool/src/stats.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Owned snapshot of mempool in-memory counters for diagnostic display. - -use crate::Mempool; - -#[derive(Debug, Clone)] -pub struct MempoolStats { - pub info_count: usize, - pub tx_count: usize, - pub unresolved_count: usize, - pub addr_count: usize, - pub entry_slot_count: usize, - pub entry_active_count: usize, - pub entry_free_count: usize, - pub outpoint_spend_count: usize, - pub graveyard_tombstone_count: usize, - pub graveyard_order_count: usize, -} - -impl From<&Mempool> for MempoolStats { - fn from(mempool: &Mempool) -> Self { - let state = mempool.state(); - let info = state.info.read(); - let txs = state.txs.read(); - let addrs = state.addrs.read(); - let entries = state.entries.read(); - let outpoint_spends = state.outpoint_spends.read(); - let graveyard = state.graveyard.read(); - Self { - info_count: info.count, - tx_count: txs.len(), - unresolved_count: txs.unresolved().len(), - addr_count: addrs.len(), - entry_slot_count: entries.entries().len(), - entry_active_count: entries.active_count(), - entry_free_count: entries.free_slots_count(), - outpoint_spend_count: outpoint_spends.len(), - graveyard_tombstone_count: graveyard.tombstones_len(), - graveyard_order_count: graveyard.order_len(), - } - } -} diff --git a/crates/brk_mempool/src/steps/applier.rs b/crates/brk_mempool/src/steps/applier.rs index 58bdffa1d..f056f3db4 100644 --- a/crates/brk_mempool/src/steps/applier.rs +++ b/crates/brk_mempool/src/steps/applier.rs @@ -1,85 +1,73 @@ -use brk_types::{Transaction, Txid, TxidPrefix}; -use tracing::warn; +use brk_types::{Transaction, TxidPrefix}; +use parking_lot::RwLock; use crate::{ TxEntry, TxRemoval, + inner::MempoolInner, steps::preparer::{TxAddition, TxsPulled}, - stores::{LockedState, MempoolState}, }; -/// Applies a prepared diff to in-memory mempool state. All five write -/// locks are taken in canonical order via `MempoolState::write_all`, -/// then the body proceeds as: bury removed → publish added → evict. +/// Applies a prepared diff to in-memory mempool state under one write +/// guard. Body proceeds: bury removed → publish added → evict. pub struct Applier; impl Applier { /// Returns true iff anything changed. - pub fn apply(state: &MempoolState, pulled: TxsPulled) -> bool { + pub fn apply(lock: &RwLock, pulled: TxsPulled) -> bool { let TxsPulled { added, removed } = pulled; let has_changes = !added.is_empty() || !removed.is_empty(); - let mut s = state.write_all(); - Self::bury_removals(&mut s, removed); - Self::publish_additions(&mut s, added); - s.graveyard.evict_old(); + let mut inner = lock.write(); + Self::bury_removals(&mut inner, removed); + Self::publish_additions(&mut inner, added); + inner.graveyard.evict_old(); has_changes } - fn bury_removals(s: &mut LockedState, removed: Vec<(TxidPrefix, TxRemoval)>) { + fn bury_removals(inner: &mut MempoolInner, removed: Vec<(TxidPrefix, TxRemoval)>) { for (prefix, reason) in removed { - Self::bury_one(s, &prefix, reason); + Self::bury_one(inner, &prefix, reason); } } - fn bury_one(s: &mut LockedState, prefix: &TxidPrefix, reason: TxRemoval) { - let Some(txid) = s.entries.get(prefix).map(|e| e.txid) else { + fn bury_one(inner: &mut MempoolInner, prefix: &TxidPrefix, reason: TxRemoval) { + let Some(record) = inner.txs.remove_by_prefix(prefix) else { return; }; - if !s.txs.contains(&txid) { - // 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"); - let tx = s.txs.remove(&txid).expect("tx present"); - s.info.remove(&tx, entry.fee); - s.addrs.remove_tx(&tx, &txid); - s.outpoint_spends.remove_spends(&tx, idx); - s.graveyard.bury(txid, tx, entry, reason); + let txid = record.entry.txid; + inner.info.remove(&record.tx, record.entry.fee); + inner.addrs.remove_tx(&record.tx, &txid); + inner.outpoint_spends.remove_spends(&record.tx, *prefix); + inner.graveyard.bury(txid, record.tx, record.entry, reason); } - fn publish_additions(s: &mut LockedState, added: Vec) { - let mut to_store: Vec<(Txid, Transaction)> = Vec::with_capacity(added.len()); + fn publish_additions(inner: &mut MempoolInner, added: Vec) { for addition in added { - if let Some((tx, entry)) = Self::resolve_addition(s, addition) { - to_store.push(Self::publish_one(s, tx, entry)); + if let Some((tx, entry)) = Self::resolve_addition(inner, addition) { + Self::publish_one(inner, tx, entry); } } - s.txs.extend(to_store); } fn resolve_addition( - s: &mut LockedState, + inner: &mut MempoolInner, addition: TxAddition, ) -> Option<(Transaction, TxEntry)> { match addition { TxAddition::Fresh { tx, entry } => Some((tx, entry)), TxAddition::Revived { entry } => { - let tomb = s.graveyard.exhume(&entry.txid)?; + let tomb = inner.graveyard.exhume(&entry.txid)?; Some((tomb.tx, entry)) } } } - fn publish_one(s: &mut LockedState, tx: Transaction, entry: TxEntry) -> (Txid, Transaction) { - s.info.add(&tx, entry.fee); - s.addrs.add_tx(&tx, &entry.txid); - let txid = entry.txid; - let idx = s.entries.insert(entry); - s.outpoint_spends.insert_spends(&tx, idx); - (txid, tx) + fn publish_one(inner: &mut MempoolInner, tx: Transaction, entry: TxEntry) { + let prefix = entry.txid_prefix(); + inner.info.add(&tx, entry.fee); + inner.addrs.add_tx(&tx, &entry.txid); + inner.outpoint_spends.insert_spends(&tx, prefix); + inner.txs.insert(tx, entry); } } diff --git a/crates/brk_mempool/src/steps/fetcher/fetched.rs b/crates/brk_mempool/src/steps/fetcher/fetched.rs index 90c5f3f10..e5c28db63 100644 --- a/crates/brk_mempool/src/steps/fetcher/fetched.rs +++ b/crates/brk_mempool/src/steps/fetcher/fetched.rs @@ -1,9 +1,10 @@ -use brk_rpc::RawTx; -use brk_types::{MempoolEntryInfo, Txid}; +use brk_rpc::{BlockTemplateTx, RawTx}; +use brk_types::{FeeRate, MempoolEntryInfo, Txid}; use rustc_hash::FxHashMap; pub struct Fetched { pub entries_info: Vec, pub new_raws: FxHashMap, - pub parent_raws: FxHashMap, + pub gbt: Vec, + pub min_fee: FeeRate, } diff --git a/crates/brk_mempool/src/steps/fetcher/mod.rs b/crates/brk_mempool/src/steps/fetcher/mod.rs index 900078f90..889e4ed37 100644 --- a/crates/brk_mempool/src/steps/fetcher/mod.rs +++ b/crates/brk_mempool/src/steps/fetcher/mod.rs @@ -3,64 +3,52 @@ mod fetched; pub use fetched::Fetched; use brk_error::Result; -use brk_rpc::{Client, RawTx}; +use brk_rpc::{Client, MempoolState}; use brk_types::{MempoolEntryInfo, Txid}; -use rustc_hash::{FxHashMap, FxHashSet}; +use parking_lot::RwLock; -use crate::stores::{MempoolState, TxGraveyard, TxStore}; +use crate::{ + MempoolInner, + stores::{TxGraveyard, TxStore}, +}; /// Cap before the batch RPC so we never hand bitcoind an unbounded batch. const MAX_TX_FETCHES_PER_CYCLE: usize = 10_000; -/// Three batched round-trips per cycle regardless of mempool size: -/// `getrawmempool verbose`, then `getrawtransaction` for new txs, then -/// `getrawtransaction` for confirmed parents. +/// Two batched round-trips per cycle regardless of mempool size: +/// `getrawmempool verbose` + `getblocktemplate` + `getmempoolinfo` in +/// one mixed batch, then `getrawtransaction` for new txs. /// -/// The third batch is best-effort. Without `-txindex` Core returns -5 -/// for every confirmed parent. `brk_query` fills missing prevouts at -/// read time from the indexer, so this is purely a latency -/// optimization when `-txindex` is available. +/// `getblocktemplate` is validated to be a subset of the verbose +/// listing inside the RPC layer; mismatches return `Ok(None)` so the +/// cycle is skipped without polluting downstream state. +/// +/// Confirmed prevouts are resolved post-apply by the caller-supplied +/// resolver passed to `Mempool::update_with`, so the in-crate path no +/// longer issues a third batch for parents. pub struct Fetcher; impl Fetcher { - pub fn fetch(client: &Client, state: &MempoolState) -> Result { - let entries_info = Self::list_pool(client)?; - let new_raws = Self::fetch_new(client, state, &entries_info)?; - let parent_raws = Self::fetch_parents(client, state, &new_raws)?; - Ok(Fetched { - entries_info, - new_raws, - parent_raws, - }) - } - - fn list_pool(client: &Client) -> Result> { - client.get_raw_mempool_verbose() - } - - fn fetch_new( - client: &Client, - state: &MempoolState, - entries_info: &[MempoolEntryInfo], - ) -> Result> { + pub fn fetch(client: &Client, lock: &RwLock) -> Result> { + let Some(MempoolState { + entries, + gbt, + min_fee, + }) = client.fetch_mempool_state()? + else { + return Ok(None); + }; let new_txids = { - let known = state.txs.read(); - let graveyard = state.graveyard.read(); - Self::new_txids(entries_info, &known, &graveyard) + let inner = lock.read(); + Self::new_txids(&entries, &inner.txs, &inner.graveyard) }; - client.get_raw_transactions(&new_txids) - } - - fn fetch_parents( - client: &Client, - state: &MempoolState, - new_raws: &FxHashMap, - ) -> Result> { - let parent_txids = { - let known = state.txs.read(); - Self::unique_confirmed_parents(new_raws, &known) - }; - client.get_raw_transactions(&parent_txids) + let new_raws = client.get_raw_transactions(&new_txids)?; + Ok(Some(Fetched { + entries_info: entries, + new_raws, + gbt, + min_fee, + })) } fn new_txids( @@ -75,18 +63,4 @@ impl Fetcher { .map(|info| info.txid) .collect() } - - fn unique_confirmed_parents(new_raws: &FxHashMap, known: &TxStore) -> Vec { - // Iterating new_raws.values() yields txs in arbitrary FxHashMap order, - // so duplicates of the same parent are typically non-adjacent. Dedup - // via a FxHashSet so a parent shared by N new txs is fetched once. - let mut seen: FxHashSet = FxHashSet::default(); - new_raws - .values() - .flat_map(|raw| &raw.tx.input) - .map(|txin| Txid::from(txin.previous_output.txid)) - .filter(|prev| !known.contains(prev) && !new_raws.contains_key(prev)) - .filter(|prev| seen.insert(*prev)) - .collect() - } } diff --git a/crates/brk_mempool/src/steps/mod.rs b/crates/brk_mempool/src/steps/mod.rs index a3e1fa4e3..3228e7956 100644 --- a/crates/brk_mempool/src/steps/mod.rs +++ b/crates/brk_mempool/src/steps/mod.rs @@ -1,13 +1,11 @@ -//! The five pipeline steps. See the crate-level docs for the cycle. +//! The four pipeline steps. See the crate-level docs for the cycle. mod applier; mod fetcher; pub(crate) mod preparer; pub(crate) mod rebuilder; -mod resolver; pub use applier::Applier; -pub use fetcher::Fetcher; +pub use fetcher::{Fetched, Fetcher}; pub use preparer::{Preparer, TxEntry, TxRemoval}; -pub use rebuilder::{BlockStats, Rebuilder, RecommendedFees, Snapshot}; -pub use resolver::Resolver; +pub use rebuilder::{BlockStats, Rebuilder, RecommendedFees, SnapTx, Snapshot, TxIndex}; diff --git a/crates/brk_mempool/src/steps/preparer/mod.rs b/crates/brk_mempool/src/steps/preparer/mod.rs index 127b37507..ff1a6ce39 100644 --- a/crates/brk_mempool/src/steps/preparer/mod.rs +++ b/crates/brk_mempool/src/steps/preparer/mod.rs @@ -1,21 +1,24 @@ //! Turn `Fetched` raws into a typed diff for the Applier. Pure CPU, -//! holds read locks on `txs` and `graveyard` for the cycle. New txs -//! are classified into three buckets: +//! holds a read guard on `MempoolInner` for the cycle. New txs are +//! classified into three buckets: //! //! - **live** - already in `known`, skipped. //! - **revivable** - in the graveyard, resurrected from the tombstone. //! - **fresh** - decoded from `new_raws`, prevouts resolved against -//! `known` or `parent_raws`. +//! the live mempool only. Confirmed-parent prevouts land as +//! `prevout: None` and are filled post-apply by the resolver passed +//! to `Mempool::update_with`. //! //! Removals are inferred by cross-referencing inputs. use brk_rpc::RawTx; use brk_types::{MempoolEntryInfo, Txid, TxidPrefix}; +use parking_lot::RwLock; use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ - steps::fetcher::Fetched, - stores::{MempoolState, TxGraveyard, TxStore}, + MempoolInner, + stores::{TxGraveyard, TxStore}, }; mod tx_addition; @@ -31,13 +34,16 @@ pub use txs_pulled::TxsPulled; pub struct Preparer; impl Preparer { - pub fn prepare(fetched: Fetched, state: &MempoolState) -> TxsPulled { - let known = state.txs.read(); - let graveyard = state.graveyard.read(); + pub fn prepare( + entries_info: Vec, + new_raws: FxHashMap, + lock: &RwLock, + ) -> TxsPulled { + let inner = lock.read(); - let live = Self::live_set(&fetched.entries_info); - let added = Self::classify_additions(fetched, &known, &graveyard); - let removed = TxRemoval::classify(&live, &added, &known); + let live = Self::live_set(&entries_info); + let added = Self::classify_additions(entries_info, new_raws, &inner.txs, &inner.graveyard); + let removed = TxRemoval::classify(&live, &added, &inner.txs); TxsPulled { added, removed } } @@ -50,19 +56,14 @@ impl Preparer { } fn classify_additions( - fetched: Fetched, + entries_info: Vec, + mut new_raws: FxHashMap, known: &TxStore, graveyard: &TxGraveyard, ) -> Vec { - let Fetched { - entries_info, - mut new_raws, - parent_raws, - } = fetched; - entries_info .iter() - .filter_map(|info| Self::classify(info, known, graveyard, &mut new_raws, &parent_raws)) + .filter_map(|info| Self::classify(info, known, graveyard, &mut new_raws)) .collect() } @@ -71,7 +72,6 @@ impl Preparer { known: &TxStore, graveyard: &TxGraveyard, new_raws: &mut FxHashMap, - parent_raws: &FxHashMap, ) -> Option { if known.contains(&info.txid) { return None; @@ -80,6 +80,6 @@ impl Preparer { return Some(TxAddition::revived(info, tomb)); } let raw = new_raws.remove(&info.txid)?; - Some(TxAddition::fresh(info, raw, parent_raws, known)) + Some(TxAddition::fresh(info, raw, known)) } } diff --git a/crates/brk_mempool/src/steps/preparer/tx_addition.rs b/crates/brk_mempool/src/steps/preparer/tx_addition.rs index 6a48293c3..64d2e661c 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_addition.rs +++ b/crates/brk_mempool/src/steps/preparer/tx_addition.rs @@ -1,8 +1,10 @@ //! Two arrival kinds: //! //! - **Fresh** - tx unknown to us. Decode the raw bytes, resolve -//! prevouts against `known` or `parent_raws`, build a full -//! `Transaction` + `Entry`. +//! prevouts against the live mempool (same-cycle parents), build a +//! full `Transaction` + `Entry`. Confirmed parents land as +//! `prevout: None` and are filled post-apply by the resolver passed +//! to `Mempool::update_with`. //! - **Revived** - tx in the graveyard. Rebuild the `Entry` only //! (preserving `rbf`, `size`). The Applier exhumes the cached tx //! body. No raw decoding. @@ -11,7 +13,6 @@ use std::mem; use brk_rpc::RawTx; use brk_types::{MempoolEntryInfo, SigOps, Transaction, TxIn, TxOut, TxStatus, Txid, Vout}; -use rustc_hash::FxHashMap; use crate::{TxTombstone, stores::TxStore}; @@ -23,18 +24,13 @@ pub enum TxAddition { } impl TxAddition { - /// Resolves prevouts against the live mempool first, then `parent_raws`. - /// Unresolved inputs land with `prevout: None` for later filling by - /// the Resolver or by `brk_query` at read time. - pub(super) fn fresh( - info: &MempoolEntryInfo, - raw: RawTx, - parent_raws: &FxHashMap, - mempool_txs: &TxStore, - ) -> Self { + /// Resolves prevouts against the live mempool only. Confirmed + /// parents land with `prevout: None` and are filled by the + /// resolver supplied to `Mempool::update_with` in the same cycle. + pub(super) fn fresh(info: &MempoolEntryInfo, raw: RawTx, mempool_txs: &TxStore) -> Self { let total_size = raw.hex.len() / 2; let rbf = raw.tx.input.iter().any(|i| i.sequence.is_rbf()); - let tx = Self::build_tx(info, raw, total_size, mempool_txs, parent_raws); + let tx = Self::build_tx(info, raw, total_size, mempool_txs); let entry = TxEntry::new(info, total_size as u64, rbf); Self::Fresh { tx, entry } } @@ -44,11 +40,10 @@ impl TxAddition { mut raw: RawTx, total_size: usize, mempool_txs: &TxStore, - parent_raws: &FxHashMap, ) -> Transaction { let input = mem::take(&mut raw.tx.input) .into_iter() - .map(|txin| Self::build_txin(txin, mempool_txs, parent_raws)) + .map(|txin| Self::build_txin(txin, mempool_txs)) .collect(); let mut tx = Transaction { index: None, @@ -72,14 +67,10 @@ impl TxAddition { Self::Revived { entry } } - fn build_txin( - txin: bitcoin::TxIn, - mempool_txs: &TxStore, - parent_raws: &FxHashMap, - ) -> TxIn { + fn build_txin(txin: bitcoin::TxIn, mempool_txs: &TxStore) -> TxIn { let prev_txid: Txid = txin.previous_output.txid.into(); let prev_vout = usize::from(Vout::from(txin.previous_output.vout)); - let prevout = Self::resolve_prevout(&prev_txid, prev_vout, mempool_txs, parent_raws); + let prevout = Self::resolve_prevout(&prev_txid, prev_vout, mempool_txs); TxIn { // Mempool txs are never coinbase (Core rejects them @@ -97,24 +88,10 @@ impl TxAddition { } } - fn resolve_prevout( - prev_txid: &Txid, - prev_vout: usize, - mempool_txs: &TxStore, - parent_raws: &FxHashMap, - ) -> Option { - if let Some(prev) = mempool_txs.get(prev_txid) { - return prev - .output - .get(prev_vout) - .map(|o| TxOut::from((o.script_pubkey.clone(), o.value))); - } - parent_raws.get(prev_txid).and_then(|parent| { - parent - .tx - .output - .get(prev_vout) - .map(|o| TxOut::from((o.script_pubkey.clone(), o.value.into()))) - }) + fn resolve_prevout(prev_txid: &Txid, prev_vout: usize, mempool_txs: &TxStore) -> Option { + let prev = mempool_txs.get(prev_txid)?; + prev.output + .get(prev_vout) + .map(|o| TxOut::from((o.script_pubkey.clone(), o.value))) } } diff --git a/crates/brk_mempool/src/steps/preparer/tx_entry.rs b/crates/brk_mempool/src/steps/preparer/tx_entry.rs index 7a264253f..3684060de 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_entry.rs +++ b/crates/brk_mempool/src/steps/preparer/tx_entry.rs @@ -1,29 +1,27 @@ use brk_types::{FeeRate, MempoolEntryInfo, Sats, Timestamp, Txid, TxidPrefix, VSize, Weight}; use smallvec::SmallVec; -/// A mempool transaction entry. -/// -/// Stores only immutable per-tx facts. Ancestor aggregates are -/// deliberately not cached: they're derivable from the live -/// dependency graph, and any cached copy would go stale the moment -/// any ancestor confirms or is replaced. +/// A mempool transaction entry. Carries the per-tx facts needed for +/// projection, plus the snapshot-time `chunk_rate` (Core's cluster-mempool +/// chunk fee rate, or the proxy fallback) used as the effective rate +/// for partitioning, fee tiers, and CPFP. #[derive(Debug, Clone)] pub struct TxEntry { pub txid: Txid, pub fee: Sats, pub vsize: VSize, pub weight: Weight, - /// Serialized tx size in bytes (witness + non-witness), from the raw tx. + /// Serialized tx size in bytes (witness + non-witness). pub size: u64, - /// Parent txid prefixes (most txs have 0-2 parents). - /// - /// May reference parents no longer in the pool. Consumers resolve - /// against the live pool and drop misses, so staleness here is - /// self-healing. pub depends: SmallVec<[TxidPrefix; 2]>, pub first_seen: Timestamp, /// BIP-125 explicit signaling: any input has sequence < 0xfffffffe. pub rbf: bool, + /// Effective per-vbyte rate Core would mine this tx at. From + /// `MempoolEntryInfo::chunk_rate()`: Core 31+ uses `fees.chunk / + /// (chunkweight/4)`, older Core falls back to + /// `max(ancestor_rate, descendant_pkg_rate)`. + pub chunk_rate: FeeRate, } impl TxEntry { @@ -37,6 +35,7 @@ impl TxEntry { depends: info.depends.iter().map(TxidPrefix::from).collect(), first_seen: info.first_seen, rbf, + chunk_rate: info.chunk_rate(), } } diff --git a/crates/brk_mempool/src/steps/preparer/tx_removal.rs b/crates/brk_mempool/src/steps/preparer/tx_removal.rs index fae358d38..65c3f7452 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_removal.rs +++ b/crates/brk_mempool/src/steps/preparer/tx_removal.rs @@ -31,13 +31,12 @@ impl TxRemoval { let spent_by = Self::build_spent_by(added); known - .iter() - .filter_map(|(txid, tx)| { - let prefix = TxidPrefix::from(txid); - if live.contains(&prefix) { + .records() + .filter_map(|(prefix, record)| { + if live.contains(prefix) { return None; } - Some((prefix, Self::find_removal(tx, &spent_by))) + Some((*prefix, Self::find_removal(&record.tx, &spent_by))) }) .collect() } diff --git a/crates/brk_mempool/src/steps/rebuilder/clusters.rs b/crates/brk_mempool/src/steps/rebuilder/clusters.rs deleted file mode 100644 index bbe8dcd1e..000000000 --- a/crates/brk_mempool/src/steps/rebuilder/clusters.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Build the cluster forest for a snapshot directly from the live -//! `EntryPool`. One traversal indexes live entries, builds parent -//! edges, floods the connected components, and constructs each -//! `Cluster` (which mirrors child edges and runs SFL -//! internally). -//! -//! Returns the cluster forest plus a `tx_index → ClusterRef` reverse -//! map for O(1) lookup back from `EntryPool` slot to cluster position. - -use brk_types::TxidPrefix; -use rustc_hash::{FxBuildHasher, FxHashMap}; -use smallvec::SmallVec; - -use crate::TxEntry; -use crate::cluster::{Cluster, ClusterId, ClusterNode, ClusterRef, LocalIdx}; -use crate::stores::TxIndex; - -/// Per-live-entry indexing position in the parents/children adjacency -/// arrays below. Local to this module; not exposed. -type Pos = u32; - -pub fn build_clusters( - entries: &[Option], -) -> (Vec>, Vec>) { - let live = index_live(entries); - if live.is_empty() { - return (Vec::new(), vec![None; entries.len()]); - } - - let parents = build_parent_edges(&live); - let children = mirror_children(&parents); - - let mut seen = vec![false; live.len()]; - let mut clusters: Vec> = Vec::new(); - let mut cluster_of: Vec> = vec![None; entries.len()]; - let mut stack: Vec = Vec::new(); - // Reused across components: `local_of[pos]` is `Some(local)` while - // we're building the current cluster, `None` otherwise. Cleared by - // walking each cluster's members at the end of its iteration. - let mut local_of: Vec> = vec![None; live.len()]; - - for start in 0..live.len() { - if seen[start] { - continue; - } - let members = flood_component(start as Pos, &parents, &children, &mut seen, &mut stack); - for (i, &pos) in members.iter().enumerate() { - local_of[pos as usize] = Some(LocalIdx::from(i)); - } - - let cluster_id = ClusterId::from(clusters.len()); - let cluster = build_cluster(&live, &parents, &members, &local_of); - for (local_pos, node) in cluster.nodes.iter().enumerate() { - cluster_of[node.id.as_usize()] = Some(ClusterRef { - cluster_id, - local: LocalIdx::from(local_pos), - }); - } - clusters.push(cluster); - - for &pos in &members { - local_of[pos as usize] = None; - } - } - - (clusters, cluster_of) -} - -fn flood_component( - start: Pos, - parents: &[SmallVec<[Pos; 4]>], - children: &[SmallVec<[Pos; 8]>], - seen: &mut [bool], - stack: &mut Vec, -) -> Vec { - let mut members: Vec = Vec::new(); - stack.clear(); - stack.push(start); - seen[start as usize] = true; - - while let Some(pos) = stack.pop() { - members.push(pos); - for &n in parents[pos as usize] - .iter() - .chain(children[pos as usize].iter()) - { - if !seen[n as usize] { - seen[n as usize] = true; - stack.push(n); - } - } - } - members -} - -/// `local_of` is set only for `Pos`es in this cluster, so each parent's -/// `LocalIdx` is one direct lookup (cross-cluster parents return `None` -/// and get filtered). -fn build_cluster( - live: &[(TxIndex, &TxEntry)], - parents: &[SmallVec<[Pos; 4]>], - members: &[Pos], - local_of: &[Option], -) -> Cluster { - let cluster_nodes: Vec> = members - .iter() - .map(|&pos| { - let (tx_index, entry) = live[pos as usize]; - ClusterNode { - id: tx_index, - txid: entry.txid, - fee: entry.fee, - vsize: entry.vsize, - weight: entry.weight, - parents: parents[pos as usize] - .iter() - .filter_map(|&p| local_of[p as usize]) - .collect(), - } - }) - .collect(); - - Cluster::new(cluster_nodes) -} - -fn index_live(entries: &[Option]) -> Vec<(TxIndex, &TxEntry)> { - entries - .iter() - .enumerate() - .filter_map(|(i, opt)| opt.as_ref().map(|e| (TxIndex::from(i), e))) - .collect() -} - -fn build_parent_edges(live: &[(TxIndex, &TxEntry)]) -> Vec> { - let mut prefix_to_pos: FxHashMap = - FxHashMap::with_capacity_and_hasher(live.len(), FxBuildHasher); - for (i, (_, entry)) in live.iter().enumerate() { - prefix_to_pos.insert(entry.txid_prefix(), i as Pos); - } - live.iter() - .map(|(_, entry)| { - entry - .depends - .iter() - .filter_map(|p| prefix_to_pos.get(p).copied()) - .collect() - }) - .collect() -} - -fn mirror_children(parents: &[SmallVec<[Pos; 4]>]) -> Vec> { - let mut children: Vec> = - (0..parents.len()).map(|_| SmallVec::new()).collect(); - for (child_pos, ps) in parents.iter().enumerate() { - for &p in ps { - children[p as usize].push(child_pos as Pos); - } - } - children -} diff --git a/crates/brk_mempool/src/steps/rebuilder/mod.rs b/crates/brk_mempool/src/steps/rebuilder/mod.rs index d4a5770d8..2faa3b6e3 100644 --- a/crates/brk_mempool/src/steps/rebuilder/mod.rs +++ b/crates/brk_mempool/src/steps/rebuilder/mod.rs @@ -1,55 +1,52 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, AtomicU64, Ordering}, - }, - time::{Duration, Instant}, +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, }; -use brk_rpc::Client; -use brk_types::FeeRate; -use parking_lot::{Mutex, RwLock}; -use tracing::warn; +use brk_rpc::BlockTemplateTx; +use brk_types::{FeeRate, TxidPrefix}; +use parking_lot::RwLock; +use rustc_hash::FxHashSet; + +use crate::inner::MempoolInner; -use crate::stores::MempoolState; -use clusters::build_clusters; use partition::Partitioner; -#[cfg(debug_assertions)] -use verify::Verifier; +use snapshot::{PrefixIndex, builder}; -pub(crate) mod clusters; mod partition; mod snapshot; -#[cfg(debug_assertions)] -mod verify; pub use brk_types::RecommendedFees; -pub use snapshot::{BlockStats, Snapshot}; +pub use snapshot::{BlockStats, SnapTx, Snapshot, TxIndex}; -const MIN_REBUILD_INTERVAL: Duration = Duration::from_secs(1); const NUM_BLOCKS: usize = 8; #[derive(Default)] pub struct Rebuilder { snapshot: RwLock>, dirty: AtomicBool, - last_rebuild: Mutex>, rebuild_count: AtomicU64, - skip_throttled: AtomicU64, skip_clean: AtomicU64, } impl Rebuilder { /// Mark dirty if the cycle changed mempool state, then rebuild iff - /// the throttle window has elapsed. Marking is sticky: a throttled - /// `changed=true` cycle keeps the bit set so a later quiet cycle - /// can still trigger the rebuild. - pub fn tick(&self, client: &Client, state: &MempoolState, changed: bool) { - self.mark_dirty(changed); + /// the dirty bit is set. Cycle pacing is the driver loop's job; the + /// rebuild itself is pure CPU on already-fetched data. + pub fn tick( + &self, + lock: &RwLock, + changed: bool, + gbt: &[BlockTemplateTx], + min_fee: FeeRate, + ) { + if changed { + self.dirty.store(true, Ordering::Release); + } if !self.try_claim_rebuild() { return; } - self.publish(Self::build_snapshot(client, state)); + *self.snapshot.write() = Arc::new(Self::build_snapshot(lock, gbt, min_fee)); self.dirty.store(false, Ordering::Release); self.rebuild_count.fetch_add(1, Ordering::Relaxed); } @@ -58,62 +55,54 @@ impl Rebuilder { self.rebuild_count.load(Ordering::Relaxed) } - pub fn skip_counts(&self) -> (u64, u64) { - ( - self.skip_clean.load(Ordering::Relaxed), - self.skip_throttled.load(Ordering::Relaxed), - ) + pub fn skip_clean_count(&self) -> u64 { + self.skip_clean.load(Ordering::Relaxed) } - fn build_snapshot(client: &Client, state: &MempoolState) -> Snapshot { - let min_fee = Self::fetch_min_fee(client); - let entries = state.entries.read(); - let entries_slice = entries.entries(); + fn build_snapshot( + lock: &RwLock, + gbt: &[BlockTemplateTx], + min_fee: FeeRate, + ) -> Snapshot { + let (txs, prefix_to_idx) = { + let inner = lock.read(); + builder::build_txs(&inner.txs) + }; - let (clusters, cluster_of) = build_clusters(entries_slice); - let blocks = Partitioner::partition(&clusters, NUM_BLOCKS); + let block0 = Self::block_from_gbt(gbt, &prefix_to_idx); + let excluded: FxHashSet = block0.iter().copied().collect(); + let rest = Partitioner::partition(&txs, &excluded, NUM_BLOCKS.saturating_sub(1)); - #[cfg(debug_assertions)] - Verifier::check(client, &blocks, &clusters, &cluster_of, entries_slice); + let mut blocks = Vec::with_capacity(NUM_BLOCKS); + blocks.push(block0); + blocks.extend(rest); - Snapshot::build(clusters, cluster_of, blocks, entries_slice, min_fee) + Snapshot::build(txs, blocks, prefix_to_idx, min_fee) + } + + /// Block 0 from `getblocktemplate`: Core's actual selection. Maps + /// each GBT txid back to its `TxIndex` via the per-build prefix + /// index. Fetcher already validated GBT ⊆ verbose mempool, so any + /// drop here is a same-cycle race and the partitioner picks up the + /// slack so callers always see eight blocks. + fn block_from_gbt(gbt: &[BlockTemplateTx], prefix_to_idx: &PrefixIndex) -> Vec { + gbt.iter() + .filter_map(|t| prefix_to_idx.get(&TxidPrefix::from(&t.txid)).copied()) + .collect() } pub fn snapshot(&self) -> Arc { self.snapshot.read().clone() } - fn mark_dirty(&self, changed: bool) { - if changed { - self.dirty.store(true, Ordering::Release); - } - } - - /// 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. + /// True iff dirty. The dirty bit is cleared in `tick` only after + /// the snapshot is published, 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); return false; } - let mut last = self.last_rebuild.lock(); - if last.is_some_and(|t| t.elapsed() < MIN_REBUILD_INTERVAL) { - self.skip_throttled.fetch_add(1, Ordering::Relaxed); - return false; - } - *last = Some(Instant::now()); true } - - fn fetch_min_fee(client: &Client) -> FeeRate { - client.get_mempool_min_fee().unwrap_or_else(|e| { - warn!("getmempoolinfo failed, falling back to FeeRate::MIN: {e}"); - FeeRate::MIN - }) - } - - fn publish(&self, snapshot: Snapshot) { - *self.snapshot.write() = Arc::new(snapshot); - } } diff --git a/crates/brk_mempool/src/steps/rebuilder/partition.rs b/crates/brk_mempool/src/steps/rebuilder/partition.rs index 37686cf26..24f384acc 100644 --- a/crates/brk_mempool/src/steps/rebuilder/partition.rs +++ b/crates/brk_mempool/src/steps/rebuilder/partition.rs @@ -1,178 +1,68 @@ +//! Pack live txs into projected blocks 1..N, sorted by descending +//! `chunk_rate`. Block 0 is filled by the caller from `getblocktemplate` +//! (Core's actual selection); blocks 1..N feed +//! `/api/v1/fees/mempool-blocks` as a coarse fee-tier gradient. +//! +//! No topological gate: a child can sit before its parent within a +//! tied-rate run, but cluster members share a `chunk_rate` so they +//! land in the same block in the common case, and the only output is +//! a per-block rate distribution where intra-block order is invisible. +//! +//! The final block is a catch-all (no vsize cap) so leftover tail +//! vsize is accounted for instead of silently dropped. +//! +//! Walk sorted candidates once. For each, push into the current +//! block if it fits; otherwise advance to the next block (unless we +//! are already on the last one, which absorbs everything remaining). + use std::cmp::Reverse; -use brk_types::{FeeRate, VSize}; +use brk_types::VSize; +use rustc_hash::FxHashSet; -use crate::cluster::{ChunkId, Cluster, ClusterId}; -use crate::stores::TxIndex; +use super::snapshot::{SnapTx, TxIndex}; -const LOOK_AHEAD_COUNT: usize = 100; +pub struct Partitioner; -/// Packs SFL chunks (referenced by `(ClusterId, ChunkId)`) into -/// `num_blocks` blocks. The first `num_blocks - 1` are filled greedily -/// up to `VSize::MAX_BLOCK`; the last is a catch-all so no low-rate tx -/// is silently dropped (matches mempool.space). -/// -/// Look-ahead respects intra-cluster order: a chunk is only taken once -/// every earlier-rate chunk of the same cluster has been placed, so a -/// child chunk never lands in an earlier block than its parent chunk. -/// -/// Output is the flat tx-list per block, parents-first within each -/// chunk via the cluster's `topo_order`. -pub struct Partitioner<'a> { - clusters: &'a [Cluster], - /// Candidate chunks sorted by descending feerate. Slots are taken - /// (set to `None`) as they're placed. - slots: Vec>, - /// Per-cluster cursor: the next `ChunkId` that must be taken next. - cluster_next: Vec, - blocks: Vec>, - current: Vec, - current_vsize: VSize, - idx: usize, +impl Partitioner { + pub fn partition( + txs: &[SnapTx], + excluded: &FxHashSet, + num_remaining_blocks: usize, + ) -> Vec> { + if num_remaining_blocks == 0 { + return Vec::new(); + } + let sorted = sorted_indices(txs, excluded); + let mut blocks: Vec> = (0..num_remaining_blocks).map(|_| Vec::new()).collect(); + let mut block_vsize = VSize::default(); + let mut current = 0; + let last = num_remaining_blocks - 1; + for (idx, vsize) in sorted { + let fits = vsize <= VSize::MAX_BLOCK.saturating_sub(block_vsize); + if !fits && current < last && !blocks[current].is_empty() { + current += 1; + block_vsize = VSize::default(); + } + blocks[current].push(idx); + block_vsize += vsize; + } + blocks + } } -#[derive(Clone, Copy)] -struct Candidate { - cluster_id: ClusterId, - chunk_id: ChunkId, - fee_rate: FeeRate, - vsize: VSize, -} - -impl<'a> Partitioner<'a> { - pub fn partition(clusters: &'a [Cluster], num_blocks: usize) -> Vec> { - let mut p = Self::new(clusters, num_blocks); - p.fill_normal_blocks(num_blocks.saturating_sub(1)); - p.flush_overflow(num_blocks); - p.blocks - } - - fn new(clusters: &'a [Cluster], num_blocks: usize) -> Self { - let mut candidates: Vec = clusters - .iter() - .enumerate() - .flat_map(|(cid, cluster)| { - let cluster_id = ClusterId::from(cid); - cluster - .chunks - .iter() - .enumerate() - .map(move |(chid, chunk)| Candidate { - cluster_id, - chunk_id: ChunkId::from(chid), - fee_rate: chunk.fee_rate(), - vsize: chunk.vsize, - }) - }) - .collect(); - // Stable sort preserves SFL's per-cluster non-increasing-rate - // order, which is what `cluster_next` relies on. - candidates.sort_by_key(|c| Reverse(c.fee_rate)); - - Self { - clusters, - slots: candidates.into_iter().map(Some).collect(), - cluster_next: vec![ChunkId::ZERO; clusters.len()], - blocks: Vec::with_capacity(num_blocks), - current: Vec::new(), - current_vsize: VSize::default(), - idx: 0, - } - } - - fn fill_normal_blocks(&mut self, target_blocks: usize) { - while self.idx < self.slots.len() && self.blocks.len() < target_blocks { - let Some(cand) = self.slots[self.idx] else { - self.idx += 1; - continue; - }; - - let remaining_space = VSize::MAX_BLOCK.saturating_sub(self.current_vsize); - - // Take if it fits, or if the current block is empty (avoids - // stalling on an oversized chunk larger than MAX_BLOCK). - if cand.vsize <= remaining_space || self.current.is_empty() { - self.take(self.idx); - self.idx += 1; - continue; - } - - if self.try_fill_with_smaller(self.idx, remaining_space) { - continue; - } - - self.flush_block(); - } - - if !self.current.is_empty() && self.blocks.len() < target_blocks { - self.flush_block(); - } - } - - /// Skips any candidate whose cluster has an earlier unplaced chunk: - /// that chunk's parents would land after its children. - fn try_fill_with_smaller(&mut self, start: usize, remaining_space: VSize) -> bool { - let end = (start + LOOK_AHEAD_COUNT).min(self.slots.len()); - for idx in (start + 1)..end { - let Some(cand) = self.slots[idx] else { - continue; - }; - if cand.vsize > remaining_space { - continue; - } - if cand.chunk_id != self.cluster_next[cand.cluster_id.as_usize()] { - continue; - } - self.take(idx); - return true; - } - false - } - - fn take(&mut self, idx: usize) { - let cand = self.slots[idx].take().unwrap(); - debug_assert_eq!( - cand.chunk_id, - self.cluster_next[cand.cluster_id.as_usize()], - "partitioner took a chunk out of cluster order" - ); - self.cluster_next[cand.cluster_id.as_usize()] = ChunkId::from(cand.chunk_id.inner() + 1); - self.current_vsize += cand.vsize; - self.current.push(cand); - } - - fn flush_block(&mut self) { - let candidates = std::mem::take(&mut self.current); - let block = Self::materialize(self.clusters, candidates); - self.blocks.push(block); - self.current_vsize = VSize::default(); - } - - fn flush_overflow(&mut self, num_blocks: usize) { - if self.blocks.len() >= num_blocks { - return; - } - let overflow: Vec = self.slots[self.idx..] - .iter_mut() - .filter_map(Option::take) - .collect(); - if !overflow.is_empty() { - let block = Self::materialize(self.clusters, overflow); - self.blocks.push(block); - } - } - - /// Expand each chunk into its txs. `chunk.txs` is already topo-ordered - /// (parents-first) by `Cluster::new`, so we iterate it directly. - fn materialize(clusters: &[Cluster], candidates: Vec) -> Vec { - let mut out: Vec = Vec::new(); - for cand in candidates { - let cluster = &clusters[cand.cluster_id.as_usize()]; - let chunk = &cluster.chunks[cand.chunk_id.as_usize()]; - for &local in &chunk.txs { - out.push(cluster.nodes[local.as_usize()].id); - } - } - out - } +fn sorted_indices(txs: &[SnapTx], excluded: &FxHashSet) -> Vec<(TxIndex, VSize)> { + let mut cands: Vec<(TxIndex, VSize, brk_types::FeeRate)> = txs + .iter() + .enumerate() + .filter_map(|(i, t)| { + let idx = TxIndex::from(i); + (!excluded.contains(&idx)).then_some((idx, t.vsize, t.chunk_rate)) + }) + .collect(); + cands.sort_by_key(|(_, _, rate)| Reverse(*rate)); + cands + .into_iter() + .map(|(idx, vsize, _)| (idx, vsize)) + .collect() } diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/builder.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/builder.rs new file mode 100644 index 000000000..e7b26762e --- /dev/null +++ b/crates/brk_mempool/src/steps/rebuilder/snapshot/builder.rs @@ -0,0 +1,79 @@ +//! Build the per-tx adjacency for a snapshot from the live `TxStore`. +//! +//! One pass over the live records to assign compact `TxIndex`es and a +//! `prefix -> TxIndex` map, then per entry resolve `depends` against +//! it to produce parent edges. Children are mirrored from parents in +//! a second pass. Cross-pool parents (confirmed or evicted) are +//! dropped silently - the live pool reflects what miners actually see, +//! and any stale `depends` entry is self-healing. +//! +//! The prefix map is returned alongside the txs so the rebuilder can +//! reuse it for GBT mapping and the final `Snapshot::build` step +//! without reconstructing it. + +use brk_types::TxidPrefix; +use rustc_hash::{FxBuildHasher, FxHashMap}; +use smallvec::SmallVec; + +use crate::TxEntry; +use crate::stores::TxStore; + +use super::{SnapTx, TxIndex}; + +pub type PrefixIndex = FxHashMap; + +pub fn build_txs(txs: &TxStore) -> (Vec, PrefixIndex) { + if txs.is_empty() { + return (Vec::new(), PrefixIndex::default()); + } + + let (prefix_to_idx, ordered) = compact_index(txs); + let mut snap_txs: Vec = ordered.iter().map(|e| live_tx(e, &prefix_to_idx)).collect(); + + mirror_children(&mut snap_txs); + (snap_txs, prefix_to_idx) +} + +fn compact_index(txs: &TxStore) -> (PrefixIndex, Vec<&TxEntry>) { + let mut map: PrefixIndex = FxHashMap::with_capacity_and_hasher(txs.len(), FxBuildHasher); + let mut ordered: Vec<&TxEntry> = Vec::with_capacity(txs.len()); + for (i, (prefix, record)) in txs.records().enumerate() { + map.insert(*prefix, TxIndex::from(i)); + ordered.push(&record.entry); + } + (map, ordered) +} + +fn live_tx(e: &TxEntry, prefix_to_idx: &PrefixIndex) -> SnapTx { + let parents: SmallVec<[TxIndex; 2]> = e + .depends + .iter() + .filter_map(|p| prefix_to_idx.get(p).copied()) + .collect(); + SnapTx { + txid: e.txid, + fee: e.fee, + vsize: e.vsize, + weight: e.weight, + size: e.size, + chunk_rate: e.chunk_rate, + parents, + children: SmallVec::new(), + } +} + +fn mirror_children(txs: &mut [SnapTx]) { + let edges: Vec<(TxIndex, TxIndex)> = txs + .iter() + .enumerate() + .flat_map(|(i, t)| { + let child = TxIndex::from(i); + t.parents.iter().map(move |&p| (p, child)) + }) + .collect(); + for (parent, child) in edges { + if let Some(t) = txs.get_mut(parent.as_usize()) { + t.children.push(child); + } + } +} diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs index ee65e9b4a..05ecf65b4 100644 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs +++ b/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs @@ -1,62 +1,72 @@ +pub mod builder; mod fees; mod stats; +mod tx; +mod tx_index; +pub use builder::PrefixIndex; pub use stats::BlockStats; +pub use tx::SnapTx; +pub use tx_index::TxIndex; use std::hash::{DefaultHasher, Hash, Hasher}; -use brk_types::{FeeRate, RecommendedFees}; - -use crate::TxEntry; -use crate::cluster::{Cluster, ClusterRef}; -use crate::stores::TxIndex; +use brk_types::{FeeRate, RecommendedFees, TxidPrefix}; use fees::Fees; #[derive(Default)] pub struct Snapshot { - /// SFL-linearized cluster forest. Snapshot is `Arc`'d, so consumers - /// share the cluster data without cloning. Each `ClusterNode.id` - /// is the live `TxIndex` (pool slot) of that node. - pub clusters: Vec>, - /// Reverse of `clusters`: indexed by `TxIndex.as_usize()`. `None` - /// means the slot is empty (between two cycles a tx confirmed/was - /// evicted) or never made it into the live pool. Read via - /// `cluster_of(idx)` from outside the snapshot. - cluster_of: Vec>, + /// Dense per-tx data indexed by `TxIndex`. Each entry carries the + /// chunk rate (Core's chunk-mempool truth or proxy fallback) plus + /// resolved parent/child adjacency, so CPFP queries don't re-read + /// any external state. + pub txs: Vec, + /// Projected blocks. `blocks[0]` is Core's `getblocktemplate` + /// (Bitcoin Core's actual selection); the rest are greedy-packed + /// by descending chunk rate, with a final overflow block. pub blocks: Vec>, pub block_stats: Vec, pub fees: RecommendedFees, - /// ETag-like cache key for the first projected block. A hash of - /// the tx ordering, not a Bitcoin block header hash (no header - /// exists yet, it's a projection). `0` iff no projected blocks. + /// Content hash of the projected next block. Same value as the + /// mempool ETag. pub next_block_hash: u64, + /// Per-snapshot `TxidPrefix -> TxIndex` index, so live queries can + /// resolve a prefix to the snapshot's compact index without + /// re-walking `txs`. Built once by `builder::build_txs` and reused + /// by the rebuilder for GBT mapping. + prefix_to_idx: PrefixIndex, } impl Snapshot { /// `min_fee` is bitcoind's live `mempoolminfee`, used as the floor /// for every recommended-fee tier. pub fn build( - clusters: Vec>, - cluster_of: Vec>, + txs: Vec, blocks: Vec>, - entries: &[Option], + prefix_to_idx: PrefixIndex, min_fee: FeeRate, ) -> Self { let block_stats: Vec = blocks .iter() - .map(|block| BlockStats::compute(block, &clusters, &cluster_of, entries)) + .enumerate() + .map(|(i, block)| { + if i == 0 { + BlockStats::compute_core(block, &txs) + } else { + BlockStats::compute_projected(block, &txs) + } + }) .collect(); let fees = Fees::compute(&block_stats, min_fee); let next_block_hash = Self::hash_next_block(&blocks); - Self { - clusters, - cluster_of, + txs, blocks, block_stats, fees, next_block_hash, + prefix_to_idx, } } @@ -69,29 +79,22 @@ impl Snapshot { hasher.finish() } - /// Cluster + local position for a live tx, or `None` if the slot - /// is empty or `idx` is out of range. - pub fn cluster_of(&self, idx: TxIndex) -> Option { - self.cluster_of.get(idx.as_usize()).copied().flatten() + pub fn tx(&self, idx: TxIndex) -> Option<&SnapTx> { + self.txs.get(idx.as_usize()) } - pub fn cluster_of_len(&self) -> usize { - self.cluster_of.len() + pub fn idx_of(&self, prefix: &TxidPrefix) -> Option { + self.prefix_to_idx.get(prefix).copied() } - pub fn cluster_of_active(&self) -> usize { - self.cluster_of.iter().filter(|c| c.is_some()).count() + pub fn txs_len(&self) -> usize { + self.txs.len() } - /// SFL chunk feerate for a live tx, or `None` if it isn't in any - /// cluster. Cheap shortcut for callers that need the rate but not - /// the full `CpfpInfo`. - pub fn chunk_rate_of(&self, idx: TxIndex) -> Option { - let ClusterRef { cluster_id, local } = self.cluster_of(idx)?; - Some( - self.clusters[cluster_id.as_usize()] - .chunk_of(local) - .fee_rate(), - ) + /// Effective chunk rate for a live tx by prefix, or `None` if the + /// tx isn't in this snapshot. + pub fn chunk_rate_for(&self, prefix: &TxidPrefix) -> Option { + let idx = self.idx_of(prefix)?; + Some(self.txs[idx.as_usize()].chunk_rate) } } diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/stats.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/stats.rs index 44f31d74a..fde786653 100644 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/stats.rs +++ b/crates/brk_mempool/src/steps/rebuilder/snapshot/stats.rs @@ -1,85 +1,74 @@ -use brk_types::{FeeRate, Sats, VSize}; +use brk_types::{FeeRate, Sats, VSize, get_weighted_percentile}; -use crate::TxEntry; -use crate::cluster::{Cluster, ClusterRef}; -use crate::stores::TxIndex; +use super::{SnapTx, TxIndex}; -/// Percentile points reported in [`BlockStats::fee_range`], in the -/// same order: 0% (min), 10%, 25%, median, 75%, 90%, 100% (max). -const PERCENTILES: [usize; 7] = [0, 10, 25, 50, 75, 90, 100]; +/// Block 0 mirrors Core's `getblocktemplate`, so the full 0..100 range +/// is exact and worth surfacing. +const CORE_PERCENTILES: [f64; 7] = [0.0, 0.10, 0.25, 0.50, 0.75, 0.90, 1.00]; + +/// Blocks 1..N are a coarse projection. Tighten to 5..95 so a single +/// stale-GBT leftover or CPFP orphan doesn't blow out the min/max +/// columns of an otherwise tightly clustered fee tier. +const PROJECTED_PERCENTILES: [f64; 7] = [0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95]; #[derive(Debug, Clone, Default)] pub struct BlockStats { pub tx_count: u32, - /// Total serialized size of all txs in bytes (witness + non-witness). pub total_size: u64, pub total_vsize: VSize, pub total_fee: Sats, - /// Fee-rate samples at the points listed in `PERCENTILES`. - pub fee_range: [FeeRate; PERCENTILES.len()], + pub fee_range: [FeeRate; 7], } impl BlockStats { - /// Each tx contributes its containing chunk's `fee_rate` to the - /// percentile distribution, since that's the rate the miner - /// collects per vsize. - pub fn compute( - block: &[TxIndex], - clusters: &[Cluster], - cluster_of: &[Option], - entries: &[Option], - ) -> Self { + /// Block 0 (Core's actual selection): exact 0/10/25/50/75/90/100. + pub fn compute_core(block: &[TxIndex], txs: &[SnapTx]) -> Self { + Self::compute(block, txs, CORE_PERCENTILES) + } + + /// Blocks 1..N (projected): clipped 5/95 bounds to hide outliers. + pub fn compute_projected(block: &[TxIndex], txs: &[SnapTx]) -> Self { + Self::compute(block, txs, PROJECTED_PERCENTILES) + } + + /// Vsize-weighted percentile distribution over `chunk_rate` - + /// matches mempool.space's `feeRange` semantics where each tx's + /// contribution scales with its vsize, so a tiny outlier rate + /// only counts for its own vsize fraction. + fn compute(block: &[TxIndex], txs: &[SnapTx], percentiles: [f64; 7]) -> Self { let mut total_fee = Sats::default(); let mut total_vsize = VSize::default(); let mut total_size: u64 = 0; - let mut fee_rates: Vec = Vec::new(); + let mut rates: Vec<(FeeRate, VSize)> = Vec::with_capacity(block.len()); for &tx_index in block { - let Some(entry) = &entries[tx_index.as_usize()] else { + let Some(t) = txs.get(tx_index.as_usize()) else { continue; }; - let Some(cref) = cluster_of[tx_index.as_usize()] else { - continue; - }; - total_fee += entry.fee; - total_vsize += entry.vsize; - total_size += entry.size; - fee_rates.push( - clusters[cref.cluster_id.as_usize()] - .chunk_of(cref.local) - .fee_rate(), - ); + total_fee += t.fee; + total_vsize += t.vsize; + total_size += t.size; + rates.push((t.chunk_rate, t.vsize)); } - let tx_count = fee_rates.len() as u32; - fee_rates.sort_unstable(); + rates.sort_unstable_by_key(|(r, _)| *r); + + let fee_range: [FeeRate; 7] = if rates.is_empty() { + [FeeRate::default(); 7] + } else { + percentiles.map(|p| get_weighted_percentile(&rates, p)) + }; Self { - tx_count, + tx_count: rates.len() as u32, total_size, total_vsize, total_fee, - fee_range: PERCENTILES.map(|p| percentile(&fee_rates, p)), + fee_range, } } - pub fn min_fee_rate(&self) -> FeeRate { - self.fee_range[0] - } - pub fn median_fee_rate(&self) -> FeeRate { self.fee_range[3] } - - pub fn max_fee_rate(&self) -> FeeRate { - self.fee_range[PERCENTILES.len() - 1] - } -} - -fn percentile(sorted: &[FeeRate], p: usize) -> FeeRate { - if sorted.is_empty() { - return FeeRate::default(); - } - let idx = (p * (sorted.len() - 1)) / 100; - sorted[idx] } diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/tx.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/tx.rs new file mode 100644 index 000000000..9008e2004 --- /dev/null +++ b/crates/brk_mempool/src/steps/rebuilder/snapshot/tx.rs @@ -0,0 +1,33 @@ +use brk_types::{CpfpEntry, FeeRate, Sats, Txid, VSize, Weight}; +use smallvec::SmallVec; + +use super::TxIndex; + +/// Frozen per-tx view used by the snapshot. Holds the chunk rate +/// (Core's `fees.chunk` / `chunkweight` when available, else proxy) +/// plus resolved parent/child adjacency in `TxIndex` space, so +/// CPFP queries are a pure walk over `Snapshot.txs`. +#[derive(Clone, Debug)] +pub struct SnapTx { + pub txid: Txid, + pub fee: Sats, + pub vsize: VSize, + pub weight: Weight, + /// Serialized tx size in bytes (witness + non-witness). + pub size: u64, + pub chunk_rate: FeeRate, + /// Direct parents in the live pool (resolved against entry slots + /// at build time; cross-pool / confirmed parents are dropped). + pub parents: SmallVec<[TxIndex; 2]>, + pub children: SmallVec<[TxIndex; 4]>, +} + +impl From<&SnapTx> for CpfpEntry { + fn from(t: &SnapTx) -> Self { + Self { + txid: t.txid, + weight: t.weight, + fee: t.fee, + } + } +} diff --git a/crates/brk_mempool/src/stores/entry_pool/tx_index.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/tx_index.rs similarity index 70% rename from crates/brk_mempool/src/stores/entry_pool/tx_index.rs rename to crates/brk_mempool/src/steps/rebuilder/snapshot/tx_index.rs index af0811eb4..c13400f77 100644 --- a/crates/brk_mempool/src/stores/entry_pool/tx_index.rs +++ b/crates/brk_mempool/src/steps/rebuilder/snapshot/tx_index.rs @@ -1,4 +1,6 @@ -/// Index into the mempool entries storage. +/// Compact index into a `Snapshot`'s dense `txs` vec. Snapshot-internal: +/// rebuilt fresh each tick from the live `TxStore`, so consumers +/// can't hold one across rebuilds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TxIndex(u32); diff --git a/crates/brk_mempool/src/steps/rebuilder/verify.rs b/crates/brk_mempool/src/steps/rebuilder/verify.rs deleted file mode 100644 index 428178e61..000000000 --- a/crates/brk_mempool/src/steps/rebuilder/verify.rs +++ /dev/null @@ -1,155 +0,0 @@ -use brk_rpc::Client; -use brk_types::{Sats, SatsSigned, TxidPrefix, VSize}; -use rustc_hash::{FxHashMap, FxHashSet}; -use tracing::{debug, warn}; - -use crate::TxEntry; -use crate::cluster::{Cluster, ClusterRef}; -use crate::stores::TxIndex; - -type PrefixSet = FxHashSet; -type FeeByPrefix = FxHashMap; - -pub struct Verifier; - -impl Verifier { - pub fn check( - client: &Client, - blocks: &[Vec], - clusters: &[Cluster], - cluster_of: &[Option], - entries: &[Option], - ) { - Self::check_structure(blocks, clusters, cluster_of, entries); - Self::compare_to_core(client, blocks, entries); - } - - fn check_structure( - blocks: &[Vec], - clusters: &[Cluster], - cluster_of: &[Option], - entries: &[Option], - ) { - let in_pool: PrefixSet = entries - .iter() - .filter_map(|e| e.as_ref().map(TxEntry::txid_prefix)) - .collect(); - let mut placed = PrefixSet::default(); - - for (b, block) in blocks.iter().enumerate() { - let mut block_vsize = VSize::default(); - for &tx_index in block { - let entry = Self::live_entry(entries, tx_index, b); - Self::assert_parents_placed_first(entry, &in_pool, &placed, b); - Self::place(entry, &mut placed, b); - Self::assert_in_a_chunk(clusters, cluster_of, tx_index, b); - block_vsize += entry.vsize; - } - if b + 1 < blocks.len() { - Self::assert_block_fits_budget(block_vsize, block.len(), b); - } - } - } - - fn assert_in_a_chunk( - clusters: &[Cluster], - cluster_of: &[Option], - tx_index: TxIndex, - b: usize, - ) { - let cref = cluster_of[tx_index.as_usize()] - .unwrap_or_else(|| panic!("block {b}: tx_index {tx_index:?} has no cluster")); - let _ = clusters[cref.cluster_id.as_usize()].chunk_of(cref.local); - } - - fn live_entry(entries: &[Option], tx_index: TxIndex, b: usize) -> &TxEntry { - entries[tx_index.as_usize()] - .as_ref() - .unwrap_or_else(|| panic!("block {b}: dead tx_index {tx_index:?}")) - } - - fn assert_parents_placed_first( - entry: &TxEntry, - in_pool: &PrefixSet, - placed: &PrefixSet, - b: usize, - ) { - for parent in &entry.depends { - assert!( - !in_pool.contains(parent) || placed.contains(parent), - "block {b}: {} placed before its parent", - entry.txid, - ); - } - } - - fn place(entry: &TxEntry, placed: &mut PrefixSet, b: usize) { - assert!( - placed.insert(entry.txid_prefix()), - "block {b}: duplicate txid {}", - entry.txid - ); - } - - fn assert_block_fits_budget(total: VSize, tx_count: usize, b: usize) { - let is_oversized_singleton = tx_count == 1 && total > VSize::MAX_BLOCK; - if is_oversized_singleton { - return; - } - assert!( - total <= VSize::MAX_BLOCK, - "block {b}: vsize {total} exceeds {}", - VSize::MAX_BLOCK - ); - } - - fn compare_to_core(client: &Client, blocks: &[Vec], entries: &[Option]) { - let Some(next_block) = blocks.first() else { - return; - }; - let core: FeeByPrefix = match client.get_block_template_txs() { - Ok(txs) => txs - .into_iter() - .map(|t| (TxidPrefix::from(&t.txid), t.fee)) - .collect(), - Err(e) => { - warn!("verify: getblocktemplate failed: {e}"); - return; - } - }; - let ours: FeeByPrefix = next_block - .iter() - .filter_map(|&i| entries[i.as_usize()].as_ref()) - .map(|e| (e.txid_prefix(), e.fee)) - .collect(); - - let overlap = ours.keys().filter(|k| core.contains_key(k)).count(); - let union = ours.len() + core.len() - overlap; - let jaccard = if union == 0 { - 1.0 - } else { - overlap as f64 / union as f64 - }; - - let ours_fee: Sats = ours.values().copied().sum(); - let core_fee: Sats = core.values().copied().sum(); - let delta = SatsSigned::from(ours_fee) - SatsSigned::from(core_fee); - let delta_bps = if core_fee == Sats::ZERO { - 0.0 - } else { - f64::from(delta) / f64::from(core_fee) * 10_000.0 - }; - - debug!( - "verify block 0: txs {}/{} (overlap {}, jaccard {:.3}) | fee {}/{} (delta {:+}, {:+.1} bps)", - ours.len(), - core.len(), - overlap, - jaccard, - ours_fee, - core_fee, - delta.inner(), - delta_bps, - ); - } -} diff --git a/crates/brk_mempool/src/steps/resolver.rs b/crates/brk_mempool/src/steps/resolver.rs deleted file mode 100644 index 7269c8972..000000000 --- a/crates/brk_mempool/src/steps/resolver.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Prevout resolution for live mempool txs. -//! -//! A fresh tx can land in the store with `prevout: None` on some -//! inputs when the Preparer can't see the parent (parent arrived in -//! the same cycle as the child, or parent is confirmed and Core -//! lacks `-txindex`). Two paths fix that, both writing through the -//! same `apply_fills` -> `add_input` plumbing: -//! -//! - [`Resolver::resolve_in_mempool`]: same-cycle parents from the -//! live `txs` map. Run by the orchestrator after each successful -//! `Applier::apply`. No external dependency. -//! - [`Resolver::resolve_external`]: caller-supplied resolver -//! (typically the brk indexer). Run on demand by API consumers -//! that have a confirmed-tx data source. Lock-free during the -//! resolver call. -//! -//! Both phases: -//! 1. Snapshot under `txs.read()`, gather work for unresolved txs -//! (early-exit if `txs.unresolved()` is empty). -//! 2. (external only) Call the resolver outside any lock. -//! 3. Write fills under `txs.write()` + `addrs.write()`, in that -//! order to match the Applier's lock order. -//! -//! Idempotent: `apply_fills` checks `prevout.is_none()` per input -//! and bails if the tx was removed between phases. - -use brk_types::{TxOut, Txid, Vin, Vout}; - -use crate::stores::{MempoolState, TxStore}; - -/// Per-tx fills to apply: (vin index, resolved prevout). -type Fills = Vec<(Vin, TxOut)>; -/// Per-tx holes to resolve: (vin index, parent txid, parent vout). -type Holes = Vec<(Vin, Txid, Vout)>; - -pub struct Resolver; - -impl Resolver { - /// Fill prevouts whose parent is also live in the mempool. - /// - /// Called by the orchestrator after each successful - /// `Applier::apply`. Catches parent/child pairs that arrived in - /// the same cycle: the Preparer resolves against a snapshot taken - /// before the cycle's adds were applied, so neither parent nor - /// child is in it. Both are in `txs` by the time we run. - pub fn resolve_in_mempool(state: &MempoolState) -> bool { - let filled = { - let txs = state.txs.read(); - Self::gather_in_mempool_fills(&txs) - }; - Self::write_back(state, filled) - } - - /// Fill prevouts via an external resolver, typically backed by the - /// brk indexer for confirmed parents. - /// - /// Phase 1 collects holes under `txs.read()`. Phase 2 runs the - /// resolver outside any lock. Phase 3 writes back. Holes already - /// resolvable from in-mempool parents have been filled by - /// [`Resolver::resolve_in_mempool`] in the preceding `apply`, so - /// anything reaching the resolver here is genuinely external. - pub fn resolve_external(state: &MempoolState, resolver: F) -> bool - where - F: Fn(&Txid, Vout) -> Option, - { - let holes = { - let txs = state.txs.read(); - Self::gather_holes(&txs) - }; - let filled = Self::run_external_resolver(holes, resolver); - Self::write_back(state, filled) - } - - fn gather_in_mempool_fills(txs: &TxStore) -> Vec<(Txid, Fills)> { - if txs.unresolved().is_empty() { - return Vec::new(); - } - txs.unresolved() - .iter() - .filter_map(|txid| { - let tx = txs.get(txid)?; - let fills: Fills = tx - .input - .iter() - .enumerate() - .filter(|(_, txin)| txin.prevout.is_none()) - .filter_map(|(i, txin)| { - let parent = txs.get(&txin.txid)?; - let out = parent.output.get(usize::from(txin.vout))?; - Some((Vin::from(i), out.clone())) - }) - .collect(); - (!fills.is_empty()).then_some((*txid, fills)) - }) - .collect() - } - - fn gather_holes(txs: &TxStore) -> Vec<(Txid, Holes)> { - if txs.unresolved().is_empty() { - return Vec::new(); - } - txs.unresolved() - .iter() - .filter_map(|txid| { - let tx = txs.get(txid)?; - let holes: Holes = tx - .input - .iter() - .enumerate() - .filter(|(_, txin)| txin.prevout.is_none()) - .map(|(i, txin)| (Vin::from(i), txin.txid, txin.vout)) - .collect(); - (!holes.is_empty()).then_some((*txid, holes)) - }) - .collect() - } - - fn run_external_resolver(holes: Vec<(Txid, Holes)>, resolver: F) -> Vec<(Txid, Fills)> - where - F: Fn(&Txid, Vout) -> Option, - { - holes - .into_iter() - .filter_map(|(txid, holes)| { - let fills: Fills = holes - .into_iter() - .filter_map(|(vin, prev_txid, vout)| { - resolver(&prev_txid, vout).map(|o| (vin, o)) - }) - .collect(); - (!fills.is_empty()).then_some((txid, fills)) - }) - .collect() - } - - /// Apply per-tx fills under `txs.write()` + `addrs.write()`. - /// Each successful prevout write is folded into `AddrTracker` via - /// `add_input`. Lock order matches the Applier's (txs before addrs). - fn write_back(state: &MempoolState, fills: Vec<(Txid, Fills)>) -> bool { - if fills.is_empty() { - return false; - } - let mut txs = state.txs.write(); - let mut addrs = state.addrs.write(); - for (txid, tx_fills) in fills { - for prevout in txs.apply_fills(&txid, tx_fills) { - addrs.add_input(&txid, &prevout); - } - } - true - } -} diff --git a/crates/brk_mempool/src/stores/addr_tracker/mod.rs b/crates/brk_mempool/src/stores/addr_tracker/mod.rs index ce1047e3a..37f479f0c 100644 --- a/crates/brk_mempool/src/stores/addr_tracker/mod.rs +++ b/crates/brk_mempool/src/stores/addr_tracker/mod.rs @@ -66,9 +66,9 @@ impl AddrTracker { } /// Fold a single newly-resolved input into the per-address stats. - /// Called by the Resolver after a prevout that was previously - /// `None` has been filled. Inputs whose prevout doesn't resolve - /// to an addr are no-ops. + /// Called by the prevout-fill paths after a prevout that was + /// previously `None` has been filled. Inputs whose prevout doesn't + /// resolve to an addr are no-ops. pub fn add_input(&mut self, txid: &Txid, prevout: &TxOut) { let Some(bytes) = prevout.addr_bytes() else { return; diff --git a/crates/brk_mempool/src/stores/entry_pool/mod.rs b/crates/brk_mempool/src/stores/entry_pool/mod.rs deleted file mode 100644 index b2bf6e0b4..000000000 --- a/crates/brk_mempool/src/stores/entry_pool/mod.rs +++ /dev/null @@ -1,81 +0,0 @@ -use brk_types::TxidPrefix; -use rustc_hash::FxHashMap; - -mod tx_index; - -pub use tx_index::TxIndex; - -use crate::TxEntry; - -/// Pool of mempool entries with slot recycling. -/// -/// Slot-based storage: removed entries leave holes that are reused -/// by the next insert, so `TxIndex` stays stable for the lifetime of -/// an entry. Only stores what can't be derived: the entries -/// themselves, their prefix-to-slot index, and the free slot list. -#[derive(Default)] -pub struct EntryPool { - entries: Vec>, - prefix_to_idx: FxHashMap, - free_slots: Vec, -} - -impl EntryPool { - pub fn insert(&mut self, entry: TxEntry) -> TxIndex { - let prefix = entry.txid_prefix(); - debug_assert!( - !self.prefix_to_idx.contains_key(&prefix), - "TxidPrefix collision in EntryPool: prefix {prefix:?} already mapped. \ - Birthday-rare on SHA-256d, but if it ever fires the previous slot \ - leaks because outpoint_spends still references it." - ); - let idx = self.claim_slot(entry); - self.prefix_to_idx.insert(prefix, idx); - idx - } - - fn claim_slot(&mut self, entry: TxEntry) -> TxIndex { - if let Some(idx) = self.free_slots.pop() { - self.entries[idx.as_usize()] = Some(entry); - idx - } else { - let idx = TxIndex::from(self.entries.len()); - self.entries.push(Some(entry)); - idx - } - } - - pub fn get(&self, prefix: &TxidPrefix) -> Option<&TxEntry> { - self.slot(self.idx_of(prefix)?) - } - - /// Slot index for a prefix, or `None` if not in the pool. - pub fn idx_of(&self, prefix: &TxidPrefix) -> Option { - self.prefix_to_idx.get(prefix).copied() - } - - /// Direct slot read by index. `None` if the slot is empty or the - /// index is out of range. - pub fn slot(&self, idx: TxIndex) -> Option<&TxEntry> { - self.entries.get(idx.as_usize())?.as_ref() - } - - pub fn remove(&mut self, prefix: &TxidPrefix) -> Option<(TxIndex, TxEntry)> { - let idx = self.prefix_to_idx.remove(prefix)?; - let entry = self.entries.get_mut(idx.as_usize())?.take()?; - self.free_slots.push(idx); - Some((idx, entry)) - } - - pub fn entries(&self) -> &[Option] { - &self.entries - } - - pub fn active_count(&self) -> usize { - self.prefix_to_idx.len() - } - - pub fn free_slots_count(&self) -> usize { - self.free_slots.len() - } -} diff --git a/crates/brk_mempool/src/stores/mod.rs b/crates/brk_mempool/src/stores/mod.rs index d2a477b2a..4251dcb0d 100644 --- a/crates/brk_mempool/src/stores/mod.rs +++ b/crates/brk_mempool/src/stores/mod.rs @@ -1,32 +1,13 @@ -//! Stateful in-memory holders. Each owns its `RwLock` and exposes a -//! behaviour-shaped API (insert, remove, evict, query). -//! -//! [`state::MempoolState`] aggregates five locked buckets: -//! -//! - [`tx_store::TxStore`] - full `Transaction` data for live txs. -//! - [`addr_tracker::AddrTracker`] - per-address mempool stats. -//! - [`entry_pool::EntryPool`] - slot-recycled [`TxEntry`](crate::TxEntry) -//! storage indexed by [`entry_pool::TxIndex`]. -//! - [`outpoint_spends::OutpointSpends`] - outpoint → spending mempool -//! tx index, used to answer mempool-to-mempool outspend queries. -//! - [`tx_graveyard::TxGraveyard`] - recently-dropped txs as -//! [`tx_graveyard::TxTombstone`]s, retained for reappearance -//! detection and post-mine analytics. -//! -//! A sixth bucket, `info`, holds a `MempoolInfo` from `brk_types`, -//! so it has no file here. +//! Stateful in-memory holders. After Phase 3 they're plain owned +//! types (no internal locks) — `MempoolInner` aggregates them under a +//! single `RwLock` in `crate::inner`. pub mod addr_tracker; -pub mod entry_pool; pub(crate) mod outpoint_spends; -pub mod state; pub mod tx_graveyard; pub mod tx_store; pub use addr_tracker::AddrTracker; -pub use entry_pool::{EntryPool, TxIndex}; pub(crate) use outpoint_spends::OutpointSpends; -pub(crate) use state::LockedState; -pub use state::MempoolState; pub use tx_graveyard::{TxGraveyard, TxTombstone}; pub use tx_store::TxStore; diff --git a/crates/brk_mempool/src/stores/outpoint_spends.rs b/crates/brk_mempool/src/stores/outpoint_spends.rs index 9ad32fcf1..c87dd9fca 100644 --- a/crates/brk_mempool/src/stores/outpoint_spends.rs +++ b/crates/brk_mempool/src/stores/outpoint_spends.rs @@ -2,44 +2,42 @@ use brk_types::{OutpointPrefix, Transaction, TxidPrefix}; use derive_more::Deref; use rustc_hash::FxHashMap; -use super::TxIndex; - /// Mempool index from spent outpoint to spending mempool tx. /// /// Keys are `OutpointPrefix` (8 bytes txid + 2 bytes vout); prefix /// collisions are possible, so callers must verify the candidate -/// spender's input list. Values are slot indices into `EntryPool`, -/// stable for the lifetime of an entry. +/// spender's input list. Values are the spender's `TxidPrefix`, +/// looked up against `TxStore` to recover the full spender record. #[derive(Default, Deref)] -pub struct OutpointSpends(FxHashMap); +pub struct OutpointSpends(FxHashMap); impl OutpointSpends { - pub fn insert_spends(&mut self, tx: &Transaction, idx: TxIndex) { + pub fn insert_spends(&mut self, tx: &Transaction, spender: TxidPrefix) { for input in &tx.input { if input.is_coinbase { continue; } let key = OutpointPrefix::new(TxidPrefix::from(&input.txid), input.vout); - self.0.insert(key, idx); + self.0.insert(key, spender); } } - /// Only removes entries whose stored `TxIndex` still matches `idx`, - /// so a slot already recycled by a later insert is left alone. - pub fn remove_spends(&mut self, tx: &Transaction, idx: TxIndex) { + /// Only removes entries whose stored prefix still matches `spender`, + /// so an outpoint already re-claimed by a later spender is left alone. + pub fn remove_spends(&mut self, tx: &Transaction, spender: TxidPrefix) { for input in &tx.input { if input.is_coinbase { continue; } let key = OutpointPrefix::new(TxidPrefix::from(&input.txid), input.vout); - if self.0.get(&key) == Some(&idx) { + if self.0.get(&key) == Some(&spender) { self.0.remove(&key); } } } #[inline] - pub fn get(&self, key: &OutpointPrefix) -> Option { + pub fn get(&self, key: &OutpointPrefix) -> Option { self.0.get(key).copied() } } diff --git a/crates/brk_mempool/src/stores/state.rs b/crates/brk_mempool/src/stores/state.rs deleted file mode 100644 index 78a2953c0..000000000 --- a/crates/brk_mempool/src/stores/state.rs +++ /dev/null @@ -1,43 +0,0 @@ -use brk_types::MempoolInfo; -use parking_lot::{RwLock, RwLockWriteGuard}; - -use super::{AddrTracker, EntryPool, OutpointSpends, TxGraveyard, TxStore}; - -/// 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, - pub(crate) txs: RwLock, - pub(crate) addrs: RwLock, - pub(crate) entries: RwLock, - pub outpoint_spends: RwLock, - pub(crate) graveyard: RwLock, -} - -impl MempoolState { - /// All six write guards in the canonical lock order. Used by the - /// Applier to apply a sync diff atomically. - pub(crate) fn write_all(&self) -> LockedState<'_> { - LockedState { - info: self.info.write(), - txs: self.txs.write(), - addrs: self.addrs.write(), - entries: self.entries.write(), - outpoint_spends: self.outpoint_spends.write(), - graveyard: self.graveyard.write(), - } - } -} - -pub(crate) struct LockedState<'a> { - pub info: RwLockWriteGuard<'a, MempoolInfo>, - pub txs: RwLockWriteGuard<'a, TxStore>, - pub addrs: RwLockWriteGuard<'a, AddrTracker>, - pub entries: RwLockWriteGuard<'a, EntryPool>, - pub outpoint_spends: RwLockWriteGuard<'a, OutpointSpends>, - pub graveyard: RwLockWriteGuard<'a, TxGraveyard>, -} diff --git a/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs b/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs index 93215ce98..13959185e 100644 --- a/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs +++ b/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs @@ -1,4 +1,4 @@ -use std::time::{Duration, Instant}; +use std::time::Instant; use brk_types::{Transaction, Txid}; @@ -32,10 +32,6 @@ impl TxTombstone { &self.removal } - pub fn age(&self) -> Duration { - self.removed_at.elapsed() - } - pub(crate) fn removed_at(&self) -> Instant { self.removed_at } diff --git a/crates/brk_mempool/src/stores/tx_store.rs b/crates/brk_mempool/src/stores/tx_store.rs index da39a6090..5a82ac164 100644 --- a/crates/brk_mempool/src/stores/tx_store.rs +++ b/crates/brk_mempool/src/stores/tx_store.rs @@ -1,94 +1,129 @@ -use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, Vin}; -use derive_more::Deref; +use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin}; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::TxEntry; + const RECENT_CAP: usize = 10; -#[derive(Default, Deref)] +/// Per-tx record: live tx body and its mempool entry, kept under one +/// key so a single map probe returns both. +pub struct TxRecord { + pub tx: Transaction, + pub entry: TxEntry, +} + +/// Live-pool index keyed by `TxidPrefix`. The full `Txid` lives in +/// `record.entry.txid`, so callers that only have a `Txid` derive the +/// prefix (an 8-byte truncation) at the callsite. `unresolved` is the +/// set of prefixes whose tx still has at least one `prevout: None`, +/// maintained on every `insert` / `remove_by_prefix` / `apply_fills` +/// so the post-update prevout filler can early-exit when empty. +#[derive(Default)] pub struct TxStore { - #[deref] - txs: FxHashMap, + records: FxHashMap, recent: Vec, - /// Txids whose tx has at least one input with `prevout == None`. - /// Maintained on every `extend` / `remove` / `apply_fills` so the - /// post-update prevout filler can early-exit when this set is empty. - unresolved: FxHashSet, + unresolved: FxHashSet, } impl TxStore { pub fn contains(&self, txid: &Txid) -> bool { - self.txs.contains_key(txid) + self.records.contains_key(&TxidPrefix::from(txid)) } - /// Insert each `(Txid, Transaction)` yielded by `items`, and push - /// up to `RECENT_CAP` of them onto the front of `recent` as the - /// newest-seen window (older entries fall off the end). - pub fn extend(&mut self, items: I) - where - I: IntoIterator, - { - let mut new_recent: Vec = Vec::with_capacity(RECENT_CAP); - for (txid, tx) in items { - Self::sample_recent(&mut new_recent, &txid, &tx); - self.track_unresolved(&txid, &tx); - self.txs.insert(txid, tx); - } - self.promote_recent(new_recent); + pub fn len(&self) -> usize { + self.records.len() } - fn sample_recent(buf: &mut Vec, txid: &Txid, tx: &Transaction) { - if buf.len() < RECENT_CAP { - buf.push(MempoolRecentTx::from((txid, tx))); - } + pub fn is_empty(&self) -> bool { + self.records.is_empty() } - fn track_unresolved(&mut self, txid: &Txid, tx: &Transaction) { + pub fn get(&self, txid: &Txid) -> Option<&Transaction> { + self.records.get(&TxidPrefix::from(txid)).map(|r| &r.tx) + } + + pub fn entry(&self, txid: &Txid) -> Option<&TxEntry> { + self.records.get(&TxidPrefix::from(txid)).map(|r| &r.entry) + } + + pub fn entry_by_prefix(&self, prefix: &TxidPrefix) -> Option<&TxEntry> { + self.records.get(prefix).map(|r| &r.entry) + } + + /// Tx + entry in one map probe. Used by the RBF builder and the + /// snapshot builder which need both per visited tx. + pub fn record_by_prefix(&self, prefix: &TxidPrefix) -> Option<&TxRecord> { + self.records.get(prefix) + } + + /// `(prefix, record)` pairs in HashMap iteration order. Used by + /// the snapshot builder to assign a compact `TxIndex` to each + /// live tx in one pass. + pub fn records(&self) -> impl Iterator { + self.records.iter() + } + + pub fn txids(&self) -> impl Iterator { + self.records.values().map(|r| &r.entry.txid) + } + + pub fn values(&self) -> impl Iterator { + self.records.values().map(|r| &r.tx) + } + + pub fn insert(&mut self, tx: Transaction, entry: TxEntry) { + let prefix = entry.txid_prefix(); + debug_assert!( + !self.records.contains_key(&prefix), + "TxidPrefix collision: {prefix:?} already mapped. Birthday-rare on SHA-256d." + ); + self.sample_recent(&entry.txid, &tx); if tx.input.iter().any(|i| i.prevout.is_none()) { - self.unresolved.insert(*txid); + self.unresolved.insert(prefix); } + self.records.insert(prefix, TxRecord { tx, entry }); } - fn promote_recent(&mut self, mut new_recent: Vec) { - if new_recent.is_empty() { - return; - } - let keep = RECENT_CAP.saturating_sub(new_recent.len()); - new_recent.extend(self.recent.drain(..keep.min(self.recent.len()))); - self.recent = new_recent; + fn sample_recent(&mut self, txid: &Txid, tx: &Transaction) { + self.recent.insert(0, MempoolRecentTx::from((txid, tx))); + self.recent.truncate(RECENT_CAP); } pub fn recent(&self) -> &[MempoolRecentTx] { &self.recent } - /// Remove a single tx and return its stored data if present. `recent` - /// isn't touched: it's an "added" window, not a live-set mirror. - pub fn remove(&mut self, txid: &Txid) -> Option { - self.unresolved.remove(txid); - self.txs.remove(txid) + /// Remove by prefix and return the full record if present. `recent` + /// is untouched: it's an "added" window, not a live-set mirror. + pub fn remove_by_prefix(&mut self, prefix: &TxidPrefix) -> Option { + let record = self.records.remove(prefix)?; + self.unresolved.remove(prefix); + Some(record) } - /// Set of txids with at least one unfilled prevout. Used by the + /// Set of prefixes with at least one unfilled prevout. Used by the /// prevout filler as a cheap "is there any work?" gate. - pub fn unresolved(&self) -> &FxHashSet { + pub fn unresolved(&self) -> &FxHashSet { &self.unresolved } /// Apply resolved prevouts to a tx in place. `fills` is `(vin, prevout)`. - /// Returns the prevouts that were actually written (so the caller can - /// fold them into `AddrTracker`). Updates `unresolved` if the tx is - /// fully resolved after the fill, and recomputes `total_sigop_cost` - /// since the P2SH and witness components depend on prevouts. - pub fn apply_fills(&mut self, txid: &Txid, fills: Vec<(Vin, TxOut)>) -> Vec { - let Some(tx) = self.txs.get_mut(txid) else { + /// Returns the prevouts actually written (so the caller can fold them + /// into `AddrTracker`). Updates `unresolved` if fully resolved after + /// the fill, and recomputes `total_sigop_cost` (P2SH and witness + /// components depend on prevouts). + pub fn apply_fills(&mut self, prefix: &TxidPrefix, fills: Vec<(Vin, TxOut)>) -> Vec { + let Some(record) = self.records.get_mut(prefix) else { return Vec::new(); }; - let applied = Self::write_prevouts(tx, fills); + let applied = Self::write_prevouts(&mut record.tx, fills); if applied.is_empty() { return applied; } - Self::recompute_sigop(tx); - self.refresh_unresolved(txid); + record.tx.total_sigop_cost = record.tx.total_sigop_cost(); + if record.tx.input.iter().all(|i| i.prevout.is_some()) { + self.unresolved.remove(prefix); + } applied } @@ -104,20 +139,4 @@ impl TxStore { } applied } - - /// `total_sigop_cost` depends on the P2SH and witness components - /// of each prevout, so it must be recomputed after any fill. - fn recompute_sigop(tx: &mut Transaction) { - tx.total_sigop_cost = tx.total_sigop_cost(); - } - - fn refresh_unresolved(&mut self, txid: &Txid) { - if self.txs.get(txid).is_some_and(Self::all_resolved) { - self.unresolved.remove(txid); - } - } - - fn all_resolved(tx: &Transaction) -> bool { - tx.input.iter().all(|i| i.prevout.is_some()) - } } diff --git a/crates/brk_mempool/src/tests/graph_bench.rs b/crates/brk_mempool/src/tests/graph_bench.rs deleted file mode 100644 index a2fc8666a..000000000 --- a/crates/brk_mempool/src/tests/graph_bench.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::time::Instant; - -use bitcoin::hashes::Hash; -use brk_types::{Sats, Timestamp, Txid, TxidPrefix, VSize, Weight}; -use smallvec::SmallVec; - -use crate::TxEntry; - -fn synthetic_mempool(n: usize) -> Vec> { - let make_txid = |i: usize| -> Txid { - let mut bytes = [0u8; 32]; - bytes[0..8].copy_from_slice(&(i as u64).to_ne_bytes()); - bytes[8..16].copy_from_slice(&((i as u64).wrapping_mul(2_654_435_761)).to_ne_bytes()); - Txid::from(bitcoin::Txid::from_slice(&bytes).unwrap()) - }; - - let mut entries: Vec> = Vec::with_capacity(n); - let mut txids: Vec = Vec::with_capacity(n); - for i in 0..n { - let txid = make_txid(i); - txids.push(txid); - - let depends: SmallVec<[TxidPrefix; 2]> = match i % 100 { - 0..=94 => SmallVec::new(), - 95..=98 if i > 0 => { - let p = (i.wrapping_mul(7919)) % i; - std::iter::once(TxidPrefix::from(&txids[p])).collect() - } - _ if i > 1 => { - let p1 = (i.wrapping_mul(7919)) % i; - let p2 = (i.wrapping_mul(6151)) % i; - [TxidPrefix::from(&txids[p1]), TxidPrefix::from(&txids[p2])] - .into_iter() - .collect() - } - _ => SmallVec::new(), - }; - - entries.push(Some(TxEntry { - txid, - fee: Sats::from((i as u64).wrapping_mul(137) % 10_000 + 1), - vsize: VSize::from(250u64), - weight: Weight::from(1000u64), - size: 250, - depends, - first_seen: Timestamp::now(), - rbf: false, - })); - } - entries -} - -#[test] -#[ignore = "perf benchmark; run with --ignored --nocapture"] -fn perf_build_clusters() { - use crate::steps::rebuilder::clusters::build_clusters; - - let sizes = [1_000usize, 10_000, 50_000, 100_000, 300_000]; - eprintln!(); - eprintln!("build_clusters perf (release, single call):"); - eprintln!(" n build"); - eprintln!(" ------------------------"); - for &n in &sizes { - let entries = synthetic_mempool(n); - let _ = build_clusters(&entries); - - let t = Instant::now(); - let (clusters, _) = build_clusters(&entries); - let dt = t.elapsed(); - let ns = dt.as_nanos(); - let pretty = if ns >= 1_000_000 { - format!("{:.2} ms", ns as f64 / 1_000_000.0) - } else { - format!("{:.2} µs", ns as f64 / 1_000.0) - }; - eprintln!(" {:<10} {:<10} ({} clusters)", n, pretty, clusters.len()); - } - eprintln!(); -} diff --git a/crates/brk_mempool/src/tests/linearize/basic.rs b/crates/brk_mempool/src/tests/linearize/basic.rs deleted file mode 100644 index e10588151..000000000 --- a/crates/brk_mempool/src/tests/linearize/basic.rs +++ /dev/null @@ -1,186 +0,0 @@ -use brk_types::{Sats, VSize}; - -use super::{Chunk, chunk_shapes, make_cluster, run}; -use crate::cluster::LocalIdx; - -#[test] -fn singleton() { - let cluster = make_cluster(&[(100, 10)], &[]); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].txs.len(), 1); - assert_eq!(chunks[0].fee, Sats::from(100u64)); - assert_eq!(chunks[0].vsize, VSize::from(10u64)); -} - -#[test] -fn two_chain_parent_richer() { - let cluster = make_cluster(&[(100, 10), (1, 1)], &[(0, 1)]); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 2); - assert!(chunks[0].txs.contains(&LocalIdx::from(0u32))); - assert_eq!(chunks[0].vsize, VSize::from(10u64)); - assert!(chunks[1].txs.contains(&LocalIdx::from(1u32))); - assert_eq!(chunks[1].vsize, VSize::from(1u64)); -} - -#[test] -fn two_chain_child_pays_parent_cpfp() { - let cluster = make_cluster(&[(1, 10), (100, 1)], &[(0, 1)]); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].txs.len(), 2); - assert_eq!(chunks[0].fee, Sats::from(101u64)); - assert_eq!(chunks[0].vsize, VSize::from(11u64)); -} - -#[test] -fn v_shape_two_parents_one_child() { - let cluster = make_cluster(&[(1, 1), (1, 1), (100, 1)], &[(0, 2), (1, 2)]); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].txs.len(), 3); - assert_eq!(chunks[0].fee, Sats::from(102u64)); - assert_eq!(chunks[0].vsize, VSize::from(3u64)); -} - -#[test] -fn lambda_shape_one_parent_two_children_uneven() { - let cluster = make_cluster(&[(1, 1), (5, 1), (5, 1)], &[(0, 1), (0, 2)]); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].fee, Sats::from(11u64)); - assert_eq!(chunks[0].vsize, VSize::from(3u64)); -} - -#[test] -fn diamond() { - let cluster = make_cluster( - &[(1, 1), (1, 1), (1, 1), (100, 1)], - &[(0, 1), (0, 2), (1, 3), (2, 3)], - ); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].txs.len(), 4); - assert_eq!(chunks[0].fee, Sats::from(103u64)); - assert_eq!(chunks[0].vsize, VSize::from(4u64)); -} - -#[test] -fn chain_alternating_high_low() { - let cluster = make_cluster( - &[(10, 1), (1, 1), (10, 1), (1, 1)], - &[(0, 1), (1, 2), (2, 3)], - ); - let chunks = run(&cluster); - assert_eq!(chunks_total_fee(chunks), Sats::from(22u64)); - assert_eq!(chunks_total_vsize(chunks), VSize::from(4u64)); - assert_non_increasing(chunks); -} - -#[test] -fn chain_starts_low_ends_high() { - let cluster = make_cluster( - &[(1, 1), (100, 1), (1, 1), (100, 1)], - &[(0, 1), (1, 2), (2, 3)], - ); - let chunks = run(&cluster); - assert_eq!(chunks_total_fee(chunks), Sats::from(202u64)); - assert_eq!(chunks_total_vsize(chunks), VSize::from(4u64)); - assert_non_increasing(chunks); -} - -#[test] -fn two_disconnected_clusters_would_each_be_separate() { - let cluster = make_cluster( - &[(1, 1), (10, 1), (20, 1), (30, 1), (40, 1), (50, 1)], - &[(0, 1), (0, 2), (0, 3), (0, 4), (0, 5)], - ); - let chunks = run(&cluster); - assert_eq!(chunks_total_fee(chunks), Sats::from(151u64)); - assert_eq!(chunks_total_vsize(chunks), VSize::from(6u64)); - assert_non_increasing(chunks); - let mut seen: Vec = Vec::new(); - for ch in chunks { - for &local in &ch.txs { - seen.push(local.as_usize()); - } - } - seen.sort_unstable(); - assert_eq!(seen, vec![0, 1, 2, 3, 4, 5]); -} - -#[test] -fn wide_fan_in() { - let cluster = make_cluster( - &[(1, 1), (1, 1), (1, 1), (1, 1), (1, 1), (100, 1)], - &[(0, 5), (1, 5), (2, 5), (3, 5), (4, 5)], - ); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].fee, Sats::from(105u64)); - assert_eq!(chunks[0].vsize, VSize::from(6u64)); -} - -#[test] -fn shapes_are_stable_on_identical_input() { - let cluster = make_cluster( - &[(1, 1), (100, 1), (1, 1), (100, 1)], - &[(0, 1), (1, 2), (2, 3)], - ); - let a = chunk_shapes(run(&cluster)); - let b = chunk_shapes(run(&cluster)); - assert_eq!(a, b); -} - -#[test] -fn singleton_zero_fee() { - let cluster = make_cluster(&[(0, 10)], &[]); - let chunks = run(&cluster); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].txs.len(), 1); - assert_eq!(chunks[0].fee, Sats::from(0u64)); -} - -#[test] -fn zero_fee_leftover_after_paying_chunk() { - let cluster = make_cluster(&[(0, 1), (10, 1), (0, 1)], &[(0, 1), (1, 2)]); - let chunks = run(&cluster); - assert_eq!(chunks_total_vsize(chunks), VSize::from(3u64)); - assert_eq!(chunks_total_fee(chunks), Sats::from(10u64)); - let mut seen: Vec = Vec::new(); - for ch in chunks { - for &local in &ch.txs { - seen.push(local.as_usize()); - } - } - seen.sort_unstable(); - assert_eq!(seen, vec![0, 1, 2]); -} - -#[test] -fn all_zero_fee_chain() { - let cluster = make_cluster(&[(0, 1), (0, 1), (0, 1)], &[(0, 1), (1, 2)]); - let chunks = run(&cluster); - assert_eq!(chunks_total_vsize(chunks), VSize::from(3u64)); - assert_eq!(chunks_total_fee(chunks), Sats::from(0u64)); -} - -fn chunks_total_fee(chunks: &[Chunk]) -> Sats { - chunks.iter().map(|c| c.fee).sum() -} - -fn chunks_total_vsize(chunks: &[Chunk]) -> VSize { - chunks.iter().map(|c| c.vsize).sum() -} - -fn assert_non_increasing(chunks: &[Chunk]) { - for pair in chunks.windows(2) { - assert!( - pair[0].fee_rate() >= pair[1].fee_rate(), - "chunk feerates not non-increasing: {:?} vs {:?}", - (pair[0].fee, pair[0].vsize), - (pair[1].fee, pair[1].vsize), - ); - } -} diff --git a/crates/brk_mempool/src/tests/linearize/mod.rs b/crates/brk_mempool/src/tests/linearize/mod.rs deleted file mode 100644 index da3190e71..000000000 --- a/crates/brk_mempool/src/tests/linearize/mod.rs +++ /dev/null @@ -1,48 +0,0 @@ -mod basic; -mod oracle; -mod stress; - -use brk_types::{Sats, Txid, VSize, Weight}; -use smallvec::SmallVec; - -use crate::cluster::{Chunk, Cluster, ClusterNode, LocalIdx}; - -/// Test cluster: each node carries its input position as `id`, so -/// invariant checks can map `LocalIdx` (post-permutation) back to the -/// caller's `fees_vsizes` / `edges` index space. -pub(super) type TestCluster = Cluster; - -pub(super) fn make_cluster(fees_vsizes: &[(u64, u64)], edges: &[(u32, u32)]) -> TestCluster { - let mut parents: Vec> = - (0..fees_vsizes.len()).map(|_| SmallVec::new()).collect(); - for &(p, c) in edges { - parents[c as usize].push(LocalIdx::from(p)); - } - - let nodes: Vec> = fees_vsizes - .iter() - .zip(parents) - .enumerate() - .map(|(i, (&(fee, vsize), parents))| ClusterNode { - id: i as u32, - txid: Txid::COINBASE, - fee: Sats::from(fee), - vsize: VSize::from(vsize), - weight: Weight::from(vsize * 4), - parents, - }) - .collect(); - - Cluster::new(nodes) -} - -pub(super) fn run(cluster: &TestCluster) -> &[Chunk] { - &cluster.chunks -} - -pub(super) fn chunk_shapes(chunks: &[Chunk]) -> Vec<(usize, Sats, VSize)> { - chunks - .iter() - .map(|c| (c.txs.len(), c.fee, c.vsize)) - .collect() -} diff --git a/crates/brk_mempool/src/tests/linearize/oracle.rs b/crates/brk_mempool/src/tests/linearize/oracle.rs deleted file mode 100644 index 2f857cec8..000000000 --- a/crates/brk_mempool/src/tests/linearize/oracle.rs +++ /dev/null @@ -1,449 +0,0 @@ -use brk_types::{FeeRate, Sats, VSize}; - -use super::{Chunk, make_cluster, run}; - -fn to_typed(fv: &[(u64, u64)]) -> Vec<(Sats, VSize)> { - fv.iter() - .map(|&(f, v)| (Sats::from(f), VSize::from(v))) - .collect() -} - -fn canonical_chunking(path: &[(Sats, VSize)]) -> Vec<(Sats, VSize)> { - let mut chunks: Vec<(Sats, VSize)> = path.to_vec(); - let mut changed = true; - while changed { - changed = false; - let mut i = 0; - while i + 1 < chunks.len() { - let (fa, va) = chunks[i]; - let (fb, vb) = chunks[i + 1]; - if FeeRate::from((fb, vb)) > FeeRate::from((fa, va)) { - chunks[i] = (fa + fb, va + vb); - chunks.remove(i + 1); - changed = true; - } else { - i += 1; - } - } - } - chunks -} - -fn all_topo_orders(parents: &[Vec]) -> Vec> { - let n = parents.len(); - let indegree: Vec = parents.iter().map(|p| p.len() as u32).collect(); - let children: Vec> = { - let mut out = vec![Vec::new(); n]; - for (c, ps) in parents.iter().enumerate() { - for &p in ps { - out[p as usize].push(c as u32); - } - } - out - }; - - let mut results = Vec::new(); - let mut current: Vec = Vec::new(); - let mut indeg = indegree.clone(); - walk(&children, &mut indeg, &mut current, n, &mut results); - return results; - - fn walk( - children: &[Vec], - indeg: &mut [u32], - current: &mut Vec, - n: usize, - out: &mut Vec>, - ) { - if current.len() == n { - out.push(current.clone()); - return; - } - let ready: Vec = (0..n as u32).filter(|&i| indeg[i as usize] == 0).collect(); - for v in ready { - indeg[v as usize] = u32::MAX; - current.push(v); - for &c in &children[v as usize] { - indeg[c as usize] -= 1; - } - walk(children, indeg, current, n, out); - current.pop(); - for &c in &children[v as usize] { - indeg[c as usize] += 1; - } - indeg[v as usize] = 0; - } - } -} - -fn oracle_best(fees_vsizes: &[(Sats, VSize)], edges: &[(u32, u32)]) -> Vec<(Sats, VSize)> { - let n = fees_vsizes.len(); - let mut parents = vec![Vec::new(); n]; - for &(p, c) in edges { - parents[c as usize].push(p); - } - - let mut best: Option> = None; - for order in all_topo_orders(&parents) { - let path: Vec<(Sats, VSize)> = order.iter().map(|&i| fees_vsizes[i as usize]).collect(); - let chunking = canonical_chunking(&path); - best = Some(match best { - None => chunking, - Some(cur) => { - if dominates(&chunking, &cur) { - chunking - } else { - cur - } - } - }); - } - best.expect("at least one topological order") -} - -fn dominates(a: &[(Sats, VSize)], b: &[(Sats, VSize)]) -> bool { - let a_points = cumulative(a); - let b_points = cumulative(b); - let total_vsize = a_points.last().map(|p| p.0).unwrap_or_default(); - debug_assert_eq!( - total_vsize, - b_points.last().map(|p| p.0).unwrap_or_default() - ); - for v in 1..=u64::from(total_vsize) { - let v = VSize::from(v); - let fa = fee_at(&a_points, v); - let fb = fee_at(&b_points, v); - if fa < fb { - return false; - } - if fa > fb { - return true; - } - } - true -} - -fn cumulative(chunks: &[(Sats, VSize)]) -> Vec<(VSize, Sats)> { - let mut out = Vec::with_capacity(chunks.len() + 1); - let mut v = VSize::default(); - let mut f = Sats::ZERO; - out.push((v, f)); - for &(fee, vsize) in chunks { - v += vsize; - f += fee; - out.push((v, f)); - } - out -} - -/// Linear interpolation of cumulative fee at vsize `v`. Returns a -/// scaled `u128` (sub-sat precision via `df * dx / dv`) so dominance -/// ties resolve at the bit level. -fn fee_at(cum: &[(VSize, Sats)], v: VSize) -> u128 { - for pair in cum.windows(2) { - let (v0, f0) = pair[0]; - let (v1, f1) = pair[1]; - if v <= v1 { - let dv = u64::from(v1 - v0) as u128; - let f0 = u64::from(f0) as u128; - if dv == 0 { - return f0; - } - let df = u64::from(f1) as u128 - f0; - let dx = u64::from(v - v0) as u128; - return f0 + df * dx / dv; - } - } - cum.last().map_or(0, |&(_, f)| u64::from(f) as u128) -} - -fn chunk_rate(chunks: &[Chunk]) -> Vec<(Sats, VSize)> { - chunks.iter().map(|c| (c.fee, c.vsize)).collect() -} - -fn assert_matches_oracle(fees_vsizes: &[(u64, u64)], edges: &[(u32, u32)]) { - let cluster = make_cluster(fees_vsizes, edges); - let chunks = run(&cluster); - let got = chunk_rate(chunks); - let want = oracle_best(&to_typed(fees_vsizes), edges); - - let got_cum = cumulative(&got); - let want_cum = cumulative(&want); - let total = got_cum.last().unwrap().0; - assert_eq!(total, want_cum.last().unwrap().0, "total vsize mismatch"); - - for v in 1..=u64::from(total) { - let v = VSize::from(v); - let fa = fee_at(&got_cum, v); - let fb = fee_at(&want_cum, v); - assert!( - fa >= fb, - "SFL diagram below oracle at vsize {:?}: got {} want {}\n got={:?}\n want={:?}", - v, - fa, - fb, - got, - want, - ); - } -} - -#[test] -fn oracle_singleton() { - assert_matches_oracle(&[(100, 10)], &[]); -} - -#[test] -fn oracle_chain_cpfp() { - assert_matches_oracle(&[(1, 10), (100, 1)], &[(0, 1)]); -} - -#[test] -fn oracle_chain_parent_richer() { - assert_matches_oracle(&[(100, 10), (1, 1)], &[(0, 1)]); -} - -#[test] -fn oracle_v_shape() { - assert_matches_oracle(&[(1, 1), (1, 1), (100, 1)], &[(0, 2), (1, 2)]); -} - -#[test] -fn oracle_lambda_non_ancestor_beats_ancestor() { - assert_matches_oracle(&[(1, 1), (5, 1), (5, 1)], &[(0, 1), (0, 2)]); -} - -#[test] -fn oracle_diamond() { - assert_matches_oracle( - &[(1, 1), (1, 1), (1, 1), (100, 1)], - &[(0, 1), (0, 2), (1, 3), (2, 3)], - ); -} - -#[test] -fn oracle_tree_depth_3() { - assert_matches_oracle( - &[(1, 1), (1, 1), (1, 1), (100, 1), (100, 1)], - &[(0, 1), (0, 2), (1, 3), (2, 4)], - ); -} - -#[test] -fn oracle_branching_with_cheap_sibling() { - assert_matches_oracle(&[(1, 1), (50, 1), (100, 1)], &[(0, 1), (0, 2)]); -} - -#[test] -fn oracle_four_chain_alternating() { - assert_matches_oracle( - &[(10, 1), (1, 1), (10, 1), (1, 1)], - &[(0, 1), (1, 2), (2, 3)], - ); -} - -struct DagRng(u64); -impl DagRng { - fn new(seed: u64) -> Self { - Self(seed | 1) - } - fn next(&mut self) -> u64 { - let mut x = self.0; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - self.0 = x; - x - } - fn range(&mut self, n: u64) -> u64 { - if n == 0 { 0 } else { self.next() % n } - } -} - -type FvAndEdges = (Vec<(u64, u64)>, Vec<(u32, u32)>); - -fn random_dag(n: usize, seed: u64) -> FvAndEdges { - let mut rng = DagRng::new(seed); - let fees_vsizes: Vec<(u64, u64)> = (0..n) - .map(|_| { - let fee = 1 + rng.range(200); - let vsize = 1 + rng.range(5); - (fee, vsize) - }) - .collect(); - let mut edges = Vec::new(); - for i in 1..n { - let k = rng.range(4) as usize; - let mut picks: Vec = Vec::new(); - for _ in 0..k { - let p = rng.range(i as u64) as u32; - if !picks.contains(&p) { - picks.push(p); - } - } - for p in picks { - edges.push((p, i as u32)); - } - } - (fees_vsizes, edges) -} - -#[expect( - dead_code, - reason = "kept for ad-hoc oracle sweeps; called via uncommented stress tests" -)] -fn assert_optimal_on_random(n: usize, seed: u64) { - let (fv, edges) = random_dag(n, seed); - let cluster = make_cluster(&fv, &edges); - let chunks = run(&cluster); - let got = chunk_rate(chunks); - - let want = oracle_best(&to_typed(&fv), &edges); - - let got_cum = cumulative(&got); - let want_cum = cumulative(&want); - let total = got_cum.last().unwrap().0; - assert_eq!(total, want_cum.last().unwrap().0); - - for v in 1..=u64::from(total) { - let v = VSize::from(v); - let fa = fee_at(&got_cum, v); - let fb = fee_at(&want_cum, v); - assert!( - fa >= fb, - "merge-only suboptimal (n={}, seed={})\n fv = {:?}\n edges = {:?}\n got = {:?}\n want = {:?}\n at vsize {:?}: got {}, want {}", - n, - seed, - fv, - edges, - got, - want, - v, - fa, - fb, - ); - } -} - -fn optimality_gap_of(got: &[(Sats, VSize)], want: &[(Sats, VSize)]) -> Option { - let got_cum = cumulative(got); - let want_cum = cumulative(want); - let total = got_cum.last().unwrap().0; - debug_assert_eq!(total, want_cum.last().unwrap().0); - - let mut worst_gap: u128 = 0; - for v in 1..=u64::from(total) { - let v = VSize::from(v); - let fa = fee_at(&got_cum, v); - let fb = fee_at(&want_cum, v); - if fb > fa { - worst_gap = worst_gap.max(fb - fa); - } - } - if worst_gap == 0 { - None - } else { - Some(worst_gap) - } -} - -fn optimality_gap(n: usize, seed: u64) -> Option { - let (fv, edges) = random_dag(n, seed); - let cluster = make_cluster(&fv, &edges); - let chunks = run(&cluster); - let got: Vec<(Sats, VSize)> = chunks.iter().map(|c| (c.fee, c.vsize)).collect(); - let want = oracle_best(&to_typed(&fv), &edges); - optimality_gap_of(&got, &want) -} - -#[test] -#[ignore = "diagnostic sweep; run with --ignored to print stats"] -fn oracle_random_sweep_stats() { - let sizes: &[(usize, u64, u64)] = &[ - (4, 500, 1), - (5, 500, 1_000), - (6, 300, 2_000), - (7, 100, 3_000), - (8, 50, 4_000), - ]; - - eprintln!(); - eprintln!("Optimality sweep (random DAGs vs brute-force optimum):"); - eprintln!(" n cases sub max-gap"); - eprintln!(" ---------------------------"); - - let mut total = 0usize; - let mut cases_total = 0usize; - for &(n, count, base) in sizes { - let mut sub = 0; - let mut gap: u128 = 0; - for seed in 0..count { - let s = seed.wrapping_add(base); - if let Some(g) = optimality_gap(n, s) { - sub += 1; - gap = gap.max(g); - } - } - total += sub; - cases_total += count as usize; - eprintln!(" {} {:5} {:3} {:4}", n, count, sub, gap); - } - eprintln!(" ---------------------------"); - let pct = (total as f64 / cases_total as f64) * 100.0; - eprintln!(" totals {:4} {:3} ({:.1}%)", cases_total, total, pct); - eprintln!(); -} - -#[test] -#[ignore = "perf benchmark; run with --ignored --nocapture"] -fn perf_linearize() { - use std::time::Instant; - - let sizes: &[(usize, u64)] = &[ - (2, 5_000), - (5, 5_000), - (10, 2_000), - (15, 1_000), - (18, 500), - (20, 500), - (30, 200), - (50, 100), - (75, 50), - (100, 30), - ]; - - eprintln!(); - eprintln!("Linearize perf (release, per-call avg):"); - eprintln!(" n calls avg total"); - eprintln!(" -------------------------------------"); - - for &(n, calls) in sizes { - let clusters: Vec<_> = (0..calls) - .map(|s| { - let (fv, edges) = random_dag(n, s + 77); - make_cluster(&fv, &edges) - }) - .collect(); - - let t = Instant::now(); - let mut sink = 0u64; - for c in &clusters { - for chunk in &c.chunks { - sink = sink.wrapping_add(u64::from(chunk.fee)); - } - } - let elapsed = t.elapsed(); - let _ = sink; - - let avg_ns = elapsed.as_nanos() / calls as u128; - let pretty = if avg_ns >= 1_000_000 { - format!("{:.2} ms", avg_ns as f64 / 1_000_000.0) - } else if avg_ns >= 1_000 { - format!("{:.2} µs", avg_ns as f64 / 1_000.0) - } else { - format!("{} ns", avg_ns) - }; - eprintln!(" {:<4} {:<8} {:<10} {:.2?}", n, calls, pretty, elapsed); - } - eprintln!(); -} diff --git a/crates/brk_mempool/src/tests/linearize/stress.rs b/crates/brk_mempool/src/tests/linearize/stress.rs deleted file mode 100644 index ff10113d2..000000000 --- a/crates/brk_mempool/src/tests/linearize/stress.rs +++ /dev/null @@ -1,160 +0,0 @@ -use brk_types::{Sats, VSize}; - -use super::{TestCluster, make_cluster, run}; - -struct Rng(u64); -impl Rng { - fn new(seed: u64) -> Self { - Self(seed | 1) - } - fn next_u64(&mut self) -> u64 { - let mut x = self.0; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - self.0 = x; - x - } - fn range(&mut self, n: u64) -> u64 { - self.next_u64() % n - } -} - -type FvAndEdges = (Vec<(u64, u64)>, Vec<(u32, u32)>); - -fn random_cluster(n: usize, seed: u64) -> FvAndEdges { - let mut rng = Rng::new(seed); - let mut fees_vsizes = Vec::with_capacity(n); - for _ in 0..n { - let fee = 1 + rng.range(1000); - let vsize = 1 + rng.range(100); - fees_vsizes.push((fee, vsize)); - } - - let mut edges = Vec::new(); - for i in 1..n { - let k = rng.range(4) as usize; - let mut picks: Vec = Vec::new(); - for _ in 0..k { - let p = rng.range(i as u64) as u32; - if !picks.contains(&p) { - picks.push(p); - } - } - for p in picks { - edges.push((p, i as u32)); - } - } - - (fees_vsizes, edges) -} - -/// `cluster.nodes` is in topological order, so each node's `LocalIdx` -/// may differ from the caller's input position. The cluster's `id` -/// field carries the input index, and we use it to map back when the -/// invariant being checked is expressed in input space (fees/vsizes -/// table, edges list). -fn check_invariants(fees_vsizes: &[(u64, u64)], edges: &[(u32, u32)], cluster: &TestCluster) { - let n = fees_vsizes.len(); - let chunks = &cluster.chunks; - let input_of = |l: crate::cluster::LocalIdx| cluster.nodes[l.as_usize()].id as usize; - - let mut seen = vec![false; n]; - for chunk in chunks { - for &local in &chunk.txs { - let i = input_of(local); - assert!(!seen[i], "input node {} appears in multiple chunks", i); - seen[i] = true; - } - } - for (i, s) in seen.iter().enumerate() { - assert!(*s, "input node {} missing from all chunks", i); - } - - for chunk in chunks { - let fee: u64 = chunk.txs.iter().map(|&l| fees_vsizes[input_of(l)].0).sum(); - let vsize: u64 = chunk.txs.iter().map(|&l| fees_vsizes[input_of(l)].1).sum(); - assert_eq!(chunk.fee, Sats::from(fee), "chunk fee mismatch"); - assert_eq!(chunk.vsize, VSize::from(vsize), "chunk vsize mismatch"); - } - - let chunk_of_input: Vec = { - let mut out = vec![usize::MAX; n]; - for (ci, chunk) in chunks.iter().enumerate() { - for &local in &chunk.txs { - out[input_of(local)] = ci; - } - } - out - }; - for &(p, c) in edges { - let cp = chunk_of_input[p as usize]; - let cc = chunk_of_input[c as usize]; - assert!( - cp <= cc, - "parent {} in chunk {} but child {} in earlier chunk {}", - p, - cp, - c, - cc - ); - } - - for pair in chunks.windows(2) { - assert!( - pair[0].fee_rate() >= pair[1].fee_rate(), - "chunk feerates not non-increasing: {}/{} then {}/{}", - pair[0].fee, - pair[0].vsize, - pair[1].fee, - pair[1].vsize, - ); - } -} - -#[test] -fn random_small_clusters() { - for seed in 0..200u64 { - let n = 2 + (seed % 10) as usize; - let (fv, edges) = random_cluster(n, seed.wrapping_add(1)); - let cluster = make_cluster(&fv, &edges); - check_invariants(&fv, &edges, &cluster); - } -} - -#[test] -fn random_medium_clusters() { - for seed in 0..50u64 { - let n = 10 + (seed % 20) as usize; - let (fv, edges) = random_cluster(n, seed.wrapping_add(100)); - let cluster = make_cluster(&fv, &edges); - check_invariants(&fv, &edges, &cluster); - } -} - -#[test] -fn random_large_clusters() { - for seed in 0..10u64 { - let (fv, edges) = random_cluster(30, seed.wrapping_add(1000)); - let cluster = make_cluster(&fv, &edges); - check_invariants(&fv, &edges, &cluster); - } -} - -#[test] -fn determinism_same_seed_same_output() { - let (fv, edges) = random_cluster(15, 42); - let cluster = make_cluster(&fv, &edges); - let a: Vec<(Sats, VSize)> = run(&cluster).iter().map(|c| (c.fee, c.vsize)).collect(); - let b: Vec<(Sats, VSize)> = run(&cluster).iter().map(|c| (c.fee, c.vsize)).collect(); - assert_eq!(a, b); -} - -#[test] -fn random_cluster_at_policy_limit() { - for seed in 0..5u64 { - let (fv, edges) = random_cluster(100, seed.wrapping_add(9000)); - let cluster = make_cluster(&fv, &edges); - check_invariants(&fv, &edges, &cluster); - } -} diff --git a/crates/brk_mempool/src/tests/mod.rs b/crates/brk_mempool/src/tests/mod.rs deleted file mode 100644 index f3ff28114..000000000 --- a/crates/brk_mempool/src/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod graph_bench; -mod linearize; diff --git a/crates/brk_query/src/impl/cpfp.rs b/crates/brk_query/src/impl/cpfp.rs index 6ed54daeb..a9af72bcf 100644 --- a/crates/brk_query/src/impl/cpfp.rs +++ b/crates/brk_query/src/impl/cpfp.rs @@ -2,16 +2,18 @@ //! `brk_mempool`) and the confirmed-tx path built here from indexer //! and computer vecs. //! -//! Confirmed clusters are built on demand by walking the same-block +//! Confirmed clusters are materialized on demand by walking same-block //! parent/child edges in `TxIndex` space (no `Transaction` -//! reconstruction, no `txid → tx_index` lookup), then handing the -//! resulting `brk_mempool::cluster::Cluster` to `Cluster::to_cpfp_info` -//! — the same wire converter the mempool path uses, so both produce -//! identical `CpfpInfo` shapes. +//! reconstruction, no `txid -> tx_index` lookup), then assembling the +//! wire shape directly. The seed's effective fee rate and the per-chunk +//! grouping both read precomputed `effective_fee_rate.tx_index`, which +//! carries the same chunk-rate semantics the live mempool produces. use brk_error::{Error, OptionData, Result}; -use brk_mempool::cluster::{Cluster, ClusterNode, LocalIdx}; -use brk_types::{CpfpInfo, FeeRate, Height, TxInIndex, TxIndex, Txid, TxidPrefix, VSize, Weight}; +use brk_types::{ + CpfpCluster, CpfpClusterChunk, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate, + Height, Sats, TxInIndex, TxIndex, Txid, TxidPrefix, VSize, Weight, +}; use rustc_hash::{FxBuildHasher, FxHashMap}; use smallvec::SmallVec; use vecdb::{ReadableVec, VecIndex}; @@ -23,15 +25,20 @@ use crate::Query; const MAX: usize = 25; struct WalkResult { - /// Cluster members in build order (`[seed, ancestors..., descendants...]`), - /// each paired with its in-cluster parent edges already resolved to - /// `LocalIdx`. Vec position equals the node's `LocalIdx`. - nodes: Vec<(TxIndex, SmallVec<[LocalIdx; 2]>)>, - /// Pre-permutation `LocalIdx` of the seed. Equals `ancestor_count` - /// because all of seed's in-cluster ancestors topo-sort before it - /// and only ancestors do, so after `Cluster::new` permutes nodes - /// into topological order seed lands at this exact position. - seed_local: LocalIdx, + /// Cluster members in `[ancestors..., seed, descendants...]` order, + /// each paired with its in-cluster parent edges resolved to the + /// member's local index. The seed's local index is `ancestors.len()`. + members: Vec<(TxIndex, SmallVec<[CpfpClusterTxIndex; 2]>)>, + seed_local: CpfpClusterTxIndex, +} + +struct Member { + txid: Txid, + fee: Sats, + weight: Weight, + vsize: VSize, + rate: FeeRate, + parents: SmallVec<[CpfpClusterTxIndex; 2]>, } impl Query { @@ -47,14 +54,14 @@ impl Query { self.confirmed_cpfp(txid) } - /// Effective fee rate for `txid` using the same SFL chunk-rate - /// semantics across paths: + /// Effective fee rate for `txid` using the same chunk-rate semantics + /// across paths: /// - /// - Live mempool: snapshot `cluster_of` lookup → seed's chunk rate. - /// If the tx is in the pool but not in the latest snapshot (e.g. - /// just added), falls back to the entry's simple `fee/vsize`. - /// - Confirmed: precomputed `effective_fee_rate.tx_index` (the same - /// SFL chunk rate, computed at index time). + /// - Live mempool: snapshot's per-tx `chunk_rate` (Core's + /// `fees.chunk` / `chunkweight`, or proxy fallback). If the tx is + /// in the pool but not in the latest snapshot (e.g. just added), + /// falls back to the entry's simple `fee/vsize`. + /// - Confirmed: precomputed `effective_fee_rate.tx_index`. /// - Graveyard-only RBF predecessor: simple `fee/vsize` snapshotted /// at burial. /// @@ -90,13 +97,17 @@ impl Query { } /// CPFP cluster for a confirmed tx: the connected component of - /// same-block parent/child edges, walked on demand. SFL runs on - /// the result so `effectiveFeePerVsize` matches the live path's - /// chunk-rate semantics. + /// same-block parent/child edges, walked on demand. Per-tx + /// `effective_fee_rate.tx_index` provides each member's chunk rate. fn confirmed_cpfp(&self, txid: &Txid) -> Result { let tx_index = self.resolve_tx_index(txid)?; let height = self.confirmed_status_height(tx_index)?; - let (cluster, seed_local) = self.build_confirmed_cluster(tx_index, height)?; + let WalkResult { + members, + seed_local, + } = self.walk_same_block_cluster(tx_index, height)?; + + let resolved = self.resolve_members(&members)?; let sigops = self .indexer() .vecs @@ -104,20 +115,52 @@ impl Query { .total_sigop_cost .collect_one(tx_index) .data()?; - Ok(cluster.to_cpfp_info(seed_local, sigops)) + + Ok(build_cpfp_info(&resolved, seed_local, sigops)) } - /// Walk the seed's same-block parent/child edges, materialize each - /// member's `(txid, weight, fee)` from indexer/computer cursors, - /// and build a `Cluster`. The seed's `LocalIdx` comes - /// straight from the walk (`ancestor_count`), since `Cluster::new` - /// preserves the "ancestors before seed before descendants" ordering - /// that defines that index. - fn build_confirmed_cluster( + fn resolve_members( &self, - seed: TxIndex, - height: Height, - ) -> Result<(Cluster, LocalIdx)> { + members: &[(TxIndex, SmallVec<[CpfpClusterTxIndex; 2]>)], + ) -> Result> { + let indexer = self.indexer(); + let computer = self.computer(); + let mut base_size = indexer.vecs.transactions.base_size.cursor(); + let mut total_size = indexer.vecs.transactions.total_size.cursor(); + let mut fee_cursor = computer.transactions.fees.fee.tx_index.cursor(); + let mut rate_cursor = computer + .transactions + .fees + .effective_fee_rate + .tx_index + .cursor(); + let txid_reader = indexer.vecs.transactions.txid.reader(); + + members + .iter() + .map(|(tx_index, parents)| { + let i = tx_index.to_usize(); + let weight = + Weight::from_sizes(*base_size.get(i).data()?, *total_size.get(i).data()?); + let vsize = VSize::from(weight); + Ok(Member { + txid: txid_reader.get(i), + fee: fee_cursor.get(i).data()?, + weight, + vsize, + rate: rate_cursor.get(i).data()?, + parents: parents.clone(), + }) + }) + .collect() + } + + /// BFS the seed's same-block ancestors (via `outpoint`) and + /// descendants (via `spent.txin_index` -> `spending_tx`), capped + /// at `MAX` each side to match Core/mempool.space. Returns members + /// laid out as `[ancestors..., seed, descendants...]` so the seed's + /// local index is `ancestors.len()`. + fn walk_same_block_cluster(&self, seed: TxIndex, height: Height) -> Result { let indexer = self.indexer(); let computer = self.computer(); let safe = self.safe_lengths(); @@ -131,46 +174,6 @@ impl Query { }; let same_block = |idx: TxIndex| idx >= block_first && idx < block_end; - let WalkResult { nodes, seed_local } = self.walk_same_block_edges(seed, same_block); - - let mut base_size = indexer.vecs.transactions.base_size.cursor(); - let mut total_size = indexer.vecs.transactions.total_size.cursor(); - let mut fee_cursor = computer.transactions.fees.fee.tx_index.cursor(); - let txid_reader = indexer.vecs.transactions.txid.reader(); - - let cluster_nodes: Vec> = nodes - .into_iter() - .map(|(tx_index, parents)| { - let i = tx_index.to_usize(); - let weight = - Weight::from_sizes(*base_size.get(i).data()?, *total_size.get(i).data()?); - Ok(ClusterNode { - id: tx_index, - txid: txid_reader.get(i), - fee: fee_cursor.get(i).data()?, - vsize: VSize::from(weight), - weight, - parents, - }) - }) - .collect::>()?; - - Ok((Cluster::new(cluster_nodes), seed_local)) - } - - /// BFS the seed's same-block ancestors (via `outpoint`) and - /// descendants (via `spent.txin_index` → `spending_tx`), capped - /// at `MAX` each side to match Core/mempool.space. Each node is - /// pushed in build order with its full parent-outpoint list, then - /// at end of walk those lists are filtered against the membership - /// map to keep only in-cluster parents (resolved to `LocalIdx`). - fn walk_same_block_edges( - &self, - seed: TxIndex, - same_block: impl Fn(TxIndex) -> bool, - ) -> WalkResult { - let indexer = self.indexer(); - let computer = self.computer(); let mut first_txin = indexer.vecs.transactions.first_txin_index.cursor(); let mut first_txout = indexer.vecs.transactions.first_txout_index.cursor(); let mut outpoint = indexer.vecs.inputs.outpoint.cursor(); @@ -197,41 +200,32 @@ impl Query { out }; - let mut raw: Vec<(TxIndex, SmallVec<[TxIndex; 2]>)> = Vec::with_capacity(2 * MAX + 1); - let mut local_of: FxHashMap = + let mut visited: FxHashMap = FxHashMap::with_capacity_and_hasher(2 * MAX + 1, FxBuildHasher); - raw.push((seed, walk_inputs(seed))); - local_of.insert(seed, LocalIdx::ZERO); + visited.insert(seed, ()); - // Ancestor BFS. Stack holds indices into `raw`; each pop reads - // that node's already-recorded parents and explores any same-block - // ones we haven't visited yet. `walk_inputs` runs at push time so - // parents are ready for the post-walk filter. - let mut stack: Vec = vec![0]; - let mut ancestor_count: usize = 0; - 'a: while let Some(idx) = stack.pop() { - let parents = raw[idx].1.clone(); + // Ancestor BFS: each push records (tx_index, raw parent tx_indices) + // so we can filter against final cluster membership at the end. + let seed_inputs = walk_inputs(seed); + let mut ancestors: Vec<(TxIndex, SmallVec<[TxIndex; 2]>)> = Vec::new(); + let mut stack: Vec> = vec![seed_inputs.clone()]; + 'a: while let Some(parents) = stack.pop() { for parent in parents { - if ancestor_count >= MAX { + if ancestors.len() >= MAX { break 'a; } - if local_of.contains_key(&parent) || !same_block(parent) { + if visited.insert(parent, ()).is_some() || !same_block(parent) { continue; } - let new_idx = raw.len(); - raw.push((parent, walk_inputs(parent))); - local_of.insert(parent, LocalIdx::from(new_idx)); - stack.push(new_idx); - ancestor_count += 1; + let parent_inputs = walk_inputs(parent); + ancestors.push((parent, parent_inputs.clone())); + stack.push(parent_inputs); } } - // Descendant BFS. Stack holds tx_indices since we look up each - // tx's txouts via `first_txout`/`spent`/`spending_tx`. `local_of` - // already contains the seed and every ancestor, so they're - // skipped by the membership check. + // Descendant BFS via spent outputs. + let mut descendants: Vec<(TxIndex, SmallVec<[TxIndex; 2]>)> = Vec::new(); let mut stack: Vec = vec![seed]; - let mut descendant_count = 0; 'd: while let Some(cur) = stack.pop() { let Ok(start) = first_txout.get(cur.to_usize()).data() else { continue; @@ -249,39 +243,145 @@ impl Query { let Ok(child) = spending_tx.get(usize::from(txin_idx)).data() else { continue; }; - if local_of.contains_key(&child) || !same_block(child) { + if visited.insert(child, ()).is_some() || !same_block(child) { continue; } - let new_idx = raw.len(); - raw.push((child, walk_inputs(child))); - local_of.insert(child, LocalIdx::from(new_idx)); + descendants.push((child, walk_inputs(child))); stack.push(child); - descendant_count += 1; - if descendant_count >= MAX { + if descendants.len() >= MAX { break 'd; } } } - // Filter each node's full input list against `local_of` to keep - // only in-cluster parents, resolved to their `LocalIdx`. - let nodes: Vec<(TxIndex, SmallVec<[LocalIdx; 2]>)> = raw + // Lay members out as [ancestors_reverse..., seed, descendants...] + // so parents come before children when a single ancestor chain + // walks back from seed. Reversing the BFS order is good enough + // for wire output; chunk grouping doesn't depend on it. + let ancestor_count = ancestors.len(); + let total = ancestor_count + 1 + descendants.len(); + let mut local_of: FxHashMap = + FxHashMap::with_capacity_and_hasher(total, FxBuildHasher); + let mut members: Vec<(TxIndex, SmallVec<[TxIndex; 2]>)> = Vec::with_capacity(total); + + for (tx, raw_parents) in ancestors.into_iter().rev() { + local_of.insert(tx, CpfpClusterTxIndex::from(members.len() as u32)); + members.push((tx, raw_parents)); + } + let seed_local = CpfpClusterTxIndex::from(members.len() as u32); + local_of.insert(seed, seed_local); + members.push((seed, seed_inputs)); + for (tx, raw_parents) in descendants { + local_of.insert(tx, CpfpClusterTxIndex::from(members.len() as u32)); + members.push((tx, raw_parents)); + } + + let resolved: Vec<(TxIndex, SmallVec<[CpfpClusterTxIndex; 2]>)> = members .into_iter() - .map(|(tx_index, full_inputs)| { - let parents: SmallVec<[LocalIdx; 2]> = full_inputs + .map(|(tx, raw_parents)| { + let parents: SmallVec<[CpfpClusterTxIndex; 2]> = raw_parents .iter() .filter_map(|p| local_of.get(p).copied()) .collect(); - (tx_index, parents) + (tx, parents) }) .collect(); - // Seed's pre-permutation index is 0; after `Cluster::new` topo-sorts - // it lands at `ancestor_count` (all in-cluster ancestors come first, - // and only ancestors do). - WalkResult { - nodes, - seed_local: LocalIdx::from(ancestor_count), - } + Ok(WalkResult { + members: resolved, + seed_local, + }) } } + +fn build_cpfp_info( + members: &[Member], + seed_local: CpfpClusterTxIndex, + sigops: brk_types::SigOps, +) -> CpfpInfo { + let seed_pos = u32::from(seed_local) as usize; + let seed = &members[seed_pos]; + + let ancestors: Vec = members[..seed_pos] + .iter() + .map(|m| CpfpEntry { + txid: m.txid, + weight: m.weight, + fee: m.fee, + }) + .collect(); + let descendants: Vec = members[seed_pos + 1..] + .iter() + .map(|m| CpfpEntry { + txid: m.txid, + weight: m.weight, + fee: m.fee, + }) + .collect(); + let best_descendant = descendants + .iter() + .max_by_key(|e| FeeRate::from((e.fee, e.weight))) + .cloned(); + + let cluster_txs: Vec = members + .iter() + .map(|m| CpfpClusterTx { + txid: m.txid, + weight: m.weight, + fee: m.fee, + parents: m.parents.iter().copied().collect(), + }) + .collect(); + let chunks = chunk_groups(members); + let chunk_index = chunks + .iter() + .position(|ch| ch.txs.contains(&seed_local)) + .map(|i| i as u32) + .unwrap_or(0); + + CpfpInfo { + ancestors, + best_descendant, + descendants, + effective_fee_per_vsize: seed.rate, + sigops, + fee: seed.fee, + vsize: seed.vsize, + adjusted_vsize: sigops.adjust_vsize(seed.vsize), + cluster: CpfpCluster { + txs: cluster_txs, + chunks, + chunk_index, + }, + } +} + +fn chunk_groups(members: &[Member]) -> Vec { + let mut groups: FxHashMap)> = + FxHashMap::with_capacity_and_hasher(members.len(), FxBuildHasher); + let mut order: Vec = Vec::new(); + for (i, m) in members.iter().enumerate() { + let key = f64::from(m.rate).to_bits(); + let local = CpfpClusterTxIndex::from(i as u32); + groups + .entry(key) + .and_modify(|(_, v)| v.push(local)) + .or_insert_with(|| { + order.push(key); + let mut v: SmallVec<[CpfpClusterTxIndex; 4]> = SmallVec::new(); + v.push(local); + (m.rate, v) + }); + } + order.sort_by_key(|k| std::cmp::Reverse(groups[k].0)); + order + .into_iter() + .map(|k| { + let (rate, txs) = groups.remove(&k).unwrap(); + CpfpClusterChunk { + txs: txs.into_vec(), + feerate: rate, + } + }) + .collect() +} diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index 898bb21bb..747b024ce 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -1,13 +1,11 @@ +use crate::Query; use brk_error::{Error, Result}; -use brk_mempool::{Mempool, RbfForTx, RbfNode}; +use brk_mempool::{Mempool, PrevoutResolver, RbfForTx, RbfNode}; use brk_types::{ - CheckedSub, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx, - RecommendedFees, ReplacementNode, Sats, Timestamp, TxOut, TxOutIndex, Txid, TxidPrefix, + CheckedSub, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, + RbfTx, RecommendedFees, ReplacementNode, Sats, Timestamp, TxOut, TxOutIndex, Txid, TxidPrefix, TypeIndex, }; -use vecdb::VecIndex; - -use crate::Query; const RECENT_REPLACEMENTS_LIMIT: usize = 25; @@ -49,44 +47,52 @@ impl Query { Ok(blocks) } - /// Fill any `prevout == None` inputs on live mempool txs from the - /// 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; - }; + /// Indexer-backed resolver for confirmed-parent prevouts. Pass + /// the returned closure to `Mempool::start_with` / + /// `Mempool::update_with`; the mempool driver calls it post-apply + /// for every still-unfilled `prevout == None` input. + /// + /// Reads go through `read_once` rather than a captured + /// `VecReader`: `VecReader::stored_len` is snapshotted at + /// construction, so a long-lived reader paired with fresh + /// `safe_lengths` would let `safe.tx_index` / `safe.txout_index` + /// advance past the reader's frozen length and panic in + /// `reader.get()`. `read_once` rebinds against the current vec + /// length per call and lets newly indexed parents become + /// resolvable on the next cycle. + pub fn indexer_prevout_resolver(&self) -> PrevoutResolver { + let query = self.clone(); + let indexer = self.0.indexer; - let indexer = self.indexer(); - let stores = &indexer.stores; - let safe = self.safe_lengths(); - let tx_index_len = safe.tx_index; - let txout_index_len = safe.txout_index; - let first_txout_index_reader = indexer.vecs.transactions.first_txout_index.reader(); - let output_type_reader = indexer.vecs.outputs.output_type.reader(); - let type_index_reader = indexer.vecs.outputs.type_index.reader(); - let value_reader = indexer.vecs.outputs.value.reader(); - let addr_readers = indexer.vecs.addrs.addr_readers(); - - mempool.fill_prevouts(|prev_txid, vout| { - let prev_tx_index = stores + Box::new(move |prev_txid, vout| { + let safe = query.safe_lengths(); + let prev_tx_index = indexer + .stores .txid_prefix_to_tx_index .get(&TxidPrefix::from(prev_txid)) .ok()?? .into_owned(); - if prev_tx_index >= tx_index_len { + if prev_tx_index >= safe.tx_index { return None; } - let first_txout: TxOutIndex = first_txout_index_reader.get(prev_tx_index.to_usize()); + let first_txout: TxOutIndex = indexer + .vecs + .transactions + .first_txout_index + .read_once(prev_tx_index) + .ok()?; let txout = first_txout + vout; - if txout >= txout_index_len { + if txout >= safe.txout_index { return None; } - let txout_index = usize::from(txout); - let output_type: OutputType = output_type_reader.get(txout_index); - let type_index: TypeIndex = type_index_reader.get(txout_index); - let value: Sats = value_reader.get(txout_index); - let script_pubkey = addr_readers.script_pubkey(output_type, type_index); + let output_type: OutputType = indexer.vecs.outputs.output_type.read_once(txout).ok()?; + let type_index: TypeIndex = indexer.vecs.outputs.type_index.read_once(txout).ok()?; + let value: Sats = indexer.vecs.outputs.value.read_once(txout).ok()?; + let script_pubkey = indexer + .vecs + .addrs + .addr_readers() + .script_pubkey(output_type, type_index); Some(TxOut::from((script_pubkey, value))) }) } @@ -125,11 +131,7 @@ impl Query { /// 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, - successor_time: Option, - ) -> ReplacementNode { + fn enrich_rbf_node(&self, node: RbfNode, successor_time: Option) -> ReplacementNode { let interval = successor_time .and_then(|st| st.checked_sub(node.first_seen)) .map(|d| *d); diff --git a/crates/brk_query/src/impl/tx.rs b/crates/brk_query/src/impl/tx.rs index 69b9e9ac4..b510603dd 100644 --- a/crates/brk_query/src/impl/tx.rs +++ b/crates/brk_query/src/impl/tx.rs @@ -123,7 +123,9 @@ impl Query { } pub fn transaction(&self, txid: &Txid) -> Result { - self.lookup_tx(txid, Transaction::clone, |idx| self.transaction_by_index(idx)) + self.lookup_tx(txid, Transaction::clone, |idx| { + self.transaction_by_index(idx) + }) } pub fn transaction_status(&self, txid: &Txid) -> Result { diff --git a/crates/brk_rpc/src/client.rs b/crates/brk_rpc/src/client.rs index 5c254673a..a274a841a 100644 --- a/crates/brk_rpc/src/client.rs +++ b/crates/brk_rpc/src/client.rs @@ -194,4 +194,37 @@ impl ClientInner { }) .collect()) } + + /// Mixed-method batch: each `(method, args)` pair becomes one request + /// in a single round-trip. Each result is independently parsed by the + /// caller using its own `T`. Outer `Result` fails on transport errors; + /// inner `Result`s fail on per-item RPC errors. + pub(crate) fn call_mixed_batch( + &self, + requests: &[(&str, Vec)], + ) -> Result>>> { + let params: Vec> = requests + .iter() + .map(|(_, args)| serde_json::value::to_raw_value(args).map_err(Error::from)) + .collect::>>()?; + + let client = self.client.read(); + let built: Vec = requests + .iter() + .zip(¶ms) + .map(|((method, _), p)| client.build_request(method, Some(p))) + .collect(); + + let responses = client + .send_batch(&built) + .map_err(|e| Error::Parse(format!("mixed batch failed: {e}")))?; + + Ok(responses + .into_iter() + .map(|resp| { + let resp = resp.ok_or(Error::Internal("Missing response in JSON-RPC batch"))?; + resp.result::>().map_err(Error::from) + }) + .collect()) + } } diff --git a/crates/brk_rpc/src/lib.rs b/crates/brk_rpc/src/lib.rs index 8b3a394a7..93272c393 100644 --- a/crates/brk_rpc/src/lib.rs +++ b/crates/brk_rpc/src/lib.rs @@ -13,6 +13,7 @@ mod client; mod methods; use client::ClientInner; +pub use methods::MempoolState; #[derive(Debug, Clone)] pub struct BlockchainInfo { diff --git a/crates/brk_rpc/src/methods.rs b/crates/brk_rpc/src/methods.rs index 11103b993..940d3a767 100644 --- a/crates/brk_rpc/src/methods.rs +++ b/crates/brk_rpc/src/methods.rs @@ -9,8 +9,7 @@ use brk_types::{ use corepc_jsonrpc::error::Error as JsonRpcError; use corepc_types::v30::{ GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne, - GetBlockVerboseZero, GetBlockchainInfo, GetMempoolInfo, GetRawMempool, GetRawMempoolVerbose, - GetTxOut, + GetBlockVerboseZero, GetBlockchainInfo, GetMempoolInfo, GetRawMempool, GetTxOut, }; use rustc_hash::FxHashMap; use serde::Deserialize; @@ -31,6 +30,94 @@ use crate::{ /// spend too long on a single batch before yielding results. const BATCH_CHUNK: usize = 2000; +/// Live mempool state fetched in one batched bitcoind round-trip: +/// `getrawmempool verbose` + `getblocktemplate` + `getmempoolinfo`. +/// `gbt` is validated to be a subset of `entries` before construction; +/// callers that want strict consistency should rely on this fact. +pub struct MempoolState { + pub entries: Vec, + pub gbt: Vec, + pub min_fee: FeeRate, +} + +#[derive(Deserialize)] +struct VerboseEntryRaw { + vsize: VSize, + weight: Weight, + time: Timestamp, + #[serde(rename = "ancestorcount")] + ancestor_count: u64, + #[serde(rename = "ancestorsize")] + ancestor_size: VSize, + #[serde(rename = "descendantsize")] + descendant_size: VSize, + fees: VerboseFeesRaw, + depends: Vec, + #[serde(rename = "chunkweight", default)] + chunk_weight: Option, +} + +#[derive(Deserialize)] +struct VerboseFeesRaw { + base: Bitcoin, + ancestor: Bitcoin, + descendant: Bitcoin, + #[serde(default)] + chunk: Option, +} + +#[derive(Deserialize)] +struct GbtResponseRaw { + transactions: Vec, +} + +#[derive(Deserialize)] +struct GbtTxRaw { + txid: bitcoin::Txid, + fee: u64, +} + +fn build_verbose(raw: FxHashMap) -> Result> { + raw.into_iter() + .map(|(txid_str, e)| { + let depends = e + .depends + .iter() + .map(|s| Client::parse_txid(s, "depends txid")) + .collect::>>()?; + Ok(MempoolEntryInfo { + txid: Client::parse_txid(&txid_str, "mempool txid")?, + vsize: e.vsize, + weight: e.weight, + fee: Sats::from(e.fees.base), + first_seen: e.time, + ancestor_count: e.ancestor_count, + ancestor_size: e.ancestor_size, + ancestor_fee: Sats::from(e.fees.ancestor), + descendant_size: e.descendant_size, + descendant_fee: Sats::from(e.fees.descendant), + chunk_fee: e.fees.chunk.map(Sats::from), + chunk_weight: e.chunk_weight, + depends, + }) + }) + .collect() +} + +fn build_gbt(raw: GbtResponseRaw) -> Vec { + raw.transactions + .into_iter() + .map(|t| BlockTemplateTx { + txid: Txid::from(t.txid), + fee: Sats::from(t.fee), + }) + .collect() +} + +fn build_min_fee(raw: GetMempoolInfo) -> FeeRate { + FeeRate::from(raw.mempool_min_fee * 100_000.0) +} + impl Client { pub fn get_blockchain_info(&self) -> Result { let r: GetBlockchainInfo = self.0.call_with_retry("getblockchaininfo", &[])?; @@ -181,15 +268,6 @@ impl Client { } } - /// Live `mempoolminfee` in sat/vB, already maxed against `minrelaytxfee` - /// per Core's contract. Wallets must pay at least this rate or bitcoind - /// will reject the broadcast; rises above the relay floor when the - /// mempool is purging by fee. - pub fn get_mempool_min_fee(&self) -> Result { - let r: GetMempoolInfo = self.0.call_with_retry("getmempoolinfo", &[])?; - Ok(FeeRate::from(r.mempool_min_fee * 100_000.0)) - } - pub fn get_raw_mempool(&self) -> Result> { let r: GetRawMempool = self.0.call_with_retry("getrawmempool", &[])?; r.0.iter() @@ -197,33 +275,6 @@ impl Client { .collect() } - /// Get all mempool entries with their fee data in a single RPC call - pub fn get_raw_mempool_verbose(&self) -> Result> { - let r: GetRawMempoolVerbose = self - .0 - .call_with_retry("getrawmempool", &[Value::Bool(true)])?; - r.0.into_iter() - .map(|(txid_str, entry)| { - let depends = entry - .depends - .iter() - .map(|s| Self::parse_txid(s, "depends txid")) - .collect::>>()?; - Ok(MempoolEntryInfo { - txid: Self::parse_txid(&txid_str, "mempool txid")?, - vsize: VSize::from(entry.vsize as u64), - weight: Weight::from(entry.weight as u64), - fee: Sats::from(Bitcoin::from(entry.fees.base)), - first_seen: Timestamp::from(entry.time), - ancestor_count: entry.ancestor_count as u64, - ancestor_size: entry.ancestor_size as u64, - ancestor_fee: Sats::from(Bitcoin::from(entry.fees.ancestor)), - depends, - }) - }) - .collect() - } - pub fn get_raw_transaction<'a, T, H>( &self, txid: &'a T, @@ -327,29 +378,50 @@ impl Client { Ok(Txid::from(txid)) } - /// Transactions (txid + fee) Bitcoin Core would include in the next - /// block it would mine, via `getblocktemplate`. Core requires the - /// `segwit` rule to be declared. - pub fn get_block_template_txs(&self) -> Result> { - #[derive(Deserialize)] - struct Response { - transactions: Vec, - } - #[derive(Deserialize)] - struct Tx { - txid: bitcoin::Txid, - fee: u64, + /// Verbose mempool listing + Core's projected next block + live + /// `mempoolminfee`, fetched in a single bitcoind round-trip. + /// Validates that every GBT txid is present in the verbose listing + /// and returns `Ok(None)` on mismatch so the caller can skip the + /// cycle (within-batch races inside bitcoind are rare; persistent + /// drift is bug-shaped). Other failures bubble up as `Err`. + pub fn fetch_mempool_state(&self) -> Result> { + let requests: [(&str, Vec); 3] = [ + ("getrawmempool", vec![Value::Bool(true)]), + ( + "getblocktemplate", + vec![serde_json::json!({ "rules": ["segwit"] })], + ), + ("getmempoolinfo", vec![]), + ]; + let mut out = self.0.call_mixed_batch(&requests)?.into_iter(); + let verbose_raw = out.next().ok_or(Error::Internal("missing verbose"))??; + let gbt_raw = out.next().ok_or(Error::Internal("missing gbt"))??; + let info_raw = out.next().ok_or(Error::Internal("missing mempoolinfo"))??; + + let verbose: FxHashMap = serde_json::from_str(verbose_raw.get())?; + let entries = build_verbose(verbose)?; + let gbt = build_gbt(serde_json::from_str(gbt_raw.get())?); + let min_fee = build_min_fee(serde_json::from_str(info_raw.get())?); + + #[cfg(debug_assertions)] + { + let entry_set: rustc_hash::FxHashSet = entries.iter().map(|e| e.txid).collect(); + let missing = gbt.iter().filter(|t| !entry_set.contains(&t.txid)).count(); + if missing > 0 { + tracing::warn!( + missing, + gbt_total = gbt.len(), + "getblocktemplate has {missing} txids not in verbose mempool; skipping cycle" + ); + return Ok(None); + } } - let args = [serde_json::json!({ "rules": ["segwit"] })]; - let r: Response = self.0.call_with_retry("getblocktemplate", &args)?; - Ok(r.transactions - .into_iter() - .map(|t| BlockTemplateTx { - txid: Txid::from(t.txid), - fee: Sats::from(t.fee), - }) - .collect()) + Ok(Some(MempoolState { + entries, + gbt, + min_fee, + })) } pub fn get_closest_valid_height(&self, hash: BlockHash) -> Result<(Height, BlockHash)> { diff --git a/crates/brk_types/src/mempool_entry_info.rs b/crates/brk_types/src/mempool_entry_info.rs index 4a15eee04..57c9513c5 100644 --- a/crates/brk_types/src/mempool_entry_info.rs +++ b/crates/brk_types/src/mempool_entry_info.rs @@ -1,6 +1,6 @@ -use crate::{Sats, Timestamp, Txid, VSize, Weight}; +use crate::{FeeRate, Sats, Timestamp, Txid, VSize, Weight}; -/// Mempool entry info from Bitcoin Core's getrawmempool verbose +/// Mempool entry info from Bitcoin Core's `getrawmempool true`. #[derive(Debug, Clone)] pub struct MempoolEntryInfo { pub txid: Txid, @@ -9,8 +9,31 @@ pub struct MempoolEntryInfo { pub fee: Sats, pub first_seen: Timestamp, pub ancestor_count: u64, - pub ancestor_size: u64, + pub ancestor_size: VSize, pub ancestor_fee: Sats, - /// Parent txids in the mempool + pub descendant_size: VSize, + pub descendant_fee: Sats, + /// Total fee of the cluster mempool chunk this tx belongs to. + /// Present from Bitcoin Core 31+ (cluster mempool); absent on + /// older Core, in which case rate-callers fall back to + /// `max(ancestor_rate, descendant_pkg_rate)`. + pub chunk_fee: Option, + pub chunk_weight: Option, + /// Parent txids in the mempool. pub depends: Vec, } + +impl MempoolEntryInfo { + /// Effective per-vbyte rate Core would mine this tx at. Uses the + /// Core-31 `fees.chunk` / `chunkweight` chunk fields when present; + /// otherwise falls back to `max(ancestor_rate, descendant_pkg_rate)`, + /// which bounds the predictive error in deep clusters. + pub fn chunk_rate(&self) -> FeeRate { + if let (Some(chunk_fee), Some(chunk_weight)) = (self.chunk_fee, self.chunk_weight) { + return FeeRate::from((chunk_fee, VSize::from(chunk_weight))); + } + let anc = FeeRate::from((self.ancestor_fee, self.ancestor_size)); + let desc = FeeRate::from((self.descendant_fee, self.descendant_size)); + anc.max(desc) + } +} diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 2e204331f..db6fde3e6 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -10471,431 +10471,58 @@ class BrkClient extends BrkClientBase { } /** - * Compact OpenAPI specification + * Health check * - * Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. + * Returns the health status of the API server, including uptime information. * - * Endpoint: `GET /api.json` - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * Endpoint: `GET /health` + * @param {{ signal?: AbortSignal, onValue?: (value: Health) => void }} [options] + * @returns {Promise} */ - async getApi({ signal, onValue } = {}) { - const path = `/api.json`; + async getHealth({ signal, onValue } = {}) { + const path = `/health`; return this.getJson(path, { signal, onValue }); } /** - * Address information + * API version * - * Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + * Returns the current version of the API server * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* - * - * Endpoint: `GET /api/address/{address}` - * - * @param {Addr} address - * @param {{ signal?: AbortSignal, onValue?: (value: AddrStats) => void }} [options] - * @returns {Promise} + * Endpoint: `GET /version` + * @param {{ signal?: AbortSignal, onValue?: (value: string) => void }} [options] + * @returns {Promise} */ - async getAddress(address, { signal, onValue } = {}) { - const path = `/api/address/${address}`; + async getVersion({ signal, onValue } = {}) { + const path = `/version`; return this.getJson(path, { signal, onValue }); } /** - * Address transactions + * Sync status * - * Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. + * Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* - * - * Endpoint: `GET /api/address/{address}/txs` - * - * @param {Addr} address - * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] - * @returns {Promise} + * Endpoint: `GET /api/server/sync` + * @param {{ signal?: AbortSignal, onValue?: (value: SyncStatus) => void }} [options] + * @returns {Promise} */ - async getAddressTxs(address, { signal, onValue } = {}) { - const path = `/api/address/${address}/txs`; + async getSyncStatus({ signal, onValue } = {}) { + const path = `/api/server/sync`; return this.getJson(path, { signal, onValue }); } /** - * Address confirmed transactions + * Disk usage * - * Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. + * Returns the disk space used by BRK and Bitcoin data. * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* - * - * Endpoint: `GET /api/address/{address}/txs/chain` - * - * @param {Addr} address - * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] - * @returns {Promise} + * Endpoint: `GET /api/server/disk` + * @param {{ signal?: AbortSignal, onValue?: (value: DiskUsage) => void }} [options] + * @returns {Promise} */ - async getAddressConfirmedTxs(address, { signal, onValue } = {}) { - const path = `/api/address/${address}/txs/chain`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Address confirmed transactions (paginated) - * - * Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* - * - * Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}` - * - * @param {Addr} address - * @param {Txid} after_txid - Last txid from the previous page (return transactions strictly older than this) - * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] - * @returns {Promise} - */ - async getAddressConfirmedTxsAfter(address, after_txid, { signal, onValue } = {}) { - const path = `/api/address/${address}/txs/chain/${after_txid}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Address mempool transactions - * - * Get unconfirmed transactions for an address from the mempool, newest first (up to 50). - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* - * - * Endpoint: `GET /api/address/{address}/txs/mempool` - * - * @param {Addr} address - * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] - * @returns {Promise} - */ - async getAddressMempoolTxs(address, { signal, onValue } = {}) { - const path = `/api/address/${address}/txs/mempool`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Address UTXOs - * - * Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* - * - * Endpoint: `GET /api/address/{address}/utxo` - * - * @param {Addr} address - * @param {{ signal?: AbortSignal, onValue?: (value: Utxo[]) => void }} [options] - * @returns {Promise} - */ - async getAddressUtxos(address, { signal, onValue } = {}) { - const path = `/api/address/${address}/utxo`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block hash by height - * - * Retrieve the block hash at a given height. Returns the hash as plain text. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* - * - * Endpoint: `GET /api/block-height/{height}` - * - * @param {Height} height - * @param {{ signal?: AbortSignal, onValue?: (value: BlockHash) => void }} [options] - * @returns {Promise} - */ - async getBlockByHeight(height, { signal, onValue } = {}) { - const path = `/api/block-height/${height}`; - return this.getText(path, { signal, onValue }); - } - - /** - * Block information - * - * Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* - * - * Endpoint: `GET /api/block/{hash}` - * - * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfo) => void }} [options] - * @returns {Promise} - */ - async getBlock(hash, { signal, onValue } = {}) { - const path = `/api/block/${hash}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block header - * - * Returns the hex-encoded 80-byte block header. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* - * - * Endpoint: `GET /api/block/{hash}/header` - * - * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: Hex) => void }} [options] - * @returns {Promise} - */ - async getBlockHeader(hash, { signal, onValue } = {}) { - const path = `/api/block/${hash}/header`; - return this.getText(path, { signal, onValue }); - } - - /** - * Raw block - * - * Returns the raw block data in binary format. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* - * - * Endpoint: `GET /api/block/{hash}/raw` - * - * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: Uint8Array) => void }} [options] - * @returns {Promise} - */ - async getBlockRaw(hash, { signal, onValue } = {}) { - const path = `/api/block/${hash}/raw`; - return this.getBytes(path, { signal, onValue }); - } - - /** - * Block status - * - * Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* - * - * Endpoint: `GET /api/block/{hash}/status` - * - * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: BlockStatus) => void }} [options] - * @returns {Promise} - */ - async getBlockStatus(hash, { signal, onValue } = {}) { - const path = `/api/block/${hash}/status`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Transaction ID at index - * - * Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* - * - * Endpoint: `GET /api/block/{hash}/txid/{index}` - * - * @param {BlockHash} hash - Bitcoin block hash - * @param {BlockTxIndex} index - Transaction index within the block (0-based) - * @param {{ signal?: AbortSignal, onValue?: (value: Txid) => void }} [options] - * @returns {Promise} - */ - async getBlockTxid(hash, index, { signal, onValue } = {}) { - const path = `/api/block/${hash}/txid/${index}`; - return this.getText(path, { signal, onValue }); - } - - /** - * Block transaction IDs - * - * Retrieve all transaction IDs in a block. Returns an array of txids in block order. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* - * - * Endpoint: `GET /api/block/{hash}/txids` - * - * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: Txid[]) => void }} [options] - * @returns {Promise} - */ - async getBlockTxids(hash, { signal, onValue } = {}) { - const path = `/api/block/${hash}/txids`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block transactions - * - * Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* - * - * Endpoint: `GET /api/block/{hash}/txs` - * - * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] - * @returns {Promise} - */ - async getBlockTxs(hash, { signal, onValue } = {}) { - const path = `/api/block/${hash}/txs`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block transactions (paginated) - * - * Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* - * - * Endpoint: `GET /api/block/{hash}/txs/{start_index}` - * - * @param {BlockHash} hash - Bitcoin block hash - * @param {BlockTxIndex} start_index - Starting transaction index within the block (0-based) - * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] - * @returns {Promise} - */ - async getBlockTxsFromIndex(hash, start_index, { signal, onValue } = {}) { - const path = `/api/block/${hash}/txs/${start_index}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Recent blocks - * - * Retrieve the last 10 blocks. Returns block metadata for each block. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* - * - * Endpoint: `GET /api/blocks` - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfo[]) => void }} [options] - * @returns {Promise} - */ - async getBlocks({ signal, onValue } = {}) { - const path = `/api/blocks`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block tip hash - * - * Returns the hash of the last block. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* - * - * Endpoint: `GET /api/blocks/tip/hash` - * @param {{ signal?: AbortSignal, onValue?: (value: BlockHash) => void }} [options] - * @returns {Promise} - */ - async getBlockTipHash({ signal, onValue } = {}) { - const path = `/api/blocks/tip/hash`; - return this.getText(path, { signal, onValue }); - } - - /** - * Block tip height - * - * Returns the height of the last block. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* - * - * Endpoint: `GET /api/blocks/tip/height` - * @param {{ signal?: AbortSignal, onValue?: (value: Height) => void }} [options] - * @returns {Promise} - */ - async getBlockTipHeight({ signal, onValue } = {}) { - const path = `/api/blocks/tip/height`; - return Number(await this.getText(path, { signal, onValue: onValue ? (v) => onValue(Number(v)) : undefined })); - } - - /** - * Blocks from height - * - * Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* - * - * Endpoint: `GET /api/blocks/{height}` - * - * @param {Height} height - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfo[]) => void }} [options] - * @returns {Promise} - */ - async getBlocksFromHeight(height, { signal, onValue } = {}) { - const path = `/api/blocks/${height}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mempool statistics - * - * Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* - * - * Endpoint: `GET /api/mempool` - * @param {{ signal?: AbortSignal, onValue?: (value: MempoolInfo) => void }} [options] - * @returns {Promise} - */ - async getMempool({ signal, onValue } = {}) { - const path = `/api/mempool`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mempool content hash - * - * Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. - * - * Endpoint: `GET /api/mempool/hash` - * @param {{ signal?: AbortSignal, onValue?: (value: number) => void }} [options] - * @returns {Promise} - */ - async getMempoolHash({ signal, onValue } = {}) { - const path = `/api/mempool/hash`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Live BTC/USD price - * - * Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. - * - * Endpoint: `GET /api/mempool/price` - * @param {{ signal?: AbortSignal, onValue?: (value: Dollars) => void }} [options] - * @returns {Promise} - */ - async getLivePrice({ signal, onValue } = {}) { - const path = `/api/mempool/price`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Recent mempool transactions - * - * Get the last 10 transactions to enter the mempool. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* - * - * Endpoint: `GET /api/mempool/recent` - * @param {{ signal?: AbortSignal, onValue?: (value: MempoolRecentTx[]) => void }} [options] - * @returns {Promise} - */ - async getMempoolRecent({ signal, onValue } = {}) { - const path = `/api/mempool/recent`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mempool transaction IDs - * - * Get all transaction IDs currently in the mempool. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* - * - * Endpoint: `GET /api/mempool/txids` - * @param {{ signal?: AbortSignal, onValue?: (value: Txid[]) => void }} [options] - * @returns {Promise} - */ - async getMempoolTxids({ signal, onValue } = {}) { - const path = `/api/mempool/txids`; + async getDiskUsage({ signal, onValue } = {}) { + const path = `/api/server/disk`; return this.getJson(path, { signal, onValue }); } @@ -10913,36 +10540,6 @@ class BrkClient extends BrkClientBase { return this.getJson(path, { signal, onValue }); } - /** - * Bulk series data - * - * Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. - * - * Endpoint: `GET /api/series/bulk` - * - * @param {SeriesList} series - Requested series - * @param {Index} index - Index to query - * @param {RangeIndex=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` - * @param {RangeIndex=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` - * @param {Limit=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` - * @param {Format=} [format] - Format of the output - * @param {{ signal?: AbortSignal, onValue?: (value: AnySeriesData[] | string) => void }} [options] - * @returns {Promise} - */ - async getSeriesBulk(series, index, start, end, limit, format, { signal, onValue } = {}) { - const params = new URLSearchParams(); - params.set('series', String(series)); - params.set('index', String(index)); - if (start !== undefined) params.set('start', String(start)); - if (end !== undefined) params.set('end', String(end)); - if (limit !== undefined) params.set('limit', String(limit)); - if (format !== undefined) params.set('format', String(format)); - const query = params.toString(); - const path = `/api/series/bulk${query ? '?' + query : ''}`; - if (format === 'csv') return this.getText(path, { signal, onValue }); - return this.getJson(path, { signal, onValue }); - } - /** * Series count * @@ -11137,49 +10734,1043 @@ class BrkClient extends BrkClientBase { } /** - * Disk usage + * Bulk series data * - * Returns the disk space used by BRK and Bitcoin data. + * Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. * - * Endpoint: `GET /api/server/disk` - * @param {{ signal?: AbortSignal, onValue?: (value: DiskUsage) => void }} [options] - * @returns {Promise} + * Endpoint: `GET /api/series/bulk` + * + * @param {SeriesList} series - Requested series + * @param {Index} index - Index to query + * @param {RangeIndex=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` + * @param {RangeIndex=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` + * @param {Limit=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` + * @param {Format=} [format] - Format of the output + * @param {{ signal?: AbortSignal, onValue?: (value: AnySeriesData[] | string) => void }} [options] + * @returns {Promise} */ - async getDiskUsage({ signal, onValue } = {}) { - const path = `/api/server/disk`; + async getSeriesBulk(series, index, start, end, limit, format, { signal, onValue } = {}) { + const params = new URLSearchParams(); + params.set('series', String(series)); + params.set('index', String(index)); + if (start !== undefined) params.set('start', String(start)); + if (end !== undefined) params.set('end', String(end)); + if (limit !== undefined) params.set('limit', String(limit)); + if (format !== undefined) params.set('format', String(format)); + const query = params.toString(); + const path = `/api/series/bulk${query ? '?' + query : ''}`; + if (format === 'csv') return this.getText(path, { signal, onValue }); return this.getJson(path, { signal, onValue }); } /** - * Sync status + * Available URPD cohorts * - * Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. + * Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. * - * Endpoint: `GET /api/server/sync` - * @param {{ signal?: AbortSignal, onValue?: (value: SyncStatus) => void }} [options] - * @returns {Promise} + * Endpoint: `GET /api/urpd` + * @param {{ signal?: AbortSignal, onValue?: (value: Cohort[]) => void }} [options] + * @returns {Promise} */ - async getSyncStatus({ signal, onValue } = {}) { - const path = `/api/server/sync`; + async listUrpdCohorts({ signal, onValue } = {}) { + const path = `/api/urpd`; return this.getJson(path, { signal, onValue }); } /** - * Broadcast transaction + * Available URPD dates * - * Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. + * Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* + * Endpoint: `GET /api/urpd/{cohort}/dates` * - * Endpoint: `POST /api/tx` + * @param {Cohort} cohort + * @param {{ signal?: AbortSignal, onValue?: (value: Date[]) => void }} [options] + * @returns {Promise} + */ + async listUrpdDates(cohort, { signal, onValue } = {}) { + const path = `/api/urpd/${cohort}/dates`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Latest URPD * - * @param {string} body - Request body - * @param {{ signal?: AbortSignal }} [options] + * URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. + * + * See the URPD tag description for the response shape and `agg` options. + * + * Endpoint: `GET /api/urpd/{cohort}` + * + * @param {Cohort} cohort + * @param {UrpdAggregation=} [agg] - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. + * @param {{ signal?: AbortSignal, onValue?: (value: Urpd) => void }} [options] + * @returns {Promise} + */ + async getUrpd(cohort, agg, { signal, onValue } = {}) { + const params = new URLSearchParams(); + if (agg !== undefined) params.set('agg', String(agg)); + const query = params.toString(); + const path = `/api/urpd/${cohort}${query ? '?' + query : ''}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * URPD at date + * + * URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. + * + * See the URPD tag description for unit conventions and `agg` options. + * + * Endpoint: `GET /api/urpd/{cohort}/{date}` + * + * @param {Cohort} cohort + * @param {string} date + * @param {UrpdAggregation=} [agg] - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. + * @param {{ signal?: AbortSignal, onValue?: (value: Urpd) => void }} [options] + * @returns {Promise} + */ + async getUrpdAt(cohort, date, agg, { signal, onValue } = {}) { + const params = new URLSearchParams(); + if (agg !== undefined) params.set('agg', String(agg)); + const query = params.toString(); + const path = `/api/urpd/${cohort}/${date}${query ? '?' + query : ''}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Difficulty adjustment + * + * Get current difficulty adjustment progress and estimates. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* + * + * Endpoint: `GET /api/v1/difficulty-adjustment` + * @param {{ signal?: AbortSignal, onValue?: (value: DifficultyAdjustment) => void }} [options] + * @returns {Promise} + */ + async getDifficultyAdjustment({ signal, onValue } = {}) { + const path = `/api/v1/difficulty-adjustment`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Current BTC price + * + * Returns bitcoin latest price (on-chain derived, USD only). + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* + * + * Endpoint: `GET /api/v1/prices` + * @param {{ signal?: AbortSignal, onValue?: (value: Prices) => void }} [options] + * @returns {Promise} + */ + async getPrices({ signal, onValue } = {}) { + const path = `/api/v1/prices`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Historical price + * + * Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* + * + * Endpoint: `GET /api/v1/historical-price` + * + * @param {Timestamp=} [timestamp] + * @param {{ signal?: AbortSignal, onValue?: (value: HistoricalPrice) => void }} [options] + * @returns {Promise} + */ + async getHistoricalPrice(timestamp, { signal, onValue } = {}) { + const params = new URLSearchParams(); + if (timestamp !== undefined) params.set('timestamp', String(timestamp)); + const query = params.toString(); + const path = `/api/v1/historical-price${query ? '?' + query : ''}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Address information + * + * Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* + * + * Endpoint: `GET /api/address/{address}` + * + * @param {Addr} address + * @param {{ signal?: AbortSignal, onValue?: (value: AddrStats) => void }} [options] + * @returns {Promise} + */ + async getAddress(address, { signal, onValue } = {}) { + const path = `/api/address/${address}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Address transactions + * + * Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* + * + * Endpoint: `GET /api/address/{address}/txs` + * + * @param {Addr} address + * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] + * @returns {Promise} + */ + async getAddressTxs(address, { signal, onValue } = {}) { + const path = `/api/address/${address}/txs`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Address confirmed transactions + * + * Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* + * + * Endpoint: `GET /api/address/{address}/txs/chain` + * + * @param {Addr} address + * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] + * @returns {Promise} + */ + async getAddressConfirmedTxs(address, { signal, onValue } = {}) { + const path = `/api/address/${address}/txs/chain`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Address confirmed transactions (paginated) + * + * Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* + * + * Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}` + * + * @param {Addr} address + * @param {Txid} after_txid - Last txid from the previous page (return transactions strictly older than this) + * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] + * @returns {Promise} + */ + async getAddressConfirmedTxsAfter(address, after_txid, { signal, onValue } = {}) { + const path = `/api/address/${address}/txs/chain/${after_txid}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Address mempool transactions + * + * Get unconfirmed transactions for an address from the mempool, newest first (up to 50). + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* + * + * Endpoint: `GET /api/address/{address}/txs/mempool` + * + * @param {Addr} address + * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] + * @returns {Promise} + */ + async getAddressMempoolTxs(address, { signal, onValue } = {}) { + const path = `/api/address/${address}/txs/mempool`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Address UTXOs + * + * Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* + * + * Endpoint: `GET /api/address/{address}/utxo` + * + * @param {Addr} address + * @param {{ signal?: AbortSignal, onValue?: (value: Utxo[]) => void }} [options] + * @returns {Promise} + */ + async getAddressUtxos(address, { signal, onValue } = {}) { + const path = `/api/address/${address}/utxo`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Validate address + * + * Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* + * + * Endpoint: `GET /api/v1/validate-address/{address}` + * + * @param {string} address - Bitcoin address to validate (can be any string) + * @param {{ signal?: AbortSignal, onValue?: (value: AddrValidation) => void }} [options] + * @returns {Promise} + */ + async validateAddress(address, { signal, onValue } = {}) { + const path = `/api/v1/validate-address/${address}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block information + * + * Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* + * + * Endpoint: `GET /api/block/{hash}` + * + * @param {BlockHash} hash + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfo) => void }} [options] + * @returns {Promise} + */ + async getBlock(hash, { signal, onValue } = {}) { + const path = `/api/block/${hash}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block (v1) + * + * Returns block details with extras by hash. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* + * + * Endpoint: `GET /api/v1/block/{hash}` + * + * @param {BlockHash} hash + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1) => void }} [options] + * @returns {Promise} + */ + async getBlockV1(hash, { signal, onValue } = {}) { + const path = `/api/v1/block/${hash}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block header + * + * Returns the hex-encoded 80-byte block header. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* + * + * Endpoint: `GET /api/block/{hash}/header` + * + * @param {BlockHash} hash + * @param {{ signal?: AbortSignal, onValue?: (value: Hex) => void }} [options] + * @returns {Promise} + */ + async getBlockHeader(hash, { signal, onValue } = {}) { + const path = `/api/block/${hash}/header`; + return this.getText(path, { signal, onValue }); + } + + /** + * Block hash by height + * + * Retrieve the block hash at a given height. Returns the hash as plain text. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* + * + * Endpoint: `GET /api/block-height/{height}` + * + * @param {Height} height + * @param {{ signal?: AbortSignal, onValue?: (value: BlockHash) => void }} [options] + * @returns {Promise} + */ + async getBlockByHeight(height, { signal, onValue } = {}) { + const path = `/api/block-height/${height}`; + return this.getText(path, { signal, onValue }); + } + + /** + * Block by timestamp + * + * Find the block closest to a given UNIX timestamp. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* + * + * Endpoint: `GET /api/v1/mining/blocks/timestamp/{timestamp}` + * + * @param {Timestamp} timestamp + * @param {{ signal?: AbortSignal, onValue?: (value: BlockTimestamp) => void }} [options] + * @returns {Promise} + */ + async getBlockByTimestamp(timestamp, { signal, onValue } = {}) { + const path = `/api/v1/mining/blocks/timestamp/${timestamp}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Raw block + * + * Returns the raw block data in binary format. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* + * + * Endpoint: `GET /api/block/{hash}/raw` + * + * @param {BlockHash} hash + * @param {{ signal?: AbortSignal, onValue?: (value: Uint8Array) => void }} [options] + * @returns {Promise} + */ + async getBlockRaw(hash, { signal, onValue } = {}) { + const path = `/api/block/${hash}/raw`; + return this.getBytes(path, { signal, onValue }); + } + + /** + * Block status + * + * Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* + * + * Endpoint: `GET /api/block/{hash}/status` + * + * @param {BlockHash} hash + * @param {{ signal?: AbortSignal, onValue?: (value: BlockStatus) => void }} [options] + * @returns {Promise} + */ + async getBlockStatus(hash, { signal, onValue } = {}) { + const path = `/api/block/${hash}/status`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block tip height + * + * Returns the height of the last block. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* + * + * Endpoint: `GET /api/blocks/tip/height` + * @param {{ signal?: AbortSignal, onValue?: (value: Height) => void }} [options] + * @returns {Promise} + */ + async getBlockTipHeight({ signal, onValue } = {}) { + const path = `/api/blocks/tip/height`; + return Number(await this.getText(path, { signal, onValue: onValue ? (v) => onValue(Number(v)) : undefined })); + } + + /** + * Block tip hash + * + * Returns the hash of the last block. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* + * + * Endpoint: `GET /api/blocks/tip/hash` + * @param {{ signal?: AbortSignal, onValue?: (value: BlockHash) => void }} [options] + * @returns {Promise} + */ + async getBlockTipHash({ signal, onValue } = {}) { + const path = `/api/blocks/tip/hash`; + return this.getText(path, { signal, onValue }); + } + + /** + * Transaction ID at index + * + * Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* + * + * Endpoint: `GET /api/block/{hash}/txid/{index}` + * + * @param {BlockHash} hash - Bitcoin block hash + * @param {BlockTxIndex} index - Transaction index within the block (0-based) + * @param {{ signal?: AbortSignal, onValue?: (value: Txid) => void }} [options] * @returns {Promise} */ - async postTx(body, { signal } = {}) { - const path = `/api/tx`; - return this.postJson(path, body, { signal }); + async getBlockTxid(hash, index, { signal, onValue } = {}) { + const path = `/api/block/${hash}/txid/${index}`; + return this.getText(path, { signal, onValue }); + } + + /** + * Block transaction IDs + * + * Retrieve all transaction IDs in a block. Returns an array of txids in block order. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* + * + * Endpoint: `GET /api/block/{hash}/txids` + * + * @param {BlockHash} hash + * @param {{ signal?: AbortSignal, onValue?: (value: Txid[]) => void }} [options] + * @returns {Promise} + */ + async getBlockTxids(hash, { signal, onValue } = {}) { + const path = `/api/block/${hash}/txids`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block transactions + * + * Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* + * + * Endpoint: `GET /api/block/{hash}/txs` + * + * @param {BlockHash} hash + * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] + * @returns {Promise} + */ + async getBlockTxs(hash, { signal, onValue } = {}) { + const path = `/api/block/${hash}/txs`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block transactions (paginated) + * + * Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* + * + * Endpoint: `GET /api/block/{hash}/txs/{start_index}` + * + * @param {BlockHash} hash - Bitcoin block hash + * @param {BlockTxIndex} start_index - Starting transaction index within the block (0-based) + * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] + * @returns {Promise} + */ + async getBlockTxsFromIndex(hash, start_index, { signal, onValue } = {}) { + const path = `/api/block/${hash}/txs/${start_index}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Recent blocks + * + * Retrieve the last 10 blocks. Returns block metadata for each block. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* + * + * Endpoint: `GET /api/blocks` + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfo[]) => void }} [options] + * @returns {Promise} + */ + async getBlocks({ signal, onValue } = {}) { + const path = `/api/blocks`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Blocks from height + * + * Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* + * + * Endpoint: `GET /api/blocks/{height}` + * + * @param {Height} height + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfo[]) => void }} [options] + * @returns {Promise} + */ + async getBlocksFromHeight(height, { signal, onValue } = {}) { + const path = `/api/blocks/${height}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Recent blocks with extras + * + * Retrieve the last 15 blocks with extended data including pool identification and fee statistics. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* + * + * Endpoint: `GET /api/v1/blocks` + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] + * @returns {Promise} + */ + async getBlocksV1({ signal, onValue } = {}) { + const path = `/api/v1/blocks`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Blocks from height with extras + * + * Retrieve up to 15 blocks with extended data going backwards from the given height. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* + * + * Endpoint: `GET /api/v1/blocks/{height}` + * + * @param {Height} height + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] + * @returns {Promise} + */ + async getBlocksV1FromHeight(height, { signal, onValue } = {}) { + const path = `/api/v1/blocks/${height}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * List all mining pools + * + * Get list of all known mining pools with their identifiers. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + * + * Endpoint: `GET /api/v1/mining/pools` + * @param {{ signal?: AbortSignal, onValue?: (value: PoolInfo[]) => void }} [options] + * @returns {Promise} + */ + async getPools({ signal, onValue } = {}) { + const path = `/api/v1/mining/pools`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mining pool statistics + * + * Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + * + * Endpoint: `GET /api/v1/mining/pools/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: PoolsSummary) => void }} [options] + * @returns {Promise} + */ + async getPoolStats(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/pools/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mining pool details + * + * Get detailed information about a specific mining pool including block counts and shares for different time periods. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* + * + * Endpoint: `GET /api/v1/mining/pool/{slug}` + * + * @param {PoolSlug} slug + * @param {{ signal?: AbortSignal, onValue?: (value: PoolDetail) => void }} [options] + * @returns {Promise} + */ + async getPool(slug, { signal, onValue } = {}) { + const path = `/api/v1/mining/pool/${slug}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * All pools hashrate (all time) + * + * Get hashrate data for all mining pools. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + * + * Endpoint: `GET /api/v1/mining/hashrate/pools` + * @param {{ signal?: AbortSignal, onValue?: (value: PoolHashrateEntry[]) => void }} [options] + * @returns {Promise} + */ + async getPoolsHashrate({ signal, onValue } = {}) { + const path = `/api/v1/mining/hashrate/pools`; + return this.getJson(path, { signal, onValue }); + } + + /** + * All pools hashrate + * + * Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + * + * Endpoint: `GET /api/v1/mining/hashrate/pools/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: PoolHashrateEntry[]) => void }} [options] + * @returns {Promise} + */ + async getPoolsHashrateByPeriod(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/hashrate/pools/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mining pool hashrate + * + * Get hashrate history for a specific mining pool. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* + * + * Endpoint: `GET /api/v1/mining/pool/{slug}/hashrate` + * + * @param {PoolSlug} slug + * @param {{ signal?: AbortSignal, onValue?: (value: PoolHashrateEntry[]) => void }} [options] + * @returns {Promise} + */ + async getPoolHashrate(slug, { signal, onValue } = {}) { + const path = `/api/v1/mining/pool/${slug}/hashrate`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mining pool blocks + * + * Get the 10 most recent blocks mined by a specific pool. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + * + * Endpoint: `GET /api/v1/mining/pool/{slug}/blocks` + * + * @param {PoolSlug} slug + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] + * @returns {Promise} + */ + async getPoolBlocks(slug, { signal, onValue } = {}) { + const path = `/api/v1/mining/pool/${slug}/blocks`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mining pool blocks from height + * + * Get 10 blocks mined by a specific pool before (and including) the given height. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + * + * Endpoint: `GET /api/v1/mining/pool/{slug}/blocks/{height}` + * + * @param {PoolSlug} slug + * @param {Height} height + * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] + * @returns {Promise} + */ + async getPoolBlocksFrom(slug, height, { signal, onValue } = {}) { + const path = `/api/v1/mining/pool/${slug}/blocks/${height}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Network hashrate (all time) + * + * Get network hashrate and difficulty data for all time. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + * + * Endpoint: `GET /api/v1/mining/hashrate` + * @param {{ signal?: AbortSignal, onValue?: (value: HashrateSummary) => void }} [options] + * @returns {Promise} + */ + async getHashrate({ signal, onValue } = {}) { + const path = `/api/v1/mining/hashrate`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Network hashrate + * + * Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + * + * Endpoint: `GET /api/v1/mining/hashrate/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: HashrateSummary) => void }} [options] + * @returns {Promise} + */ + async getHashrateByPeriod(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/hashrate/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Difficulty adjustments (all time) + * + * Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + * + * Endpoint: `GET /api/v1/mining/difficulty-adjustments` + * @param {{ signal?: AbortSignal, onValue?: (value: DifficultyAdjustmentEntry[]) => void }} [options] + * @returns {Promise} + */ + async getDifficultyAdjustments({ signal, onValue } = {}) { + const path = `/api/v1/mining/difficulty-adjustments`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Difficulty adjustments + * + * Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + * + * Endpoint: `GET /api/v1/mining/difficulty-adjustments/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: DifficultyAdjustmentEntry[]) => void }} [options] + * @returns {Promise} + */ + async getDifficultyAdjustmentsByPeriod(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/difficulty-adjustments/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mining reward statistics + * + * Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* + * + * Endpoint: `GET /api/v1/mining/reward-stats/{block_count}` + * + * @param {number} block_count - Number of recent blocks to include + * @param {{ signal?: AbortSignal, onValue?: (value: RewardStats) => void }} [options] + * @returns {Promise} + */ + async getRewardStats(block_count, { signal, onValue } = {}) { + const path = `/api/v1/mining/reward-stats/${block_count}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block fees + * + * Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* + * + * Endpoint: `GET /api/v1/mining/blocks/fees/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: BlockFeesEntry[]) => void }} [options] + * @returns {Promise} + */ + async getBlockFees(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/blocks/fees/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block rewards + * + * Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* + * + * Endpoint: `GET /api/v1/mining/blocks/rewards/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: BlockRewardsEntry[]) => void }} [options] + * @returns {Promise} + */ + async getBlockRewards(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/blocks/rewards/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block fee rates + * + * Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* + * + * Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: BlockFeeRatesEntry[]) => void }} [options] + * @returns {Promise} + */ + async getBlockFeeRates(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/blocks/fee-rates/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Block sizes and weights + * + * Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* + * + * Endpoint: `GET /api/v1/mining/blocks/sizes-weights/{time_period}` + * + * @param {TimePeriod} time_period + * @param {{ signal?: AbortSignal, onValue?: (value: BlockSizesWeights) => void }} [options] + * @returns {Promise} + */ + async getBlockSizesWeights(time_period, { signal, onValue } = {}) { + const path = `/api/v1/mining/blocks/sizes-weights/${time_period}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Projected mempool blocks + * + * Get projected blocks from the mempool for fee estimation. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* + * + * Endpoint: `GET /api/v1/fees/mempool-blocks` + * @param {{ signal?: AbortSignal, onValue?: (value: MempoolBlock[]) => void }} [options] + * @returns {Promise} + */ + async getMempoolBlocks({ signal, onValue } = {}) { + const path = `/api/v1/fees/mempool-blocks`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Recommended fees + * + * Get recommended fee rates for different confirmation targets. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* + * + * Endpoint: `GET /api/v1/fees/recommended` + * @param {{ signal?: AbortSignal, onValue?: (value: RecommendedFees) => void }} [options] + * @returns {Promise} + */ + async getRecommendedFees({ signal, onValue } = {}) { + const path = `/api/v1/fees/recommended`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Precise recommended fees + * + * Get recommended fee rates with up to 3 decimal places. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* + * + * Endpoint: `GET /api/v1/fees/precise` + * @param {{ signal?: AbortSignal, onValue?: (value: RecommendedFees) => void }} [options] + * @returns {Promise} + */ + async getPreciseFees({ signal, onValue } = {}) { + const path = `/api/v1/fees/precise`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mempool statistics + * + * Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* + * + * Endpoint: `GET /api/mempool` + * @param {{ signal?: AbortSignal, onValue?: (value: MempoolInfo) => void }} [options] + * @returns {Promise} + */ + async getMempool({ signal, onValue } = {}) { + const path = `/api/mempool`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mempool content hash + * + * Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. + * + * Endpoint: `GET /api/mempool/hash` + * @param {{ signal?: AbortSignal, onValue?: (value: number) => void }} [options] + * @returns {Promise} + */ + async getMempoolHash({ signal, onValue } = {}) { + const path = `/api/mempool/hash`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Mempool transaction IDs + * + * Get all transaction IDs currently in the mempool. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* + * + * Endpoint: `GET /api/mempool/txids` + * @param {{ signal?: AbortSignal, onValue?: (value: Txid[]) => void }} [options] + * @returns {Promise} + */ + async getMempoolTxids({ signal, onValue } = {}) { + const path = `/api/mempool/txids`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Recent mempool transactions + * + * Get the last 10 transactions to enter the mempool. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* + * + * Endpoint: `GET /api/mempool/recent` + * @param {{ signal?: AbortSignal, onValue?: (value: MempoolRecentTx[]) => void }} [options] + * @returns {Promise} + */ + async getMempoolRecent({ signal, onValue } = {}) { + const path = `/api/mempool/recent`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Recent RBF replacements + * + * Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + * + * Endpoint: `GET /api/v1/replacements` + * @param {{ signal?: AbortSignal, onValue?: (value: ReplacementNode[]) => void }} [options] + * @returns {Promise} + */ + async getReplacements({ signal, onValue } = {}) { + const path = `/api/v1/replacements`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Recent full-RBF replacements + * + * Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* + * + * Endpoint: `GET /api/v1/fullrbf/replacements` + * @param {{ signal?: AbortSignal, onValue?: (value: ReplacementNode[]) => void }} [options] + * @returns {Promise} + */ + async getFullrbfReplacements({ signal, onValue } = {}) { + const path = `/api/v1/fullrbf/replacements`; + return this.getJson(path, { signal, onValue }); + } + + /** + * Live BTC/USD price + * + * Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. + * + * Endpoint: `GET /api/mempool/price` + * @param {{ signal?: AbortSignal, onValue?: (value: Dollars) => void }} [options] + * @returns {Promise} + */ + async getLivePrice({ signal, onValue } = {}) { + const path = `/api/mempool/price`; + return this.getJson(path, { signal, onValue }); } /** @@ -11198,6 +11789,42 @@ class BrkClient extends BrkClientBase { return this.getText(path, { signal, onValue }); } + /** + * CPFP info + * + * Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* + * + * Endpoint: `GET /api/v1/cpfp/{txid}` + * + * @param {Txid} txid + * @param {{ signal?: AbortSignal, onValue?: (value: CpfpInfo) => void }} [options] + * @returns {Promise} + */ + async getCpfp(txid, { signal, onValue } = {}) { + const path = `/api/v1/cpfp/${txid}`; + return this.getJson(path, { signal, onValue }); + } + + /** + * RBF replacement history + * + * Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + * + * Endpoint: `GET /api/v1/tx/{txid}/rbf` + * + * @param {Txid} txid + * @param {{ signal?: AbortSignal, onValue?: (value: RbfResponse) => void }} [options] + * @returns {Promise} + */ + async getTxRbf(txid, { signal, onValue } = {}) { + const path = `/api/v1/tx/${txid}/rbf`; + return this.getJson(path, { signal, onValue }); + } + /** * Transaction information * @@ -11234,24 +11861,6 @@ class BrkClient extends BrkClientBase { return this.getText(path, { signal, onValue }); } - /** - * Transaction merkle proof - * - * Get the merkle inclusion proof for a transaction. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* - * - * Endpoint: `GET /api/tx/{txid}/merkle-proof` - * - * @param {Txid} txid - * @param {{ signal?: AbortSignal, onValue?: (value: MerkleProof) => void }} [options] - * @returns {Promise} - */ - async getTxMerkleProof(txid, { signal, onValue } = {}) { - const path = `/api/tx/${txid}/merkle-proof`; - return this.getJson(path, { signal, onValue }); - } - /** * Transaction merkleblock proof * @@ -11270,6 +11879,24 @@ class BrkClient extends BrkClientBase { return this.getText(path, { signal, onValue }); } + /** + * Transaction merkle proof + * + * Get the merkle inclusion proof for a transaction. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* + * + * Endpoint: `GET /api/tx/{txid}/merkle-proof` + * + * @param {Txid} txid + * @param {{ signal?: AbortSignal, onValue?: (value: MerkleProof) => void }} [options] + * @returns {Promise} + */ + async getTxMerkleProof(txid, { signal, onValue } = {}) { + const path = `/api/tx/${txid}/merkle-proof`; + return this.getJson(path, { signal, onValue }); + } + /** * Output spend status * @@ -11343,601 +11970,6 @@ class BrkClient extends BrkClientBase { return this.getJson(path, { signal, onValue }); } - /** - * Available URPD cohorts - * - * Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. - * - * Endpoint: `GET /api/urpd` - * @param {{ signal?: AbortSignal, onValue?: (value: Cohort[]) => void }} [options] - * @returns {Promise} - */ - async listUrpdCohorts({ signal, onValue } = {}) { - const path = `/api/urpd`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Latest URPD - * - * URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. - * - * See the URPD tag description for the response shape and `agg` options. - * - * Endpoint: `GET /api/urpd/{cohort}` - * - * @param {Cohort} cohort - * @param {UrpdAggregation=} [agg] - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. - * @param {{ signal?: AbortSignal, onValue?: (value: Urpd) => void }} [options] - * @returns {Promise} - */ - async getUrpd(cohort, agg, { signal, onValue } = {}) { - const params = new URLSearchParams(); - if (agg !== undefined) params.set('agg', String(agg)); - const query = params.toString(); - const path = `/api/urpd/${cohort}${query ? '?' + query : ''}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Available URPD dates - * - * Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. - * - * Endpoint: `GET /api/urpd/{cohort}/dates` - * - * @param {Cohort} cohort - * @param {{ signal?: AbortSignal, onValue?: (value: Date[]) => void }} [options] - * @returns {Promise} - */ - async listUrpdDates(cohort, { signal, onValue } = {}) { - const path = `/api/urpd/${cohort}/dates`; - return this.getJson(path, { signal, onValue }); - } - - /** - * URPD at date - * - * URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. - * - * See the URPD tag description for unit conventions and `agg` options. - * - * Endpoint: `GET /api/urpd/{cohort}/{date}` - * - * @param {Cohort} cohort - * @param {string} date - * @param {UrpdAggregation=} [agg] - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. - * @param {{ signal?: AbortSignal, onValue?: (value: Urpd) => void }} [options] - * @returns {Promise} - */ - async getUrpdAt(cohort, date, agg, { signal, onValue } = {}) { - const params = new URLSearchParams(); - if (agg !== undefined) params.set('agg', String(agg)); - const query = params.toString(); - const path = `/api/urpd/${cohort}/${date}${query ? '?' + query : ''}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block (v1) - * - * Returns block details with extras by hash. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* - * - * Endpoint: `GET /api/v1/block/{hash}` - * - * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1) => void }} [options] - * @returns {Promise} - */ - async getBlockV1(hash, { signal, onValue } = {}) { - const path = `/api/v1/block/${hash}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Recent blocks with extras - * - * Retrieve the last 15 blocks with extended data including pool identification and fee statistics. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* - * - * Endpoint: `GET /api/v1/blocks` - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] - * @returns {Promise} - */ - async getBlocksV1({ signal, onValue } = {}) { - const path = `/api/v1/blocks`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Blocks from height with extras - * - * Retrieve up to 15 blocks with extended data going backwards from the given height. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* - * - * Endpoint: `GET /api/v1/blocks/{height}` - * - * @param {Height} height - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] - * @returns {Promise} - */ - async getBlocksV1FromHeight(height, { signal, onValue } = {}) { - const path = `/api/v1/blocks/${height}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * CPFP info - * - * Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* - * - * Endpoint: `GET /api/v1/cpfp/{txid}` - * - * @param {Txid} txid - * @param {{ signal?: AbortSignal, onValue?: (value: CpfpInfo) => void }} [options] - * @returns {Promise} - */ - async getCpfp(txid, { signal, onValue } = {}) { - const path = `/api/v1/cpfp/${txid}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Difficulty adjustment - * - * Get current difficulty adjustment progress and estimates. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* - * - * Endpoint: `GET /api/v1/difficulty-adjustment` - * @param {{ signal?: AbortSignal, onValue?: (value: DifficultyAdjustment) => void }} [options] - * @returns {Promise} - */ - async getDifficultyAdjustment({ signal, onValue } = {}) { - const path = `/api/v1/difficulty-adjustment`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Projected mempool blocks - * - * Get projected blocks from the mempool for fee estimation. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* - * - * Endpoint: `GET /api/v1/fees/mempool-blocks` - * @param {{ signal?: AbortSignal, onValue?: (value: MempoolBlock[]) => void }} [options] - * @returns {Promise} - */ - async getMempoolBlocks({ signal, onValue } = {}) { - const path = `/api/v1/fees/mempool-blocks`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Precise recommended fees - * - * Get recommended fee rates with up to 3 decimal places. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* - * - * Endpoint: `GET /api/v1/fees/precise` - * @param {{ signal?: AbortSignal, onValue?: (value: RecommendedFees) => void }} [options] - * @returns {Promise} - */ - async getPreciseFees({ signal, onValue } = {}) { - const path = `/api/v1/fees/precise`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Recommended fees - * - * Get recommended fee rates for different confirmation targets. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* - * - * Endpoint: `GET /api/v1/fees/recommended` - * @param {{ signal?: AbortSignal, onValue?: (value: RecommendedFees) => void }} [options] - * @returns {Promise} - */ - async getRecommendedFees({ signal, onValue } = {}) { - const path = `/api/v1/fees/recommended`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Recent full-RBF replacements - * - * Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* - * - * Endpoint: `GET /api/v1/fullrbf/replacements` - * @param {{ signal?: AbortSignal, onValue?: (value: ReplacementNode[]) => void }} [options] - * @returns {Promise} - */ - async getFullrbfReplacements({ signal, onValue } = {}) { - const path = `/api/v1/fullrbf/replacements`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Historical price - * - * Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* - * - * Endpoint: `GET /api/v1/historical-price` - * - * @param {Timestamp=} [timestamp] - * @param {{ signal?: AbortSignal, onValue?: (value: HistoricalPrice) => void }} [options] - * @returns {Promise} - */ - async getHistoricalPrice(timestamp, { signal, onValue } = {}) { - const params = new URLSearchParams(); - if (timestamp !== undefined) params.set('timestamp', String(timestamp)); - const query = params.toString(); - const path = `/api/v1/historical-price${query ? '?' + query : ''}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block fee rates - * - * Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* - * - * Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: BlockFeeRatesEntry[]) => void }} [options] - * @returns {Promise} - */ - async getBlockFeeRates(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/blocks/fee-rates/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block fees - * - * Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* - * - * Endpoint: `GET /api/v1/mining/blocks/fees/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: BlockFeesEntry[]) => void }} [options] - * @returns {Promise} - */ - async getBlockFees(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/blocks/fees/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block rewards - * - * Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* - * - * Endpoint: `GET /api/v1/mining/blocks/rewards/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: BlockRewardsEntry[]) => void }} [options] - * @returns {Promise} - */ - async getBlockRewards(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/blocks/rewards/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block sizes and weights - * - * Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* - * - * Endpoint: `GET /api/v1/mining/blocks/sizes-weights/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: BlockSizesWeights) => void }} [options] - * @returns {Promise} - */ - async getBlockSizesWeights(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/blocks/sizes-weights/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Block by timestamp - * - * Find the block closest to a given UNIX timestamp. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* - * - * Endpoint: `GET /api/v1/mining/blocks/timestamp/{timestamp}` - * - * @param {Timestamp} timestamp - * @param {{ signal?: AbortSignal, onValue?: (value: BlockTimestamp) => void }} [options] - * @returns {Promise} - */ - async getBlockByTimestamp(timestamp, { signal, onValue } = {}) { - const path = `/api/v1/mining/blocks/timestamp/${timestamp}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Difficulty adjustments (all time) - * - * Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* - * - * Endpoint: `GET /api/v1/mining/difficulty-adjustments` - * @param {{ signal?: AbortSignal, onValue?: (value: DifficultyAdjustmentEntry[]) => void }} [options] - * @returns {Promise} - */ - async getDifficultyAdjustments({ signal, onValue } = {}) { - const path = `/api/v1/mining/difficulty-adjustments`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Difficulty adjustments - * - * Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* - * - * Endpoint: `GET /api/v1/mining/difficulty-adjustments/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: DifficultyAdjustmentEntry[]) => void }} [options] - * @returns {Promise} - */ - async getDifficultyAdjustmentsByPeriod(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/difficulty-adjustments/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Network hashrate (all time) - * - * Get network hashrate and difficulty data for all time. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* - * - * Endpoint: `GET /api/v1/mining/hashrate` - * @param {{ signal?: AbortSignal, onValue?: (value: HashrateSummary) => void }} [options] - * @returns {Promise} - */ - async getHashrate({ signal, onValue } = {}) { - const path = `/api/v1/mining/hashrate`; - return this.getJson(path, { signal, onValue }); - } - - /** - * All pools hashrate (all time) - * - * Get hashrate data for all mining pools. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* - * - * Endpoint: `GET /api/v1/mining/hashrate/pools` - * @param {{ signal?: AbortSignal, onValue?: (value: PoolHashrateEntry[]) => void }} [options] - * @returns {Promise} - */ - async getPoolsHashrate({ signal, onValue } = {}) { - const path = `/api/v1/mining/hashrate/pools`; - return this.getJson(path, { signal, onValue }); - } - - /** - * All pools hashrate - * - * Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* - * - * Endpoint: `GET /api/v1/mining/hashrate/pools/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: PoolHashrateEntry[]) => void }} [options] - * @returns {Promise} - */ - async getPoolsHashrateByPeriod(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/hashrate/pools/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Network hashrate - * - * Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* - * - * Endpoint: `GET /api/v1/mining/hashrate/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: HashrateSummary) => void }} [options] - * @returns {Promise} - */ - async getHashrateByPeriod(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/hashrate/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mining pool details - * - * Get detailed information about a specific mining pool including block counts and shares for different time periods. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* - * - * Endpoint: `GET /api/v1/mining/pool/{slug}` - * - * @param {PoolSlug} slug - * @param {{ signal?: AbortSignal, onValue?: (value: PoolDetail) => void }} [options] - * @returns {Promise} - */ - async getPool(slug, { signal, onValue } = {}) { - const path = `/api/v1/mining/pool/${slug}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mining pool blocks - * - * Get the 10 most recent blocks mined by a specific pool. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* - * - * Endpoint: `GET /api/v1/mining/pool/{slug}/blocks` - * - * @param {PoolSlug} slug - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] - * @returns {Promise} - */ - async getPoolBlocks(slug, { signal, onValue } = {}) { - const path = `/api/v1/mining/pool/${slug}/blocks`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mining pool blocks from height - * - * Get 10 blocks mined by a specific pool before (and including) the given height. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* - * - * Endpoint: `GET /api/v1/mining/pool/{slug}/blocks/{height}` - * - * @param {PoolSlug} slug - * @param {Height} height - * @param {{ signal?: AbortSignal, onValue?: (value: BlockInfoV1[]) => void }} [options] - * @returns {Promise} - */ - async getPoolBlocksFrom(slug, height, { signal, onValue } = {}) { - const path = `/api/v1/mining/pool/${slug}/blocks/${height}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mining pool hashrate - * - * Get hashrate history for a specific mining pool. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* - * - * Endpoint: `GET /api/v1/mining/pool/{slug}/hashrate` - * - * @param {PoolSlug} slug - * @param {{ signal?: AbortSignal, onValue?: (value: PoolHashrateEntry[]) => void }} [options] - * @returns {Promise} - */ - async getPoolHashrate(slug, { signal, onValue } = {}) { - const path = `/api/v1/mining/pool/${slug}/hashrate`; - return this.getJson(path, { signal, onValue }); - } - - /** - * List all mining pools - * - * Get list of all known mining pools with their identifiers. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* - * - * Endpoint: `GET /api/v1/mining/pools` - * @param {{ signal?: AbortSignal, onValue?: (value: PoolInfo[]) => void }} [options] - * @returns {Promise} - */ - async getPools({ signal, onValue } = {}) { - const path = `/api/v1/mining/pools`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mining pool statistics - * - * Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* - * - * Endpoint: `GET /api/v1/mining/pools/{time_period}` - * - * @param {TimePeriod} time_period - * @param {{ signal?: AbortSignal, onValue?: (value: PoolsSummary) => void }} [options] - * @returns {Promise} - */ - async getPoolStats(time_period, { signal, onValue } = {}) { - const path = `/api/v1/mining/pools/${time_period}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Mining reward statistics - * - * Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* - * - * Endpoint: `GET /api/v1/mining/reward-stats/{block_count}` - * - * @param {number} block_count - Number of recent blocks to include - * @param {{ signal?: AbortSignal, onValue?: (value: RewardStats) => void }} [options] - * @returns {Promise} - */ - async getRewardStats(block_count, { signal, onValue } = {}) { - const path = `/api/v1/mining/reward-stats/${block_count}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Current BTC price - * - * Returns bitcoin latest price (on-chain derived, USD only). - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* - * - * Endpoint: `GET /api/v1/prices` - * @param {{ signal?: AbortSignal, onValue?: (value: Prices) => void }} [options] - * @returns {Promise} - */ - async getPrices({ signal, onValue } = {}) { - const path = `/api/v1/prices`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Recent RBF replacements - * - * Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* - * - * Endpoint: `GET /api/v1/replacements` - * @param {{ signal?: AbortSignal, onValue?: (value: ReplacementNode[]) => void }} [options] - * @returns {Promise} - */ - async getReplacements({ signal, onValue } = {}) { - const path = `/api/v1/replacements`; - return this.getJson(path, { signal, onValue }); - } - /** * Transaction first-seen times * @@ -11960,53 +11992,21 @@ class BrkClient extends BrkClientBase { } /** - * RBF replacement history + * Broadcast transaction * - * Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. + * Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + * *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* * - * Endpoint: `GET /api/v1/tx/{txid}/rbf` + * Endpoint: `POST /api/tx` * - * @param {Txid} txid - * @param {{ signal?: AbortSignal, onValue?: (value: RbfResponse) => void }} [options] - * @returns {Promise} + * @param {string} body - Request body + * @param {{ signal?: AbortSignal }} [options] + * @returns {Promise} */ - async getTxRbf(txid, { signal, onValue } = {}) { - const path = `/api/v1/tx/${txid}/rbf`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Validate address - * - * Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. - * - * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* - * - * Endpoint: `GET /api/v1/validate-address/{address}` - * - * @param {string} address - Bitcoin address to validate (can be any string) - * @param {{ signal?: AbortSignal, onValue?: (value: AddrValidation) => void }} [options] - * @returns {Promise} - */ - async validateAddress(address, { signal, onValue } = {}) { - const path = `/api/v1/validate-address/${address}`; - return this.getJson(path, { signal, onValue }); - } - - /** - * Health check - * - * Returns the health status of the API server, including uptime information. - * - * Endpoint: `GET /health` - * @param {{ signal?: AbortSignal, onValue?: (value: Health) => void }} [options] - * @returns {Promise} - */ - async getHealth({ signal, onValue } = {}) { - const path = `/health`; - return this.getJson(path, { signal, onValue }); + async postTx(body, { signal } = {}) { + const path = `/api/tx`; + return this.postJson(path, body, { signal }); } /** @@ -12024,16 +12024,16 @@ class BrkClient extends BrkClientBase { } /** - * API version + * Compact OpenAPI specification * - * Returns the current version of the API server + * Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. * - * Endpoint: `GET /version` - * @param {{ signal?: AbortSignal, onValue?: (value: string) => void }} [options] - * @returns {Promise} + * Endpoint: `GET /api.json` + * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] + * @returns {Promise<*>} */ - async getVersion({ signal, onValue } = {}) { - const path = `/version`; + async getApi({ signal, onValue } = {}) { + const path = `/api.json`; return this.getJson(path, { signal, onValue }); } diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 29ea237e4..b34eb5d69 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -7825,249 +7825,37 @@ class BrkClient(BrkClientBase): """Convert a date/datetime to an index value for date-based indexes.""" return _date_to_index(index, d) - def get_api(self) -> Any: - """Compact OpenAPI specification. + def get_health(self) -> Health: + """Health check. - Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. + Returns the health status of the API server, including uptime information. - Endpoint: `GET /api.json`""" - return self.get_json('/api.json') + Endpoint: `GET /health`""" + return self.get_json('/health') - def get_address(self, address: Addr) -> AddrStats: - """Address information. + def get_version(self) -> str: + """API version. - Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + Returns the current version of the API server - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* + Endpoint: `GET /version`""" + return self.get_json('/version') - Endpoint: `GET /api/address/{address}`""" - return self.get_json(f'/api/address/{address}') + def get_sync_status(self) -> SyncStatus: + """Sync status. - def get_address_txs(self, address: Addr) -> List[Transaction]: - """Address transactions. + Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. - Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. + Endpoint: `GET /api/server/sync`""" + return self.get_json('/api/server/sync') - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* + def get_disk_usage(self) -> DiskUsage: + """Disk usage. - Endpoint: `GET /api/address/{address}/txs`""" - return self.get_json(f'/api/address/{address}/txs') + Returns the disk space used by BRK and Bitcoin data. - def get_address_confirmed_txs(self, address: Addr) -> List[Transaction]: - """Address confirmed transactions. - - Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* - - Endpoint: `GET /api/address/{address}/txs/chain`""" - return self.get_json(f'/api/address/{address}/txs/chain') - - def get_address_confirmed_txs_after(self, address: Addr, after_txid: Txid) -> List[Transaction]: - """Address confirmed transactions (paginated). - - Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* - - Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}`""" - return self.get_json(f'/api/address/{address}/txs/chain/{after_txid}') - - def get_address_mempool_txs(self, address: Addr) -> List[Transaction]: - """Address mempool transactions. - - Get unconfirmed transactions for an address from the mempool, newest first (up to 50). - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* - - Endpoint: `GET /api/address/{address}/txs/mempool`""" - return self.get_json(f'/api/address/{address}/txs/mempool') - - def get_address_utxos(self, address: Addr) -> List[Utxo]: - """Address UTXOs. - - Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* - - Endpoint: `GET /api/address/{address}/utxo`""" - return self.get_json(f'/api/address/{address}/utxo') - - def get_block_by_height(self, height: Height) -> BlockHash: - """Block hash by height. - - Retrieve the block hash at a given height. Returns the hash as plain text. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* - - Endpoint: `GET /api/block-height/{height}`""" - return self.get_text(f'/api/block-height/{height}') - - def get_block(self, hash: BlockHash) -> BlockInfo: - """Block information. - - Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* - - Endpoint: `GET /api/block/{hash}`""" - return self.get_json(f'/api/block/{hash}') - - def get_block_header(self, hash: BlockHash) -> Hex: - """Block header. - - Returns the hex-encoded 80-byte block header. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* - - Endpoint: `GET /api/block/{hash}/header`""" - return self.get_text(f'/api/block/{hash}/header') - - def get_block_raw(self, hash: BlockHash) -> bytes: - """Raw block. - - Returns the raw block data in binary format. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* - - Endpoint: `GET /api/block/{hash}/raw`""" - return self.get(f'/api/block/{hash}/raw') - - def get_block_status(self, hash: BlockHash) -> BlockStatus: - """Block status. - - Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* - - Endpoint: `GET /api/block/{hash}/status`""" - return self.get_json(f'/api/block/{hash}/status') - - def get_block_txid(self, hash: BlockHash, index: BlockTxIndex) -> Txid: - """Transaction ID at index. - - Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* - - Endpoint: `GET /api/block/{hash}/txid/{index}`""" - return self.get_text(f'/api/block/{hash}/txid/{index}') - - def get_block_txids(self, hash: BlockHash) -> List[Txid]: - """Block transaction IDs. - - Retrieve all transaction IDs in a block. Returns an array of txids in block order. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* - - Endpoint: `GET /api/block/{hash}/txids`""" - return self.get_json(f'/api/block/{hash}/txids') - - def get_block_txs(self, hash: BlockHash) -> List[Transaction]: - """Block transactions. - - Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* - - Endpoint: `GET /api/block/{hash}/txs`""" - return self.get_json(f'/api/block/{hash}/txs') - - def get_block_txs_from_index(self, hash: BlockHash, start_index: BlockTxIndex) -> List[Transaction]: - """Block transactions (paginated). - - Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* - - Endpoint: `GET /api/block/{hash}/txs/{start_index}`""" - return self.get_json(f'/api/block/{hash}/txs/{start_index}') - - def get_blocks(self) -> List[BlockInfo]: - """Recent blocks. - - Retrieve the last 10 blocks. Returns block metadata for each block. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* - - Endpoint: `GET /api/blocks`""" - return self.get_json('/api/blocks') - - def get_block_tip_hash(self) -> BlockHash: - """Block tip hash. - - Returns the hash of the last block. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* - - Endpoint: `GET /api/blocks/tip/hash`""" - return self.get_text('/api/blocks/tip/hash') - - def get_block_tip_height(self) -> Height: - """Block tip height. - - Returns the height of the last block. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* - - Endpoint: `GET /api/blocks/tip/height`""" - return int(self.get_text('/api/blocks/tip/height')) - - def get_blocks_from_height(self, height: Height) -> List[BlockInfo]: - """Blocks from height. - - Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* - - Endpoint: `GET /api/blocks/{height}`""" - return self.get_json(f'/api/blocks/{height}') - - def get_mempool(self) -> MempoolInfo: - """Mempool statistics. - - Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* - - Endpoint: `GET /api/mempool`""" - return self.get_json('/api/mempool') - - def get_mempool_hash(self) -> int: - """Mempool content hash. - - Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. - - Endpoint: `GET /api/mempool/hash`""" - return self.get_json('/api/mempool/hash') - - def get_live_price(self) -> Dollars: - """Live BTC/USD price. - - Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. - - Endpoint: `GET /api/mempool/price`""" - return self.get_json('/api/mempool/price') - - def get_mempool_recent(self) -> List[MempoolRecentTx]: - """Recent mempool transactions. - - Get the last 10 transactions to enter the mempool. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* - - Endpoint: `GET /api/mempool/recent`""" - return self.get_json('/api/mempool/recent') - - def get_mempool_txids(self) -> List[Txid]: - """Mempool transaction IDs. - - Get all transaction IDs currently in the mempool. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* - - Endpoint: `GET /api/mempool/txids`""" - return self.get_json('/api/mempool/txids') + Endpoint: `GET /api/server/disk`""" + return self.get_json('/api/server/disk') def get_series_tree(self) -> TreeNode: """Series catalog. @@ -8077,25 +7865,6 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/series`""" return self.get_json('/api/series') - def get_series_bulk(self, series: SeriesList, index: Index, start: Optional[RangeIndex] = None, end: Optional[RangeIndex] = None, limit: Optional[Limit] = None, format: Optional[Format] = None) -> Union[List[AnySeriesData], str]: - """Bulk series data. - - Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. - - Endpoint: `GET /api/series/bulk`""" - params = [] - params.append(f'series={series}') - params.append(f'index={index}') - if start is not None: params.append(f'start={start}') - if end is not None: params.append(f'end={end}') - if limit is not None: params.append(f'limit={limit}') - if format is not None: params.append(f'format={format}') - query = '&'.join(params) - path = f'/api/series/bulk{"?" + query if query else ""}' - if format == 'csv': - return self.get_text(path) - return self.get_json(path) - def get_series_count(self) -> List[SeriesCount]: """Series count. @@ -8204,31 +7973,608 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/series/{series}/{index}/version`""" return self.get_json(f'/api/series/{series}/{index}/version') - def get_disk_usage(self) -> DiskUsage: - """Disk usage. + def get_series_bulk(self, series: SeriesList, index: Index, start: Optional[RangeIndex] = None, end: Optional[RangeIndex] = None, limit: Optional[Limit] = None, format: Optional[Format] = None) -> Union[List[AnySeriesData], str]: + """Bulk series data. - Returns the disk space used by BRK and Bitcoin data. + Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. - Endpoint: `GET /api/server/disk`""" - return self.get_json('/api/server/disk') + Endpoint: `GET /api/series/bulk`""" + params = [] + params.append(f'series={series}') + params.append(f'index={index}') + if start is not None: params.append(f'start={start}') + if end is not None: params.append(f'end={end}') + if limit is not None: params.append(f'limit={limit}') + if format is not None: params.append(f'format={format}') + query = '&'.join(params) + path = f'/api/series/bulk{"?" + query if query else ""}' + if format == 'csv': + return self.get_text(path) + return self.get_json(path) - def get_sync_status(self) -> SyncStatus: - """Sync status. + def list_urpd_cohorts(self) -> List[Cohort]: + """Available URPD cohorts. - Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. + Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. - Endpoint: `GET /api/server/sync`""" - return self.get_json('/api/server/sync') + Endpoint: `GET /api/urpd`""" + return self.get_json('/api/urpd') - def post_tx(self, body: str) -> Txid: - """Broadcast transaction. + def list_urpd_dates(self, cohort: Cohort) -> List[Date]: + """Available URPD dates. - Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. + Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. - *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* + Endpoint: `GET /api/urpd/{cohort}/dates`""" + return self.get_json(f'/api/urpd/{cohort}/dates') - Endpoint: `POST /api/tx`""" - return self.post_json('/api/tx', body) + def get_urpd(self, cohort: Cohort, agg: Optional[UrpdAggregation] = None) -> Urpd: + """Latest URPD. + + URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. + + See the URPD tag description for the response shape and `agg` options. + + Endpoint: `GET /api/urpd/{cohort}`""" + params = [] + if agg is not None: params.append(f'agg={agg}') + query = '&'.join(params) + path = f'/api/urpd/{cohort}{"?" + query if query else ""}' + return self.get_json(path) + + def get_urpd_at(self, cohort: Cohort, date: str, agg: Optional[UrpdAggregation] = None) -> Urpd: + """URPD at date. + + URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. + + See the URPD tag description for unit conventions and `agg` options. + + Endpoint: `GET /api/urpd/{cohort}/{date}`""" + params = [] + if agg is not None: params.append(f'agg={agg}') + query = '&'.join(params) + path = f'/api/urpd/{cohort}/{date}{"?" + query if query else ""}' + return self.get_json(path) + + def get_difficulty_adjustment(self) -> DifficultyAdjustment: + """Difficulty adjustment. + + Get current difficulty adjustment progress and estimates. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* + + Endpoint: `GET /api/v1/difficulty-adjustment`""" + return self.get_json('/api/v1/difficulty-adjustment') + + def get_prices(self) -> Prices: + """Current BTC price. + + Returns bitcoin latest price (on-chain derived, USD only). + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* + + Endpoint: `GET /api/v1/prices`""" + return self.get_json('/api/v1/prices') + + def get_historical_price(self, timestamp: Optional[Timestamp] = None) -> HistoricalPrice: + """Historical price. + + Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* + + Endpoint: `GET /api/v1/historical-price`""" + params = [] + if timestamp is not None: params.append(f'timestamp={timestamp}') + query = '&'.join(params) + path = f'/api/v1/historical-price{"?" + query if query else ""}' + return self.get_json(path) + + def get_address(self, address: Addr) -> AddrStats: + """Address information. + + Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* + + Endpoint: `GET /api/address/{address}`""" + return self.get_json(f'/api/address/{address}') + + def get_address_txs(self, address: Addr) -> List[Transaction]: + """Address transactions. + + Get transaction history for an address, sorted with newest first. Returns up to 50 entries: mempool transactions first, then confirmed transactions filling the remainder. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* + + Endpoint: `GET /api/address/{address}/txs`""" + return self.get_json(f'/api/address/{address}/txs') + + def get_address_confirmed_txs(self, address: Addr) -> List[Transaction]: + """Address confirmed transactions. + + Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* + + Endpoint: `GET /api/address/{address}/txs/chain`""" + return self.get_json(f'/api/address/{address}/txs/chain') + + def get_address_confirmed_txs_after(self, address: Addr, after_txid: Txid) -> List[Transaction]: + """Address confirmed transactions (paginated). + + Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* + + Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}`""" + return self.get_json(f'/api/address/{address}/txs/chain/{after_txid}') + + def get_address_mempool_txs(self, address: Addr) -> List[Transaction]: + """Address mempool transactions. + + Get unconfirmed transactions for an address from the mempool, newest first (up to 50). + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* + + Endpoint: `GET /api/address/{address}/txs/mempool`""" + return self.get_json(f'/api/address/{address}/txs/mempool') + + def get_address_utxos(self, address: Addr) -> List[Utxo]: + """Address UTXOs. + + Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* + + Endpoint: `GET /api/address/{address}/utxo`""" + return self.get_json(f'/api/address/{address}/utxo') + + def validate_address(self, address: str) -> AddrValidation: + """Validate address. + + Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* + + Endpoint: `GET /api/v1/validate-address/{address}`""" + return self.get_json(f'/api/v1/validate-address/{address}') + + def get_block(self, hash: BlockHash) -> BlockInfo: + """Block information. + + Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* + + Endpoint: `GET /api/block/{hash}`""" + return self.get_json(f'/api/block/{hash}') + + def get_block_v1(self, hash: BlockHash) -> BlockInfoV1: + """Block (v1). + + Returns block details with extras by hash. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* + + Endpoint: `GET /api/v1/block/{hash}`""" + return self.get_json(f'/api/v1/block/{hash}') + + def get_block_header(self, hash: BlockHash) -> Hex: + """Block header. + + Returns the hex-encoded 80-byte block header. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* + + Endpoint: `GET /api/block/{hash}/header`""" + return self.get_text(f'/api/block/{hash}/header') + + def get_block_by_height(self, height: Height) -> BlockHash: + """Block hash by height. + + Retrieve the block hash at a given height. Returns the hash as plain text. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* + + Endpoint: `GET /api/block-height/{height}`""" + return self.get_text(f'/api/block-height/{height}') + + def get_block_by_timestamp(self, timestamp: Timestamp) -> BlockTimestamp: + """Block by timestamp. + + Find the block closest to a given UNIX timestamp. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* + + Endpoint: `GET /api/v1/mining/blocks/timestamp/{timestamp}`""" + return self.get_json(f'/api/v1/mining/blocks/timestamp/{timestamp}') + + def get_block_raw(self, hash: BlockHash) -> bytes: + """Raw block. + + Returns the raw block data in binary format. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* + + Endpoint: `GET /api/block/{hash}/raw`""" + return self.get(f'/api/block/{hash}/raw') + + def get_block_status(self, hash: BlockHash) -> BlockStatus: + """Block status. + + Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* + + Endpoint: `GET /api/block/{hash}/status`""" + return self.get_json(f'/api/block/{hash}/status') + + def get_block_tip_height(self) -> Height: + """Block tip height. + + Returns the height of the last block. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* + + Endpoint: `GET /api/blocks/tip/height`""" + return int(self.get_text('/api/blocks/tip/height')) + + def get_block_tip_hash(self) -> BlockHash: + """Block tip hash. + + Returns the hash of the last block. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* + + Endpoint: `GET /api/blocks/tip/hash`""" + return self.get_text('/api/blocks/tip/hash') + + def get_block_txid(self, hash: BlockHash, index: BlockTxIndex) -> Txid: + """Transaction ID at index. + + Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* + + Endpoint: `GET /api/block/{hash}/txid/{index}`""" + return self.get_text(f'/api/block/{hash}/txid/{index}') + + def get_block_txids(self, hash: BlockHash) -> List[Txid]: + """Block transaction IDs. + + Retrieve all transaction IDs in a block. Returns an array of txids in block order. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* + + Endpoint: `GET /api/block/{hash}/txids`""" + return self.get_json(f'/api/block/{hash}/txids') + + def get_block_txs(self, hash: BlockHash) -> List[Transaction]: + """Block transactions. + + Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* + + Endpoint: `GET /api/block/{hash}/txs`""" + return self.get_json(f'/api/block/{hash}/txs') + + def get_block_txs_from_index(self, hash: BlockHash, start_index: BlockTxIndex) -> List[Transaction]: + """Block transactions (paginated). + + Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* + + Endpoint: `GET /api/block/{hash}/txs/{start_index}`""" + return self.get_json(f'/api/block/{hash}/txs/{start_index}') + + def get_blocks(self) -> List[BlockInfo]: + """Recent blocks. + + Retrieve the last 10 blocks. Returns block metadata for each block. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* + + Endpoint: `GET /api/blocks`""" + return self.get_json('/api/blocks') + + def get_blocks_from_height(self, height: Height) -> List[BlockInfo]: + """Blocks from height. + + Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* + + Endpoint: `GET /api/blocks/{height}`""" + return self.get_json(f'/api/blocks/{height}') + + def get_blocks_v1(self) -> List[BlockInfoV1]: + """Recent blocks with extras. + + Retrieve the last 15 blocks with extended data including pool identification and fee statistics. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* + + Endpoint: `GET /api/v1/blocks`""" + return self.get_json('/api/v1/blocks') + + def get_blocks_v1_from_height(self, height: Height) -> List[BlockInfoV1]: + """Blocks from height with extras. + + Retrieve up to 15 blocks with extended data going backwards from the given height. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* + + Endpoint: `GET /api/v1/blocks/{height}`""" + return self.get_json(f'/api/v1/blocks/{height}') + + def get_pools(self) -> List[PoolInfo]: + """List all mining pools. + + Get list of all known mining pools with their identifiers. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + + Endpoint: `GET /api/v1/mining/pools`""" + return self.get_json('/api/v1/mining/pools') + + def get_pool_stats(self, time_period: TimePeriod) -> PoolsSummary: + """Mining pool statistics. + + Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + + Endpoint: `GET /api/v1/mining/pools/{time_period}`""" + return self.get_json(f'/api/v1/mining/pools/{time_period}') + + def get_pool(self, slug: PoolSlug) -> PoolDetail: + """Mining pool details. + + Get detailed information about a specific mining pool including block counts and shares for different time periods. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* + + Endpoint: `GET /api/v1/mining/pool/{slug}`""" + return self.get_json(f'/api/v1/mining/pool/{slug}') + + def get_pools_hashrate(self) -> List[PoolHashrateEntry]: + """All pools hashrate (all time). + + Get hashrate data for all mining pools. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + + Endpoint: `GET /api/v1/mining/hashrate/pools`""" + return self.get_json('/api/v1/mining/hashrate/pools') + + def get_pools_hashrate_by_period(self, time_period: TimePeriod) -> List[PoolHashrateEntry]: + """All pools hashrate. + + Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + + Endpoint: `GET /api/v1/mining/hashrate/pools/{time_period}`""" + return self.get_json(f'/api/v1/mining/hashrate/pools/{time_period}') + + def get_pool_hashrate(self, slug: PoolSlug) -> List[PoolHashrateEntry]: + """Mining pool hashrate. + + Get hashrate history for a specific mining pool. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* + + Endpoint: `GET /api/v1/mining/pool/{slug}/hashrate`""" + return self.get_json(f'/api/v1/mining/pool/{slug}/hashrate') + + def get_pool_blocks(self, slug: PoolSlug) -> List[BlockInfoV1]: + """Mining pool blocks. + + Get the 10 most recent blocks mined by a specific pool. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + + Endpoint: `GET /api/v1/mining/pool/{slug}/blocks`""" + return self.get_json(f'/api/v1/mining/pool/{slug}/blocks') + + def get_pool_blocks_from(self, slug: PoolSlug, height: Height) -> List[BlockInfoV1]: + """Mining pool blocks from height. + + Get 10 blocks mined by a specific pool before (and including) the given height. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + + Endpoint: `GET /api/v1/mining/pool/{slug}/blocks/{height}`""" + return self.get_json(f'/api/v1/mining/pool/{slug}/blocks/{height}') + + def get_hashrate(self) -> HashrateSummary: + """Network hashrate (all time). + + Get network hashrate and difficulty data for all time. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + + Endpoint: `GET /api/v1/mining/hashrate`""" + return self.get_json('/api/v1/mining/hashrate') + + def get_hashrate_by_period(self, time_period: TimePeriod) -> HashrateSummary: + """Network hashrate. + + Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + + Endpoint: `GET /api/v1/mining/hashrate/{time_period}`""" + return self.get_json(f'/api/v1/mining/hashrate/{time_period}') + + def get_difficulty_adjustments(self) -> List[DifficultyAdjustmentEntry]: + """Difficulty adjustments (all time). + + Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + + Endpoint: `GET /api/v1/mining/difficulty-adjustments`""" + return self.get_json('/api/v1/mining/difficulty-adjustments') + + def get_difficulty_adjustments_by_period(self, time_period: TimePeriod) -> List[DifficultyAdjustmentEntry]: + """Difficulty adjustments. + + Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + + Endpoint: `GET /api/v1/mining/difficulty-adjustments/{time_period}`""" + return self.get_json(f'/api/v1/mining/difficulty-adjustments/{time_period}') + + def get_reward_stats(self, block_count: int) -> RewardStats: + """Mining reward statistics. + + Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* + + Endpoint: `GET /api/v1/mining/reward-stats/{block_count}`""" + return self.get_json(f'/api/v1/mining/reward-stats/{block_count}') + + def get_block_fees(self, time_period: TimePeriod) -> List[BlockFeesEntry]: + """Block fees. + + Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* + + Endpoint: `GET /api/v1/mining/blocks/fees/{time_period}`""" + return self.get_json(f'/api/v1/mining/blocks/fees/{time_period}') + + def get_block_rewards(self, time_period: TimePeriod) -> List[BlockRewardsEntry]: + """Block rewards. + + Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* + + Endpoint: `GET /api/v1/mining/blocks/rewards/{time_period}`""" + return self.get_json(f'/api/v1/mining/blocks/rewards/{time_period}') + + def get_block_fee_rates(self, time_period: TimePeriod) -> List[BlockFeeRatesEntry]: + """Block fee rates. + + Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* + + Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}`""" + return self.get_json(f'/api/v1/mining/blocks/fee-rates/{time_period}') + + def get_block_sizes_weights(self, time_period: TimePeriod) -> BlockSizesWeights: + """Block sizes and weights. + + Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* + + Endpoint: `GET /api/v1/mining/blocks/sizes-weights/{time_period}`""" + return self.get_json(f'/api/v1/mining/blocks/sizes-weights/{time_period}') + + def get_mempool_blocks(self) -> List[MempoolBlock]: + """Projected mempool blocks. + + Get projected blocks from the mempool for fee estimation. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* + + Endpoint: `GET /api/v1/fees/mempool-blocks`""" + return self.get_json('/api/v1/fees/mempool-blocks') + + def get_recommended_fees(self) -> RecommendedFees: + """Recommended fees. + + Get recommended fee rates for different confirmation targets. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* + + Endpoint: `GET /api/v1/fees/recommended`""" + return self.get_json('/api/v1/fees/recommended') + + def get_precise_fees(self) -> RecommendedFees: + """Precise recommended fees. + + Get recommended fee rates with up to 3 decimal places. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* + + Endpoint: `GET /api/v1/fees/precise`""" + return self.get_json('/api/v1/fees/precise') + + def get_mempool(self) -> MempoolInfo: + """Mempool statistics. + + Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* + + Endpoint: `GET /api/mempool`""" + return self.get_json('/api/mempool') + + def get_mempool_hash(self) -> int: + """Mempool content hash. + + Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. + + Endpoint: `GET /api/mempool/hash`""" + return self.get_json('/api/mempool/hash') + + def get_mempool_txids(self) -> List[Txid]: + """Mempool transaction IDs. + + Get all transaction IDs currently in the mempool. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* + + Endpoint: `GET /api/mempool/txids`""" + return self.get_json('/api/mempool/txids') + + def get_mempool_recent(self) -> List[MempoolRecentTx]: + """Recent mempool transactions. + + Get the last 10 transactions to enter the mempool. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* + + Endpoint: `GET /api/mempool/recent`""" + return self.get_json('/api/mempool/recent') + + def get_replacements(self) -> List[ReplacementNode]: + """Recent RBF replacements. + + Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + + Endpoint: `GET /api/v1/replacements`""" + return self.get_json('/api/v1/replacements') + + def get_fullrbf_replacements(self) -> List[ReplacementNode]: + """Recent full-RBF replacements. + + Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* + + Endpoint: `GET /api/v1/fullrbf/replacements`""" + return self.get_json('/api/v1/fullrbf/replacements') + + def get_live_price(self) -> Dollars: + """Live BTC/USD price. + + Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. + + Endpoint: `GET /api/mempool/price`""" + return self.get_json('/api/mempool/price') def get_tx_by_index(self, index: TxIndex) -> Txid: """Txid by index. @@ -8238,6 +8584,26 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx-index/{index}`""" return self.get_text(f'/api/tx-index/{index}') + def get_cpfp(self, txid: Txid) -> CpfpInfo: + """CPFP info. + + Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* + + Endpoint: `GET /api/v1/cpfp/{txid}`""" + return self.get_json(f'/api/v1/cpfp/{txid}') + + def get_tx_rbf(self, txid: Txid) -> RbfResponse: + """RBF replacement history. + + Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + + Endpoint: `GET /api/v1/tx/{txid}/rbf`""" + return self.get_json(f'/api/v1/tx/{txid}/rbf') + def get_tx(self, txid: Txid) -> Transaction: """Transaction information. @@ -8258,16 +8624,6 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx/{txid}/hex`""" return self.get_text(f'/api/tx/{txid}/hex') - def get_tx_merkle_proof(self, txid: Txid) -> MerkleProof: - """Transaction merkle proof. - - Get the merkle inclusion proof for a transaction. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* - - Endpoint: `GET /api/tx/{txid}/merkle-proof`""" - return self.get_json(f'/api/tx/{txid}/merkle-proof') - def get_tx_merkleblock_proof(self, txid: Txid) -> Hex: """Transaction merkleblock proof. @@ -8278,6 +8634,16 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx/{txid}/merkleblock-proof`""" return self.get_text(f'/api/tx/{txid}/merkleblock-proof') + def get_tx_merkle_proof(self, txid: Txid) -> MerkleProof: + """Transaction merkle proof. + + Get the merkle inclusion proof for a transaction. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* + + Endpoint: `GET /api/tx/{txid}/merkle-proof`""" + return self.get_json(f'/api/tx/{txid}/merkle-proof') + def get_tx_outspend(self, txid: Txid, vout: Vout) -> TxOutspend: """Output spend status. @@ -8318,354 +8684,6 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx/{txid}/status`""" return self.get_json(f'/api/tx/{txid}/status') - def list_urpd_cohorts(self) -> List[Cohort]: - """Available URPD cohorts. - - Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. - - Endpoint: `GET /api/urpd`""" - return self.get_json('/api/urpd') - - def get_urpd(self, cohort: Cohort, agg: Optional[UrpdAggregation] = None) -> Urpd: - """Latest URPD. - - URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. - - See the URPD tag description for the response shape and `agg` options. - - Endpoint: `GET /api/urpd/{cohort}`""" - params = [] - if agg is not None: params.append(f'agg={agg}') - query = '&'.join(params) - path = f'/api/urpd/{cohort}{"?" + query if query else ""}' - return self.get_json(path) - - def list_urpd_dates(self, cohort: Cohort) -> List[Date]: - """Available URPD dates. - - Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. - - Endpoint: `GET /api/urpd/{cohort}/dates`""" - return self.get_json(f'/api/urpd/{cohort}/dates') - - def get_urpd_at(self, cohort: Cohort, date: str, agg: Optional[UrpdAggregation] = None) -> Urpd: - """URPD at date. - - URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. - - See the URPD tag description for unit conventions and `agg` options. - - Endpoint: `GET /api/urpd/{cohort}/{date}`""" - params = [] - if agg is not None: params.append(f'agg={agg}') - query = '&'.join(params) - path = f'/api/urpd/{cohort}/{date}{"?" + query if query else ""}' - return self.get_json(path) - - def get_block_v1(self, hash: BlockHash) -> BlockInfoV1: - """Block (v1). - - Returns block details with extras by hash. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* - - Endpoint: `GET /api/v1/block/{hash}`""" - return self.get_json(f'/api/v1/block/{hash}') - - def get_blocks_v1(self) -> List[BlockInfoV1]: - """Recent blocks with extras. - - Retrieve the last 15 blocks with extended data including pool identification and fee statistics. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* - - Endpoint: `GET /api/v1/blocks`""" - return self.get_json('/api/v1/blocks') - - def get_blocks_v1_from_height(self, height: Height) -> List[BlockInfoV1]: - """Blocks from height with extras. - - Retrieve up to 15 blocks with extended data going backwards from the given height. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* - - Endpoint: `GET /api/v1/blocks/{height}`""" - return self.get_json(f'/api/v1/blocks/{height}') - - def get_cpfp(self, txid: Txid) -> CpfpInfo: - """CPFP info. - - Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* - - Endpoint: `GET /api/v1/cpfp/{txid}`""" - return self.get_json(f'/api/v1/cpfp/{txid}') - - def get_difficulty_adjustment(self) -> DifficultyAdjustment: - """Difficulty adjustment. - - Get current difficulty adjustment progress and estimates. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* - - Endpoint: `GET /api/v1/difficulty-adjustment`""" - return self.get_json('/api/v1/difficulty-adjustment') - - def get_mempool_blocks(self) -> List[MempoolBlock]: - """Projected mempool blocks. - - Get projected blocks from the mempool for fee estimation. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* - - Endpoint: `GET /api/v1/fees/mempool-blocks`""" - return self.get_json('/api/v1/fees/mempool-blocks') - - def get_precise_fees(self) -> RecommendedFees: - """Precise recommended fees. - - Get recommended fee rates with up to 3 decimal places. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* - - Endpoint: `GET /api/v1/fees/precise`""" - return self.get_json('/api/v1/fees/precise') - - def get_recommended_fees(self) -> RecommendedFees: - """Recommended fees. - - Get recommended fee rates for different confirmation targets. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* - - Endpoint: `GET /api/v1/fees/recommended`""" - return self.get_json('/api/v1/fees/recommended') - - def get_fullrbf_replacements(self) -> List[ReplacementNode]: - """Recent full-RBF replacements. - - Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* - - Endpoint: `GET /api/v1/fullrbf/replacements`""" - return self.get_json('/api/v1/fullrbf/replacements') - - def get_historical_price(self, timestamp: Optional[Timestamp] = None) -> HistoricalPrice: - """Historical price. - - Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* - - Endpoint: `GET /api/v1/historical-price`""" - params = [] - if timestamp is not None: params.append(f'timestamp={timestamp}') - query = '&'.join(params) - path = f'/api/v1/historical-price{"?" + query if query else ""}' - return self.get_json(path) - - def get_block_fee_rates(self, time_period: TimePeriod) -> List[BlockFeeRatesEntry]: - """Block fee rates. - - Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* - - Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}`""" - return self.get_json(f'/api/v1/mining/blocks/fee-rates/{time_period}') - - def get_block_fees(self, time_period: TimePeriod) -> List[BlockFeesEntry]: - """Block fees. - - Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* - - Endpoint: `GET /api/v1/mining/blocks/fees/{time_period}`""" - return self.get_json(f'/api/v1/mining/blocks/fees/{time_period}') - - def get_block_rewards(self, time_period: TimePeriod) -> List[BlockRewardsEntry]: - """Block rewards. - - Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* - - Endpoint: `GET /api/v1/mining/blocks/rewards/{time_period}`""" - return self.get_json(f'/api/v1/mining/blocks/rewards/{time_period}') - - def get_block_sizes_weights(self, time_period: TimePeriod) -> BlockSizesWeights: - """Block sizes and weights. - - Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* - - Endpoint: `GET /api/v1/mining/blocks/sizes-weights/{time_period}`""" - return self.get_json(f'/api/v1/mining/blocks/sizes-weights/{time_period}') - - def get_block_by_timestamp(self, timestamp: Timestamp) -> BlockTimestamp: - """Block by timestamp. - - Find the block closest to a given UNIX timestamp. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* - - Endpoint: `GET /api/v1/mining/blocks/timestamp/{timestamp}`""" - return self.get_json(f'/api/v1/mining/blocks/timestamp/{timestamp}') - - def get_difficulty_adjustments(self) -> List[DifficultyAdjustmentEntry]: - """Difficulty adjustments (all time). - - Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* - - Endpoint: `GET /api/v1/mining/difficulty-adjustments`""" - return self.get_json('/api/v1/mining/difficulty-adjustments') - - def get_difficulty_adjustments_by_period(self, time_period: TimePeriod) -> List[DifficultyAdjustmentEntry]: - """Difficulty adjustments. - - Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* - - Endpoint: `GET /api/v1/mining/difficulty-adjustments/{time_period}`""" - return self.get_json(f'/api/v1/mining/difficulty-adjustments/{time_period}') - - def get_hashrate(self) -> HashrateSummary: - """Network hashrate (all time). - - Get network hashrate and difficulty data for all time. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* - - Endpoint: `GET /api/v1/mining/hashrate`""" - return self.get_json('/api/v1/mining/hashrate') - - def get_pools_hashrate(self) -> List[PoolHashrateEntry]: - """All pools hashrate (all time). - - Get hashrate data for all mining pools. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* - - Endpoint: `GET /api/v1/mining/hashrate/pools`""" - return self.get_json('/api/v1/mining/hashrate/pools') - - def get_pools_hashrate_by_period(self, time_period: TimePeriod) -> List[PoolHashrateEntry]: - """All pools hashrate. - - Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* - - Endpoint: `GET /api/v1/mining/hashrate/pools/{time_period}`""" - return self.get_json(f'/api/v1/mining/hashrate/pools/{time_period}') - - def get_hashrate_by_period(self, time_period: TimePeriod) -> HashrateSummary: - """Network hashrate. - - Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* - - Endpoint: `GET /api/v1/mining/hashrate/{time_period}`""" - return self.get_json(f'/api/v1/mining/hashrate/{time_period}') - - def get_pool(self, slug: PoolSlug) -> PoolDetail: - """Mining pool details. - - Get detailed information about a specific mining pool including block counts and shares for different time periods. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* - - Endpoint: `GET /api/v1/mining/pool/{slug}`""" - return self.get_json(f'/api/v1/mining/pool/{slug}') - - def get_pool_blocks(self, slug: PoolSlug) -> List[BlockInfoV1]: - """Mining pool blocks. - - Get the 10 most recent blocks mined by a specific pool. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* - - Endpoint: `GET /api/v1/mining/pool/{slug}/blocks`""" - return self.get_json(f'/api/v1/mining/pool/{slug}/blocks') - - def get_pool_blocks_from(self, slug: PoolSlug, height: Height) -> List[BlockInfoV1]: - """Mining pool blocks from height. - - Get 10 blocks mined by a specific pool before (and including) the given height. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* - - Endpoint: `GET /api/v1/mining/pool/{slug}/blocks/{height}`""" - return self.get_json(f'/api/v1/mining/pool/{slug}/blocks/{height}') - - def get_pool_hashrate(self, slug: PoolSlug) -> List[PoolHashrateEntry]: - """Mining pool hashrate. - - Get hashrate history for a specific mining pool. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* - - Endpoint: `GET /api/v1/mining/pool/{slug}/hashrate`""" - return self.get_json(f'/api/v1/mining/pool/{slug}/hashrate') - - def get_pools(self) -> List[PoolInfo]: - """List all mining pools. - - Get list of all known mining pools with their identifiers. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* - - Endpoint: `GET /api/v1/mining/pools`""" - return self.get_json('/api/v1/mining/pools') - - def get_pool_stats(self, time_period: TimePeriod) -> PoolsSummary: - """Mining pool statistics. - - Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* - - Endpoint: `GET /api/v1/mining/pools/{time_period}`""" - return self.get_json(f'/api/v1/mining/pools/{time_period}') - - def get_reward_stats(self, block_count: int) -> RewardStats: - """Mining reward statistics. - - Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* - - Endpoint: `GET /api/v1/mining/reward-stats/{block_count}`""" - return self.get_json(f'/api/v1/mining/reward-stats/{block_count}') - - def get_prices(self) -> Prices: - """Current BTC price. - - Returns bitcoin latest price (on-chain derived, USD only). - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* - - Endpoint: `GET /api/v1/prices`""" - return self.get_json('/api/v1/prices') - - def get_replacements(self) -> List[ReplacementNode]: - """Recent RBF replacements. - - Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* - - Endpoint: `GET /api/v1/replacements`""" - return self.get_json('/api/v1/replacements') - def get_transaction_times(self, txId: List[Txid]) -> List[int]: """Transaction first-seen times. @@ -8680,33 +8698,15 @@ class BrkClient(BrkClientBase): path = f'/api/v1/transaction-times{"?" + query if query else ""}' return self.get_json(path) - def get_tx_rbf(self, txid: Txid) -> RbfResponse: - """RBF replacement history. + def post_tx(self, body: str) -> Txid: + """Broadcast transaction. - Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. + Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* - Endpoint: `GET /api/v1/tx/{txid}/rbf`""" - return self.get_json(f'/api/v1/tx/{txid}/rbf') - - def validate_address(self, address: str) -> AddrValidation: - """Validate address. - - Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. - - *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* - - Endpoint: `GET /api/v1/validate-address/{address}`""" - return self.get_json(f'/api/v1/validate-address/{address}') - - def get_health(self) -> Health: - """Health check. - - Returns the health status of the API server, including uptime information. - - Endpoint: `GET /health`""" - return self.get_json('/health') + Endpoint: `POST /api/tx`""" + return self.post_json('/api/tx', body) def get_openapi(self) -> str: """OpenAPI specification. @@ -8716,11 +8716,11 @@ class BrkClient(BrkClientBase): Endpoint: `GET /openapi.json`""" return self.get_text('/openapi.json') - def get_version(self) -> str: - """API version. + def get_api(self) -> Any: + """Compact OpenAPI specification. - Returns the current version of the API server + Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. - Endpoint: `GET /version`""" - return self.get_json('/version') + Endpoint: `GET /api.json`""" + return self.get_json('/api.json')