From de33f210cb2e7e4158ca1e5ad5d3da0ca87096be Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:09:04 -0700 Subject: [PATCH 1/2] esplora: full JSON for mempool-only txs Class A miss used a txid+fee stub on /txs and 404 on GET /tx. Build the Esplora tx object from mp.get_tx (vin/vout/size/weight/fee) so BDK-class clients can parse unconfirmed history. Co-authored-by: Cursor --- crates/rbitcoin-esplora/src/handlers.rs | 78 +++++++------- crates/rbitcoin-esplora/src/server.rs | 130 +++++++++++++++++++++++- crates/rbitcoin-esplora/src/tx_json.rs | 80 ++++++++++++--- crates/rbitcoin-esplora/src/ws.rs | 60 ++--------- 4 files changed, 246 insertions(+), 102 deletions(-) diff --git a/crates/rbitcoin-esplora/src/handlers.rs b/crates/rbitcoin-esplora/src/handlers.rs index 8aefcbe5..387b140a 100644 --- a/crates/rbitcoin-esplora/src/handlers.rs +++ b/crates/rbitcoin-esplora/src/handlers.rs @@ -1,10 +1,13 @@ //! Esplora route handlers beyond tip/header/basic tx. use crate::server::{ - block_hash_hex, maybe_attach_view, not_found, parse_hash32, pin_or_reject, plain_ok, store_err, - AppState, AsOf, + block_hash_hex, maybe_attach_view, mempool_wire, not_found, parse_hash32, pin_or_reject, + plain_ok, store_err, AppState, AsOf, +}; +use crate::tx_json::{ + build_tx_json, build_tx_json_from_tx, history_items_to_tx_json, tx_status_json_in, + utxo_list_json, }; -use crate::tx_json::{build_tx_json, history_items_to_tx_json, tx_status_json_in, utxo_list_json}; use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::{header, StatusCode}; @@ -14,7 +17,7 @@ use bitcoin::address::Address; use bitcoin::consensus::{deserialize, encode::serialize, Encodable}; use bitcoin::hashes::Hash; use bitcoin::pow::{CompactTarget, Target}; -use bitcoin::{MerkleBlock, Network}; +use bitcoin::{MerkleBlock, Network, Txid}; use rbitcoin_primitives::{median_time_past_times, Height}; use rbitcoin_query::{ChainViewKind, HistoryFilter, Query, ScriptHashChainStats}; use rbitcoin_store::{script_hash, StoreError}; @@ -347,7 +350,15 @@ pub async fn tx_raw(State(st): State, Path(txid_hex): Path) -> .into_response(), Err(e) => store_err(e), }, - Ok(None) => not_found(), + Ok(None) => match mempool_wire(&st, &txid) { + Some(tx) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/octet-stream")], + bitcoin::consensus::serialize(&tx), + ) + .into_response(), + None => not_found(), + }, Err(e) => store_err(e), } }) @@ -817,21 +828,7 @@ fn combined_txs(st: &AppState, sh: &[u8; 32], asof: Option<[u8; 32]>) -> Respons }; let mut out = Vec::new(); if asof.is_none() { - if let Some(mp) = st.mempool.as_ref() { - for item in mp.scripthash_mempool(sh).into_iter().take(50) { - if let Ok(Some((fk, _))) = st.query.get_tx_by_txid(&item.txid) { - if let Ok(v) = build_tx_json(&st.query, fk, st.network) { - out.push(v); - continue; - } - } - out.push(json!({ - "txid": block_hash_hex(&item.txid), - "status": { "confirmed": false }, - "fee": item.fee, - })); - } - } + out.extend(mempool_txs_json(st, sh)); } let filter = HistoryFilter::esplora_chain_page(None); let resp = if asof.is_some() { @@ -986,24 +983,37 @@ pub async fn address_txs_mempool( } } -fn mempool_txs_for_sh(st: &AppState, sh: &[u8; 32]) -> Response { +fn mempool_txs_json(st: &AppState, sh: &[u8; 32]) -> Vec { + let Some(mp) = st.mempool.as_ref() else { + return Vec::new(); + }; let mut out = Vec::new(); - if let Some(mp) = st.mempool.as_ref() { - for item in mp.scripthash_mempool(sh).into_iter().take(50) { - if let Ok(Some((fk, _))) = st.query.get_tx_by_txid(&item.txid) { - if let Ok(v) = build_tx_json(&st.query, fk, st.network) { - out.push(v); - continue; - } + for item in mp.scripthash_mempool(sh).into_iter().take(50) { + if let Ok(Some((fk, _))) = st.query.get_tx_by_txid(&item.txid) { + if let Ok(v) = build_tx_json(&st.query, fk, st.network) { + out.push(v); + continue; } - out.push(json!({ - "txid": block_hash_hex(&item.txid), - "status": { "confirmed": false }, - "fee": item.fee, - })); + } + let txid = Txid::from_byte_array(item.txid); + let Some(tx) = mp.get_tx(&txid) else { + continue; + }; + if let Ok(v) = build_tx_json_from_tx( + &st.query, + &tx, + st.network, + Some(item.fee), + Some(mp.as_ref()), + ) { + out.push(v); } } - Json(out).into_response() + out +} + +fn mempool_txs_for_sh(st: &AppState, sh: &[u8; 32]) -> Response { + Json(mempool_txs_json(st, sh)).into_response() } pub async fn post_tx(State(st): State, body: Bytes) -> Response { diff --git a/crates/rbitcoin-esplora/src/server.rs b/crates/rbitcoin-esplora/src/server.rs index 409959b7..c8ddbb40 100644 --- a/crates/rbitcoin-esplora/src/server.rs +++ b/crates/rbitcoin-esplora/src/server.rs @@ -1,7 +1,7 @@ //! Esplora HTTP listener (axum + tower limits) and wallet WebSocket live path. use crate::handlers; -use crate::tx_json::{build_tx_json, tx_status_json_in}; +use crate::tx_json::{build_tx_json, build_tx_json_from_tx, tx_status_json_in}; use crate::ws; use axum::extract::{FromRequestParts, Path, Query as AxumQuery, Request, State}; use axum::http::request::Parts; @@ -527,7 +527,19 @@ async fn tx_full(State(st): State, Path(txid_hex): Path) -> Re Ok(v) => Json(v).into_response(), Err(e) => store_err(e), }, - Ok(None) => not_found(), + Ok(None) => match mempool_wire(&st, &txid) { + Some(tx) => match build_tx_json_from_tx( + &st.query, + &tx, + st.network, + None, + st.mempool.as_deref(), + ) { + Ok(v) => Json(v).into_response(), + Err(e) => store_err(e), + }, + None => not_found(), + }, Err(e) => store_err(e), } }) @@ -545,7 +557,13 @@ async fn tx_hex(State(st): State, Path(txid_hex): Path) -> Res Ok(raw) => plain_ok(rbitcoin_primitives::hex_encode(raw)), Err(e) => store_err(e), }, - Ok(None) => not_found(), + Ok(None) => match mempool_wire(&st, &txid) { + Some(tx) => { + let raw = bitcoin::consensus::serialize(&tx); + plain_ok(rbitcoin_primitives::hex_encode(raw)) + } + None => not_found(), + }, Err(e) => store_err(e), } }) @@ -628,6 +646,12 @@ pub(crate) fn parse_hash32(s: &str) -> Result<[u8; 32], ()> { Ok(out) } +pub(crate) fn mempool_wire(st: &AppState, txid: &[u8; 32]) -> Option { + use bitcoin::hashes::Hash; + let tid = bitcoin::Txid::from_byte_array(*txid); + st.mempool.as_ref().and_then(|m| m.get_tx(&tid)) +} + fn encode_header_hex(hdr: &bitcoin::block::Header) -> Result { let mut buf = Vec::with_capacity(80); hdr.consensus_encode(&mut buf) @@ -2240,4 +2264,104 @@ mod tests { handle.shutdown().await; let _ = std::fs::remove_dir_all(&dir); } + + /// Mempool-only txs (not in Class A) must be full Esplora JSON, not a stub. + #[tokio::test] + async fn mempool_only_tx_json_has_vin_vout_size_weight() { + use bitcoin::absolute::LockTime; + use bitcoin::hashes::Hash; + use bitcoin::transaction::Version as TxVersion; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; + use rbitcoin_net::MempoolHub; + use rbitcoin_store::script_hash; + + if std::env::var_os("RBITCOIN_HEAD_SCALE").is_none() { + std::env::set_var("RBITCOIN_HEAD_SCALE", "tiny"); + } + let (dir, q) = temp_query("mp-tx-json"); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + let mut coinbase_txids = Vec::new(); + for h in 0..101u32 { + let (header, ta) = coinbase(h, prev, parent_hash); + parent_hash = Some(header.hash); + coinbase_txids.push(ta.tx.txid); + prev = q.connect_block(Height(h), &header, &[ta]).unwrap(); + } + let q = Arc::new(q); + let mp_dir = dir.join("mp"); + std::fs::create_dir_all(&mp_dir).unwrap(); + let hub = MempoolHub::open(&mp_dir, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + let spend = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: bitcoin::Txid::from_byte_array(coinbase_txids[0]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - 1_000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + hub.accept_tx(&spend).expect("accept mempool spend"); + let txid_hex = display_txid(spend.compute_txid()); + let sh_hex = block_hash_hex(&script_hash(&[0x51])); + + let cfg = EsploraConfig::with_network("127.0.0.1:0".parse().unwrap(), Network::Regtest); + let handle = run_esplora(cfg, Arc::clone(&q), Some(Arc::clone(&hub)), None) + .await + .expect("listen"); + let addr = handle.local_addr; + + let (st, body) = http_get(addr, &format!("/scripthash/{sh_hex}/txs/mempool")).await; + assert_eq!(st, 200, "{body}"); + let arr: serde_json::Value = serde_json::from_str(&body).unwrap(); + let row = arr + .as_array() + .unwrap() + .iter() + .find(|r| r["txid"] == txid_hex); + let row = row.expect("mempool list contains spend"); + assert!(row.get("vin").is_some(), "stub omitted vin: {row}"); + assert!(row.get("vout").is_some(), "stub omitted vout: {row}"); + assert!(row.get("size").is_some(), "stub omitted size: {row}"); + assert!(row.get("weight").is_some(), "stub omitted weight: {row}"); + assert_eq!(row["status"]["confirmed"], false); + assert_eq!(row["fee"], 1_000); + + let (st, body) = http_get(addr, &format!("/scripthash/{sh_hex}/txs")).await; + assert_eq!(st, 200, "{body}"); + let arr: serde_json::Value = serde_json::from_str(&body).unwrap(); + let row = arr + .as_array() + .unwrap() + .iter() + .find(|r| r["txid"] == txid_hex) + .expect("combined /txs contains spend"); + assert!(row.get("vin").is_some(), "combined stub omitted vin: {row}"); + + let (st, body) = http_get(addr, &format!("/tx/{txid_hex}")).await; + assert_eq!(st, 200, "GET /tx mempool-only: {body}"); + let full: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(full.get("vin").is_some()); + assert!(full.get("vout").is_some()); + assert!(full.get("size").is_some()); + assert!(full.get("weight").is_some()); + assert_eq!(full["status"]["confirmed"], false); + + let (st, hex_body) = http_get(addr, &format!("/tx/{txid_hex}/hex")).await; + assert_eq!(st, 200, "{hex_body}"); + let (st, _) = http_get(addr, &format!("/tx/{txid_hex}/raw")).await; + assert_eq!(st, 200); + + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/rbitcoin-esplora/src/tx_json.rs b/crates/rbitcoin-esplora/src/tx_json.rs index 7e3ce881..4a8fcece 100644 --- a/crates/rbitcoin-esplora/src/tx_json.rs +++ b/crates/rbitcoin-esplora/src/tx_json.rs @@ -2,7 +2,8 @@ use crate::script_fields::esplora_script_fields; use bitcoin::hashes::Hash; -use bitcoin::Network; +use bitcoin::{Network, Transaction}; +use rbitcoin_net::MempoolHub; use rbitcoin_primitives::hex_encode; use rbitcoin_primitives::{Fk, Height}; use rbitcoin_query::{Query, QueryError, ScriptHashHistoryItem, ScriptHashUtxo}; @@ -147,7 +148,53 @@ pub fn build_tx_json(query: &Query, tx_fk: Fk, network: Network) -> Result, + mempool: Option<&MempoolHub>, +) -> Result { + tx_json_from_wire( + query, + tx, + network, + json!({ "confirmed": false }), + &[], + tx.compute_txid().to_byte_array(), + fee, + mempool, + ) +} +fn tx_json_from_wire( + query: &Query, + wire: &Transaction, + network: Network, + status: Value, + stored_inputs: &[InputRecord], + txid_bytes: [u8; 32], + fee_override: Option, + mempool: Option<&MempoolHub>, +) -> Result { let mut vin = Vec::with_capacity(wire.input.len()); let mut fee_in: Option = Some(0); for (i, tin) in wire.input.iter().enumerate() { @@ -179,7 +226,7 @@ pub fn build_tx_json(query: &Query, tx_fk: Fk, network: Network) -> Result Result, ) -> Result, QueryError> { if let Some(inp) = stored_inputs.get(idx) { if !inp.create_fk.is_null() { @@ -265,6 +306,15 @@ fn prevout_json( return Ok(Some(vout_fields(&out.script, out.value, network))); } } + if let Some(prev) = mempool.and_then(|m| m.get_tx(&tin.previous_output.txid)) { + if let Some(o) = prev.output.get(tin.previous_output.vout as usize) { + return Ok(Some(vout_fields( + o.script_pubkey.as_bytes(), + o.value.to_sat() as i64, + network, + ))); + } + } Ok(None) } diff --git a/crates/rbitcoin-esplora/src/ws.rs b/crates/rbitcoin-esplora/src/ws.rs index 643f1ac5..eafeff1c 100644 --- a/crates/rbitcoin-esplora/src/ws.rs +++ b/crates/rbitcoin-esplora/src/ws.rs @@ -4,14 +4,14 @@ use crate::handlers::resolve_address_sh; use crate::server::AppState; -use crate::tx_json::{build_tx_json, tx_status_json}; +use crate::tx_json::{build_tx_json, build_tx_json_from_tx, tx_status_json}; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{State, WebSocketUpgrade}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use bitcoin::consensus::Encodable; use bitcoin::hashes::Hash; -use bitcoin::{Address, Network, Transaction, Txid}; +use bitcoin::{Transaction, Txid}; use futures_util::{SinkExt, StreamExt}; use rbitcoin_net::{MempoolAnnounce, TipEvent}; use rbitcoin_primitives::{hex_encode, Height}; @@ -215,51 +215,6 @@ fn scripts_touched_full( set } -fn mempool_tx_json(tx: &Transaction, network: Network) -> Value { - let txid = tx.compute_txid(); - let mut vin = Vec::new(); - for (i, inp) in tx.input.iter().enumerate() { - vin.push(json!({ - "txid": txid_display_hex(&inp.previous_output.txid), - "vout": inp.previous_output.vout, - "scriptsig": "", - "scriptsig_asm": "", - "is_coinbase": inp.previous_output.is_null(), - "sequence": inp.sequence.0, - "vin": i, - })); - } - let mut vout = Vec::new(); - for (i, o) in tx.output.iter().enumerate() { - let spk = o.script_pubkey.as_bytes(); - let addr = Address::from_script(&o.script_pubkey, network) - .ok() - .map(|a| a.to_string()); - let mut vo = json!({ - "scriptpubkey": hex_encode(spk), - "scriptpubkey_asm": "", - "scriptpubkey_type": "", - "value": o.value.to_sat(), - }); - if let Some(a) = addr { - vo["scriptpubkey_address"] = Value::String(a); - } - vo["n"] = json!(i); - vout.push(vo); - } - json!({ - "txid": txid_display_hex(&txid), - "version": tx.version.0, - "locktime": tx.lock_time.to_consensus_u32(), - "vin": vin, - "vout": vout, - "size": bitcoin::consensus::serialize(tx).len(), - "weight": tx.weight().to_wu(), - "fee": 0, - "status": { "confirmed": false }, - }) -} - fn tip_push_json(ev: &TipEvent) -> Value { let mut header_bytes = Vec::with_capacity(80); let _ = ev.header.consensus_encode(&mut header_bytes); @@ -553,9 +508,14 @@ async fn on_mempool_announce( let hit = shs.iter().any(|s| conn.addresses.contains_key(s)); if hit { let body = match st.query.get_tx_by_txid(&ann.txid.to_byte_array()) { - Ok(Some((fk, _))) => build_tx_json(&st.query, fk, st.network) - .unwrap_or_else(|_| mempool_tx_json(&tx, st.network)), - _ => mempool_tx_json(&tx, st.network), + Ok(Some((fk, _))) => { + build_tx_json(&st.query, fk, st.network).unwrap_or_else(|_| { + build_tx_json_from_tx(&st.query, &tx, st.network, None, Some(m.as_ref())) + .unwrap_or_else(|_| json!({ "txid": txid_display_hex(&ann.txid) })) + }) + } + _ => build_tx_json_from_tx(&st.query, &tx, st.network, None, Some(m.as_ref())) + .unwrap_or_else(|_| json!({ "txid": txid_display_hex(&ann.txid) })), }; send_json(sink, &json!({ "address-transactions": [body] })).await?; } From f96bc42921b92e6d6d5457d171cdc19ce585f157 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:10:43 -0700 Subject: [PATCH 2/2] docs: close X-M1; retire X-M3 as won't-fix Mempool-only Esplora tx JSON is from the wire. HTTP SH join stays one process slot: tiny LRU still evicts, per-IP and large caches are the wrong product; Electrum per-connection is the sticky model. Co-authored-by: Cursor --- CHANGELOG.md | 4 ++++ COMPAT.md | 4 ++-- docs/algo-review.md | 31 +++++++++++++------------------ docs/quality.md | 2 +- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe406270..17ae2e53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,10 @@ before 1.0). ### Fixed +- **Esplora mempool-only tx JSON includes vin/vout/size/weight:** Class A + miss uses the mempool wire body (`GET /tx`, `/txs`, `/txs/mempool`, WS + address-transactions). No more txid+fee stub. + - **Mempool `relay_seq` / `accept_at` drop on unindex:** confirm, RBF, and eviction no longer leak per-admit INV maps for the process lifetime. diff --git a/COMPAT.md b/COMPAT.md index d279bfe0..70c2d6c1 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -169,8 +169,8 @@ via reverse proxy; app `ServeLimits` always on (same model as Electrum). | Tip | done | `/blocks/tip/height`, `/blocks/tip/hash`. REST stamps `X-Bitcoin-Chain-Tip` / `X-Bitcoin-Chain-Tip-Height` (CORS-exposed): **live tip** for block/tx/header routes; **SH watermark** for `/address/` and `/scripthash/` so wallet JSON matches the SH join. Empty chain omits them (existing 503). If the pin dies mid-request: **503** `chain view moved`. | | Blocks list | done | `/blocks`, `/blocks/:start_height` (10 summaries, newest-first) | | Block | done | `/block/:hash` JSON, `/raw`, `/status`, `/header`, `/txids`, `/txid/:i`, `/txs[/:start]`. JSON `bits` is the compact-target **u32** (Esplora schema, not Core hex). `size` / `weight` are BIP144 total size and BIP141 weight (witness included). | -| Tx | done | `/tx/:txid` full JSON, `/hex`, `/raw`, `/status`, Electrum `/merkle-proof`, BIP37 `/merkleblock-proof`, `/outspend(s)`. `?asof=` on `/status` and `/outspend(s)`: confirmed/spent as of that ancestor; 404 if not on chain. | -| Address / scripthash | done | stats + `/utxo` + `/txs` + `/txs/mempool` + `/txs/chain[/:last_seen_txid]`; `/utxo` matches Electrum listunspent (mempool funding + drop mempool-spent confirmed); `/txs` from SH join fks; last SH join reused across sequential REST calls until SH-view **hash** changes; needs SH finalize. Stamp is visible SH (durable + pending write-behind), matching live tip while jobs sit in RAM. `?asof=` on `/`, `/utxo`, `/txs`, `/txs/chain`: confirmed join at that ancestor **at or behind visible SH**, **no** mempool; headers are the asof hash; 404 if not on chain or ahead of visible SH. | +| Tx | done | `/tx/:txid` full JSON, `/hex`, `/raw`, `/status`, Electrum `/merkle-proof`, BIP37 `/merkleblock-proof`, `/outspend(s)`. Mempool-only txs (not in Class A) use the wire body from the mempool hub (`vin`/`vout`/`size`/`weight`/`fee`, `status.confirmed` false). `?asof=` on `/status` and `/outspend(s)`: confirmed/spent as of that ancestor; 404 if not on chain. | +| Address / scripthash | done | stats + `/utxo` + `/txs` + `/txs/mempool` + `/txs/chain[/:last_seen_txid]`; `/utxo` matches Electrum listunspent (mempool funding + drop mempool-spent confirmed); `/txs` and `/txs/mempool` use full Esplora tx JSON for mempool-only rows (wire from the hub). Last **one** SH join reused across sequential REST calls until SH-view **hash** changes; concurrent different SHs re-join. Needs SH finalize. Stamp is visible SH (durable + pending write-behind), matching live tip while jobs sit in RAM. `?asof=` on `/`, `/utxo`, `/txs`, `/txs/chain`: confirmed join at that ancestor **at or behind visible SH**, **no** mempool; headers are the asof hash; 404 if not on chain or ahead of visible SH. | | Mempool / fees | done | `/mempool`, `/mempool/txids`, `/mempool/recent` (accept-order ring), `/fee-estimates` | | `POST /tx` | done | broadcast via mempool hub; **503** if hub absent | | `POST /txs/package` | done | JSON array of hex txs → `accept_package`; **503** without hub; max 25 txs | diff --git a/docs/algo-review.md b/docs/algo-review.md index d179aca3..40131816 100644 --- a/docs/algo-review.md +++ b/docs/algo-review.md @@ -98,7 +98,7 @@ What the node runs. Findings follow. | RPC `active` | id-keyed map | `dispatch` removes by id | | Electrum status | `sha256(concat rows)` | confirmed rows include **blockhash** (COMPAT A-B-A); mempool appended last (same as `get_history`) | | Esplora `/blocks` | reconstruct 10 full blocks | for size/weight only | -| SH join cache | process-wide single slot | serializes clients | +| SH join cache | process-wide single slot | sequential REST reuse; concurrent different SHs re-join | ### Node / primitives @@ -190,13 +190,8 @@ What the node runs. Findings follow. `maxburnamount` and ignore them. Enforce or reject the params. - **E-M1.** `scripthash_mempool_stats` undercounts chained unconfirmed vs Esplora. -- **X-M1.** Esplora mempool tx JSON stubs omit vin/vout/size/weight when the - tx is not in Class A (`handlers.rs`). BDK-class clients fail to parse. - Wire tx is in `mp.get_tx`. - **X-M2.** Esplora WS does store IO on the tokio thread (`ws.rs` `on_tip` / `on_mempool_announce`). REST uses `spawn_blocking`. -- **X-M3.** Process-wide single `sh_join` slot: concurrent clients serialize - and evict each other. - **O-M1.** Conf `milestone=0` overwritten by network default (`cli.rs`). CLI `--milestone 0` works; conf cannot disable skip. - **O-M2.** `--minrelaytxfee` parse failure silently ignored; negatives → 0. @@ -264,16 +259,15 @@ Do not flatten io_uring machines. Do not add a process pin FIFO. 3. **Held/pending eviction:** FIFO via existing `held_seq` / `VecDeque`. 4. **Announced-tx sets:** rolling bloom (Core `CRollingBloomFilter`). 5. **BlockCache:** `VecDeque` + height offset. -6. **Esplora `sh_join`:** small LRU, not one global slot. -7. **Esplora `/blocks`:** stored summary, not reconstruct. -8. **`last_push_data`:** `Script::instructions()` (gets PUSHDATA4). -9. **`U64IdentityHasher`:** multiply by odd golden-ratio constant if +6. **Esplora `/blocks`:** stored summary, not reconstruct. +7. **`last_push_data`:** `Script::instructions()` (gets PUSHDATA4). +8. **`U64IdentityHasher`:** multiply by odd golden-ratio constant if hashbrown clustering shows. -10. **Seqlock:** fences, or stop rolling your own for a 16-byte pair. -11. **CLI parsers:** table-driven `take_parsed` (node + bench). -12. **Bench hex:** use `rbitcoin_primitives::hex_*`. -13. **Bit count:** `u8::count_ones`. -14. **SH `put_sorted_creates` `seen`:** `put_chain` already sorts+dedups. +9. **Seqlock:** fences, or stop rolling your own for a 16-byte pair. +10. **CLI parsers:** table-driven `take_parsed` (node + bench). +11. **Bench hex:** use `rbitcoin_primitives::hex_*`. +12. **Bit count:** `u8::count_ones`. +13. **SH `put_sorted_creates` `seen`:** `put_chain` already sorts+dedups. --- @@ -347,7 +341,7 @@ Intentional COMPAT Electrum status extra field is **not** counted as High. | mempool | 0 | orphan vout, eviction tie, persist order, package feerate | free slot, persist_all | | rpc | 0 | submitblock gate, gettxout, hashps, unbounded batch, blockmintxfee, maxfeerate | GBT depends, longpoll | | electrum | 0 | mempool_stats | status full-history, announce O(subs) | -| esplora | 0 | stub mempool JSON, WS on runtime, sh_join slot | `/blocks` reconstruct, WS announce IO | +| esplora | 0 | WS on runtime | `/blocks` reconstruct, WS announce IO | | node/cli/log/bench | 0 | milestone=0 conf, minrelay silent, frozen AddrMan | log gating, api_log mutex | --- @@ -358,10 +352,11 @@ Not a plan (no red/green steps). Split-risk, then operator-visible, then IBD CPU. 1. Mempool persist order, AddrMan cap. -2. Esplora `/blocks` summaries (P11) and mempool JSON stubs (X-M1). +2. Esplora `/blocks` summaries (P11). Out of scope (Won't-fix / policy): flattening uring, process pin FIFO, -leftover `Vec`, explorer APIs, `rbitcoin-bench` in required CI. +leftover `Vec`, explorer APIs, `rbitcoin-bench` in required CI, +Esplora SH join LRU / per-IP / large cache (**X-M3**). Dropped from this list as too small or already owner-doc: ping RTT in whole seconds, bench Electrum response pairing, CLI `--` for negative RPC args, chunked HTTP in `rbitcoin-cli`. diff --git a/docs/quality.md b/docs/quality.md index e4fbdae7..5d2276ea 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -156,7 +156,7 @@ Retired on purpose. Not a backlog. Not a failure. | **Q-35** | Mainnet soak program | Not a program. Run signet first, then mainnet with monitoring. No gated checklist or badge | | **—** | Darwin notarization / Developer ID | Ad-hoc `codesign -s -` on the macos snapshot. Notarization is still not a product | | **—** | Leftover maps as `txid → Vec` | [`errata.md`](./errata.md): only if a mainnet miss is shown | -| **—** | Explorer APIs, full Core RPC, prune, ZMQ, IPC, v1 P2P, GUI, wallet keys | Product never. Inventory skips already say so | +| **X-M3** | Esplora process-wide `sh_join` LRU / per-IP / large cache | HTTP is not a session. A tiny LRU still evicts wallets; per-IP is NAT/DoS; a large cache is RSS (join payload × addresses × clients). Sticky joins stay on Electrum TCP (one slot per connection). Esplora keeps one last SH for sequential REST. | | **—** | Headerless SH extent interior pages | Extent is a span-read of the existing 4 KiB delta-page record. Interiors keep `ver`/`n_fks`/`next` so one decoder serves leftovers, tails, and last-page append. Full-page payload is ~0.2% and a schema bump | | **—** | Restore `rbtc-script-coord-*` | `ibd-confirm` publishes waves, polls lock-free completion, feeds `scriptq` when steal is empty. Steal workers unpark the publisher. Do not add coordinator threads to keep the pool fed | | **—** | Flatten purpose-built io_uring machines | AGENTS.md: fix the machine; do not replace it with batched `pread`/`pwrite` without an explicit ask |